初始化本地仓库
This commit is contained in:
@@ -0,0 +1,724 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\enum;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use ReflectionClass;
|
||||
use ReflectionException;
|
||||
|
||||
/**
|
||||
* 枚举服务类
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
final class EnumService
|
||||
{
|
||||
/**
|
||||
* 缓存的插件名称列表
|
||||
*/
|
||||
private array $pluginNames = [];
|
||||
|
||||
/**
|
||||
* 获取所有插件名称
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getPluginNames(): array
|
||||
{
|
||||
if (!empty($this->pluginNames)) {
|
||||
return $this->pluginNames;
|
||||
}
|
||||
|
||||
$pluginDir = base_path('plugin');
|
||||
if (!is_dir($pluginDir)) {
|
||||
$this->pluginNames = [];
|
||||
return $this->pluginNames;
|
||||
}
|
||||
|
||||
$this->pluginNames = [];
|
||||
$dirs = scandir($pluginDir);
|
||||
foreach ($dirs as $dir) {
|
||||
if ($dir !== '.' && $dir !== '..' && is_dir($pluginDir . DIRECTORY_SEPARATOR . $dir)) {
|
||||
$this->pluginNames[] = $dir;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->pluginNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有枚举目录(递归扫描)
|
||||
* '支持扫描 app/enum 和 plugin//app/enum 目录
|
||||
* @return array
|
||||
*/
|
||||
public function getEnumDirectories(): array
|
||||
{
|
||||
$directories = [];
|
||||
|
||||
// 扫描 app/enum 目录
|
||||
$appEnumDir = app_path('enum');
|
||||
if (is_dir($appEnumDir)) {
|
||||
$directories = array_merge($directories, $this->scanDirectory($appEnumDir));
|
||||
}
|
||||
|
||||
// 扫描插件目录 plugin/*/app/enum
|
||||
$pluginDir = base_path('plugin');
|
||||
if (is_dir($pluginDir)) {
|
||||
foreach ($this->getPluginNames() as $pluginName) {
|
||||
$pluginEnumDir = $pluginDir . DIRECTORY_SEPARATOR . $pluginName . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'enum';
|
||||
if (is_dir($pluginEnumDir)) {
|
||||
$directories = array_merge($directories, $this->scanDirectory($pluginEnumDir, $pluginName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $directories;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描指定目录并返回目录映射
|
||||
*
|
||||
* @param string $baseDir 基础目录
|
||||
* @param string $pluginName 插件名称(可选)
|
||||
* @return array
|
||||
*/
|
||||
private function scanDirectory(string $baseDir, string $pluginName = ''): array
|
||||
{
|
||||
$directories = [];
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($baseDir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isDir() && $file->getFilename() !== '.' && $file->getFilename() !== '..') {
|
||||
$relativePath = str_replace($baseDir . DIRECTORY_SEPARATOR, '', $file->getPathname());
|
||||
$relativePath = str_replace(DIRECTORY_SEPARATOR, '.', $relativePath);
|
||||
// 如果是插件,则分类以插件名开头
|
||||
$category = $pluginName ? ($pluginName . '.' . $relativePath) : $relativePath;
|
||||
$directories[$category] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return $directories;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描所有枚举类(支持多级目录)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function scanEnums(): array
|
||||
{
|
||||
$result = [];
|
||||
$directories = $this->getEnumDirectories();
|
||||
|
||||
foreach ($directories as $category => $directory) {
|
||||
if (!is_dir($directory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = scandir($directory);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..' || !str_ends_with($file, '.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$className = pathinfo($file, PATHINFO_FILENAME);
|
||||
$namespace = $this->buildNamespace($category, $className);
|
||||
|
||||
if ($this->isEnumClass($namespace)) {
|
||||
$result[] = [
|
||||
'category' => $category,
|
||||
'namespace' => $namespace,
|
||||
'name' => $className,
|
||||
'code' => strtolower($category . '.' . $className),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分类路径构建命名空间
|
||||
*
|
||||
* @param string $category 分类路径(如:system 或 official)
|
||||
* @param string $className 类名
|
||||
* @return string
|
||||
*/
|
||||
private function buildNamespace(string $category, string $className): string
|
||||
{
|
||||
$parts = explode('.', $category);
|
||||
$firstPart = $parts[0];
|
||||
|
||||
// 判断第一部分是否为插件名
|
||||
if (in_array($firstPart, $this->getPluginNames())) {
|
||||
// 插件枚举:official → plugin\official\app\enum\PermissionType
|
||||
$subPath = count($parts) > 1 ? implode('\\', array_slice($parts, 1)) : '';
|
||||
$namespace = "plugin\\{$firstPart}\\app\\enum";
|
||||
if ($subPath) {
|
||||
$namespace .= "\\{$subPath}";
|
||||
}
|
||||
$namespace .= "\\{$className}";
|
||||
} else {
|
||||
// 系统枚举:system → app\enum\system\PermissionType
|
||||
$subPath = str_replace('.', '\\', $category);
|
||||
$namespace = "app\\enum\\{$subPath}\\{$className}";
|
||||
}
|
||||
|
||||
return $namespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据枚举code获取枚举数据(支持多级目录)
|
||||
*
|
||||
* @param string $enumCode 枚举代码,格式如:system.permissiontype 或 official.permissiontype
|
||||
* @return array
|
||||
*/
|
||||
public function getEnumByCode(string $enumCode): array
|
||||
{
|
||||
$parts = explode('.', $enumCode);
|
||||
if (count($parts) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$enumName = array_pop($parts);
|
||||
$category = implode('.', $parts);
|
||||
|
||||
$namespace = $this->buildNamespace($category, $enumName);
|
||||
|
||||
if (!$this->isEnumClass($namespace)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->getEnumOptions($namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取枚举的选项数据
|
||||
*
|
||||
* @param string $enumClass 枚举类名
|
||||
* @return array
|
||||
*/
|
||||
private function getEnumOptions(string $enumClass): array
|
||||
{
|
||||
try {
|
||||
$reflectionClass = new ReflectionClass($enumClass);
|
||||
$cases = $reflectionClass->getConstants();
|
||||
$options = [];
|
||||
|
||||
// 预定义颜色数组,用于随机分配
|
||||
$defaultColors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#fa541c', '#13c2c2', '#eb2f96'];
|
||||
|
||||
foreach ($cases as $caseName => $case) {
|
||||
$option = [
|
||||
'label' => method_exists($case, 'label') ? $case->label() : $caseName,
|
||||
'value' => $case->value ?? $caseName,
|
||||
];
|
||||
|
||||
// 添加颜色信息 - 优先使用枚举的color方法,否则使用随机颜色
|
||||
if (method_exists($case, 'color')) {
|
||||
$option['color'] = $case->color();
|
||||
} else {
|
||||
// 使用随机颜色,确保相同caseName总是得到相同颜色
|
||||
$colorIndex = crc32($caseName) % count($defaultColors);
|
||||
$option['color'] = $defaultColors[$colorIndex];
|
||||
}
|
||||
|
||||
// 添加其他扩展信息 - 总是设置ext字段,没有方法时返回空数组
|
||||
$option['ext'] = method_exists($case, 'ext') ? $case->ext() : [];
|
||||
|
||||
$options[] = $option;
|
||||
}
|
||||
|
||||
return $options;
|
||||
} catch (ReflectionException $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为枚举类
|
||||
*
|
||||
* @param string $className 类名
|
||||
* @return bool
|
||||
*/
|
||||
private function isEnumClass(string $className): bool
|
||||
{
|
||||
try {
|
||||
$reflectionClass = new ReflectionClass($className);
|
||||
return $reflectionClass->isEnum();
|
||||
} catch (ReflectionException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有枚举的完整列表(支持多级目录,一维结构)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getAllEnums(): array
|
||||
{
|
||||
$result = [];
|
||||
$directories = $this->getEnumDirectories();
|
||||
foreach ($directories as $category => $directory) {
|
||||
if (!is_dir($directory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = scandir($directory);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..' || !str_ends_with($file, '.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$className = pathinfo($file, PATHINFO_FILENAME);
|
||||
$namespace = $this->buildNamespace($category, $className);
|
||||
|
||||
if ($this->isEnumClass($namespace)) {
|
||||
$options = $this->getEnumOptions($namespace);
|
||||
$result[] = [
|
||||
'name' => $className,
|
||||
'namespace' => $namespace,
|
||||
'category' => $category,
|
||||
'category_label' => $this->getCategoryLabel($category),
|
||||
'code' => strtolower($category . '.' . $className),
|
||||
'options' => $options,
|
||||
'count' => count($options),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类标签(支持多级目录)
|
||||
*
|
||||
* @param string $category 分类
|
||||
* @return string
|
||||
*/
|
||||
private function getCategoryLabel(string $category): string
|
||||
{
|
||||
$categoryEnum = \app\enum\common\CategoryEnum::fromDirectory($category);
|
||||
if ($categoryEnum !== null) {
|
||||
return $categoryEnum->label();
|
||||
}
|
||||
|
||||
$labels = [
|
||||
'common' => '通用枚举',
|
||||
'system' => '系统枚举',
|
||||
];
|
||||
|
||||
return $labels[$category] ?? $this->generateCategoryLabel($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分类标签(用于未映射的分类)
|
||||
*
|
||||
* @param string $category 分类路径
|
||||
* @return string
|
||||
*/
|
||||
private function generateCategoryLabel(string $category): string
|
||||
{
|
||||
$parts = explode('.', $category);
|
||||
$lastPart = end($parts);
|
||||
return ucfirst($lastPart) . '枚举';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的枚举代码列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getAvailableEnumCodes(): array
|
||||
{
|
||||
$enums = $this->scanEnums();
|
||||
return array_column($enums, 'code');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查枚举代码是否有效
|
||||
*
|
||||
* @param string $enumCode 枚举代码
|
||||
* @return bool
|
||||
*/
|
||||
public function isValidEnumCode(string $enumCode): bool
|
||||
{
|
||||
$parts = explode('.', $enumCode);
|
||||
if (count($parts) < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$enumName = array_pop($parts);
|
||||
$category = implode('.', $parts);
|
||||
$namespace = $this->buildNamespace($category, $enumName);
|
||||
|
||||
return $this->isEnumClass($namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多个枚举数据
|
||||
*
|
||||
* @param array $enumCodes 枚举代码数组
|
||||
* @return array
|
||||
*/
|
||||
public function getBatchEnums(array $enumCodes): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($enumCodes as $enumCode) {
|
||||
if ($this->isValidEnumCode($enumCode)) {
|
||||
$result[$enumCode] = $this->getEnumByCode($enumCode);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取枚举的详细信息
|
||||
*
|
||||
* @param string $enumCode 枚举代码
|
||||
* @return array
|
||||
*/
|
||||
public function getEnumInfo(string $enumCode): array
|
||||
{
|
||||
if (!$this->isValidEnumCode($enumCode)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parts = explode('.', $enumCode);
|
||||
$enumName = array_pop($parts);
|
||||
$category = implode('.', $parts);
|
||||
$namespace = $this->buildNamespace($category, $enumName);
|
||||
|
||||
try {
|
||||
$reflectionClass = new ReflectionClass($namespace);
|
||||
$cases = $reflectionClass->getConstants();
|
||||
|
||||
return [
|
||||
'code' => $enumCode,
|
||||
'name' => $enumName,
|
||||
'category' => $category,
|
||||
'namespace' => $namespace,
|
||||
'case_count' => count($cases),
|
||||
'cases' => array_keys($cases),
|
||||
];
|
||||
} catch (ReflectionException $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取枚举列表-支持搜索和分类筛选,不分页,一维结构
|
||||
*
|
||||
* @param string $search 搜索关键词
|
||||
* @param string $category 分类筛选
|
||||
* @return array
|
||||
*/
|
||||
public function getEnumsList(string $search = '', string $category = ''): array
|
||||
{
|
||||
$allEnums = $this->getAllEnums();
|
||||
$filteredEnums = [];
|
||||
|
||||
foreach ($allEnums as $enum) {
|
||||
// 分类筛选
|
||||
if ($category && $enum['category'] !== $category) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 搜索筛选
|
||||
if ($search && !str_contains(strtolower($enum['name']), strtolower($search))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filteredEnums[] = $enum;
|
||||
}
|
||||
|
||||
return $filteredEnums;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取枚举总数(用于分页计数)
|
||||
*
|
||||
* @param string $search 搜索关键词
|
||||
* @param string $category 分类筛选
|
||||
* @return int
|
||||
*/
|
||||
public function getEnumsCount(string $search = '', string $category = ''): int
|
||||
{
|
||||
return count($this->getEnumsList($search, $category));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取枚举列表(以枚举类为计数单位,一维结构)
|
||||
*
|
||||
* @param int $page 页码
|
||||
* @param int $limit 每页数量
|
||||
* @param string $search 搜索关键词
|
||||
* @param string $category 分类筛选
|
||||
* @return array
|
||||
*/
|
||||
public function getEnumsWithPagination(int $page = 1, int $limit = 10, string $search = '', string $category = ''): array
|
||||
{
|
||||
$filteredEnums = $this->getEnumsList($search, $category);
|
||||
|
||||
// 分页处理
|
||||
$total = count($filteredEnums);
|
||||
$offset = ($page - 1) * $limit;
|
||||
$pagedEnums = array_slice($filteredEnums, $offset, $limit);
|
||||
|
||||
return [
|
||||
'items' => $pagedEnums,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'total_page' => ceil($total / $limit),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分类获取枚举列表
|
||||
*
|
||||
* @param string $category 分类
|
||||
* @return array
|
||||
*/
|
||||
public function getEnumsByCategory(string $category): array
|
||||
{
|
||||
return $this->getEnumsList('', $category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索枚举
|
||||
*
|
||||
* @param string $keyword 关键词
|
||||
* @param int $limit 限制数量
|
||||
* @return array
|
||||
*/
|
||||
public function searchEnums(string $keyword, int $limit = 20): array
|
||||
{
|
||||
$allEnums = $this->getAllEnums();
|
||||
$result = [];
|
||||
|
||||
foreach ($allEnums as $enum) {
|
||||
if (str_contains(strtolower($enum['name']), strtolower($keyword))) {
|
||||
$result[] = $enum;
|
||||
|
||||
if (count($result) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取枚举统计信息
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getEnumStatistics(): array
|
||||
{
|
||||
$allEnums = $this->getAllEnums();
|
||||
$statistics = [
|
||||
'total_enums' => count($allEnums),
|
||||
'total_options' => 0,
|
||||
'categories' => [],
|
||||
];
|
||||
|
||||
$categoryStats = [];
|
||||
foreach ($allEnums as $enum) {
|
||||
$statistics['total_options'] += $enum['count'];
|
||||
|
||||
if (!isset($categoryStats[$enum['category']])) {
|
||||
$categoryStats[$enum['category']] = [
|
||||
'category' => $enum['category'],
|
||||
'label' => $enum['categoryLabel'],
|
||||
'enum_count' => 0,
|
||||
'option_count' => 0,
|
||||
];
|
||||
}
|
||||
$categoryStats[$enum['category']]['enum_count']++;
|
||||
$categoryStats[$enum['category']]['option_count'] += $enum['count'];
|
||||
}
|
||||
|
||||
$statistics['categories'] = array_values($categoryStats);
|
||||
$statistics['total_categories'] = count($categoryStats);
|
||||
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分类列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getCategoryList(): array
|
||||
{
|
||||
$allEnums = $this->getAllEnums();
|
||||
$categories = [];
|
||||
$seenCategories = [];
|
||||
|
||||
foreach ($allEnums as $enum) {
|
||||
if (!in_array($enum['category'], $seenCategories)) {
|
||||
$seenCategories[] = $enum['category'];
|
||||
$categories[] = [
|
||||
'value' => $enum['category'],
|
||||
'label' => $enum['categoryLabel'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查枚举是否存在
|
||||
*
|
||||
* @param string $enumCode 枚举代码
|
||||
* @return bool
|
||||
*/
|
||||
public function enumExists(string $enumCode): bool
|
||||
{
|
||||
return $this->isValidEnumCode($enumCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取枚举的选项数量
|
||||
*
|
||||
* @param string $enumCode 枚举代码
|
||||
* @return int
|
||||
*/
|
||||
public function getEnumOptionCount(string $enumCode): int
|
||||
{
|
||||
$options = $this->getEnumByCode($enumCode);
|
||||
return count($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查枚举代码有效性
|
||||
*
|
||||
* @param array $enumCodes 枚举代码数组
|
||||
* @return array
|
||||
*/
|
||||
public function validateEnumCodes(array $enumCodes): array
|
||||
{
|
||||
$result = [
|
||||
'valid' => [],
|
||||
'invalid' => [],
|
||||
];
|
||||
|
||||
foreach ($enumCodes as $enumCode) {
|
||||
if ($this->isValidEnumCode($enumCode)) {
|
||||
$result['valid'][] = $enumCode;
|
||||
} else {
|
||||
$result['invalid'][] = $enumCode;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取枚举的默认值(第一个选项的值)
|
||||
*
|
||||
* @param string $enumCode 枚举代码
|
||||
* @return mixed
|
||||
*/
|
||||
public function getEnumDefaultValue(string $enumCode): mixed
|
||||
{
|
||||
$options = $this->getEnumByCode($enumCode);
|
||||
if (empty($options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $options[0]['value'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据值获取枚举标签
|
||||
*
|
||||
* @param string $enumCode 枚举代码
|
||||
* @param mixed $value 枚举值
|
||||
* @return string|null
|
||||
*/
|
||||
public function getLabelByValue(string $enumCode, mixed $value): ?string
|
||||
{
|
||||
$options = $this->getEnumByCode($enumCode);
|
||||
|
||||
foreach ($options as $option) {
|
||||
if ($option['value'] == $value) {
|
||||
return $option['label'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据标签获取枚举值
|
||||
*
|
||||
* @param string $enumCode 枚举代码
|
||||
* @param string $label 枚举标签
|
||||
* @return mixed
|
||||
*/
|
||||
public function getValueByLabel(string $enumCode, string $label): mixed
|
||||
{
|
||||
$options = $this->getEnumByCode($enumCode);
|
||||
|
||||
foreach ($options as $option) {
|
||||
if ($option['label'] === $label) {
|
||||
return $option['value'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取枚举的颜色信息
|
||||
*
|
||||
* @param string $enumCode 枚举代码
|
||||
* @param mixed $value 枚举值
|
||||
* @return string|null
|
||||
*/
|
||||
public function getColorByValue(string $enumCode, mixed $value): ?string
|
||||
{
|
||||
$options = $this->getEnumByCode($enumCode);
|
||||
|
||||
foreach ($options as $option) {
|
||||
if ($option['value'] == $value) {
|
||||
return $option['color'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新扫描枚举
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rescanEnums(): array
|
||||
{
|
||||
// 这里可以添加缓存清除逻辑
|
||||
return $this->getAllEnums();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\install\traits;
|
||||
|
||||
use core\uuid\Snowflake;
|
||||
|
||||
/**
|
||||
* 管理员创建 Trait
|
||||
*/
|
||||
trait AdminTrait
|
||||
{
|
||||
|
||||
/**
|
||||
* 创建管理员
|
||||
*/
|
||||
public function createAdmin(array $adminParams): void
|
||||
{
|
||||
$adminTable = $this->table('sys_admin');
|
||||
$pdo = $this->getPdo();
|
||||
|
||||
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM `{$adminTable}`");
|
||||
$result = $stmt->fetch();
|
||||
if ($result['cnt'] > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$username = $adminParams['username'] ?? 'admin';
|
||||
$password = $adminParams['password'] ?? '123456';
|
||||
$email = $adminParams['email'] ?? 'admin@example.com';
|
||||
|
||||
$this->insert($adminTable, [
|
||||
'id' => 1,
|
||||
'user_name' => $username,
|
||||
'password' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'real_name' => '超级管理员',
|
||||
'nick_name' => '超级管理员',
|
||||
'email' => $email,
|
||||
'is_super' => 1,
|
||||
'enabled' => 1,
|
||||
'created_at' => $this->currentTime,
|
||||
'updated_at' => $this->currentTime,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\install\traits;
|
||||
|
||||
use core\uuid\Snowflake;
|
||||
|
||||
/**
|
||||
* 配置导入 Trait
|
||||
*/
|
||||
trait ConfigTrait
|
||||
{
|
||||
|
||||
/**
|
||||
* 运行配置种子
|
||||
*/
|
||||
public function runConfig(): void
|
||||
{
|
||||
$configFile = base_path('resource/data/config/config.php');
|
||||
if (!file_exists($configFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$configs = require $configFile;
|
||||
$configTable = $this->table('sys_config');
|
||||
$pdo = $this->getPdo();
|
||||
|
||||
$pdo->exec("TRUNCATE TABLE `{$configTable}`");
|
||||
|
||||
foreach ($configs as $config) {
|
||||
$content = isset($config['content'])
|
||||
? (is_array($config['content'])
|
||||
? json_encode($config['content'], JSON_UNESCAPED_UNICODE)
|
||||
: $config['content'])
|
||||
: '';
|
||||
|
||||
$this->insert($configTable, [
|
||||
'id' => Snowflake::generate(),
|
||||
'group_code' => $config['group_code'] ?? 'system',
|
||||
'code' => $config['code'] ?? '',
|
||||
'name' => $config['name'] ?? '',
|
||||
'content' => $content,
|
||||
'is_sys' => $config['is_sys'] ?? 0,
|
||||
'enabled' => $config['enabled'] ?? 0,
|
||||
'remark' => $config['remark'] ?? '',
|
||||
'created_at' => $this->currentTime,
|
||||
'created_by' => $config['created_by'] ?? null,
|
||||
'updated_at' => $this->currentTime,
|
||||
'updated_by' => $config['updated_by'] ?? null,
|
||||
'deleted_at' => $config['deleted_at'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\install\traits;
|
||||
|
||||
use core\uuid\Snowflake;
|
||||
|
||||
/**
|
||||
* 字典导入 Trait
|
||||
*/
|
||||
trait DictTrait
|
||||
{
|
||||
|
||||
/**
|
||||
* 运行字典种子
|
||||
*/
|
||||
public function runDict(): void
|
||||
{
|
||||
$dictFile = base_path('resource/data/dict/dict.php');
|
||||
if (!file_exists($dictFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dicts = require $dictFile;
|
||||
$dictTable = $this->table('sys_dict');
|
||||
$dictItemTable = $this->table('sys_dict_item');
|
||||
$pdo = $this->getPdo();
|
||||
|
||||
$pdo->exec("TRUNCATE TABLE `{$dictItemTable}`");
|
||||
$pdo->exec("TRUNCATE TABLE `{$dictTable}`");
|
||||
|
||||
foreach ($dicts as $dict) {
|
||||
$dictId = Snowflake::generate();
|
||||
|
||||
$this->insert($dictTable, [
|
||||
'id' => $dictId,
|
||||
'group_code' => $dict['group'] ?? 'system',
|
||||
'code' => $dict['code'] ?? '',
|
||||
'name' => $dict['name'] ?? '',
|
||||
'sort' => $dict['sort'] ?? 0,
|
||||
'data_type' => $dict['data_type'] ?? 1,
|
||||
'description' => $dict['remark'] ?? '',
|
||||
'enabled' => $dict['status'] ?? 1,
|
||||
'created_at' => $this->currentTime,
|
||||
'updated_at' => $this->currentTime,
|
||||
'created_by' => 1,
|
||||
'updated_by' => 1,
|
||||
]);
|
||||
|
||||
if (!empty($dict['items'])) {
|
||||
foreach ($dict['items'] as $item) {
|
||||
$this->insert($dictItemTable, [
|
||||
'id' => Snowflake::generate(),
|
||||
'dict_id' => $dictId,
|
||||
'code' => $dict['code'] ?? '',
|
||||
'value' => $item['value'] ?? '',
|
||||
'label' => $item['label'] ?? '',
|
||||
'color' => $item['color'] ?? '',
|
||||
'sort' => $item['sort'] ?? 0,
|
||||
'enabled' => $item['status'] ?? 1,
|
||||
'created_at' => $this->currentTime,
|
||||
'updated_at' => $this->currentTime,
|
||||
'created_by' => 1,
|
||||
'updated_by' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\install\traits;
|
||||
|
||||
/**
|
||||
* 安装数据库基础 Trait
|
||||
*/
|
||||
trait InstallDatabaseTrait
|
||||
{
|
||||
protected string $prefix;
|
||||
protected ?\PDO $pdo = null;
|
||||
protected array $dbConfig = [];
|
||||
protected int $currentTime;
|
||||
|
||||
/**
|
||||
* 初始化配置
|
||||
*/
|
||||
protected function initDbConfig(array $dbConfig): void
|
||||
{
|
||||
$this->dbConfig = $dbConfig;
|
||||
$this->prefix = $dbConfig['prefix'] ?? 'ma_';
|
||||
$this->currentTime = time();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 PDO 连接(单例)
|
||||
*/
|
||||
protected function getPdo(): \PDO
|
||||
{
|
||||
if ($this->pdo === null) {
|
||||
$config = $this->dbConfig;
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
|
||||
$config['host'] ?? '127.0.0.1',
|
||||
$config['port'] ?? '3306',
|
||||
$config['database'] ?? 'md_admin'
|
||||
);
|
||||
$this->pdo = new \PDO(
|
||||
$dsn,
|
||||
$config['username'] ?? 'root',
|
||||
$config['password'] ?? '',
|
||||
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
}
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取带前缀的表名
|
||||
*/
|
||||
protected function table(string $name): string
|
||||
{
|
||||
return $this->prefix . $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一插入数据
|
||||
*/
|
||||
protected function insert(string $tableName, array $data): bool
|
||||
{
|
||||
$fields = array_keys($data);
|
||||
$values = array_values($data);
|
||||
$placeholders = array_fill(0, count($values), '?');
|
||||
$sql = "INSERT INTO `{$tableName}` (`" . implode('`, `', $fields) . "`) VALUES (" . implode(', ', $placeholders) . ")";
|
||||
$stmt = $this->getPdo()->prepare($sql);
|
||||
return $stmt->execute($values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\install\traits;
|
||||
|
||||
use core\uuid\Snowflake;
|
||||
|
||||
/**
|
||||
* 菜单导入 Trait
|
||||
*/
|
||||
trait MenuTrait
|
||||
{
|
||||
|
||||
/**
|
||||
* 运行菜单种子
|
||||
*/
|
||||
public function runMenu(): void
|
||||
{
|
||||
$this->runAdminMenu();
|
||||
$this->runWebMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行管理端菜单种子
|
||||
*/
|
||||
public function runAdminMenu(): void
|
||||
{
|
||||
$menuFile = base_path('resource/data/menu/admin.php');
|
||||
if (!file_exists($menuFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$menus = require $menuFile;
|
||||
$menuTable = $this->table('sys_menu');
|
||||
$this->getPdo()->exec("TRUNCATE TABLE `{$menuTable}`");
|
||||
|
||||
foreach ($menus as $menu) {
|
||||
$this->insertAdminMenu($menuTable, $menu, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行Web端菜单种子
|
||||
*/
|
||||
public function runWebMenu(): void
|
||||
{
|
||||
$menuFile = base_path('resource/data/menu/web.php');
|
||||
if (!file_exists($menuFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$menus = require $menuFile;
|
||||
$menuTable = $this->table('web_menu');
|
||||
$this->getPdo()->exec("TRUNCATE TABLE `{$menuTable}`");
|
||||
|
||||
foreach ($menus as $menu) {
|
||||
$this->insertWebMenu($menuTable, $menu, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归插入管理端菜单
|
||||
*/
|
||||
private function insertAdminMenu(string $tableName, array $menu, int|string $pid): int|string
|
||||
{
|
||||
$id = Snowflake::generate();
|
||||
|
||||
$data = [
|
||||
'id' => $id,
|
||||
'pid' => $pid,
|
||||
'app' => $menu['app'] ?? 'admin',
|
||||
'source' => $menu['source'] ?? 'system',
|
||||
'title' => $menu['title'] ?? '',
|
||||
'code' => $menu['code'] ?? '',
|
||||
'level' => $this->getMenuLevel($pid),
|
||||
'type' => $menu['type'] ?? 1,
|
||||
'sort' => $menu['sort'] ?? 999,
|
||||
'path' => $menu['path'] ?? '',
|
||||
'component' => $menu['component'] ?? '',
|
||||
'redirect' => $menu['redirect'] ?? '',
|
||||
'icon' => $menu['icon'] ?? '',
|
||||
'is_show' => $menu['is_show'] ?? 1,
|
||||
'is_link' => $menu['is_link'] ?? 0,
|
||||
'link_url' => $menu['link_url'] ?? '',
|
||||
'enabled' => $menu['enabled'] ?? 1,
|
||||
'open_type' => $menu['open_type'] ?? 0,
|
||||
'is_cache' => $menu['is_cache'] ?? 0,
|
||||
'is_sync' => $menu['is_sync'] ?? 1,
|
||||
'is_affix' => $menu['is_affix'] ?? 0,
|
||||
'is_global' => $menu['is_global'] ?? 0,
|
||||
'variable' => $menu['variable'] ?? '',
|
||||
'methods' => strtolower($menu['methods'] ?? 'get'),
|
||||
'is_frame' => $menu['is_frame'] ?? 1,
|
||||
'created_at' => $this->currentTime,
|
||||
'created_by' => 0,
|
||||
'updated_at' => $this->currentTime,
|
||||
'updated_by' => 0,
|
||||
];
|
||||
|
||||
$this->insert($tableName, $data);
|
||||
|
||||
if (!empty($menu['children'])) {
|
||||
foreach ($menu['children'] as $child) {
|
||||
$this->insertAdminMenu($tableName, $child, $id);
|
||||
}
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归插入Web端菜单
|
||||
*/
|
||||
private function insertWebMenu(string $tableName, array $menu, int|string $pid): int|string
|
||||
{
|
||||
$id = (int)Snowflake::generate();
|
||||
|
||||
$data = [
|
||||
'id' => $id,
|
||||
'pid' => $pid,
|
||||
'app' => $menu['app'] ?? 'web',
|
||||
'category' => $menu['category'] ?? 1,
|
||||
'source' => $menu['source'] ?? 'system',
|
||||
'code' => $menu['code'] ?? '',
|
||||
'name' => $menu['name'] ?? '',
|
||||
'url' => $menu['url'] ?? '',
|
||||
'icon' => $menu['icon'] ?? '',
|
||||
'level' => $menu['level'] ?? 1,
|
||||
'type' => $menu['type'] ?? 1,
|
||||
'sort' => $menu['sort'] ?? 999,
|
||||
'target' => $menu['target'] ?? 1,
|
||||
'is_show' => $menu['is_show'] ?? 1,
|
||||
'enabled' => $menu['enabled'] ?? 1,
|
||||
'created_at' => $this->currentTime,
|
||||
'updated_at' => $this->currentTime,
|
||||
'deleted_at' => null,
|
||||
];
|
||||
|
||||
$this->insert($tableName, $data);
|
||||
|
||||
if (!empty($menu['children'])) {
|
||||
foreach ($menu['children'] as $child) {
|
||||
$this->insertWebMenu($tableName, $child, $id);
|
||||
}
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单层级
|
||||
*/
|
||||
private function getMenuLevel(int|string $pid): int
|
||||
{
|
||||
if ($pid == 0) return 1;
|
||||
$menuTable = $this->table('sys_menu');
|
||||
$stmt = $this->getPdo()->prepare("SELECT `level` FROM `{$menuTable}` WHERE id = ?");
|
||||
$stmt->execute([$pid]);
|
||||
$parent = $stmt->fetch();
|
||||
return ($parent['level'] ?? 1) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\lang;
|
||||
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 翻译服务类
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
final class TranslationService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 语言包目录
|
||||
*/
|
||||
protected string $translationsPath;
|
||||
|
||||
/**
|
||||
* 支持的语言列表
|
||||
*/
|
||||
protected array $supportedLanguages;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
protected function initialize(): void
|
||||
{
|
||||
$this->translationsPath = config('translation.path', base_path('resource/translations'));
|
||||
$this->supportedLanguages = $this->getSupportedLanguages();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的语言列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getSupportedLanguages(): array
|
||||
{
|
||||
$languages = [];
|
||||
if (is_dir($this->translationsPath)) {
|
||||
$dirItems = scandir($this->translationsPath);
|
||||
foreach ($dirItems as $item) {
|
||||
if ($item === '.' || $item === '..' || !is_dir($this->translationsPath . DIRECTORY_SEPARATOR . $item)) {
|
||||
continue;
|
||||
}
|
||||
$languages[] = $item;
|
||||
}
|
||||
}
|
||||
return $languages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语言包列表
|
||||
*
|
||||
* @param string|null $language 语言类型(如en|zh_CN等)
|
||||
* @param string|null $file 翻译文件名称
|
||||
* @param string|null $keyword 搜索关键词(支持key和翻译内容)
|
||||
* @param int $page 页码
|
||||
* @param int $limit 每页数量
|
||||
*
|
||||
* @return array 语言包列表
|
||||
*/
|
||||
public function getList(string|null $language = null, string|null $file = null, string|null $keyword = null, int $page = 1, int $limit = 10): array
|
||||
{
|
||||
// 获取所有翻译内容
|
||||
$allTranslations = $this->getAllTranslations($language, $file);
|
||||
|
||||
// 搜索过滤
|
||||
$filteredTranslations = $this->filterTranslations($allTranslations, $keyword);
|
||||
|
||||
// 分页处理
|
||||
$total = count($filteredTranslations);
|
||||
$items = array_slice($filteredTranslations, ($page - 1) * $limit, $limit);
|
||||
|
||||
return [
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'total' => $total,
|
||||
'items' => $items
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有翻译内容
|
||||
*
|
||||
* @param string|null $language 语言类型
|
||||
* @param string|null $file 翻译文件名称
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getAllTranslations(string|null $language = null, string|null $file = null): array
|
||||
{
|
||||
$translations = [];
|
||||
$languages = $language ? [$language] : $this->supportedLanguages;
|
||||
|
||||
foreach ($languages as $lang) {
|
||||
$langPath = $this->translationsPath . DIRECTORY_SEPARATOR . $lang;
|
||||
$files = $file ? [$file] : $this->getTranslationFiles($langPath);
|
||||
|
||||
foreach ($files as $fileName) {
|
||||
$filePath = $langPath . DIRECTORY_SEPARATOR . $fileName;
|
||||
$translationData = $this->loadTranslationFile($filePath);
|
||||
|
||||
if (!empty($translationData)) {
|
||||
$fileKey = pathinfo($fileName, PATHINFO_FILENAME);
|
||||
$this->parseTranslations($translationData, $fileKey, $lang, $translations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语言包目录下的所有翻译文件
|
||||
*
|
||||
* @param string $langPath 语言目录路径
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getTranslationFiles(string $langPath): array
|
||||
{
|
||||
$files = [];
|
||||
if (is_dir($langPath)) {
|
||||
$dirItems = scandir($langPath);
|
||||
foreach ($dirItems as $item) {
|
||||
if ($item === '.' || $item === '..' || !is_file($langPath . DIRECTORY_SEPARATOR . $item) || pathinfo($item, PATHINFO_EXTENSION) !== 'php') {
|
||||
continue;
|
||||
}
|
||||
$files[] = $item;
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载翻译文件内容
|
||||
*
|
||||
* @param string $filePath 文件路径
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function loadTranslationFile(string $filePath): array
|
||||
{
|
||||
if (!is_file($filePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = include $filePath;
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析翻译内容
|
||||
*
|
||||
* @param array $translationData 翻译数据
|
||||
* @param string $fileKey 文件键名
|
||||
* @param string $language 语言类型
|
||||
* @param array &$result 结果数组
|
||||
* @param string $prefix 前缀(用于处理嵌套key)
|
||||
*/
|
||||
protected function parseTranslations(array $translationData, string $fileKey, string $language, array &$result, string $prefix = ''): void
|
||||
{
|
||||
foreach ($translationData as $key => $value) {
|
||||
$fullKey = $prefix ? $prefix . '.' . $key : $key;
|
||||
$translationKey = $fileKey . '.' . $fullKey;
|
||||
|
||||
if (is_array($value)) {
|
||||
$this->parseTranslations($value, $fileKey, $language, $result, $fullKey);
|
||||
} else {
|
||||
// 确保每个翻译key都有一个条目
|
||||
if (!isset($result[$translationKey])) {
|
||||
$result[$translationKey] = [
|
||||
'key' => $translationKey,
|
||||
'file' => $fileKey,
|
||||
'translations' => []
|
||||
];
|
||||
}
|
||||
|
||||
// 添加对应语言的翻译
|
||||
$result[$translationKey]['translations'][$language] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤翻译内容
|
||||
*
|
||||
* @param array $translations 翻译内容
|
||||
* @param string|null $keyword 搜索关键词
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function filterTranslations(array $translations, string|null $keyword = null): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return array_values($translations);
|
||||
}
|
||||
|
||||
$filtered = [];
|
||||
foreach ($translations as $translation) {
|
||||
// 检查key是否匹配
|
||||
if (stripos($translation['key'], $keyword) !== false) {
|
||||
$filtered[] = $translation;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查翻译内容是否匹配
|
||||
foreach ($translation['translations'] as $lang => $content) {
|
||||
if (is_string($content) && stripos($content, $keyword) !== false) {
|
||||
$filtered[] = $translation;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取翻译文件列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getFileList(): array
|
||||
{
|
||||
$files = [];
|
||||
if (!empty($this->supportedLanguages)) {
|
||||
// 获取第一个语言的文件列表作为参考
|
||||
$firstLangPath = $this->translationsPath . DIRECTORY_SEPARATOR . $this->supportedLanguages[0];
|
||||
$fileList = $this->getTranslationFiles($firstLangPath);
|
||||
|
||||
foreach ($fileList as $fileName) {
|
||||
$files[] = pathinfo($fileName, PATHINFO_FILENAME);
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语言包统计信息
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'languages' => count($this->supportedLanguages),
|
||||
'files' => count($this->getFileList()),
|
||||
'total_translations' => 0,
|
||||
'language_details' => []
|
||||
];
|
||||
|
||||
foreach ($this->supportedLanguages as $lang) {
|
||||
$langPath = $this->translationsPath . DIRECTORY_SEPARATOR . $lang;
|
||||
$files = $this->getTranslationFiles($langPath);
|
||||
$fileCount = count($files);
|
||||
$translationCount = 0;
|
||||
|
||||
foreach ($files as $fileName) {
|
||||
$filePath = $langPath . DIRECTORY_SEPARATOR . $fileName;
|
||||
$translationData = $this->loadTranslationFile($filePath);
|
||||
$translationCount += $this->countTranslations($translationData);
|
||||
}
|
||||
|
||||
$stats['language_details'][$lang] = [
|
||||
'files' => $fileCount,
|
||||
'translations' => $translationCount
|
||||
];
|
||||
$stats['total_translations'] += $translationCount;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计翻译条目数量
|
||||
*
|
||||
* @param array $translationData 翻译数据
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function countTranslations(array $translationData): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($translationData as $value) {
|
||||
if (is_array($value)) {
|
||||
$count += $this->countTranslations($value);
|
||||
} else {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据key获取翻译内容
|
||||
*
|
||||
* @param string $key 翻译key
|
||||
* @param string|null $language 语言类型
|
||||
* @param array $parameters 替换参数
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getTranslationByKey(string $key, ?string $language = null, array $parameters = []): ?string
|
||||
{
|
||||
// 如果没有指定语言,使用默认语言
|
||||
if (!$language) {
|
||||
$language = $this->supportedLanguages[0] ?? 'zh_CN';
|
||||
}
|
||||
|
||||
// 解析key,获取文件和实际key
|
||||
$parts = explode('.', $key, 2);
|
||||
if (count($parts) !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$file, $actualKey] = $parts;
|
||||
|
||||
// 加载对应的翻译文件
|
||||
$filePath = $this->translationsPath . DIRECTORY_SEPARATOR . $language . DIRECTORY_SEPARATOR . $file . '.php';
|
||||
$translationData = $this->loadTranslationFile($filePath);
|
||||
|
||||
if (empty($translationData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取翻译内容
|
||||
$value = $this->getValueByKey($translationData, $actualKey);
|
||||
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 替换参数
|
||||
if (!empty($parameters) && is_string($value)) {
|
||||
foreach ($parameters as $paramKey => $paramValue) {
|
||||
$value = str_replace(':' . $paramKey, $paramValue, $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据key获取嵌套数组中的值
|
||||
*
|
||||
* @param array $data 数据数组
|
||||
* @param string $key 嵌套key
|
||||
*
|
||||
* @return mixed|null
|
||||
*/
|
||||
protected function getValueByKey(array $data, string $key)
|
||||
{
|
||||
$keys = explode('.', $key);
|
||||
$current = $data;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($current[$k])) {
|
||||
return null;
|
||||
}
|
||||
$current = $current[$k];
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\metadata;
|
||||
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
|
||||
/**
|
||||
* 权限收集服务
|
||||
* 用于扫描控制器中的 Permission 注解并生成权限列表
|
||||
*/
|
||||
class MetadataCollectorService
|
||||
{
|
||||
/**
|
||||
* 收集权限列表
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function collect(): array
|
||||
{
|
||||
$permissions = [];
|
||||
|
||||
try {
|
||||
// 扫描 adminapi 控制器目录
|
||||
$adminApiDir = base_path('app/adminapi/controller');
|
||||
$this->scanDirectory($adminApiDir, $permissions, 'adminapi');
|
||||
|
||||
// 扫描 api 控制器目录
|
||||
$apiDir = base_path('app/api/controller');
|
||||
$this->scanDirectory($apiDir, $permissions, 'api');
|
||||
|
||||
// 扫描 plugin 目录中的控制器
|
||||
$pluginDir = base_path('plugin');
|
||||
$this->scanDirectory($pluginDir, $permissions, 'plugin');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('扫描目录时出错: ' . $e->getMessage());
|
||||
}
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描目录中的控制器文件
|
||||
*
|
||||
* @param string $directory
|
||||
* @param array &$permissions
|
||||
* @param string $module
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function scanDirectory(string $directory, array &$permissions, string $module): void
|
||||
{
|
||||
try {
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = scandir($directory);
|
||||
if ($files === false) {
|
||||
throw new \Exception('无法读取目录: ' . $directory);
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
if (is_dir($path)) {
|
||||
// 跳过 member 和 web 目录,因为其中的控制器可能会导致错误
|
||||
if (str_contains($path, 'member') || str_contains($path, 'web')) {
|
||||
continue;
|
||||
}
|
||||
// 递归扫描子目录
|
||||
try {
|
||||
$this->scanDirectory($path, $permissions, $module);
|
||||
} catch (\Exception $e) {
|
||||
// 忽略扫描子目录时的错误,继续处理其他目录
|
||||
continue;
|
||||
}
|
||||
} elseif (str_ends_with($file, 'Controller.php')) {
|
||||
// 扫描控制器文件,使用 try-catch 块捕获所有可能的错误
|
||||
try {
|
||||
$this->scanController($path, $permissions, $module);
|
||||
} catch (\Exception $e) {
|
||||
// 忽略扫描控制器时的错误,继续处理其他控制器
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('扫描目录时出错: ' . $e->getMessage() . ' (目录: ' . $directory . ')');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描控制器文件中的权限注解
|
||||
*
|
||||
* @param string $filePath
|
||||
* @param array &$permissions
|
||||
* @param string $module
|
||||
*/
|
||||
private function scanController(string $filePath, array &$permissions, string $module): void
|
||||
{
|
||||
try {
|
||||
// 获取类名
|
||||
$relativePath = str_replace(base_path(), '', $filePath);
|
||||
$relativePath = ltrim($relativePath, DIRECTORY_SEPARATOR);
|
||||
// 确保使用反斜杠作为命名空间分隔符
|
||||
$className = str_replace(['/', '\\'], '\\', $relativePath);
|
||||
$className = str_replace('.php', '', $className);
|
||||
$className = '\\' . $className;
|
||||
|
||||
// 检查类是否存在
|
||||
if (!class_exists($className)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用反射获取类信息
|
||||
$reflectionClass = new ReflectionClass($className);
|
||||
|
||||
// 生成路由地址
|
||||
$routePath = $this->generateRoutePath($filePath, $module);
|
||||
|
||||
// 扫描所有方法
|
||||
$methods = $reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC);
|
||||
foreach ($methods as $method) {
|
||||
// 跳过魔术方法和继承的方法
|
||||
$methodName = $method->getName();
|
||||
if (str_starts_with($methodName, '__')) {
|
||||
continue;
|
||||
}
|
||||
// 不需要跳过继承的方法,因为我们需要收集所有带有 Permission 注解的方法
|
||||
|
||||
// 初始化权限信息和 swagger 信息
|
||||
$permission = null;
|
||||
$swaggerInfo = [];
|
||||
|
||||
// 尝试获取方法的注解
|
||||
try {
|
||||
$attributes = $method->getAttributes();
|
||||
foreach ($attributes as $attribute) {
|
||||
try {
|
||||
$attributeName = $attribute->getName();
|
||||
// 检查是否是 Permission 注解
|
||||
if (str_ends_with($attributeName, 'Permission')) {
|
||||
try {
|
||||
$args = $attribute->getArguments();
|
||||
$permission = $this->parsePermission($args);
|
||||
} catch (\Exception $e) {
|
||||
// 忽略获取权限注解参数时的错误
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 检查是否是 swagger 注解
|
||||
if (str_starts_with($attributeName, 'OpenApi\\Attributes\\')) {
|
||||
try {
|
||||
$args = $attribute->getArguments();
|
||||
if (isset($args['tags'])) {
|
||||
// 处理 tags,确保是字符串数组
|
||||
$tags = $args['tags'];
|
||||
if (is_array($tags)) {
|
||||
$swaggerInfo['tags'] = array_map(function($tag) {
|
||||
// 如果是对象,尝试获取 name 属性
|
||||
if (is_object($tag) && isset($tag->name)) {
|
||||
return $tag->name;
|
||||
}
|
||||
// 如果是字符串,直接使用
|
||||
if (is_string($tag)) {
|
||||
return $tag;
|
||||
}
|
||||
// 其他情况,转换为字符串
|
||||
return (string) $tag;
|
||||
}, $tags);
|
||||
} elseif (is_string($tags)) {
|
||||
$swaggerInfo['tags'] = [$tags];
|
||||
}
|
||||
}
|
||||
if (isset($args['summary'])) {
|
||||
$swaggerInfo['summary'] = $args['summary'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略获取 swagger 注解参数时的错误
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略获取注解信息时的错误
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略获取方法注解时的错误
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果有 Permission 注解,添加控制器和方法信息,并合并 swagger 信息
|
||||
if ($permission) {
|
||||
// 添加控制器和方法信息
|
||||
$permission['controller'] = $reflectionClass->getShortName();
|
||||
$permission['method'] = $method->getName();
|
||||
$permission['module'] = $module;
|
||||
$permission['route'] = $this->generateMethodRoute($routePath, $method->getName());
|
||||
|
||||
// 合并 swagger 信息
|
||||
if (!empty($swaggerInfo)) {
|
||||
$permission = array_merge($permission, $swaggerInfo);
|
||||
}
|
||||
|
||||
$permissions[] = $permission;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略扫描控制器时的错误,继续处理其他控制器
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析权限注解参数
|
||||
*
|
||||
* @param array $args
|
||||
* @return array|null
|
||||
*/
|
||||
private function parsePermission(array $args): ?array
|
||||
{
|
||||
$permission = [];
|
||||
|
||||
// 处理直接传递字符串的情况
|
||||
if (isset($args[0]) && is_string($args[0])) {
|
||||
$permission['code'] = $args[0];
|
||||
} elseif (isset($args['code'])) {
|
||||
$permission['code'] = $args['code'];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 处理描述
|
||||
if (isset($args['description'])) {
|
||||
$permission['description'] = $args['description'];
|
||||
}
|
||||
|
||||
// 处理 tags,确保是字符串数组
|
||||
if (isset($args['tags'])) {
|
||||
$tags = $args['tags'];
|
||||
if (is_array($tags)) {
|
||||
$permission['tags'] = array_map(function($tag) {
|
||||
// 如果是对象,尝试获取 name 属性
|
||||
if (is_object($tag) && isset($tag->name)) {
|
||||
return $tag->name;
|
||||
}
|
||||
// 如果是字符串,直接使用
|
||||
if (is_string($tag)) {
|
||||
return $tag;
|
||||
}
|
||||
// 其他情况,转换为字符串
|
||||
return (string) $tag;
|
||||
}, $tags);
|
||||
} elseif (is_string($tags)) {
|
||||
$permission['tags'] = [$tags];
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 summary
|
||||
if (isset($args['summary'])) {
|
||||
$permission['summary'] = $args['summary'];
|
||||
}
|
||||
|
||||
return $permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成控制器的路由路径
|
||||
*
|
||||
* @param string $filePath
|
||||
* @param string $module
|
||||
*
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function generateRoutePath(string $filePath, string $module): string
|
||||
{
|
||||
try {
|
||||
// 移除基础路径
|
||||
$relativePath = str_replace(base_path(), '', $filePath);
|
||||
$relativePath = ltrim($relativePath, DIRECTORY_SEPARATOR);
|
||||
|
||||
// 标准化分隔符
|
||||
$relativePath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativePath);
|
||||
|
||||
// 构建控制器目录路径
|
||||
$controllerDir = 'app' . DIRECTORY_SEPARATOR . $module . DIRECTORY_SEPARATOR . 'controller' . DIRECTORY_SEPARATOR;
|
||||
|
||||
// 检查路径是否包含控制器目录
|
||||
if (str_starts_with($relativePath, $controllerDir)) {
|
||||
// 提取控制器目录之后的部分
|
||||
$routePath = substr($relativePath, strlen($controllerDir));
|
||||
// 移除 .php 后缀
|
||||
$routePath = str_replace('.php', '', $routePath);
|
||||
} else {
|
||||
// 如果没有匹配到,返回默认路径
|
||||
return '/' . $module;
|
||||
}
|
||||
|
||||
// 移除 Controller 后缀
|
||||
$routePath = str_replace('Controller', '', $routePath);
|
||||
|
||||
// 将目录分隔符转换为斜杠
|
||||
$routePath = str_replace(DIRECTORY_SEPARATOR, '/', $routePath);
|
||||
|
||||
// 转换为小写并添加连字符
|
||||
$routePath = strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $routePath));
|
||||
|
||||
// 添加模块前缀
|
||||
if ($module === 'adminapi') {
|
||||
return '/adminapi/' . ltrim($routePath, '/');
|
||||
} elseif ($module === 'api') {
|
||||
return '/api/' . ltrim($routePath, '/');
|
||||
} else {
|
||||
return '/' . ltrim($routePath, '/');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('生成路由路径时出错: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成方法的路由路径
|
||||
*
|
||||
* @param string $controllerRoute
|
||||
* @param string $methodName
|
||||
* @return string
|
||||
*/
|
||||
private function generateMethodRoute(string $controllerRoute, string $methodName): string
|
||||
{
|
||||
// 转换方法名为路由格式
|
||||
$methodRoute = strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $methodName));
|
||||
|
||||
// 移除常见的 HTTP 方法前缀
|
||||
$methodRoute = preg_replace('/^(get|post|put|delete|patch|options)_/', '', $methodRoute);
|
||||
|
||||
// 组合路由路径
|
||||
return rtrim($controllerRoute, '/') . '/' . $methodRoute;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出权限列表为 JSON 文件
|
||||
*
|
||||
* @param string $outputPath
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function exportToJson(string $outputPath): bool
|
||||
{
|
||||
$permissions = $this->collect();
|
||||
$json = json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
return file_put_contents($outputPath, $json) !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\plugin;
|
||||
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 插件基类服务
|
||||
*
|
||||
* 提供基础路径属性和插件方法执行能力
|
||||
* CLI 和在线模式共享同一套逻辑
|
||||
*/
|
||||
abstract class PluginBaseService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 插件临时目录基础路径
|
||||
*/
|
||||
const RUNTIME_PLUGIN_PATH = 'install/plugin';
|
||||
|
||||
/**
|
||||
* 获取插件构建目录
|
||||
*/
|
||||
public static function getPluginBuildPath(): string
|
||||
{
|
||||
return self::RUNTIME_PLUGIN_PATH . '/build';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件包输出目录
|
||||
*/
|
||||
public static function getPluginPackagesPath(): string
|
||||
{
|
||||
return self::RUNTIME_PLUGIN_PATH . '/packages';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件终端输出目录
|
||||
*/
|
||||
public static function getPluginTerminalPath(): string
|
||||
{
|
||||
return self::RUNTIME_PLUGIN_PATH . '/terminal';
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件根目录
|
||||
*/
|
||||
protected string $plugin_path;
|
||||
|
||||
/**
|
||||
* 项目根目录(前端和后端的父级目录)
|
||||
* 注意:此路径已不再直接指向前端目录
|
||||
* 前端目录统一在 $project_path/frontend 下,如 admin、web、uni-app 等
|
||||
*/
|
||||
protected string $project_path;
|
||||
|
||||
/**
|
||||
* 后端根目录
|
||||
*/
|
||||
protected string $server_path;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->plugin_path = base_path('plugin');
|
||||
$this->project_path = dirname(base_path()); // 项目根目录(前端和后端的父级)
|
||||
$this->server_path = base_path(); // 后端根目录
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取前端基础目录
|
||||
* 前端代码统一在 frontend 目录下
|
||||
*
|
||||
* @return string 前端基础目录
|
||||
*/
|
||||
public function getFrontendPath(): string
|
||||
{
|
||||
return $this->project_path . DIRECTORY_SEPARATOR . 'frontend';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取前端指定项目目录
|
||||
*
|
||||
* @param string $frontendType 前端类型(admin/web/uni-app)
|
||||
* @return string 前端项目目录
|
||||
*/
|
||||
public function getFrontendProjectPath(string $frontendType): string
|
||||
{
|
||||
return $this->getFrontendPath() . DIRECTORY_SEPARATOR . $frontendType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置(兼容 ConfigTrait)
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
* @return array|null
|
||||
*/
|
||||
protected function getPluginConfig(string $code): ?array
|
||||
{
|
||||
$configFile = $this->plugin_path . DIRECTORY_SEPARATOR . $code . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'info.php';
|
||||
if (is_file($configFile)) {
|
||||
return include $configFile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行插件 Install 类方法
|
||||
*
|
||||
* 统一调用插件的 install/uninstall/update 方法
|
||||
* CLI 和在线模式都通过此方法调用
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
* @param string $action 方法名 (install/uninstall/update)
|
||||
* @param string|null $version 版本(对于 install 是目标版本,对于 update 是新版本)
|
||||
* @param string|null $oldVersion 旧版本(仅 update 时使用)
|
||||
* @param bool $silent 是否静默执行失败(true: 失败不抛出异常, false: 失败抛出异常)
|
||||
* @return bool 是否成功执行
|
||||
* @throws \Exception 当 silent=false 且执行失败时抛出异常
|
||||
*/
|
||||
protected function executePluginMethod(string $code, string $action, ?string $version = null, ?string $oldVersion = null, bool $silent = false): bool
|
||||
{
|
||||
$className = "plugin\\" . $code . "\\Install";
|
||||
if (!class_exists($className)) {
|
||||
// 插件未提供 Install 类,静默跳过
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$pluginConfig = $this->getPluginConfig($code);
|
||||
$version = $pluginConfig['version'] ?? ($version ?? '1.0.0');
|
||||
|
||||
// 实例化插件 Install 类
|
||||
$installInstance = new $className();
|
||||
|
||||
switch ($action) {
|
||||
case 'install':
|
||||
if (method_exists($installInstance, 'install')) {
|
||||
$installInstance->install($version);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'uninstall':
|
||||
if (method_exists($installInstance, 'uninstall')) {
|
||||
$installInstance->uninstall($version);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
if (method_exists($installInstance, 'update')) {
|
||||
$installInstance->update($oldVersion ?? '1.0.0', $version);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
// 插件方法执行失败,记录日志
|
||||
$logLevel = $silent ? 'warning' : 'error';
|
||||
\support\Log::$logLevel('插件方法执行失败', [
|
||||
'plugin' => $code,
|
||||
'action' => $action,
|
||||
'version' => $version,
|
||||
'old_version' => $oldVersion,
|
||||
'exception' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
|
||||
// 静默模式不抛出异常,非静默模式抛出异常
|
||||
if (!$silent) {
|
||||
throw $e;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用插件 Install 类的方法(兼容 CLI 模式)
|
||||
*
|
||||
* 此方法专门用于 CLI 命令直接调用插件方法
|
||||
* 与在线模式的区别:CLI 模式不需要 SSE 返回,直接执行
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
* @param string $action 方法名 (install/uninstall/update)
|
||||
* @param string|null $version 版本
|
||||
* @param string|null $oldVersion 旧版本(仅 update 时使用)
|
||||
* @return bool 是否成功
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function callPluginInstallMethod(string $code, string $action, ?string $version = null, ?string $oldVersion = null): bool
|
||||
{
|
||||
return $this->executePluginMethod($code, $action, $version, $oldVersion, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\plugin;
|
||||
|
||||
use app\service\admin\system\MenuService;
|
||||
use core\exception\handler\PluginException;
|
||||
|
||||
/**
|
||||
* 插件构建服务
|
||||
*/
|
||||
final class PluginBuildService extends PluginBaseService
|
||||
{
|
||||
private ?string $plugin;
|
||||
private ?string $pluginPath;
|
||||
|
||||
/**
|
||||
* 插件打包
|
||||
*
|
||||
* @param string $plugin 插件名称
|
||||
*
|
||||
* @return true
|
||||
* @throws PluginException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function build(string $plugin): true
|
||||
{
|
||||
$this->plugin = $plugin;
|
||||
$this->pluginPath = base_path() . 'plugin' . DIRECTORY_SEPARATOR . $plugin . DIRECTORY_SEPARATOR;
|
||||
|
||||
if (!is_dir($this->pluginPath)) {
|
||||
throw new PluginException('目录中不存在此项插件');
|
||||
}
|
||||
|
||||
// 执行各模块打包
|
||||
$this->packageAdmin();
|
||||
$this->packageUniapp();
|
||||
$this->buildUniappPagesJson();
|
||||
$this->buildUniappLangJson();
|
||||
$this->packageWeb();
|
||||
$this->packageResource();
|
||||
$this->syncMenu('admin');
|
||||
$this->syncMenu('site');
|
||||
|
||||
// 清理临时文件并准备打包
|
||||
$this->cleanupAndPreparePackage();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步菜单
|
||||
*
|
||||
* @param string $appType 应用类型
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function syncMenu(string $appType): true
|
||||
{
|
||||
|
||||
$menuService = new MenuService();
|
||||
$where = [
|
||||
['app_type', '=', $appType],
|
||||
['plugin', '=', $this->plugin]
|
||||
];
|
||||
$fields = [
|
||||
'menu_name', 'menu_key', 'menu_short_name', 'parent_select_key',
|
||||
'parent_key', 'menu_type', 'icon', 'api_url', 'router_path',
|
||||
'view_path', 'methods', 'sort', 'status', 'is_show'
|
||||
];
|
||||
|
||||
$menus = $menuService->getModel()
|
||||
->query()
|
||||
->where($where)
|
||||
->order('sort', 'desc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$menuTree = [];
|
||||
if (!empty($menus)) {
|
||||
$menuTree = $this->buildMenuTree($menus);
|
||||
$menuService->getModel()->query()->where($where)->update(['source' => 'system']);
|
||||
}
|
||||
|
||||
$menuConfigPath = $this->pluginPath . 'resource' . DIRECTORY_SEPARATOR . 'menu' . DIRECTORY_SEPARATOR . 'menu' . DIRECTORY_SEPARATOR . $appType . '.php';
|
||||
|
||||
$deleteMenuKeys = [];
|
||||
if (file_exists($menuConfigPath)) {
|
||||
$menuConfig = include $menuConfigPath;
|
||||
$deleteMenuKeys = $menuConfig['delete'] ?? [];
|
||||
}
|
||||
|
||||
$content = $this->generateMenuConfigContent($menuTree, $deleteMenuKeys);
|
||||
file_put_contents($menuConfigPath, $content);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建菜单树
|
||||
*
|
||||
* @param array $menuList 菜单列表
|
||||
* @param string $pk 主键字段
|
||||
* @param string $pid 父级ID字段
|
||||
* @param string $child 子节点字段
|
||||
* @param string $root 根节点值
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function buildMenuTree(array $menuList, string $pk = 'menu_key', string $pid = 'parent_key', string $child = 'children', string $root = ''): array
|
||||
{
|
||||
$tree = [];
|
||||
if (empty($menuList)) {
|
||||
return $tree;
|
||||
}
|
||||
|
||||
// 创建基于主键的数组引用
|
||||
$menuRefer = [];
|
||||
foreach ($menuList as $key => &$menu) {
|
||||
$menuRefer[$menu[$pk]] = &$menuList[$key];
|
||||
}
|
||||
|
||||
foreach ($menuList as $key => &$menu) {
|
||||
$parentId = $menu[$pid];
|
||||
if ($root === $parentId) {
|
||||
$tree[] = &$menuList[$key];
|
||||
} else {
|
||||
if (isset($menuRefer[$parentId])) {
|
||||
$parentMenu = &$menuRefer[$parentId];
|
||||
$parentMenu[$child][] = &$menuList[$key];
|
||||
} else {
|
||||
$tree[] = &$menuList[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成菜单配置内容
|
||||
*
|
||||
* @param array $menuTree 菜单树
|
||||
* @param array $deleteMenuKeys 要删除的菜单键
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function generateMenuConfigContent(array $menuTree, array $deleteMenuKeys): string
|
||||
{
|
||||
$content = '<?php' . PHP_EOL;
|
||||
$content .= 'return [' . PHP_EOL;
|
||||
$content .= $this->formatArrayToPhp($menuTree);
|
||||
|
||||
if (!empty($deleteMenuKeys)) {
|
||||
$deleteKeys = array_map(function ($key) {
|
||||
return "'{$key}'";
|
||||
}, $deleteMenuKeys);
|
||||
$content .= " 'delete' => [" . implode(',', $deleteKeys) . "]" . PHP_EOL;
|
||||
}
|
||||
|
||||
$content .= '];';
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数组为PHP代码字符串
|
||||
*
|
||||
* @param array $array 要格式化的数组
|
||||
* @param int $level 缩进级别
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function formatArrayToPhp(array $array, int $level = 1): string
|
||||
{
|
||||
$indent = str_repeat(' ', $level);
|
||||
$content = '';
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
// 跳过不需要的字段
|
||||
if (in_array($key, ['status_name', 'menu_type_name']) || ($level > 2 && $key == 'parent_key')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content .= $indent;
|
||||
if (is_string($key)) {
|
||||
$content .= "'{$key}' => ";
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$content .= '[' . PHP_EOL . $this->formatArrayToPhp($value, $level + 1);
|
||||
$content .= $indent . '],' . PHP_EOL;
|
||||
} else {
|
||||
$content .= "'{$value}'," . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包Admin模块
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function packageAdmin(): true
|
||||
{
|
||||
$sourcePath = $this->getFrontendProjectPath('admin') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . $this->plugin . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($sourcePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$targetPath = $this->pluginPath . 'admin' . DIRECTORY_SEPARATOR;
|
||||
$this->copyDirectoryWithCleanup($sourcePath, $targetPath);
|
||||
|
||||
// 打包admin icon文件
|
||||
$iconSourcePath = $this->getFrontendProjectPath('admin') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'styles' . DIRECTORY_SEPARATOR . 'icon' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . $this->plugin;
|
||||
$iconTargetPath = $targetPath . 'icon' . DIRECTORY_SEPARATOR;
|
||||
|
||||
if (is_dir($iconSourcePath)) {
|
||||
$this->copyDirectoryWithCleanup($iconSourcePath, $iconTargetPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包Uniapp模块
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function packageUniapp(): true
|
||||
{
|
||||
$sourcePath = $this->getFrontendProjectPath('uni-app') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . $this->plugin . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($sourcePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$targetPath = $this->pluginPath . 'uni-app' . DIRECTORY_SEPARATOR;
|
||||
$this->copyDirectoryWithCleanup($sourcePath, $targetPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建uni-app页面配置
|
||||
*
|
||||
* @return true
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function buildUniappPagesJson(): true
|
||||
{
|
||||
$pagesJsonPath = $this->getFrontendProjectPath('uni-app') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'pages.json';
|
||||
if (!file_exists($pagesJsonPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$pagesJsonContent = file_get_contents($pagesJsonPath);
|
||||
$codeBegin = strtoupper($this->plugin) . '_PAGE_BEGIN' . PHP_EOL;
|
||||
$codeEnd = strtoupper($this->plugin) . '_PAGE_END' . PHP_EOL;
|
||||
|
||||
if (!str_contains($pagesJsonContent, $codeBegin) || !str_contains($pagesJsonContent, $codeEnd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$pattern = "/\\/\\/\s+{$codeBegin}([\\S\\s]+)\\/\\/\s+{$codeEnd}?/";
|
||||
preg_match($pattern, $pagesJsonContent, $matches);
|
||||
|
||||
if (empty($matches)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$addonPages = str_replace(PHP_EOL . ',' . PHP_EOL, '', $matches[1]);
|
||||
$packagePath = $this->pluginPath . 'package';
|
||||
|
||||
if (!is_dir($packagePath)) {
|
||||
create_directory($packagePath);
|
||||
}
|
||||
|
||||
$content = '<?php' . PHP_EOL;
|
||||
$content .= 'return [' . PHP_EOL . " 'pages' => <<<EOT" . PHP_EOL . ' // PAGE_BEGIN' . PHP_EOL;
|
||||
$content .= $addonPages;
|
||||
$content .= '// PAGE_END' . PHP_EOL . 'EOT' . PHP_EOL . '];';
|
||||
|
||||
file_put_contents($packagePath . DIRECTORY_SEPARATOR . 'uni-app-pages.php', $content);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建uni-app语言文件
|
||||
*
|
||||
* @return true
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function buildUniappLangJson(): true
|
||||
{
|
||||
$zhJsonPath = $this->getFrontendProjectPath('uni-app') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'locale' . DIRECTORY_SEPARATOR . 'zh-Hans.json';
|
||||
$enJsonPath = $this->getFrontendProjectPath('uni-app') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'locale' . DIRECTORY_SEPARATOR . 'en.json';
|
||||
|
||||
if (!file_exists($zhJsonPath) || !file_exists($enJsonPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$zhJson = json_decode(file_get_contents($zhJsonPath), true);
|
||||
$enJson = json_decode(file_get_contents($enJsonPath), true);
|
||||
|
||||
$pluginZh = [];
|
||||
$pluginEn = [];
|
||||
$pluginKeyPrefix = $this->plugin . '.';
|
||||
|
||||
foreach ($zhJson as $key => $value) {
|
||||
if (str_starts_with($key, $pluginKeyPrefix)) {
|
||||
$newKey = str_replace($pluginKeyPrefix, '', $key);
|
||||
$pluginZh[$newKey] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($enJson as $key => $value) {
|
||||
if (str_starts_with($key, $pluginKeyPrefix)) {
|
||||
$newKey = str_replace($pluginKeyPrefix, '', $key);
|
||||
$pluginEn[$newKey] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$langDir = $this->pluginPath . 'uni-app' . DIRECTORY_SEPARATOR . 'locale' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($langDir)) {
|
||||
create_directory($langDir);
|
||||
}
|
||||
|
||||
$this->writeJsonFile($pluginZh, $langDir . 'zh-Hans.json');
|
||||
$this->writeJsonFile($pluginEn, $langDir . 'en.json');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包Web模块
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function packageWeb(): true
|
||||
{
|
||||
$sourcePath = $this->getFrontendProjectPath('web') . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . $this->plugin . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($sourcePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$targetPath = $this->pluginPath . 'web' . DIRECTORY_SEPARATOR;
|
||||
$this->copyDirectoryWithCleanup($sourcePath, $targetPath);
|
||||
|
||||
// 处理布局文件
|
||||
$layoutSourcePath = $this->getFrontendProjectPath('web') . DIRECTORY_SEPARATOR . 'layouts' . DIRECTORY_SEPARATOR . $this->plugin;
|
||||
if (is_dir($layoutSourcePath)) {
|
||||
$layoutTargetPath = $targetPath . 'layouts' . DIRECTORY_SEPARATOR . $this->plugin;
|
||||
$this->copyDirectoryWithCleanup($layoutSourcePath, $layoutTargetPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包资源文件
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function packageResource(): true
|
||||
{
|
||||
$sourcePath = public_path() . 'plugin' . DIRECTORY_SEPARATOR . $this->plugin . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($sourcePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$targetPath = $this->pluginPath . 'resource' . DIRECTORY_SEPARATOR;
|
||||
$this->copyDirectoryWithCleanup($sourcePath, $targetPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理临时文件并准备打包
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function cleanupAndPreparePackage(): void
|
||||
{
|
||||
$runtimePath = runtime_path() . $this->plugin . DIRECTORY_SEPARATOR;
|
||||
$pluginRuntimePath = $runtimePath . $this->plugin;
|
||||
|
||||
// 先拷贝
|
||||
copy_directory($this->pluginPath, $pluginRuntimePath);
|
||||
|
||||
// 清理和准备ZIP文件
|
||||
$zipFile = runtime_path() . $this->plugin . '.zip';
|
||||
if (file_exists($zipFile)) {
|
||||
unlink($zipFile);
|
||||
}
|
||||
|
||||
// 清理临时目录
|
||||
remove_directory($runtimePath, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制目录并清理目标目录
|
||||
*
|
||||
* @param string $sourcePath 源目录路径
|
||||
* @param string $targetPath 目标目录路径
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function copyDirectoryWithCleanup(string $sourcePath, string $targetPath): void
|
||||
{
|
||||
if (is_dir($targetPath)) {
|
||||
remove_directory($targetPath, true);
|
||||
}
|
||||
copy_directory($sourcePath, $targetPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\plugin;
|
||||
|
||||
use app\enum\plugin\FrontendType;
|
||||
|
||||
/**
|
||||
* Plugin开发服务层
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class PluginDevelopService extends PluginBaseService
|
||||
{
|
||||
/**
|
||||
* 模板文件路径映射
|
||||
*/
|
||||
private const STUB_PATHS = [
|
||||
'api' => '/stubs/admin/api/index.stub',
|
||||
'view' => '/stubs/admin/views/index.stub',
|
||||
'routes' => '/stubs/admin/routes/index.stub',
|
||||
'config' => '/stubs/server/config.stub',
|
||||
'translation' => '/stubs/server/translation.stub',
|
||||
'controller' => '/stubs/server/controller.stub',
|
||||
'install' => '/stubs/server/install.stub',
|
||||
'config_info' => '/stubs/server/config-info.stub',
|
||||
'route' => '/stubs/server/route.stub',
|
||||
'menu_admin' => '/stubs/server/menu-admin.stub',
|
||||
'menu_frontend' => '/stubs/server/menu-frontend.stub',
|
||||
'menu_web' => '/stubs/server/menu-web.stub',
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成插件模板
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
* @param string $pluginDescription 插件描述
|
||||
* @param string $frontendType 前端类型,默认为 admin
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function generatePluginTemplate(string $pluginName, string $pluginTitle, string $pluginDescription = '', string $frontendType = 'admin'): array
|
||||
{
|
||||
try {
|
||||
// 生成前端目录结构
|
||||
$this->generateFrontendStructure($pluginName, $frontendType);
|
||||
|
||||
// 生成后端目录结构
|
||||
$this->generateBackendStructure($pluginName);
|
||||
|
||||
// 生成前端文件
|
||||
$this->generateFrontendFiles($pluginName, $pluginTitle, $pluginDescription, $frontendType);
|
||||
|
||||
// 生成后端文件
|
||||
$this->generateBackendFiles($pluginName, $pluginTitle, $pluginDescription);
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'message' => '插件模板生成成功',
|
||||
'data' => [
|
||||
'frontend_path' => $this->getPluginFrontendPath($pluginName, $frontendType),
|
||||
'backend_path' => $this->getBackendPath($pluginName),
|
||||
],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'code' => 500,
|
||||
'message' => '插件模板生成失败: ' . $e->getMessage(),
|
||||
'data' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前端目录结构
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $frontendType 前端类型
|
||||
*/
|
||||
private function generateFrontendStructure(string $pluginName, string $frontendType): void
|
||||
{
|
||||
$frontendPath = $this->getPluginFrontendPath($pluginName, $frontendType);
|
||||
$pluginKey = $this->toKebabCase($pluginName);
|
||||
|
||||
// 创建前端目录结构(参考 official 模块)
|
||||
$directories = [
|
||||
$frontendPath . '/api',
|
||||
$frontendPath . '/lang/zh-cn',
|
||||
$frontendPath . '/lang/en',
|
||||
$frontendPath . '/views/' . $pluginKey . '/index',
|
||||
$frontendPath . '/components',
|
||||
$frontendPath . '/routes',
|
||||
];
|
||||
|
||||
$this->createDirectories($directories);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成后端目录结构
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
*/
|
||||
private function generateBackendStructure(string $pluginName): void
|
||||
{
|
||||
$backendPath = $this->getBackendPath($pluginName);
|
||||
|
||||
// 创建后端目录结构(参考 official 模块)
|
||||
$directories = [
|
||||
$backendPath . '/config',
|
||||
$backendPath . '/app/adminapi/controller',
|
||||
$backendPath . '/app/api/controller',
|
||||
$backendPath . '/app/service/admin',
|
||||
$backendPath . '/app/service/api',
|
||||
$backendPath . '/app/validate',
|
||||
$backendPath . '/app/schema/request',
|
||||
$backendPath . '/app/schema/response',
|
||||
$backendPath . '/app/dao',
|
||||
$backendPath . '/app/model',
|
||||
$backendPath . '/public',
|
||||
// Resource 目录
|
||||
$backendPath . '/resource/database/migrations',
|
||||
$backendPath . '/resource/database/seeds',
|
||||
$backendPath . '/resource/menu',
|
||||
$backendPath . '/resource/template/admin',
|
||||
$backendPath . '/resource/template/web',
|
||||
];
|
||||
|
||||
$this->createDirectories($directories);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建目录
|
||||
*
|
||||
* @param array $directories 目录路径数组
|
||||
*/
|
||||
private function createDirectories(array $directories): void
|
||||
{
|
||||
foreach ($directories as $directory) {
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前端文件
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
* @param string $pluginDescription 插件描述
|
||||
* @param string $frontendType 前端类型
|
||||
*/
|
||||
private function generateFrontendFiles(string $pluginName, string $pluginTitle, string $pluginDescription, string $frontendType): void
|
||||
{
|
||||
$frontendPath = $this->getPluginFrontendPath($pluginName, $frontendType);
|
||||
$camelPluginName = $this->toCamelCase($pluginName);
|
||||
$pluginKey = $this->toKebabCase($pluginName);
|
||||
|
||||
// 生成 API 文件
|
||||
$this->generateApiFile($frontendPath, $pluginName, $pluginKey);
|
||||
|
||||
// 生成语言文件(中英文)
|
||||
$this->generateLangFiles($frontendPath, $pluginName, $pluginTitle, $pluginDescription);
|
||||
|
||||
// 生成页面文件(index.vue)
|
||||
$this->generatePageFile($frontendPath, $pluginName, $camelPluginName, $pluginKey);
|
||||
|
||||
// 生成 routes/index.ts 文件
|
||||
$this->generateRoutesIndexFile($frontendPath, $pluginName, $camelPluginName, $pluginTitle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成后端文件
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
* @param string $pluginDescription 插件描述
|
||||
*/
|
||||
private function generateBackendFiles(string $pluginName, string $pluginTitle, string $pluginDescription): void
|
||||
{
|
||||
$backendPath = $this->getBackendPath($pluginName);
|
||||
$camelPluginName = $this->toCamelCase($pluginName);
|
||||
$pluginKey = $this->toKebabCase($pluginName);
|
||||
|
||||
// 生成配置文件
|
||||
$this->generateConfigFiles($backendPath, $pluginName, $pluginTitle, $pluginDescription);
|
||||
|
||||
// 生成 Install.php 文件
|
||||
$this->generateInstallFile($backendPath, $pluginName, $camelPluginName);
|
||||
|
||||
// 生成路由文件(Swagger 注解方式)- 在配置文件生成之后
|
||||
$this->generateRouteFile($backendPath, $pluginName, $camelPluginName, $pluginTitle);
|
||||
|
||||
// 生成控制器文件
|
||||
$this->generateControllerFile($backendPath, $pluginName, $camelPluginName, $pluginKey);
|
||||
|
||||
// 生成菜单配置文件
|
||||
$this->generateMenuFiles($backendPath, $pluginName, $pluginTitle);
|
||||
|
||||
// 生成前端模板到 resource/template/admin 目录
|
||||
$this->generateFrontendTemplates($backendPath, $pluginName, $camelPluginName, $pluginKey, $pluginTitle);
|
||||
|
||||
// 初始化公共资源文件
|
||||
$this->initializePublicAssets($backendPath);
|
||||
|
||||
// 为其他目录创建 .gitkeep 文件
|
||||
$this->createGitKeepFiles($backendPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化公共资源文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
*/
|
||||
private function initializePublicAssets(string $backendPath): void
|
||||
{
|
||||
$publicPath = $backendPath . '/public';
|
||||
|
||||
// 创建 public 目录(如果不存在)
|
||||
if (!is_dir($publicPath)) {
|
||||
mkdir($publicPath, 0755, true);
|
||||
}
|
||||
|
||||
// 创建默认的 icon.png 文件(简单透明PNG)
|
||||
$iconPath = $publicPath . '/icon.png';
|
||||
if (!file_exists($iconPath)) {
|
||||
$transparentPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH5AgHFA0yMklDQgAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAvElEQVRYw+2W0QqDMBBEbwQv/r/vW7HgVSh4EcSi3mw2SQm0ONjZzQfOwsLMZDLZZjAYuNfr1f6ZJCImIpqmeX+1WnUDuL8AACYFQRCgqqo+AFRV1d+dw+FoAQAiQlEUPQB4PB5YLpcAgMViAcMwIIoiHo/HXwGA4jgcDiiKYgiAuq6RZRmyLOsBoGkaEZHf9/0AAJqmQRRFaNsWvu/Dtm0wxkBKS0sp6bquCyJaEkXRLwCqqkIURei6DkVRQCl1CYBhGFBVFYqiAKX0JgBSSnRdh6IoEEURGGPKA4BlWZBSou/7SwCklEiSBEmSgDHmNgBSSnRdh6IoYIzpAWCMQRQJaa27AUAIIYQQQgghhBBCCCHf8wL0taA/yDgfmQAAAABJRU5ErkJggg==');
|
||||
file_put_contents($iconPath, $transparentPng);
|
||||
}
|
||||
|
||||
// 创建默认的 cover.png 文件(简单蓝色PNG)
|
||||
$coverPath = $publicPath . '/cover.png';
|
||||
if (!file_exists($coverPath)) {
|
||||
$bluePng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH5AgHFA4ZEwgJOwAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAyUlEQVRYw+2WsQrCQBCGP1+hFhYWaS0sLCz8gIWFhYWFrQVBsLQQFEQLCwuxsBAEwUISBEELC0EQBMHvJGcnF8gFNzezm82+ZTYzM8nswvx+xJjFGDPHcYwxxlLK+LZKKSGEGMcx5pzjnBs3ACmlEEKMc05rbTcAUsrY7/cwxmCMQWttFwCtNVhr0VrDGNMNgNYarTWMMTDGYIyxCwBrLUKIUErBGINSCq01WmtQSsE5hzEGay2cc2OMMcYY/xZAKQXn3BiGAADGGDjnMMaglILWGkopOOcwxsAYgzEGYwycc3DOjf8CQAhhrLVwzuGcQ2uN1hqcc3DOYYxBKYXWWjjn4JyDc278VgDGGLTWcM6NzjmstXDODc45OOcwxiCEgNYarTU45+Ccg3Nu/BYAay2cc3DOIYSAtRbOOYwxCCGgtYYxBmMMnHMwxmCthXMOzjk45+Ccg/M/AKy1cM7BOYcQAloLnHNwzsE5ByEEtNZgjMFaC2MMnHMwxmCtgXMOzjk45+Ccg/M/AKy1cM7BOYcQAloLnHNwzsE5ByEEtNZgjMFaC+ccnHNwzsE5Nzjn4JyDcw7OuTHG+DMAAP//AwDclW/p/qcTdAAAAABJRU5ErkJggg==');
|
||||
file_put_contents($coverPath, $bluePng);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 API 文件
|
||||
*
|
||||
* @param string $frontendPath 前端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginKey 插件键名
|
||||
*/
|
||||
private function generateApiFile(string $frontendPath, string $pluginName, string $pluginKey): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['api'];
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
]);
|
||||
file_put_contents($frontendPath . '/api/index.ts', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成语言文件(中英文)
|
||||
*
|
||||
* @param string $frontendPath 前端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
* @param string $pluginDescription 插件描述
|
||||
*/
|
||||
private function generateLangFiles(string $frontendPath, string $pluginName, string $pluginTitle, string $pluginDescription): void
|
||||
{
|
||||
// 中文语言包
|
||||
$zhContent = json_encode([
|
||||
$pluginName => [
|
||||
'title' => $pluginTitle,
|
||||
'description' => $pluginDescription,
|
||||
'card_title' => $pluginTitle . '数据',
|
||||
'refresh_btn' => '刷新',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
file_put_contents($frontendPath . '/lang/zh-cn/zh.json', $zhContent . PHP_EOL);
|
||||
|
||||
// 英文语言包
|
||||
$enContent = json_encode([
|
||||
$pluginName => [
|
||||
'title' => $pluginTitle,
|
||||
'description' => $pluginDescription,
|
||||
'card_title' => $pluginTitle . ' Data',
|
||||
'refresh_btn' => 'Refresh',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
file_put_contents($frontendPath . '/lang/en/en.json', $enContent . PHP_EOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成页面文件
|
||||
*
|
||||
* @param string $frontendPath 前端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
* @param string $pluginKey 插件键名
|
||||
*/
|
||||
private function generatePageFile(string $frontendPath, string $pluginName, string $camelPluginName, string $pluginKey): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['view'];
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
]);
|
||||
file_put_contents($frontendPath . '/views/' . $pluginKey . '/index/index.vue', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成路由文件
|
||||
*
|
||||
* @param string $frontendPath 前端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
*/
|
||||
private function generateRoutesIndexFile(string $frontendPath, string $pluginName, string $camelPluginName, string $pluginTitle): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['routes'];
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $this->toKebabCase($pluginName),
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($frontendPath . '/routes/index.ts', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成配置文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
* @param string $pluginDescription 插件描述
|
||||
*/
|
||||
private function generateConfigFiles(string $backendPath, string $pluginName, string $pluginTitle, string $pluginDescription): void
|
||||
{
|
||||
// app.php 配置
|
||||
$appStubPath = __DIR__ . self::STUB_PATHS['config'];
|
||||
$appContent = $this->renderStub($appStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
'{{pluginDescription}}' => $pluginDescription,
|
||||
]);
|
||||
file_put_contents($backendPath . '/config/app.php', $appContent);
|
||||
|
||||
// translation.php 配置
|
||||
$translationStubPath = __DIR__ . self::STUB_PATHS['translation'];
|
||||
$translationContent = $this->renderStub($translationStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
]);
|
||||
file_put_contents($backendPath . '/config/translation.php', $translationContent);
|
||||
|
||||
// info.php 配置
|
||||
$infoStubPath = __DIR__ . self::STUB_PATHS['config_info'];
|
||||
$infoContent = $this->renderStub($infoStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
'{{pluginDescription}}' => $pluginDescription,
|
||||
]);
|
||||
file_put_contents($backendPath . '/config/info.php', $infoContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Install.php 文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
*/
|
||||
private function generateInstallFile(string $backendPath, string $pluginName, string $camelPluginName): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['install'];
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
]);
|
||||
file_put_contents($backendPath . '/Install.php', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成路由文件(Swagger 注解方式)
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
*/
|
||||
private function generateRouteFile(string $backendPath, string $pluginName, string $camelPluginName, string $pluginTitle): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['route'];
|
||||
$pluginKey = $this->toKebabCase($pluginName);
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($backendPath . '/config/route.php', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成控制器文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
* @param string $pluginKey 插件键名
|
||||
*/
|
||||
private function generateControllerFile(string $backendPath, string $pluginName, string $camelPluginName, string $pluginKey): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['controller'];
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
]);
|
||||
file_put_contents($backendPath . '/app/adminapi/controller/' . $camelPluginName . 'Controller.php', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为目录创建 .gitkeep 文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
*/
|
||||
private function createGitKeepFiles(string $backendPath): void
|
||||
{
|
||||
$directories = [
|
||||
'/app/schema/request',
|
||||
'/app/schema/response',
|
||||
'/app/dao',
|
||||
'/app/model',
|
||||
'/app/service/admin',
|
||||
'/app/service/api',
|
||||
'/app/validate',
|
||||
'/app/api/controller',
|
||||
'/resource/database/migrations',
|
||||
'/resource/database/seeds',
|
||||
'/resource/template/admin',
|
||||
'/resource/template/web',
|
||||
];
|
||||
|
||||
foreach ($directories as $path) {
|
||||
$directory = $backendPath . $path;
|
||||
if (is_dir($directory)) {
|
||||
file_put_contents($directory . '/.gitkeep', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成菜单配置文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
*/
|
||||
private function generateMenuFiles(string $backendPath, string $pluginName, string $pluginTitle): void
|
||||
{
|
||||
$pluginKey = $this->toKebabCase($pluginName);
|
||||
|
||||
// 生成 admin 菜单
|
||||
$adminStubPath = __DIR__ . self::STUB_PATHS['menu_admin'];
|
||||
$adminContent = $this->renderStub($adminStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($backendPath . '/resource/menu/admin.php', $adminContent);
|
||||
|
||||
// 生成 frontend 菜单
|
||||
$frontendStubPath = __DIR__ . self::STUB_PATHS['menu_frontend'];
|
||||
$frontendContent = $this->renderStub($frontendStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($backendPath . '/resource/menu/frontend.php', $frontendContent);
|
||||
|
||||
// 生成 web 菜单
|
||||
$webStubPath = __DIR__ . self::STUB_PATHS['menu_web'];
|
||||
$webContent = $this->renderStub($webStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($backendPath . '/resource/menu/web.php', $webContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前端模板到 resource/template/admin 目录
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
* @param string $pluginKey 插件键名
|
||||
* @param string $pluginTitle 插件标题
|
||||
*/
|
||||
private function generateFrontendTemplates(string $backendPath, string $pluginName, string $camelPluginName, string $pluginKey, string $pluginTitle): void
|
||||
{
|
||||
$templatePath = $backendPath . '/resource/template/admin';
|
||||
|
||||
// 创建模板目录结构
|
||||
$templateDirectories = [
|
||||
$templatePath . '/api',
|
||||
$templatePath . '/lang/zh-cn',
|
||||
$templatePath . '/lang/en',
|
||||
$templatePath . '/views/' . $pluginKey . '/index',
|
||||
$templatePath . '/components',
|
||||
$templatePath . '/routes',
|
||||
];
|
||||
|
||||
foreach ($templateDirectories as $directory) {
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 API 文件
|
||||
$apiStubPath = __DIR__ . self::STUB_PATHS['api'];
|
||||
$apiContent = $this->renderStub($apiStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
]);
|
||||
file_put_contents($templatePath . '/api/index.ts', $apiContent);
|
||||
|
||||
// 生成语言文件
|
||||
$zhContent = json_encode([
|
||||
$pluginName => [
|
||||
'title' => $pluginTitle,
|
||||
'description' => '插件示例页面',
|
||||
'card_title' => $pluginTitle . '数据',
|
||||
'refresh_btn' => '刷新',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
file_put_contents($templatePath . '/lang/zh-cn/zh.json', $zhContent . PHP_EOL);
|
||||
|
||||
$enContent = json_encode([
|
||||
$pluginName => [
|
||||
'title' => $pluginTitle,
|
||||
'description' => 'Plugin demo page',
|
||||
'card_title' => $pluginTitle . ' Data',
|
||||
'refresh_btn' => 'Refresh',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
file_put_contents($templatePath . '/lang/en/en.json', $enContent . PHP_EOL);
|
||||
|
||||
// 生成页面文件
|
||||
$viewStubPath = __DIR__ . self::STUB_PATHS['view'];
|
||||
$viewContent = $this->renderStub($viewStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
]);
|
||||
file_put_contents($templatePath . '/views/' . $pluginKey . '/index/index.vue', $viewContent);
|
||||
|
||||
// 生成路由文件
|
||||
$routesStubPath = __DIR__ . self::STUB_PATHS['routes'];
|
||||
$routesContent = $this->renderStub($routesStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($templatePath . '/routes/index.ts', $routesContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模板文件
|
||||
*
|
||||
* @param string $stubPath 模板文件路径
|
||||
* @param array $replacements 替换内容数组
|
||||
*
|
||||
* @return string 渲染后的内容
|
||||
*/
|
||||
private function renderStub(string $stubPath, array $replacements): string
|
||||
{
|
||||
if (!file_exists($stubPath)) {
|
||||
throw new \RuntimeException("模板文件不存在: {$stubPath}");
|
||||
}
|
||||
|
||||
$content = file_get_contents($stubPath);
|
||||
return str_replace(array_keys($replacements), array_values($replacements), $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为驼峰命名
|
||||
*
|
||||
* @param string $string 原始字符串
|
||||
*
|
||||
* @return string 驼峰命名的字符串
|
||||
*/
|
||||
private function toCamelCase(string $string): string
|
||||
{
|
||||
return str_replace(' ', '', ucwords(str_replace('_', ' ', $string)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为短横线命名
|
||||
*
|
||||
* @param string $string 原始字符串
|
||||
*
|
||||
* @return string 短横线命名的字符串
|
||||
*/
|
||||
private function toKebabCase(string $string): string
|
||||
{
|
||||
return strtolower(str_replace('_', '-', $string));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件前端路径
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $frontendType 前端类型,默认为 admin
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getPluginFrontendPath(string $pluginName, string $frontendType = 'admin'): string
|
||||
{
|
||||
// 使用 base_path() 获取项目根目录
|
||||
$basePath = base_path();
|
||||
|
||||
// 处理已知类型
|
||||
if (FrontendType::isKnownType($frontendType)) {
|
||||
$typeEnum = FrontendType::from($frontendType);
|
||||
return dirname($basePath) . '/' . sprintf($typeEnum->pathTemplate(), $pluginName);
|
||||
}
|
||||
// 处理未知类型(使用默认格式)
|
||||
return dirname($basePath) . "/{$frontendType}/src/apps/{$pluginName}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建插件
|
||||
*
|
||||
* @param string $pluginKey 插件键名(kebab-case 格式,如 test-demo)
|
||||
* @param string|null $targetDirectory 目标目录路径(可选,默认为 runtime/plugins)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function build(string $pluginKey, ?string $targetDirectory = null): array
|
||||
{
|
||||
try {
|
||||
// 将 kebab-case 转换为 snake_case 作为插件名称
|
||||
$pluginName = str_replace('-', '_', $pluginKey);
|
||||
|
||||
// 定义路径
|
||||
$sourceBackendPath = $this->getBackendPath($pluginName);
|
||||
// 尝试不同的前端类型路径
|
||||
$frontendTypes = ['admin', 'web'];
|
||||
$foundFrontendPath = null;
|
||||
$foundFrontendType = null;
|
||||
|
||||
foreach ($frontendTypes as $type) {
|
||||
$path = $this->getPluginFrontendPath($pluginName, $type);
|
||||
if (is_dir($path)) {
|
||||
$foundFrontendPath = $path;
|
||||
$foundFrontendType = $type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用临时构建目录,不删除插件本身
|
||||
$buildPath = runtime_path(self::getPluginBuildPath() . '/' . $pluginKey);
|
||||
|
||||
// 确定目标目录
|
||||
if ($targetDirectory === null) {
|
||||
$targetDirectory = runtime_path(self::getPluginPackagesPath());
|
||||
}
|
||||
|
||||
// 确保目标目录存在
|
||||
if (!is_dir($targetDirectory)) {
|
||||
mkdir($targetDirectory, 0755, true);
|
||||
}
|
||||
|
||||
$zipFilePath = $targetDirectory . '/' . $pluginKey . '.zip';
|
||||
|
||||
// 验证源目录是否存在
|
||||
if (!is_dir($sourceBackendPath)) {
|
||||
throw new \RuntimeException("插件源目录不存在: {$sourceBackendPath}");
|
||||
}
|
||||
|
||||
// 创建构建目录结构
|
||||
$this->createBuildDirectories($buildPath);
|
||||
|
||||
// 复制后端资源
|
||||
$this->copyDirectory($sourceBackendPath . '/app', $buildPath . '/app');
|
||||
$this->copyDirectory($sourceBackendPath . '/config', $buildPath . '/config');
|
||||
// 复制其他必要文件(如 Install.php, public, resource 等)
|
||||
$this->copyFileIfExists($sourceBackendPath . '/Install.php', $buildPath . '/Install.php');
|
||||
if (is_dir($sourceBackendPath . '/public')) {
|
||||
$this->copyDirectory($sourceBackendPath . '/public', $buildPath . '/public');
|
||||
}
|
||||
if (is_dir($sourceBackendPath . '/resource')) {
|
||||
$this->copyDirectory($sourceBackendPath . '/resource', $buildPath . '/resource');
|
||||
}
|
||||
|
||||
// 复制前端资源到 resource/template 目录
|
||||
if ($foundFrontendPath && $foundFrontendType) {
|
||||
$templateTargetDir = $buildPath . '/resource/template/' . $foundFrontendType;
|
||||
if (is_dir($templateTargetDir)) {
|
||||
$this->copyDirectory($foundFrontendPath, $templateTargetDir);
|
||||
}
|
||||
}
|
||||
|
||||
// 压缩构建结果为 zip 文件
|
||||
$this->createZipArchive($buildPath, $zipFilePath);
|
||||
|
||||
// 清理构建目录
|
||||
$this->deleteDirectory($buildPath);
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'message' => '插件构建成功',
|
||||
'data' => [
|
||||
'build_path' => $buildPath,
|
||||
'target_directory' => $targetDirectory,
|
||||
'zip_file_path' => $zipFilePath,
|
||||
'plugin_key' => $pluginKey,
|
||||
],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'code' => 500,
|
||||
'message' => '插件构建失败: ' . $e->getMessage(),
|
||||
'data' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制目录
|
||||
*
|
||||
* @param string $source 源目录
|
||||
* @param string $target 目标目录
|
||||
*/
|
||||
private function copyDirectory(string $source, string $target): void
|
||||
{
|
||||
if (!is_dir($source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir($target)) {
|
||||
mkdir($target, 0755, true);
|
||||
}
|
||||
|
||||
$files = scandir($source);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourceFile = $source . '/' . $file;
|
||||
$targetFile = $target . '/' . $file;
|
||||
|
||||
if (is_dir($sourceFile)) {
|
||||
$this->copyDirectory($sourceFile, $targetFile);
|
||||
} else {
|
||||
copy($sourceFile, $targetFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文件(如果存在)
|
||||
*
|
||||
* @param string $sourceFile 源文件路径
|
||||
* @param string $targetFile 目标文件路径
|
||||
*/
|
||||
private function copyFileIfExists(string $sourceFile, string $targetFile): void
|
||||
{
|
||||
if (file_exists($sourceFile)) {
|
||||
// 确保目标目录存在
|
||||
$targetDir = dirname($targetFile);
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0755, true);
|
||||
}
|
||||
copy($sourceFile, $targetFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目录
|
||||
*
|
||||
* @param string $directory 目录路径
|
||||
*/
|
||||
private function deleteDirectory(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = scandir($directory);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $directory . '/' . $file;
|
||||
if (is_dir($path)) {
|
||||
$this->deleteDirectory($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 ZIP 压缩文件
|
||||
*
|
||||
* @param string $sourcePath 源目录路径
|
||||
* @param string $zipFilePath ZIP 文件路径
|
||||
*/
|
||||
private function createZipArchive(string $sourcePath, string $zipFilePath): void
|
||||
{
|
||||
// 如果 ZIP 文件已存在,先删除
|
||||
if (file_exists($zipFilePath)) {
|
||||
unlink($zipFilePath);
|
||||
}
|
||||
|
||||
// 创建 ZIP 归档对象
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipFilePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException("无法创建 ZIP 文件: {$zipFilePath}");
|
||||
}
|
||||
|
||||
// 获取源目录的绝对路径
|
||||
$sourcePath = realpath($sourcePath);
|
||||
if ($sourcePath === false) {
|
||||
throw new \RuntimeException("无法获取源目录的绝对路径: {$sourcePath}");
|
||||
}
|
||||
|
||||
// 遍历源目录并添加文件到 ZIP
|
||||
$files = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($sourcePath),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
// 跳过目录
|
||||
if (!$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取文件的绝对路径
|
||||
$filePath = $file->getRealPath();
|
||||
if ($filePath === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算 ZIP 中的相对路径
|
||||
$relativePath = substr($filePath, strlen($sourcePath) + 1);
|
||||
|
||||
// 添加文件到 ZIP
|
||||
if (!$zip->addFile($filePath, $relativePath)) {
|
||||
throw new \RuntimeException("无法添加文件到 ZIP: {$filePath}");
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭 ZIP 归档
|
||||
if (!$zip->close()) {
|
||||
throw new \RuntimeException("无法关闭 ZIP 文件: {$zipFilePath}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取后端路径
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getBackendPath(string $pluginName): string
|
||||
{
|
||||
return base_path() . '/plugin/' . $pluginName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建构建目录结构
|
||||
*
|
||||
* @param string $buildPath 构建路径
|
||||
*/
|
||||
private function createBuildDirectories(string $buildPath): void
|
||||
{
|
||||
$directories = [
|
||||
$buildPath . '/app/controller',
|
||||
$buildPath . '/app/service/admin',
|
||||
$buildPath . '/app/service/api',
|
||||
$buildPath . '/app/validate',
|
||||
$buildPath . '/app/dao',
|
||||
$buildPath . '/app/model',
|
||||
$buildPath . '/app/schema/request',
|
||||
$buildPath . '/app/schema/response',
|
||||
$buildPath . '/config',
|
||||
$buildPath . '/resource/template/admin',
|
||||
$buildPath . '/resource/template/web',
|
||||
];
|
||||
|
||||
$this->createDirectories($directories);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\plugin;
|
||||
|
||||
use core\exception\handler\PluginException;
|
||||
use core\tool\Util;
|
||||
use ZipArchive;
|
||||
use GuzzleHttp\Client;
|
||||
|
||||
/**
|
||||
* 插件下载服务
|
||||
* 职责:下载插件ZIP包到插件目录并解压
|
||||
*/
|
||||
final class PluginDownloadService extends PluginBaseService
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载插件到插件目录并解压
|
||||
*
|
||||
* @param string $pluginCode 插件编码(name)
|
||||
* @param string $downloadUrl 下载链接
|
||||
*
|
||||
* @return array 返回插件信息
|
||||
* @throws PluginException
|
||||
*/
|
||||
public function downloadAndExtract(string $pluginCode, string $downloadUrl): array
|
||||
{
|
||||
// 1. 准备下载路径
|
||||
$pluginDir = $this->plugin_path . DIRECTORY_SEPARATOR . $pluginCode;
|
||||
$zipFile = $this->plugin_path . DIRECTORY_SEPARATOR . $pluginCode . '.zip';
|
||||
|
||||
// 2. 创建插件目录(如果不存在)
|
||||
if (!is_dir($this->plugin_path) && !mkdir($this->plugin_path, 0755, true)) {
|
||||
throw new PluginException("无法创建插件目录: {$this->plugin_path}");
|
||||
}
|
||||
|
||||
// 如果插件目录已存在,先删除
|
||||
if (is_dir($pluginDir)) {
|
||||
$this->deleteDirectory($pluginDir);
|
||||
}
|
||||
|
||||
// 3. 下载ZIP文件
|
||||
$this->downloadZipFile($downloadUrl, $zipFile);
|
||||
|
||||
// 4. 解压ZIP到插件目录
|
||||
$this->extractZipFile($zipFile, $this->plugin_path);
|
||||
|
||||
// 5. 读取插件配置信息
|
||||
$pluginInfo = $this->readPluginInfo($pluginCode);
|
||||
|
||||
return [
|
||||
'plugin_code' => $pluginCode,
|
||||
'plugin_dir' => $pluginDir,
|
||||
'plugin_info' => $pluginInfo,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归删除目录
|
||||
*
|
||||
* @param string $dir 目录路径
|
||||
* @return void
|
||||
*/
|
||||
private function deleteDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $file;
|
||||
if (is_dir($path)) {
|
||||
$this->deleteDirectory($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动目录内容
|
||||
*
|
||||
* @param string $source 源目录
|
||||
* @param string $target 目标目录
|
||||
* @return void
|
||||
*/
|
||||
private function moveDirectoryContents(string $source, string $target): void
|
||||
{
|
||||
// 确保目标目录存在
|
||||
if (!is_dir($target)) {
|
||||
mkdir($target, 0755, true);
|
||||
}
|
||||
|
||||
$files = array_diff(scandir($source), ['.', '..']);
|
||||
foreach ($files as $file) {
|
||||
$sourcePath = $source . DIRECTORY_SEPARATOR . $file;
|
||||
$targetPath = $target . DIRECTORY_SEPARATOR . $file;
|
||||
|
||||
if (is_dir($sourcePath)) {
|
||||
// 递归移动子目录
|
||||
$this->moveDirectoryContents($sourcePath, $targetPath);
|
||||
} else {
|
||||
// 移动文件
|
||||
rename($sourcePath, $targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载ZIP文件
|
||||
*
|
||||
* @param string $url 下载URL
|
||||
* @param string $file 保存路径
|
||||
*
|
||||
* @return void
|
||||
* @throws PluginException
|
||||
*/
|
||||
private function downloadZipFile(string $url, string $file): void
|
||||
{
|
||||
$client = new Client([
|
||||
'timeout' => 300, // 下载大文件设置更长的超时时间
|
||||
'verify' => false,
|
||||
]);
|
||||
|
||||
$response = $client->get($url, ['sink' => $file]);
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
if ($status !== 200) {
|
||||
throw new PluginException("下载失败,HTTP状态码: {$status}");
|
||||
}
|
||||
|
||||
// 检查文件是否下载成功
|
||||
if (!file_exists($file) || filesize($file) === 0) {
|
||||
throw new PluginException("下载的文件为空或不存在: {$file}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压ZIP文件
|
||||
*
|
||||
* @param string $zipFile ZIP文件路径
|
||||
* @param string $extractTo 解压目标路径(插件目录)
|
||||
*
|
||||
* @return void
|
||||
* @throws PluginException
|
||||
*/
|
||||
private function extractZipFile(string $zipFile, string $extractTo): void
|
||||
{
|
||||
$hasZipArchive = class_exists(ZipArchive::class, false);
|
||||
|
||||
if (!$hasZipArchive) {
|
||||
$cmd = $this->getUnzipCmd($zipFile, $extractTo);
|
||||
if (!$cmd) {
|
||||
throw new PluginException('请给php安装zip模块或者给系统安装unzip命令');
|
||||
}
|
||||
if (!function_exists('proc_open')) {
|
||||
throw new PluginException('请解除proc_open函数的禁用或者给php安装zip模块');
|
||||
}
|
||||
}
|
||||
|
||||
Util::pauseFileMonitor();
|
||||
try {
|
||||
// 创建临时解压目录
|
||||
$tempDir = $extractTo . '_temp';
|
||||
if (is_dir($tempDir)) {
|
||||
$this->deleteDirectory($tempDir);
|
||||
}
|
||||
mkdir($tempDir, 0755, true);
|
||||
|
||||
// 解压zip到临时目录
|
||||
if ($hasZipArchive) {
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFile) === true) {
|
||||
if (!$zip->extractTo($tempDir)) {
|
||||
throw new PluginException("解压失败: {$zipFile}");
|
||||
}
|
||||
$zip->close();
|
||||
} else {
|
||||
throw new PluginException("无法打开压缩包: {$zipFile}");
|
||||
}
|
||||
} else {
|
||||
$this->unzipWithCmd($cmd);
|
||||
}
|
||||
|
||||
// 处理解压后的目录结构
|
||||
$extractedFiles = scandir($tempDir);
|
||||
$extractedFiles = array_diff($extractedFiles, ['.', '..']);
|
||||
|
||||
// 如果只有一个目录,说明ZIP包含了一个根目录(如 nexus/),需要移动内容
|
||||
if (count($extractedFiles) === 1) {
|
||||
$onlyDir = $tempDir . DIRECTORY_SEPARATOR . $extractedFiles[0];
|
||||
if (is_dir($onlyDir)) {
|
||||
// 移动目录内容到目标位置
|
||||
$this->moveDirectoryContents($onlyDir, $extractTo);
|
||||
$this->deleteDirectory($onlyDir);
|
||||
} else {
|
||||
// 是单个文件,直接移动
|
||||
rename($onlyDir, $extractTo . DIRECTORY_SEPARATOR . $extractedFiles[0]);
|
||||
}
|
||||
} else {
|
||||
// 多个文件,直接移动所有内容
|
||||
foreach ($extractedFiles as $file) {
|
||||
rename($tempDir . DIRECTORY_SEPARATOR . $file, $extractTo . DIRECTORY_SEPARATOR . $file);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除临时目录
|
||||
$this->deleteDirectory($tempDir);
|
||||
|
||||
// 删除临时zip文件
|
||||
if (file_exists($zipFile)) {
|
||||
unlink($zipFile);
|
||||
}
|
||||
} finally {
|
||||
Util::resumeFileMonitor();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取插件配置信息
|
||||
*
|
||||
* @param string $pluginCode 插件编码
|
||||
*
|
||||
* @return array 插件信息
|
||||
* @throws PluginException
|
||||
*/
|
||||
private function readPluginInfo(string $pluginCode): array
|
||||
{
|
||||
$infoFile = $this->plugin_path . DIRECTORY_SEPARATOR . $pluginCode . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'info.php';
|
||||
|
||||
if (!file_exists($infoFile)) {
|
||||
throw new PluginException("插件配置文件不存在: {$infoFile}");
|
||||
}
|
||||
|
||||
$info = include $infoFile;
|
||||
|
||||
if (!is_array($info)) {
|
||||
throw new PluginException("插件配置文件格式错误: {$infoFile}");
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统支持的解压命令
|
||||
*
|
||||
* @param string $zipFile ZIP文件路径
|
||||
* @param string $extractTo 解压目标路径
|
||||
*
|
||||
* @return string|null 解压命令
|
||||
*/
|
||||
private function getUnzipCmd(string $zipFile, string $extractTo): ?string
|
||||
{
|
||||
if ($cmd = $this->findCmd('unzip')) {
|
||||
return "{$cmd} -o -qq {$zipFile} -d {$extractTo}";
|
||||
}
|
||||
|
||||
if ($cmd = $this->findCmd('7z')) {
|
||||
return "{$cmd} x -bb0 -y {$zipFile} -o{$extractTo}";
|
||||
}
|
||||
|
||||
if ($cmd = $this->findCmd('7zz')) {
|
||||
return "{$cmd} x -bb0 -y {$zipFile} -o{$extractTo}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用命令行解压ZIP文件
|
||||
*
|
||||
* @param string $cmd 解压命令
|
||||
*
|
||||
* @return void
|
||||
* @throws PluginException
|
||||
*/
|
||||
private function unzipWithCmd(string $cmd): void
|
||||
{
|
||||
$desc = [
|
||||
0 => ["pipe", "r"],
|
||||
1 => ["pipe", "w"],
|
||||
2 => ["pipe", "w"],
|
||||
];
|
||||
|
||||
$handler = proc_open($cmd, $desc, $pipes);
|
||||
if (!is_resource($handler)) {
|
||||
throw new PluginException("解压zip时出错: proc_open调用失败");
|
||||
}
|
||||
|
||||
$err = fread($pipes[2], 1024);
|
||||
fclose($pipes[2]);
|
||||
proc_close($handler);
|
||||
|
||||
if (!empty($err)) {
|
||||
throw new PluginException("解压zip时出错: {$err}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找系统命令
|
||||
*
|
||||
* @param string $name 命令名称
|
||||
* @param string|null $default 默认值
|
||||
* @param array $extraDirs 额外搜索目录
|
||||
*
|
||||
* @return string|null 命令路径
|
||||
*/
|
||||
private function findCmd(string $name, ?string $default = null, array $extraDirs = []): ?string
|
||||
{
|
||||
if (ini_get('open_basedir')) {
|
||||
$searchPath = array_merge(explode(PATH_SEPARATOR, ini_get('open_basedir')), $extraDirs);
|
||||
$dirs = [];
|
||||
|
||||
foreach ($searchPath as $path) {
|
||||
if (@is_dir($path)) {
|
||||
$dirs[] = $path;
|
||||
} else {
|
||||
if (basename($path) === $name && @is_executable($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$dirs = array_merge(
|
||||
explode(PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')),
|
||||
$extraDirs
|
||||
);
|
||||
}
|
||||
|
||||
$suffixes = [''];
|
||||
if ('\\' === DIRECTORY_SEPARATOR) {
|
||||
$pathExt = getenv('PATHEXT');
|
||||
$suffixes = array_merge(
|
||||
$pathExt ? explode(PATH_SEPARATOR, $pathExt) : ['.exe', '.bat', '.cmd', '.com'],
|
||||
$suffixes
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($suffixes as $suffix) {
|
||||
foreach ($dirs as $dir) {
|
||||
$file = $dir . DIRECTORY_SEPARATOR . $name . $suffix;
|
||||
if (@is_file($file) && ('\\' === DIRECTORY_SEPARATOR || @is_executable($file))) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\plugin;
|
||||
|
||||
use app\enum\common\PluginInstallEvent;
|
||||
use app\process\Monitor;
|
||||
use core\exception\handler\PluginException;
|
||||
use core\tool\Sse;
|
||||
use core\uuid\UUIDGenerator;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 安装插件服务
|
||||
*/
|
||||
final class PluginInstallService extends PluginBaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件安装
|
||||
*
|
||||
* 安装流程统一使用插件自带的 Install.php
|
||||
* PluginInstallService 只负责流程控制,实际安装逻辑完全由插件 Install.php 处理
|
||||
*
|
||||
* @param string $code
|
||||
* @param string $mode
|
||||
*
|
||||
* @return \Generator
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function install(string $code, string $mode = 'local'): \Generator
|
||||
{
|
||||
$sessionUuid = UUIDGenerator::generate();
|
||||
$request = request();
|
||||
if ($request) {
|
||||
$sessionUuid = $request->input('uuid', $sessionUuid);
|
||||
}
|
||||
|
||||
// 检查插件是否已安装(检查 installed.php 文件)
|
||||
$pluginDir = $this->plugin_path . DIRECTORY_SEPARATOR . $code;
|
||||
$installedConfigFile = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
||||
if (is_file($installedConfigFile)) {
|
||||
yield Sse::warning('插件已安装,跳过安装', ['plugin' => $code], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
yield Sse::progress(PluginInstallEvent::INSTALL_START->label(), 0, ['plugin' => $code], $sessionUuid);
|
||||
|
||||
// 1. 检查本地是否已经下载了模块
|
||||
yield Sse::progress('检查本地模块', 5, [], $sessionUuid);
|
||||
$localPluginPath = $this->plugin_path . DIRECTORY_SEPARATOR . $code;
|
||||
$moduleExists = is_dir($localPluginPath);
|
||||
|
||||
// 2. 如果是local模式,只检测plugin目录是否包含,没有则直接返回异常安装失败
|
||||
if ($mode === 'local') {
|
||||
if (!$moduleExists) {
|
||||
yield Sse::error('本地模块不存在,安装失败', [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
yield Sse::progress('本地模块存在', 10, [], $sessionUuid);
|
||||
} else {
|
||||
// 3. 如果没有指定,优先检测本地,没有则通过远程下载模块zip
|
||||
if (!$moduleExists) {
|
||||
yield Sse::progress('本地模块不存在,开始远程下载', 10, [], $sessionUuid);
|
||||
|
||||
// 获取下载链接
|
||||
$remoteService = Container::make(PluginRemoteService::class);
|
||||
$authCode = config('madong.auth_code');
|
||||
$secretKey = config('madong.auth_secret');
|
||||
|
||||
// 检查授权配置
|
||||
if (empty($authCode) || empty($secretKey)) {
|
||||
yield Sse::error('授权配置缺失,无法下载远程插件。请在配置文件中设置 madong.auth_code 和 madong.auth_secret', [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
$downloadInfo = $remoteService->getRemoteDownloadService($authCode, $secretKey, $code, 'latest');
|
||||
|
||||
// 记录下载信息,方便调试
|
||||
yield Sse::progress('下载接口返回数据: ' . json_encode($downloadInfo, JSON_UNESCAPED_UNICODE), 12, [], $sessionUuid);
|
||||
|
||||
if (empty($downloadInfo['url'])) {
|
||||
$errorMsg = '获取下载链接失败';
|
||||
if (isset($downloadInfo['msg'])) {
|
||||
$errorMsg .= ': ' . $downloadInfo['msg'];
|
||||
}
|
||||
if (isset($downloadInfo['message'])) {
|
||||
$errorMsg .= ': ' . $downloadInfo['message'];
|
||||
}
|
||||
yield Sse::error($errorMsg, [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
yield Sse::progress('获取下载链接成功: ' . $downloadInfo['url'], 15, [], $sessionUuid);
|
||||
|
||||
// 下载并解压插件
|
||||
yield Sse::progress('开始下载并解压模块', 20, [], $sessionUuid);
|
||||
$downloadService = Container::make(PluginDownloadService::class);
|
||||
$downloadService->downloadAndExtract($code, $downloadInfo['url']);
|
||||
yield Sse::progress('模块下载并解压完成', 30, [], $sessionUuid);
|
||||
} else {
|
||||
yield Sse::progress('本地模块存在', 20, [], $sessionUuid);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 安装检查
|
||||
yield Sse::progress(PluginInstallEvent::INSTALL_CHECK->label(), 35, [], $sessionUuid);
|
||||
$checkResult = $this->installCheck($code);
|
||||
if ($checkResult['overall_status'] !== 'success') {
|
||||
yield Sse::error($checkResult['message'], [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
Monitor::pause();
|
||||
|
||||
// 6. 统一调用插件 Install.php 进行安装(这是唯一的安装入口)
|
||||
yield Sse::progress(PluginInstallEvent::EXECUTE_INSTALL_METHOD->label(), 50, [], $sessionUuid);
|
||||
$pluginConfig = $this->getPluginConfig($code);
|
||||
$version = $pluginConfig['version'] ?? '1.0.0';
|
||||
|
||||
// 检查插件是否提供 Install.php
|
||||
$className = "plugin\\{$code}\\Install";
|
||||
if (!class_exists($className)) {
|
||||
throw new PluginException("插件未提供 Install.php,无法进行安装");
|
||||
}
|
||||
|
||||
// 实例化并执行插件 Install 类(传入进度回调以支持在线模式反馈)
|
||||
$installInstance = new $className();
|
||||
|
||||
// 创建临时输出文件
|
||||
$outputFile = runtime_path(self::RUNTIME_PLUGIN_PATH . '/install_' . $sessionUuid . '.log');
|
||||
// 确保目录存在
|
||||
$outputDir = dirname($outputFile);
|
||||
if (!is_dir($outputDir)) {
|
||||
mkdir($outputDir, 0777, true);
|
||||
}
|
||||
file_put_contents($outputFile, '');
|
||||
|
||||
// 定义进度回调,将插件的安装进度实时写入文件
|
||||
$callbackCount = 0;
|
||||
$progressCallback = function(string $message, ?int $progress = null) use ($outputFile, &$callbackCount) {
|
||||
$callbackCount++;
|
||||
$progressStr = $progress !== null ? "(progress: {$progress})" : "(no progress)";
|
||||
$logLine = "[CALLBACK #{$callbackCount}] {$message} {$progressStr}\n";
|
||||
// 写入文件(追加模式),不输出到控制台避免重复
|
||||
file_put_contents($outputFile, $logLine, FILE_APPEND);
|
||||
};
|
||||
|
||||
// 设置进度回调和在线模式
|
||||
$installInstance->setProgressCallback($progressCallback);
|
||||
$installInstance->setOnlineMode(true);
|
||||
|
||||
// 开启输出缓冲,捕获 Install::install() 内部的 echo 输出
|
||||
ob_start();
|
||||
try {
|
||||
$installInstance->install($version);
|
||||
} catch (\Throwable $e) {
|
||||
ob_end_clean();
|
||||
@unlink($outputFile); // 清理临时文件
|
||||
yield Sse::error('插件安装执行失败: ' . $e->getMessage(), [
|
||||
'exception' => get_class($e),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
$output = ob_get_clean();
|
||||
|
||||
// 将缓冲区输出也写入临时文件
|
||||
if (!empty($output)) {
|
||||
file_put_contents($outputFile, $output, FILE_APPEND);
|
||||
}
|
||||
|
||||
// 读取文件内容并 yield 所有日志
|
||||
$fileContent = file_get_contents($outputFile);
|
||||
if (!empty($fileContent)) {
|
||||
$lines = array_filter(explode("\n", $fileContent));
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (!empty($line)) {
|
||||
yield Sse::progress($line, 50, [], $sessionUuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
@unlink($outputFile);
|
||||
|
||||
yield Sse::progress('插件安装方法执行成功', 85, [], $sessionUuid);
|
||||
|
||||
// 8. 创建安装标记文件
|
||||
yield Sse::progress('创建安装标记', 90, [], $sessionUuid);
|
||||
$installedConfig = [
|
||||
'version' => $pluginConfig['version'] ?? '1.0.0',
|
||||
'installed_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
file_put_contents($installedConfigFile, '<?php return ' . var_export($installedConfig, true) . ';');
|
||||
yield Sse::progress('安装标记创建成功', 95, [], $sessionUuid);
|
||||
|
||||
// 9. 安装完成
|
||||
yield Sse::completed(PluginInstallEvent::INSTALL_COMPLETED->label(), ['plugin' => $code], $sessionUuid);
|
||||
Monitor::pause();
|
||||
|
||||
} catch (PluginException $e) {
|
||||
yield Sse::error($e->getMessage(), [], $sessionUuid);
|
||||
return;
|
||||
} catch (\Exception $e) {
|
||||
yield Sse::error('安装失败:' . $e->getMessage(), [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装检查
|
||||
*
|
||||
* @param string $name 插件名称
|
||||
*
|
||||
* @return array 检查结果
|
||||
*/
|
||||
public function installCheck(string $name): array
|
||||
{
|
||||
// 初始化返回结果
|
||||
$result = [
|
||||
'project' => $name,
|
||||
'overall_status' => 'success',
|
||||
'message' => '检查通过',
|
||||
'checks' => [],
|
||||
];
|
||||
|
||||
// 定义检查项数组
|
||||
$checks = [];
|
||||
|
||||
try {
|
||||
// 1. 核心目录存在性检查(前端目录统一在 frontend 下)
|
||||
$coreDirectories = [
|
||||
'admin' => [
|
||||
'path' => $this->getFrontendProjectPath('admin') . DIRECTORY_SEPARATOR,
|
||||
'name' => 'Admin目录',
|
||||
],
|
||||
'web' => [
|
||||
'path' => $this->getFrontendProjectPath('web') . DIRECTORY_SEPARATOR,
|
||||
'name' => 'Web目录',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($coreDirectories as $dirKey => $dirInfo) {
|
||||
if (is_dir($dirInfo['path'])) {
|
||||
$checks[] = [
|
||||
'type' => 'directory',
|
||||
'name' => $dirInfo['name'],
|
||||
'path' => $dirInfo['path'],
|
||||
'status' => 'success',
|
||||
'message' => "{$dirInfo['name']}存在",
|
||||
'exception' => null,
|
||||
];
|
||||
} else {
|
||||
$checks[] = [
|
||||
'type' => 'directory',
|
||||
'name' => $dirInfo['name'],
|
||||
'path' => $dirInfo['path'],
|
||||
'status' => 'error',
|
||||
'message' => "{$dirInfo['name']}不存在",
|
||||
'exception' => [
|
||||
'type' => 'DirectoryNotFoundException',
|
||||
'message' => __('common.plugin.error.dir_not_exist', ['name' => $dirKey]),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 插件安装目录权限检查(前端目录统一在 frontend 下)
|
||||
$adminPluginDirectory = $this->getFrontendProjectPath('admin') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR;
|
||||
$webPluginDirectory = $this->getFrontendProjectPath('web') . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR;
|
||||
$resourceDirectory = public_path() . DIRECTORY_SEPARATOR;
|
||||
|
||||
$permissionChecks = [
|
||||
[
|
||||
'name' => '插件源码目录',
|
||||
'path' => $this->plugin_path,
|
||||
'permission_type' => 'readable',
|
||||
'description' => '插件源码目录可读性检查',
|
||||
],
|
||||
[
|
||||
'name' => 'Admin插件目录',
|
||||
'path' => $adminPluginDirectory,
|
||||
'permission_type' => 'writable',
|
||||
'description' => '插件将安装到的Admin目录可写性检查',
|
||||
],
|
||||
[
|
||||
'name' => 'Web插件目录',
|
||||
'path' => $webPluginDirectory,
|
||||
'permission_type' => 'writable',
|
||||
'description' => '插件将安装到的Web目录可写性检查',
|
||||
],
|
||||
[
|
||||
'name' => '公共资源目录',
|
||||
'path' => $resourceDirectory,
|
||||
'permission_type' => 'writable',
|
||||
'description' => '插件资源将安装到的公共目录可写性检查',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($permissionChecks as $checkItem) {
|
||||
$path = $checkItem['path'];
|
||||
$checkType = $checkItem['permission_type'];
|
||||
$checkName = $checkItem['name'];
|
||||
|
||||
if ($checkType === 'readable') {
|
||||
$status = is_readable($path) ? 'success' : 'error';
|
||||
$message = $status === 'success' ? "{$checkName}可读" : "{$checkName}不可读";
|
||||
} else {
|
||||
// 可写检查:目录存在则检查是否可写,不存在则尝试创建
|
||||
if (is_dir($path)) {
|
||||
$status = is_write($path) ? 'success' : 'error';
|
||||
$message = $status === 'success' ? "{$checkName}可写" : "{$checkName}不可写";
|
||||
} else {
|
||||
$status = mkdir($path, 0777, true) ? 'success' : 'error';
|
||||
$message = $status === 'success' ? "{$checkName}创建成功并可写" : "{$checkName}创建失败或不可写";
|
||||
}
|
||||
}
|
||||
|
||||
$checks[] = [
|
||||
'type' => 'permission',
|
||||
'name' => $checkName,
|
||||
'path' => $path,
|
||||
'permission_type' => $checkType,
|
||||
'description' => $checkItem['description'],
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'exception' => $status === 'error' ? [
|
||||
'type' => 'PermissionException',
|
||||
'message' => $message,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// 4. 插件信息检查
|
||||
$pluginConfig = $this->getPluginConfig($name);
|
||||
if (empty($pluginConfig)) {
|
||||
$checks[] = [
|
||||
'type' => 'plugin_info',
|
||||
'name' => '插件信息检查',
|
||||
'description' => '检查插件的info.php文件是否存在',
|
||||
'status' => 'error',
|
||||
'message' => "插件信息文件不存在",
|
||||
'exception' => [
|
||||
'type' => 'InfoFileNotFoundException',
|
||||
'message' => __('common.plugin.error.info_file_not_exist', ['name' => $name]),
|
||||
],
|
||||
];
|
||||
} else {
|
||||
$checks[] = [
|
||||
'type' => 'plugin_info',
|
||||
'name' => '插件信息检查',
|
||||
'description' => '验证插件信息文件的完整性',
|
||||
'status' => 'success',
|
||||
'message' => "插件信息文件存在",
|
||||
'plugin_info' => [
|
||||
'code' => $pluginConfig['code']??'',
|
||||
'name' => $pluginConfig['name']??'',
|
||||
'description' => $pluginConfig['description'] ?? '',
|
||||
'version' => $pluginConfig['version'],
|
||||
],
|
||||
'exception' => null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 3. 插件安装状态检查(检查 installed.php 文件)
|
||||
$pluginDir = $this->plugin_path . DIRECTORY_SEPARATOR . $name;
|
||||
$installedConfigFile = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
||||
|
||||
if (is_file($installedConfigFile)) {
|
||||
$checks[] = [
|
||||
'type' => 'installation_status',
|
||||
'name' => '插件安装状态检查',
|
||||
'description' => '检查插件是否已安装',
|
||||
'status' => 'error',
|
||||
'message' => "插件已安装",
|
||||
'exception' => [
|
||||
'type' => 'PluginAlreadyInstalledException',
|
||||
'message' => __('common.plugin.error.repeat_install', ['name' => $name]),
|
||||
],
|
||||
];
|
||||
} else {
|
||||
$checks[] = [
|
||||
'type' => 'installation_status',
|
||||
'name' => '插件安装状态检查',
|
||||
'description' => '确认插件未安装',
|
||||
'status' => 'success',
|
||||
'message' => "插件未安装,可以继续",
|
||||
'exception' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// 4. 插件依赖检查(检查 installed.php 文件)
|
||||
if (isset($pluginConfig['support_app']) && !empty($pluginConfig['support_app']) && $pluginConfig['support_app'] != $name) {
|
||||
$supportAppCode = $pluginConfig['support_app'];
|
||||
$supportAppDir = $this->plugin_path . DIRECTORY_SEPARATOR . $supportAppCode;
|
||||
$supportAppInstalledFile = $supportAppDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
||||
|
||||
if (!is_file($supportAppInstalledFile)) {
|
||||
$supportAppConfig = $this->getPluginConfig($supportAppCode);
|
||||
$supportAppTitle = empty($supportAppConfig) ? $supportAppCode : $supportAppConfig['title'];
|
||||
|
||||
$checks[] = [
|
||||
'type' => 'dependency',
|
||||
'name' => '插件依赖检查',
|
||||
'description' => '检查插件依赖的主应用是否已安装',
|
||||
'dependency_name' => $supportAppCode,
|
||||
'dependency_title' => $supportAppTitle,
|
||||
'status' => 'error',
|
||||
'message' => "依赖插件未安装",
|
||||
'exception' => [
|
||||
'type' => 'DependencyNotFoundException',
|
||||
'message' => $pluginConfig['title'] . '插件的主应用' . $supportAppTitle . '插件还未安装,请先安装主应用',
|
||||
],
|
||||
];
|
||||
} else {
|
||||
$checks[] = [
|
||||
'type' => 'dependency',
|
||||
'name' => '插件依赖检查',
|
||||
'description' => '确认插件依赖已满足',
|
||||
'dependency_name' => $supportAppCode,
|
||||
'status' => 'success',
|
||||
'message' => "依赖插件已安装",
|
||||
'exception' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 框架版本支持检查
|
||||
$currentFrameworkVersion = config('config.app.version', 'v1.0.1');
|
||||
|
||||
if (isset($pluginConfig['support_version']) && !empty($pluginConfig['support_version'])) {
|
||||
$checks[] = [
|
||||
'type' => 'version',
|
||||
'name' => '框架版本支持检查',
|
||||
'description' => '验证当前框架版本是否支持该插件',
|
||||
'current_version' => $currentFrameworkVersion,
|
||||
'required_version' => $pluginConfig['support_version'],
|
||||
'status' => 'success',
|
||||
'message' => "当前框架版本支持该插件",
|
||||
'exception' => null,
|
||||
];
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// 捕获异常
|
||||
$checks[] = [
|
||||
'type' => 'system',
|
||||
'name' => '系统检查',
|
||||
'description' => '系统级检查',
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
'exception' => [
|
||||
'type' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 计算整体状态
|
||||
$errorCount = 0;
|
||||
$warningCount = 0;
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if ($check['status'] === 'error') {
|
||||
$errorCount++;
|
||||
} elseif ($check['status'] === 'warning') {
|
||||
$warningCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($errorCount > 0) {
|
||||
$result['overall_status'] = 'error';
|
||||
$result['message'] = "检查失败,发现{$errorCount}个错误";
|
||||
} elseif ($warningCount > 0) {
|
||||
$result['overall_status'] = 'warning';
|
||||
$result['message'] = "检查通过但有{$warningCount}个警告";
|
||||
} else {
|
||||
$result['overall_status'] = 'success';
|
||||
$result['message'] = "所有检查通过";
|
||||
}
|
||||
|
||||
// 添加检查项到结果
|
||||
$result['checks'] = $checks;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\plugin;
|
||||
|
||||
use core\exception\handler\AdminException;
|
||||
use madong\helper\Dict;
|
||||
|
||||
/**
|
||||
* 插件远程服务
|
||||
*/
|
||||
final class PluginRemoteService extends PluginBaseService
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 HTTP 客户端
|
||||
*
|
||||
* @param string $baseUri 基础URI
|
||||
*
|
||||
* @return \GuzzleHttp\Client
|
||||
*/
|
||||
private function createHttpClient(string $baseUri): \GuzzleHttp\Client
|
||||
{
|
||||
return new \GuzzleHttp\Client([
|
||||
'base_uri' => $baseUri,
|
||||
'timeout' => 60,
|
||||
'connect_timeout' => 5,
|
||||
'verify' => false,
|
||||
'http_errors' => false,
|
||||
'headers' => [
|
||||
'Referer' => \request()->fullUrl() ?? '',
|
||||
'User-Agent' => 'madong-app-plugin',
|
||||
'Accept' => 'application/json;charset=UTF-8',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取授权市场的购买应用
|
||||
*
|
||||
* @param array $params
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getPurchasedModules(array $params = []): array
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'auth_code' => config('madong.auth_code', ''),
|
||||
'auth_secret' => config('madong.auth_secret', ''),
|
||||
'page' => config('madong.page', 1),
|
||||
'limit' => config('madong.limit', 999),
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
];
|
||||
$args = Dict::of($config);
|
||||
//优先使用传入的参数
|
||||
if (!empty($params['auth_code'])) {
|
||||
$args->put('auth_code', $params['auth_code']);
|
||||
}
|
||||
if (!empty($params['auth_secret'])) {
|
||||
$args->put('auth_secret', $params['auth_secret']);
|
||||
}
|
||||
if (!empty($params['page'])) {
|
||||
$args->put('page', $params['page']);
|
||||
}
|
||||
if (!empty($params['limit'])) {
|
||||
$args->put('limit', $params['limit']);
|
||||
}
|
||||
if (!empty($params['market_host'])) {
|
||||
$args->put('market_host', $params['market_host']);
|
||||
}
|
||||
|
||||
if (empty($args->get('auth_code')) || empty($args->get('auth_secret'))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 所有请求都通过远程 curl 获取
|
||||
return $this->getPurchasedModulesFromRemote($args->toArray());
|
||||
} catch (\Throwable $e) {
|
||||
\support\Log::error('getPurchasedModules error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取远程模块
|
||||
*
|
||||
* @param array $args
|
||||
*
|
||||
* @return array
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
*/
|
||||
public function getPurchasedModulesFromRemote(array $args): array
|
||||
{
|
||||
try {
|
||||
$client = $this->createHttpClient($args['market_host']);
|
||||
|
||||
$sign = md5($args['auth_code'] . $args['auth_secret'] . (string)time());
|
||||
$response = $client->get('/api/madong/apps', [
|
||||
'query' => [
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret' => $args['auth_secret'],
|
||||
'timestamp' => time(),
|
||||
'sign' => $sign,
|
||||
'page' => 1,
|
||||
'limit' => 999,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = $response->getBody()->getContents();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
// 如果返回格式错误,记录日志并返回空数组
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
\support\Log::error('getPurchasedModulesFromRemote: JSON解析失败 - ' . json_last_error_msg());
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查并验证返回的数据结构
|
||||
if (!is_array($data)) {
|
||||
\support\Log::error('getPurchasedModulesFromRemote: 返回数据不是数组 - ' . $content);
|
||||
return [];
|
||||
}
|
||||
|
||||
// API返回格式: {code: int, msg: string, data: {items: array, total: int}}
|
||||
if (isset($data['code']) && $data['code'] !== 0) {
|
||||
\support\Log::error('getPurchasedModulesFromRemote: API返回错误 - ' . ($data['msg'] ?? '未知错误'));
|
||||
return [];
|
||||
}
|
||||
|
||||
// 提取 items 列表
|
||||
if (isset($data['data']) && is_array($data['data']) && isset($data['data']['items'])) {
|
||||
$items = $data['data']['items'];
|
||||
if (!is_array($items)) {
|
||||
\support\Log::error('getPurchasedModulesFromRemote: items 不是数组');
|
||||
return [];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
// 没有找到items,返回空数组
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getPurchasedModulesFromRemote error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取远程模块的更新列表
|
||||
*
|
||||
* @param string $authCode 授权码
|
||||
* @param string $secretKey 密钥
|
||||
* @param array $filters 过滤条件
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getRemoteUpdateList(string $authCode, string $secretKey, array $filters = []): array
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'auth_code' => $authCode,
|
||||
'auth_secret' => $secretKey,
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
] + $filters;
|
||||
|
||||
// 所有请求都通过远程 curl 获取
|
||||
return $this->getRemoteUpdateListFromRemote($config);
|
||||
} catch (\Exception) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程获取模块更新列表
|
||||
*
|
||||
* @param array $args
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getRemoteUpdateListFromRemote(array $args): array
|
||||
{
|
||||
try {
|
||||
$client = $this->createHttpClient($args['market_host']);
|
||||
|
||||
$sign = md5($args['auth_code'] . $args['auth_secret'] . (string)time());
|
||||
$response = $client->get('/api/madong/apps', [
|
||||
'query' => [
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret' => $args['auth_secret'],
|
||||
'timestamp' => time(),
|
||||
'sign' => $sign,
|
||||
'page' => 1,
|
||||
'limit' => 999,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = $response->getBody()->getContents();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
// 如果返回格式错误,记录日志并返回空数组
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
\support\Log::error('getRemoteUpdateListFromRemote: JSON解析失败 - ' . json_last_error_msg());
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查并验证返回的数据结构
|
||||
if (!is_array($data)) {
|
||||
\support\Log::error('getRemoteUpdateListFromRemote: 返回数据不是数组 - ' . $content);
|
||||
return [];
|
||||
}
|
||||
|
||||
// API返回格式: {code: int, msg: string, data: {items: array, total: int}}
|
||||
// 检查code,非0表示错误
|
||||
if (isset($data['code']) && $data['code'] !== 0) {
|
||||
\support\Log::error('getRemoteUpdateListFromRemote: API返回错误 - ' . ($data['msg'] ?? '未知错误'));
|
||||
return [];
|
||||
}
|
||||
|
||||
// 提取 items 列表
|
||||
if (isset($data['data']) && is_array($data['data']) && isset($data['data']['items'])) {
|
||||
$items = $data['data']['items'];
|
||||
if (!is_array($items)) {
|
||||
\support\Log::error('getRemoteUpdateListFromRemote: items 不是数组');
|
||||
return [];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
// 没有找到items,返回空数组
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getRemoteUpdateListFromRemote error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取远程模块下载服务
|
||||
*
|
||||
* @param string $authCode 授权码
|
||||
* @param string $secretKey 密钥
|
||||
* @param string $appId 应用ID
|
||||
* @param string $version 版本号
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getRemoteDownloadService(string $authCode, string $secretKey, string $appId, string $version): array
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'auth_code' => $authCode,
|
||||
'auth_secret' => $secretKey,
|
||||
'app_id' => $appId,
|
||||
'version' => $version,
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
];
|
||||
|
||||
// 所有请求都通过远程 curl 获取
|
||||
return $this->getRemoteDownloadServiceFromRemote($config);
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getRemoteDownloadService error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程获取模块下载服务
|
||||
*
|
||||
* @param array $args
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getRemoteDownloadServiceFromRemote(array $args): array
|
||||
{
|
||||
try {
|
||||
$client = $this->createHttpClient($args['market_host']);
|
||||
|
||||
$sign = md5($args['auth_code'] . $args['auth_secret'] . (string)time());
|
||||
|
||||
// 记录请求参数
|
||||
\support\Log::info('getRemoteDownloadServiceFromRemote 请求参数', [
|
||||
'market_host' => $args['market_host'],
|
||||
'app_id' => $args['app_id'],
|
||||
'version' => $args['version'],
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret_prefix' => substr($args['auth_secret'], 0, 4) . '***',
|
||||
]);
|
||||
|
||||
$response = $client->get('/api/madong/apps/download', [
|
||||
'query' => [
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret' => $args['auth_secret'],
|
||||
'app_id' => $args['app_id'],
|
||||
'version' => $args['version'],
|
||||
'timestamp' => time(),
|
||||
'sign' => $sign,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = $response->getBody()->getContents();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
// 记录响应内容
|
||||
\support\Log::info('getRemoteDownloadServiceFromRemote 响应内容', [
|
||||
'status_code' => $response->getStatusCode(),
|
||||
'content_length' => strlen($content),
|
||||
'content_preview' => substr($content, 0, 500),
|
||||
]);
|
||||
|
||||
// 如果返回格式错误,记录日志并返回空数组
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
\support\Log::error('getRemoteDownloadServiceFromRemote: JSON解析失败 - ' . json_last_error_msg(), [
|
||||
'content' => $content,
|
||||
]);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查并验证返回的数据结构
|
||||
if (!is_array($data)) {
|
||||
\support\Log::error('getRemoteDownloadServiceFromRemote: 返回数据不是数组 - ' . $content);
|
||||
return [];
|
||||
}
|
||||
|
||||
// API返回格式: {code: int, msg: string, data: {...}}
|
||||
// 检查code,非0表示错误
|
||||
if (isset($data['code']) && $data['code'] !== 0) {
|
||||
\support\Log::error('getRemoteDownloadServiceFromRemote: API返回错误', [
|
||||
'code' => $data['code'],
|
||||
'msg' => $data['msg'] ?? '未知错误',
|
||||
'data' => $data['data'] ?? null,
|
||||
]);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 确保返回的是下载服务数据
|
||||
if (isset($data['data']) && is_array($data['data'])) {
|
||||
\support\Log::info('getRemoteDownloadServiceFromRemote: 成功获取下载链接', [
|
||||
'has_url' => isset($data['data']['url']),
|
||||
'url_length' => isset($data['data']['url']) ? strlen($data['data']['url']) : 0,
|
||||
]);
|
||||
return $data['data'];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getRemoteDownloadServiceFromRemote error', [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证远程授权
|
||||
*
|
||||
* @param string $authCode 授权码
|
||||
* @param string $secretKey 密钥
|
||||
*
|
||||
* @return array
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function verifyRemoteAuthorization(string $authCode, string $secretKey): array
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'auth_code' => $authCode,
|
||||
'auth_secret' => $secretKey,
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
];
|
||||
|
||||
// 所有请求都通过远程 curl 获取
|
||||
return $this->verifyRemoteAuthorizationFromRemote($config);
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程验证授权
|
||||
*
|
||||
* @param array $args
|
||||
*
|
||||
* @return array
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
private function verifyRemoteAuthorizationFromRemote(array $args): array
|
||||
{
|
||||
try {
|
||||
$client = $this->createHttpClient($args['market_host']);
|
||||
|
||||
$sign = md5($args['auth_code'] . $args['auth_secret'] . (string)time());
|
||||
$response = $client->get('/api/madong/authorization/verify', [
|
||||
'query' => [
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret' => $args['auth_secret'],
|
||||
'timestamp' => time(),
|
||||
'sign' => $sign,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = $response->getBody()->getContents();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
// 如果返回格式错误,记录日志
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \Exception('JSON解析失败: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
// 检查并验证返回的数据结构
|
||||
if (!is_array($data)) {
|
||||
throw new \Exception('返回数据不是数组');
|
||||
}
|
||||
|
||||
// 检查返回的code是否为0
|
||||
if (isset($data['code']) && $data['code'] !== 0) {
|
||||
throw new \Exception($data['msg'] ?? '授权验证失败');
|
||||
}
|
||||
|
||||
// 确保返回的是授权验证数据
|
||||
if (isset($data['data']) && is_array($data['data'])) {
|
||||
return $data['data'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取远程应用升级日志
|
||||
*
|
||||
* @param string $authCode 授权码
|
||||
* @param string $secretKey 密钥
|
||||
* @param string $appCode 应用编码
|
||||
* @param int $limit 限制数量
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getRemoteUpdateLogs(string $authCode, string $secretKey, string $appCode, int $limit = 100): array
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'auth_code' => $authCode,
|
||||
'auth_secret' => $secretKey,
|
||||
'code' => $appCode,
|
||||
'limit' => $limit,
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
];
|
||||
|
||||
// 所有请求都通过远程 curl 获取
|
||||
return $this->getRemoteUpdateLogsFromRemote($config);
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getRemoteUpdateLogs error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程获取应用升级日志
|
||||
*
|
||||
* @param array $args
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getRemoteUpdateLogsFromRemote(array $args): array
|
||||
{
|
||||
try {
|
||||
$client = $this->createHttpClient($args['market_host']);
|
||||
|
||||
$sign = md5($args['auth_code'] . $args['auth_secret'] . (string)time());
|
||||
$response = $client->get('/api/madong/apps/update-logs', [
|
||||
'query' => [
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret' => $args['auth_secret'],
|
||||
'code' => $args['code'],
|
||||
'limit' => $args['limit'],
|
||||
'timestamp' => time(),
|
||||
'sign' => $sign,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = $response->getBody()->getContents();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
// 如果返回格式错误,记录日志并返回空数组
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
\support\Log::error('getRemoteUpdateLogsFromRemote: JSON解析失败 - ' . json_last_error_msg());
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查并验证返回的数据结构
|
||||
if (!is_array($data)) {
|
||||
\support\Log::error('getRemoteUpdateLogsFromRemote: 返回数据不是数组 - ' . $content);
|
||||
return [];
|
||||
}
|
||||
|
||||
// API返回格式: {code: int, msg: string, data: {...}}
|
||||
// 检查code,非0表示错误
|
||||
if (isset($data['code']) && $data['code'] !== 0) {
|
||||
\support\Log::error('getRemoteUpdateLogsFromRemote: API返回错误 - ' . ($data['msg'] ?? '未知错误'));
|
||||
return [];
|
||||
}
|
||||
|
||||
// 确保返回的是升级日志数据
|
||||
if (isset($data['data']) && is_array($data['data'])) {
|
||||
return $data['data'];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getRemoteUpdateLogsFromRemote error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\plugin;
|
||||
|
||||
use app\dao\plugin\PluginDao;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 插件服务类
|
||||
*
|
||||
* @package app\service\core\plugin
|
||||
*/
|
||||
final class PluginService extends PluginBaseService
|
||||
{
|
||||
|
||||
/**
|
||||
* 插件根目录
|
||||
*/
|
||||
protected string $plugin_path;
|
||||
|
||||
/**
|
||||
* 项目根目录(前端和后端的父级目录)
|
||||
*/
|
||||
protected string $project_path;
|
||||
|
||||
/**
|
||||
* 后端根目录
|
||||
*/
|
||||
protected string $server_path;
|
||||
|
||||
public function __construct(PluginDao $dao)
|
||||
{
|
||||
// 初始化基础路径
|
||||
$this->plugin_path = base_path('plugin');
|
||||
$this->project_path = dirname(base_path()); // 项目根目录(前端和后端的父级)
|
||||
$this->server_path = base_path(); // 后端根目录
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件列表
|
||||
*
|
||||
* @param string|null $category 插件分类(all/installed/un_installed/purchased/updatable)
|
||||
* @param string|null $type 插件类型
|
||||
* @param string|null $keyword 搜索关键词
|
||||
* @param int $page 页码
|
||||
* @param int $limit 每页数量
|
||||
*
|
||||
* @return array 插件列表
|
||||
*/
|
||||
public function getList(string|null $category = 'all', string|null $type = null, string|null $keyword = null, int $page = 1, int $limit = 9999): array
|
||||
{
|
||||
// 获取授权配置
|
||||
$config = [
|
||||
'auth_code' => config('madong.auth_code', ''),
|
||||
'auth_secret' => config('madong.auth_secret', ''),
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
'name' => $keyword,
|
||||
];
|
||||
// 获取本地插件列表(原始数据)
|
||||
$localModules = $this->getLocalModules($config['auth_code']);
|
||||
/** @var PluginRemoteService $pluginRemoteService */
|
||||
// 获取已购买的插件列表(原始数据)
|
||||
$pluginRemoteService = Container::make(PluginRemoteService::class);
|
||||
$purchasedModules = $pluginRemoteService->getPurchasedModules($config);
|
||||
// 从本地插件中派生出已安装的模块
|
||||
$installedModules = array_filter($localModules, function ($module) {
|
||||
return $module['is_installed'] === true;
|
||||
});
|
||||
// 构建已安装插件的映射
|
||||
$installedMap = array_column($installedModules, null, 'name');
|
||||
|
||||
// 合并本地和远程市场的插件
|
||||
$allModules = $this->mergeModules($localModules, $purchasedModules);
|
||||
|
||||
// 根据不同类型处理数据
|
||||
$processedItems = [];
|
||||
switch ($category) {
|
||||
case 'installed':
|
||||
// 已安装(本地已安装状态)
|
||||
$processedItems = $this->getInstalledModulesData($allModules, $installedMap, $installedModules);
|
||||
break;
|
||||
case 'un_installed':
|
||||
// 未安装(本地未安装状态)
|
||||
$processedItems = $this->getUninstalledModulesData($localModules, $installedMap);
|
||||
break;
|
||||
case 'purchased':
|
||||
// 已购买(远程市场返回的所有)
|
||||
$processedItems = $this->getPurchasedModulesData($purchasedModules, $installedMap);
|
||||
break;
|
||||
case 'updatable':
|
||||
// 可更新(远程市场跟本地安装对比 市场版本大于安装版本的状态)
|
||||
$processedItems = $this->getUpdatableModulesData($allModules, $installedMap, $installedModules);
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
// 全部(本地+市场)
|
||||
$processedItems = $this->getAllModulesData($allModules, $installedMap);
|
||||
break;
|
||||
}
|
||||
|
||||
// 应用通用过滤
|
||||
$filteredItems = [];
|
||||
foreach ($processedItems as $module) {
|
||||
if ($type !== null && (string)$module['status'] !== (string)$type) {
|
||||
continue;
|
||||
}
|
||||
if ($keyword && !$this->matchKeyword($module, $keyword)) {
|
||||
continue;
|
||||
}
|
||||
$filteredItems[] = $module;
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
$total = count($filteredItems);
|
||||
$items = array_slice($filteredItems, ($page - 1) * $limit, $limit);
|
||||
return compact('page', 'limit', 'total', 'items');
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并本地和远程市场的插件
|
||||
*
|
||||
* @param array $localModules 本地插件列表
|
||||
* @param array $purchasedModules 已购买的插件列表
|
||||
*
|
||||
* @return array 合并后的插件列表
|
||||
*/
|
||||
private function mergeModules(array $localModules, array $purchasedModules): array
|
||||
{
|
||||
$merged = [];
|
||||
$moduleMap = [];
|
||||
|
||||
// 先添加本地插件
|
||||
foreach ($localModules as $module) {
|
||||
$merged[] = $module;
|
||||
$moduleMap[$module['name']] = count($merged) - 1;
|
||||
}
|
||||
|
||||
// 再添加远程市场的插件(如果本地没有)
|
||||
foreach ($purchasedModules as $module) {
|
||||
if (!isset($moduleMap[$module['name']])) {
|
||||
$merged[] = $module;
|
||||
}
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地插件列表(从 plugin 目录扫描)
|
||||
*
|
||||
* @param string $authCode 授权码
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getLocalModules(string $authCode = ''): array
|
||||
{
|
||||
$modules = [];
|
||||
$pluginPath = $this->plugin_path;
|
||||
|
||||
if (!is_dir($pluginPath)) {
|
||||
return $modules;
|
||||
}
|
||||
|
||||
$dirItems = scandir($pluginPath);
|
||||
foreach ($dirItems as $key => $item) {
|
||||
if ($item === '.' || $item === '..' || !is_dir($pluginPath . DIRECTORY_SEPARATOR . $item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemPath = $pluginPath . DIRECTORY_SEPARATOR . $item;
|
||||
$infoPath = $itemPath . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'info.php';
|
||||
$appConfigPath = $itemPath . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'app.php';
|
||||
$installedPath = $itemPath . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
||||
|
||||
// 只读取 info.php
|
||||
if (!file_exists($infoPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = include $infoPath;
|
||||
if (!is_array($config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查插件归属类型
|
||||
$pluginType = $config['type'] ?? 'madong';
|
||||
if (!str_starts_with($pluginType, 'madong:') && $pluginType !== 'madong') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pluginKey = $config['name'] ?? $item;
|
||||
$existingPlugin = $this->dao->findByKey($pluginKey);
|
||||
$appConfig = [];
|
||||
if (file_exists($appConfigPath)) {
|
||||
$loadedAppConfig = include $appConfigPath;
|
||||
if (is_array($loadedAppConfig)) {
|
||||
$appConfig = $loadedAppConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查安装状态 - 通过 installed.php 判断
|
||||
$isInstalled = file_exists($installedPath);
|
||||
$installedAt = 0;
|
||||
if ($isInstalled) {
|
||||
$installedConfig = include $installedPath;
|
||||
if (is_array($installedConfig) && !empty($installedConfig['installed_at'])) {
|
||||
$installedAt = strtotime($installedConfig['installed_at']);
|
||||
} else {
|
||||
$installedAt = time();
|
||||
}
|
||||
}
|
||||
|
||||
$icon = $this->getPluginImageBase64($item, 'icon');
|
||||
$cover = $this->getPluginImageBase64($item, 'cover');
|
||||
|
||||
$title = '';
|
||||
$description = $appConfig['description'] ?? ($config['description'] ?? '');
|
||||
$author = $appConfig['author'] ?? ($config['author'] ?? '');
|
||||
$version = $appConfig['version'] ?? ($config['version'] ?? '1.0.0');
|
||||
$type = $appConfig['type'] ?? ($config['type'] ?? 'madong');
|
||||
|
||||
if (!empty($appConfig['title'])) {
|
||||
$title = (string)$appConfig['title'];
|
||||
}
|
||||
|
||||
if ($existingPlugin) {
|
||||
$title = $title ?: (string)($existingPlugin->title ?? '');
|
||||
$description = (string)($existingPlugin->desc ?? $description);
|
||||
$author = (string)($existingPlugin->author ?? $author);
|
||||
$icon = $icon ?: (string)($existingPlugin->icon ?? '');
|
||||
$cover = $cover ?: (string)($existingPlugin->cover ?? '');
|
||||
}
|
||||
|
||||
if ($title === '') {
|
||||
$title = $description !== '' ? $description : $pluginKey;
|
||||
}
|
||||
|
||||
$modules[] = [
|
||||
'id' => $key,
|
||||
'code' => $config['name'] ?? $item,
|
||||
'name' => $config['name'] ?? $item,
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
'version' => $version,
|
||||
'status' => $isInstalled ? 1 : 0,
|
||||
'description' => $description,
|
||||
'author' => $author,
|
||||
'price' => 0,
|
||||
'cover' => $cover,
|
||||
'icon' => $icon,
|
||||
'downloads' => 0,
|
||||
'rating' => 0,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'auth_code' => $authCode,
|
||||
'is_installed' => $isInstalled,
|
||||
'installed_at' => $installedAt,
|
||||
'is_local' => 1,
|
||||
'undeletable' => isset($config['uninstall']['undeletable']) && $config['uninstall']['undeletable'] === true ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
private function getPluginImageBase64(string $pluginName, string $imageName): string
|
||||
{
|
||||
$imagePath = $this->plugin_path . DIRECTORY_SEPARATOR . $pluginName . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $imageName . '.png';
|
||||
if (!file_exists($imagePath)) {
|
||||
$resourcePath = $this->plugin_path . DIRECTORY_SEPARATOR . $pluginName . DIRECTORY_SEPARATOR . 'resource' . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $imageName . '.png';
|
||||
if (!file_exists($resourcePath)) {
|
||||
return '';
|
||||
}
|
||||
$imagePath = $resourcePath;
|
||||
}
|
||||
|
||||
$imageData = file_get_contents($imagePath);
|
||||
if ($imageData === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'data:image/png;base64,' . base64_encode($imageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已安装模块数据
|
||||
*/
|
||||
private function getInstalledModulesData(array $allModules, array $installedMap, array $installedModules): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($installedModules as $localModule) {
|
||||
// 从localModules派生的installedModules可能没有enable字段,默认为true
|
||||
$enable = $localModule['enable'] ?? true;
|
||||
if (!$enable) continue;
|
||||
|
||||
$name = $localModule['name'];
|
||||
$remoteModule = $this->findModuleByName($allModules, $name);
|
||||
|
||||
$result[] = $this->buildModuleItem(
|
||||
$remoteModule ?: $this->createFallbackModule($localModule),
|
||||
$localModule,
|
||||
$installedMap
|
||||
);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未安装模块数据
|
||||
*/
|
||||
private function getUninstalledModulesData(array $localModules, array $installedMap): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($localModules as $module) {
|
||||
if (isset($installedMap[$module['name']])) continue;
|
||||
|
||||
$result[] = $this->buildModuleItem($module, null, $installedMap);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已购买模块数据
|
||||
*/
|
||||
private function getPurchasedModulesData(array $purchasedModules, array $installedMap): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($purchasedModules as $module) {
|
||||
$localModule = $installedMap[$module['name']] ?? null;
|
||||
$result[] = $this->buildModuleItem($module, $localModule, $installedMap);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可更新模块数据
|
||||
*/
|
||||
private function getUpdatableModulesData(array $allModules, array $installedMap, array $installedModules): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($installedModules as $localModule) {
|
||||
// 从localModules派生的installedModules可能没有enable字段,默认为true
|
||||
$enable = $localModule['enable'] ?? true;
|
||||
if (!$enable) continue;
|
||||
|
||||
$name = $localModule['name'];
|
||||
$remoteModule = $this->findModuleByName($allModules, $name);
|
||||
|
||||
if (!$remoteModule) continue;
|
||||
|
||||
if (version_compare($remoteModule['version'], $localModule['version'], '>')) {
|
||||
$result[] = $this->buildModuleItem($remoteModule, $localModule, $installedMap);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有模块数据
|
||||
*/
|
||||
private function getAllModulesData(array $allModules, array $installedMap): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($allModules as $module) {
|
||||
$localModule = $installedMap[$module['name']] ?? null;
|
||||
$result[] = $this->buildModuleItem($module, $localModule, $installedMap);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建模块数据项(包含安装状态、版本差异等)
|
||||
*/
|
||||
private function buildModuleItem(?array $remoteModule, ?array $localModule, array $installedMap): array
|
||||
{
|
||||
$name = $remoteModule['name'] ?? $localModule['name'] ?? '';
|
||||
$title = $remoteModule['title'] ?? $localModule['title'] ?? $name;
|
||||
$isInstalled = $localModule !== null;
|
||||
$localVersion = $isInstalled ? $localModule['version'] : null;
|
||||
$remoteVersion = $remoteModule['version'] ?? null;
|
||||
|
||||
// 获取本地插件标志
|
||||
$isLocal = ($localModule && isset($localModule['is_local'])) ? $localModule['is_local'] : 0;
|
||||
|
||||
// 获取不可删除标志(优先从 localModule 获取)
|
||||
$undeletable = 0;
|
||||
if ($localModule && isset($localModule['undeletable'])) {
|
||||
$undeletable = $localModule['undeletable'];
|
||||
}
|
||||
|
||||
// 计算可更新状态
|
||||
$hasUpdate = false;
|
||||
$versionComparison = null;
|
||||
if ($isInstalled && $remoteVersion) {
|
||||
$hasUpdate = version_compare($remoteVersion, $localVersion, '>');
|
||||
$versionComparison = $hasUpdate ? "{$remoteVersion} > {$localVersion}" : null;
|
||||
}
|
||||
|
||||
// 计算购买状态
|
||||
$isPurchased = false;
|
||||
if ($remoteModule) {
|
||||
$isPurchased = $remoteModule['price'] == 0 ||
|
||||
in_array($remoteModule['name'], ['sms-verification', 'wechat-integration']);
|
||||
}
|
||||
|
||||
// 统一数据结构 - 使用专业命名
|
||||
return [
|
||||
'id' => $remoteModule['id'] ?? $localModule['name'] ?? '',
|
||||
'code' => $remoteModule['code'] ?? $localModule['name'] ?? '',
|
||||
'name' => $name,
|
||||
'title' => $title,
|
||||
'type' => $remoteModule['type'] ?? ($localModule['type'] ?? 'module'),
|
||||
'version' => $remoteVersion ?? $localVersion ?? '',
|
||||
'status' => $remoteModule['status'] ?? 1,
|
||||
'description' => $remoteModule['description'] ?? ($localModule['description'] ?? ''),
|
||||
'detail_description' => $remoteModule['detail_description'] ?? '',
|
||||
'author' => $remoteModule['author'] ?? ($localModule['author'] ?? ''),
|
||||
'icon' => $remoteModule['icon'] ?? ($localModule['icon'] ?? ''),
|
||||
'cover' => $remoteModule['cover'] ?? ($localModule['cover'] ?? ''),
|
||||
'poster' => $remoteModule['poster'] ?? ($localModule['poster'] ?? ($localModule['cover'] ?? '')),
|
||||
'price' => $remoteModule['price'] ?? 0,
|
||||
'downloads' => $remoteModule['downloads'] ?? 0,
|
||||
'rating' => $remoteModule['rating'] ?? 0,
|
||||
'created_at' => $remoteModule['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
'updated_at' => $remoteModule['updated_at'] ?? date('Y-m-d H:i:s'),
|
||||
'update_time' => $remoteModule['update_time'] ?? '',
|
||||
'update_logs' => $remoteModule['update_logs'] ?? [],
|
||||
'category' => $remoteModule['category'] ?? null,
|
||||
'category_name' => $remoteModule['category_name'] ?? '',
|
||||
'tags' => $remoteModule['tags'] ?? [],
|
||||
'is_new' => (int)($remoteModule['is_new'] ?? 0),
|
||||
'is_hot' => (int)($remoteModule['is_hot'] ?? 0),
|
||||
'purchased' => (int)($remoteModule['purchased'] ?? 0),
|
||||
'installed' => (int)($remoteModule['installed'] ?? 0),
|
||||
'manual_uninstall' => (int)($remoteModule['manual_uninstall'] ?? 0),
|
||||
'composer_dependencies' => $remoteModule['composer_dependencies'] ?? [],
|
||||
'npm_dependencies' => $remoteModule['npm_dependencies'] ?? [],
|
||||
|
||||
// 状态标识 - 使用更专业的命名
|
||||
'is_installed' => (int)$isInstalled,
|
||||
'installed_version' => $localVersion,
|
||||
'has_update' => (int)$hasUpdate,
|
||||
'is_purchased' => (int)$isPurchased,
|
||||
'is_downloaded' => (int)($isInstalled || in_array($name, ['data-export'])),
|
||||
'can_uninstall' => (int)($isInstalled && $name !== 'user-management'),
|
||||
'can_download' => (int)$isPurchased,
|
||||
'is_local' => (int)$isLocal,
|
||||
'undeletable' => $undeletable,
|
||||
|
||||
// 专业化的版本信息结构
|
||||
'version_info' => [
|
||||
'remote' => [
|
||||
'version' => $remoteVersion,
|
||||
'release_date' => $remoteModule['updated_at'] ?? null,
|
||||
],
|
||||
'local' => [
|
||||
'version' => $localVersion,
|
||||
'install_date' => $localModule['created_at'] ?? null,
|
||||
],
|
||||
'comparison' => [
|
||||
'needs_update' => (int)$hasUpdate,
|
||||
'is_latest' => (int)($isInstalled && $remoteVersion && version_compare($remoteVersion, $localVersion, '<=')),
|
||||
'version_difference' => $versionComparison,
|
||||
],
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过名称查找模块
|
||||
*/
|
||||
private function findModuleByName(array $modules, string $name): ?array
|
||||
{
|
||||
foreach ($modules as $module) {
|
||||
if ($module['name'] === $name) {
|
||||
return $module;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建回退模块(当远程模块不存在时使用)
|
||||
*/
|
||||
private function createFallbackModule(array $localModule): array
|
||||
{
|
||||
return [
|
||||
'name' => $localModule['name'],
|
||||
'title' => $localModule['title'] ?? $localModule['name'],
|
||||
'type' => 'module',
|
||||
'version' => $localModule['version'],
|
||||
'status' => 1,
|
||||
'description' => $localModule['description'] ?? '',
|
||||
'author' => $localModule['author'] ?? '',
|
||||
'price' => 0,
|
||||
'cover' => $localModule['cover'] ?? '',
|
||||
'icon' => $localModule['icon'] ?? '',
|
||||
'downloads' => 0,
|
||||
'rating' => 0,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'undeletable' => $localModule['undeletable'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配模块名称、描述和作者是否包含关键词
|
||||
*/
|
||||
private function matchKeyword(array $module, string $keyword): bool
|
||||
{
|
||||
$lowerKeyword = strtolower($keyword);
|
||||
$title = strtolower((string)($module['title'] ?? ''));
|
||||
return str_contains(strtolower($module['name']), $lowerKeyword) ||
|
||||
str_contains($title, $lowerKeyword) ||
|
||||
str_contains(strtolower($module['description']), $lowerKeyword) ||
|
||||
str_contains(strtolower($module['author']), $lowerKeyword);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块升级日志
|
||||
*
|
||||
* @param string $moduleName 模块名称
|
||||
*
|
||||
* @return array 升级日志列表
|
||||
*/
|
||||
public function getUpgradeLogs(string $moduleName): array
|
||||
{
|
||||
try {
|
||||
// 获取授权配置
|
||||
$config = [
|
||||
'auth_code' => config('madong.auth_code', ''),
|
||||
'auth_secret' => config('madong.auth_secret', ''),
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
];
|
||||
|
||||
/** @var PluginRemoteService $pluginRemoteService */
|
||||
$pluginRemoteService = Container::make(PluginRemoteService::class);
|
||||
return $pluginRemoteService->getRemoteUpdateLogs(
|
||||
$config['auth_code'],
|
||||
$config['auth_secret'],
|
||||
$moduleName
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\plugin;
|
||||
|
||||
use core\tool\Sse;
|
||||
use core\uuid\UUIDGenerator;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 插件卸载服务(统一入口)
|
||||
* CLI 和在线卸载都统一调用此服务
|
||||
* CLI 模式通过迭代器获取 SSE 事件并解析
|
||||
* 在线模式直接返回 Generator 给前端处理
|
||||
*/
|
||||
class PluginUninstallService extends PluginBaseService
|
||||
{
|
||||
/**
|
||||
* 卸载插件(统一入口)
|
||||
* 卸载流程统一使用插件自带的 Install.php
|
||||
* PluginUninstallService 只负责流程控制,实际卸载逻辑完全由插件 Install.php 处理
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
*
|
||||
* @return \Generator CLI 模式返回数组,在线模式返回 Generator
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function uninstall(string $code): \Generator
|
||||
{
|
||||
$sessionUuid = UUIDGenerator::generate();
|
||||
$request = request();
|
||||
if ($request) {
|
||||
$sessionUuid = $request->input('uuid', $sessionUuid);
|
||||
}
|
||||
|
||||
// 检查插件实体是否存在
|
||||
$pluginDir = $this->plugin_path . DIRECTORY_SEPARATOR . $code;
|
||||
$pluginConfigFile = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'info.php';
|
||||
|
||||
if (!is_dir($pluginDir) || !is_file($pluginConfigFile)) {
|
||||
yield Sse::error('插件不存在,无法卸载', [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查插件是否允许卸载
|
||||
$pluginConfig = $this->getPluginConfig($code);
|
||||
if (isset($pluginConfig['uninstall']['undeletable']) && $pluginConfig['uninstall']['undeletable'] === true) {
|
||||
yield Sse::error('该插件为系统内置插件,不允许卸载', [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
$installedConfigPath = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
||||
if (!is_file($installedConfigPath)) {
|
||||
yield Sse::error('插件已卸载,无需重复执行卸载操作', [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查插件是否提供 Install.php
|
||||
$className = "plugin\\{$code}\\Install";
|
||||
if (!class_exists($className)) {
|
||||
yield Sse::error("插件未提供 Install.php,无法进行卸载", [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 发送卸载开始事件
|
||||
yield Sse::progress('开始卸载插件', 10, ['plugin' => $code], $sessionUuid);
|
||||
|
||||
// 统一调用插件的 Install::uninstall() 方法(这是唯一的卸载入口)
|
||||
yield Sse::progress('执行插件卸载逻辑', 30, [], $sessionUuid);
|
||||
$version = $pluginConfig['version'] ?? '1.0.0';
|
||||
|
||||
// 实例化并执行插件 Install 类(传入进度回调以支持在线模式反馈)
|
||||
$installInstance = new $className();
|
||||
|
||||
// 创建临时输出文件(类似 Terminal 的方式)
|
||||
$outputFile = runtime_path(self::RUNTIME_PLUGIN_PATH . '/uninstall_' . $sessionUuid . '.log');
|
||||
// 确保目录存在
|
||||
$outputDir = dirname($outputFile);
|
||||
if (!is_dir($outputDir)) {
|
||||
mkdir($outputDir, 0777, true);
|
||||
}
|
||||
file_put_contents($outputFile, '');
|
||||
|
||||
// 定义进度回调,将插件的卸载进度实时写入文件
|
||||
$callbackCount = 0;
|
||||
$progressCallback = function (string $message, ?int $progress = null) use ($outputFile, &$callbackCount) {
|
||||
$callbackCount++;
|
||||
$progressStr = $progress !== null ? "(progress: {$progress})" : "(no progress)";
|
||||
$logLine = "[CALLBACK #{$callbackCount}] {$message} {$progressStr}\n";
|
||||
// 写入文件(追加模式)
|
||||
file_put_contents($outputFile, $logLine, FILE_APPEND);
|
||||
};
|
||||
|
||||
// 设置进度回调和在线模式
|
||||
$installInstance->setProgressCallback($progressCallback);
|
||||
$installInstance->setOnlineMode(true);
|
||||
|
||||
// 开启输出缓冲,捕获 Install::uninstall() 内部的 echo 输出
|
||||
ob_start();
|
||||
$installInstance->uninstall($version);
|
||||
$output = ob_get_clean();
|
||||
|
||||
// 将缓冲区输出也写入临时文件
|
||||
if (!empty($output)) {
|
||||
file_put_contents($outputFile, $output, FILE_APPEND);
|
||||
}
|
||||
|
||||
// 读取文件内容并 yield 所有日志
|
||||
$fileContent = file_get_contents($outputFile);
|
||||
if (!empty($fileContent)) {
|
||||
$lines = array_filter(explode("\n", $fileContent));
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (!empty($line)) {
|
||||
// 使用 Sse::progress 统一输出,不更新进度值
|
||||
yield Sse::progress($line, 30, [], $sessionUuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
@unlink($outputFile);
|
||||
|
||||
yield Sse::progress('插件卸载方法执行成功', 80, [], $sessionUuid);
|
||||
|
||||
// 删除插件内的安装配置
|
||||
yield Sse::progress('删除安装配置', 90, [], $sessionUuid);
|
||||
if (is_file($installedConfigPath)) {
|
||||
@unlink($installedConfigPath);
|
||||
}
|
||||
|
||||
// 发送卸载完成事件
|
||||
yield Sse::completed('插件卸载成功', ['plugin' => $code], $sessionUuid);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// 发送卸载失败事件
|
||||
yield Sse::error('卸载失败:' . $e->getMessage(), [
|
||||
'exception' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
], $sessionUuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件(仅删除插件包)
|
||||
* 删除条件:
|
||||
* 1. 插件必须为已卸载状态(installed.php 不存在)
|
||||
* 2. 非官方插件(undeletable != true)
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
*
|
||||
* @return void 删除成功返回 true
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function delete(string $code): void
|
||||
{
|
||||
// 1. 检查插件实体是否存在
|
||||
$pluginDir = $this->plugin_path . DIRECTORY_SEPARATOR . $code;
|
||||
$pluginConfigFile = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'info.php';
|
||||
|
||||
if (!is_dir($pluginDir) || !is_file($pluginConfigFile)) {
|
||||
throw new \Exception('插件不存在,无法删除');
|
||||
}
|
||||
|
||||
// 2. 检查插件配置
|
||||
$pluginConfig = $this->getPluginConfig($code);
|
||||
|
||||
// 3. 检查是否为系统内置插件(不允许删除)
|
||||
if (isset($pluginConfig['uninstall']['undeletable']) && $pluginConfig['uninstall']['undeletable'] === true) {
|
||||
throw new \Exception('系统内置插件不允许删除');
|
||||
}
|
||||
|
||||
// 4. 检查插件安装状态(installed.php 不存在)
|
||||
$installedConfigPath = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
||||
$isInstalled = is_file($installedConfigPath);
|
||||
|
||||
if ($isInstalled) {
|
||||
throw new \Exception('插件已安装,请先卸载后再删除');
|
||||
}
|
||||
|
||||
// 5. 检查插件是否提供 Install.php
|
||||
$className = "plugin\\{$code}\\Install";
|
||||
if (!class_exists($className)) {
|
||||
throw new \Exception("插件未提供 Install.php,无法进行删除");
|
||||
}
|
||||
|
||||
try {
|
||||
$version = $pluginConfig['version'] ?? '1.0.0';
|
||||
|
||||
// 实例化并执行插件 Install 类
|
||||
$installInstance = new $className();
|
||||
|
||||
// 设置为 CLI 模式(不使用回调)
|
||||
$installInstance->setOnlineMode(false);
|
||||
|
||||
// 执行删除
|
||||
$installInstance->delete($version);
|
||||
|
||||
// 删除后,需要清理数据库中的插件记录
|
||||
$this->deletePluginRecord($code);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \Exception('删除失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件数据库记录
|
||||
*/
|
||||
protected function deletePluginRecord(string $code): void
|
||||
{
|
||||
try {
|
||||
/** @var PluginService $service */
|
||||
$service = Container::make(PluginService::class);
|
||||
$model = $service->dao->query()->where('key', $code)->first();
|
||||
if ($model) {
|
||||
$model->delete();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('删除插件记录失败:' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import request from '@/utils/http';
|
||||
|
||||
/**
|
||||
* {{pluginName}} 插件 API
|
||||
*/
|
||||
export default {
|
||||
/**
|
||||
* 列表 API
|
||||
* @param params 查询参数
|
||||
* @returns 响应数据
|
||||
*/
|
||||
async test(params = {}) {
|
||||
return request.get<{ msg?: string; data?: unknown }>({
|
||||
url: '/{{pluginKey}}/index',
|
||||
params
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"{{pluginName}}": {
|
||||
"title": "{{pluginTitle}}",
|
||||
"description": "{{pluginDescription}}",
|
||||
"list": "列表",
|
||||
"add": "添加",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"detail": "详情",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"search": "搜索",
|
||||
"reset": "重置",
|
||||
"confirm": "确认",
|
||||
"success": "操作成功",
|
||||
"error": "操作失败",
|
||||
"loading": "加载中..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
|
||||
export default {
|
||||
path: '/{{pluginName}}',
|
||||
name: '{{camelPluginName}}',
|
||||
component: RoutesAlias.Layout,
|
||||
meta: {
|
||||
title: '{{pluginTitle}}',
|
||||
icon: 'ri:plugin-line',
|
||||
module: '{{pluginName}}'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
name: '{{camelPluginName}}Index',
|
||||
component: '/{{pluginKey}}/index/index',
|
||||
meta: {
|
||||
title: '首页',
|
||||
icon: 'ri:home-line',
|
||||
keepAlive: false,
|
||||
module: '{{pluginName}}'
|
||||
}
|
||||
}
|
||||
]
|
||||
} as AppRouteRecord
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="{{pluginName}}-container">
|
||||
<div class="plugin-header">
|
||||
<h1>{{ $t('{{pluginName}}.title') }}</h1>
|
||||
<p>{{ $t('{{pluginName}}.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="plugin-content">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ $t('{{pluginName}}.card_title') }}</span>
|
||||
<el-button type="primary" @click="fetchTestData">
|
||||
{{ $t('{{pluginName}}.refresh_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="loading" class="card-body">
|
||||
<div v-if="error" class="error-message">
|
||||
<el-alert
|
||||
:title="error"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="testData" class="plugin-data">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="测试消息">
|
||||
{{ testData.msg }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据">
|
||||
{{ testData.data }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<el-empty description="暂无数据" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import pluginApi from '../../../api/index';
|
||||
|
||||
export default defineComponent({
|
||||
name: '{{camelPluginName}}View',
|
||||
setup() {
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const testData = ref<{ msg?: string; data?: unknown } | null>(null);
|
||||
|
||||
const fetchTestData = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await pluginApi.test();
|
||||
testData.value = response;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : '网络错误';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载数据
|
||||
fetchTestData();
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
testData,
|
||||
fetchTestData
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.{{pluginName}}-container {
|
||||
padding: 20px;
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
.plugin-header {
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plugin-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.plugin-header p {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.plugin-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.plugin-data {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.plugin-data pre {
|
||||
background-color: #f5f7fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => '{{pluginName}}',
|
||||
'title' => '{{pluginTitle}}',
|
||||
'type' => 'madong',
|
||||
'description' => '{{pluginDescription}}',
|
||||
'author' => 'Your Name',
|
||||
'email' => 'your.email@example.com',
|
||||
'version' => '1.0.0',
|
||||
// Composer 依赖
|
||||
'require' => [
|
||||
'composer' => [],
|
||||
],
|
||||
// NPM 依赖
|
||||
'npm_require' => [
|
||||
'admin' => [],
|
||||
'web' => [],
|
||||
],
|
||||
|
||||
// 卸载配置
|
||||
'uninstall' => [
|
||||
'undeletable' => false, // 是否不可删除(true: 系统内置插件不可删除, false: 可删除)
|
||||
'drop_tables' => true, // 删除数据表
|
||||
'remove_dependencies' => false, // 移除合并的依赖
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enable' => true,
|
||||
'debug' => true,
|
||||
'controller_suffix' => 'Controller',
|
||||
'controller_reuse' => false,
|
||||
'version' => '1.0.0',
|
||||
];
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
* {{camelPluginName}} 控制器
|
||||
*/
|
||||
|
||||
namespace plugin\{{pluginName}}\app\adminapi\controller;
|
||||
|
||||
use app\adminapi\middleware\OperationMiddleware;
|
||||
use app\adminapi\middleware\PermissionMiddleware;
|
||||
use core\tool\Json;
|
||||
use madong\swagger\annotation\response\DataResponse;
|
||||
use madong\swagger\annotation\response\SimpleResponse;
|
||||
use madong\swagger\attribute\Permission;
|
||||
use madong\swagger\attribute\AllowAnonymous;
|
||||
use OpenApi\Attributes as OA;
|
||||
use support\annotation\Middleware;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
#[Middleware(OperationMiddleware::class, PermissionMiddleware::class)]
|
||||
class {{camelPluginName}}Controller
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/{{pluginKey}}/index',
|
||||
summary: '获取列表',
|
||||
tags: ['{{pluginName}}']
|
||||
)]
|
||||
#[DataResponse(description: '获取成功')]
|
||||
#[Permission(code: '{{pluginName}}:index', description: '列表权限')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Json::success('获取成功', []);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/{{pluginKey}}/create',
|
||||
summary: '创建',
|
||||
tags: ['{{pluginName}}']
|
||||
)]
|
||||
#[SimpleResponse(description: '创建成功')]
|
||||
#[Permission(code: '{{pluginName}}:create', description: '创建权限')]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Json::success('创建成功');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/{{pluginKey}}/info',
|
||||
summary: '详情',
|
||||
tags: ['{{pluginName}}']
|
||||
)]
|
||||
#[DataResponse(description: '获取成功')]
|
||||
#[Permission(code: '{{pluginName}}:info', description: '详情权限')]
|
||||
public function info(Request $request): Response
|
||||
{
|
||||
return Json::success('获取成功', []);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/{{pluginKey}}/update',
|
||||
summary: '更新',
|
||||
tags: ['{{pluginName}}']
|
||||
)]
|
||||
#[SimpleResponse(description: '更新成功')]
|
||||
#[Permission(code: '{{pluginName}}:update', description: '更新权限')]
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
return Json::success('更新成功');
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/{{pluginKey}}/delete',
|
||||
summary: '删除',
|
||||
tags: ['{{pluginName}}']
|
||||
)]
|
||||
#[SimpleResponse(description: '删除成功')]
|
||||
#[Permission(code: '{{pluginName}}:delete', description: '删除权限')]
|
||||
public function delete(Request $request): Response
|
||||
{
|
||||
return Json::success('删除成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace plugin\{{pluginName}};
|
||||
|
||||
use core\plugin\PluginInstall as BasePluginInstall;
|
||||
|
||||
/**
|
||||
* 插件安装类
|
||||
* 继承 PluginInstall 基类,自动处理:
|
||||
* - 数据库迁移 (resource/database/migrations/)
|
||||
* - 种子数据 (resource/database/seeds/)
|
||||
*
|
||||
* 菜单需要按需导入或卸载;模板在安装时导入,物理删除由删除插件阶段统一处理:
|
||||
* - $this->importPluginMenus() - 导入菜单
|
||||
* - $this->importTemplates() - 复制模板
|
||||
* - $this->uninstallPluginMenus() - 卸载菜单
|
||||
*/
|
||||
class Install extends BasePluginInstall
|
||||
{
|
||||
/**
|
||||
* 安装前回调
|
||||
*/
|
||||
protected function beforeInstall(string $version): void
|
||||
{
|
||||
$this->output(" 🔄 Running beforeInstall...\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装后回调 - 按需导入菜单和模板
|
||||
*/
|
||||
protected function afterInstall(string $version): void
|
||||
{
|
||||
$this->output(" 🔄 Running afterInstall...\n");
|
||||
|
||||
// 导入菜单
|
||||
$this->importPluginMenus();
|
||||
|
||||
// 复制模板资源
|
||||
$this->importTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载前回调
|
||||
*/
|
||||
protected function beforeUninstall(string $version): void
|
||||
{
|
||||
$this->output(" 🔄 Running beforeUninstall...\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载后回调 - 清理菜单
|
||||
*/
|
||||
protected function afterUninstall(string $version): void
|
||||
{
|
||||
$this->output(" 🔄 Running afterUninstall...\n");
|
||||
// 卸载菜单
|
||||
$this->uninstallPluginMenus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 后台菜单配置
|
||||
* 安装时会自动加载
|
||||
*/
|
||||
|
||||
return [
|
||||
[
|
||||
'name' => '{{pluginTitle}}',
|
||||
'code' => '{{pluginKey}}',
|
||||
'app' => '{{pluginName}}',
|
||||
'path' => '/{{pluginKey}}',
|
||||
'icon' => 'plugin',
|
||||
'children' => [
|
||||
[
|
||||
'name' => '列表',
|
||||
'code' => '{{pluginKey}}:index',
|
||||
'app' => '{{pluginName}}',
|
||||
'path' => '/{{pluginKey}}/index',
|
||||
'component' => '/{{pluginKey}}/index/index',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 前端菜单配置
|
||||
*/
|
||||
|
||||
return [
|
||||
// 前端菜单配置
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Web 菜单配置
|
||||
*/
|
||||
|
||||
return [
|
||||
// Web 菜单配置
|
||||
];
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* {{pluginName}} 插件路由配置
|
||||
* 使用 Swagger 注解自动生成路由
|
||||
*/
|
||||
|
||||
use Webman\Route;
|
||||
use WebmanTech\Swagger\Swagger;
|
||||
use OpenApi\Annotations as OA;
|
||||
|
||||
/**
|
||||
* 注册 API 路由
|
||||
*/
|
||||
Route::group('/api', function () {
|
||||
Swagger::create()->registerRoute([
|
||||
'route_prefix' => '/{{pluginKey}}/openapi',
|
||||
'register_route' => true,
|
||||
'openapi_doc' => [
|
||||
'scan_path' => [
|
||||
base_path('plugin/{{pluginName}}/app/api'),
|
||||
],
|
||||
'modify' => function (OA\OpenApi $openapi) {
|
||||
$openapi->info->title = '{{pluginTitle}} API';
|
||||
$openapi->info->version = '1.0.0';
|
||||
$openapi->servers = [
|
||||
new OA\Server(
|
||||
[
|
||||
'url' => '/api',
|
||||
'description' => request()->host(),
|
||||
]
|
||||
),
|
||||
];
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (!$openapi->components instanceof OA\Components) {
|
||||
$openapi->components = new OA\Components([]);
|
||||
}
|
||||
$openapi->components->securitySchemes = [
|
||||
new OA\SecurityScheme([
|
||||
'securityScheme' => 'api_key',
|
||||
'type' => 'apiKey',
|
||||
'name' => config('core.jwt.app.token_name', 'Authorization'),
|
||||
'in' => 'header',
|
||||
]),
|
||||
];
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 注册 AdminAPI 路由
|
||||
*/
|
||||
Route::group('/adminapi', function () {
|
||||
Swagger::create()->registerRoute([
|
||||
'route_prefix' => '/{{pluginKey}}/openapi',
|
||||
'register_route' => true,
|
||||
'openapi_doc' => [
|
||||
'scan_path' => [
|
||||
base_path('plugin/{{pluginName}}/app/adminapi'),
|
||||
],
|
||||
'modify' => function (OA\OpenApi $openapi) {
|
||||
$openapi->info->title = '{{pluginTitle}} API';
|
||||
$openapi->info->version = '1.0.0';
|
||||
$openapi->servers = [
|
||||
new OA\Server(
|
||||
[
|
||||
'url' => '/adminapi',
|
||||
'description' => request()->host(),
|
||||
]
|
||||
),
|
||||
];
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (!$openapi->components instanceof OA\Components) {
|
||||
$openapi->components = new OA\Components([]);
|
||||
}
|
||||
$openapi->components->securitySchemes = [
|
||||
new OA\SecurityScheme([
|
||||
'securityScheme' => 'api_key',
|
||||
'type' => 'apiKey',
|
||||
'name' => config('core.jwt.app.token_name', 'Authorization'),
|
||||
'in' => 'header',
|
||||
]),
|
||||
];
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'locale' => config('app.lang'),
|
||||
'fallback_locale' => ['zh_CN', 'en'],
|
||||
'path' => base_path() . '/plugin/{{pluginName}}/resource/translations',
|
||||
];
|
||||
@@ -0,0 +1,834 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\terminal;
|
||||
|
||||
use app\enum\terminal\CommandEvent;
|
||||
use core\tool\Sse;
|
||||
use core\tool\Util;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
|
||||
/**
|
||||
* Terminal 执行终端命令并通过 SSE 实时输出结果。
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
final class Terminal
|
||||
{
|
||||
/**
|
||||
* 插件临时目录基础路径
|
||||
*/
|
||||
const RUNTIME_PLUGIN_PATH = 'runtime/install/plugin';
|
||||
|
||||
/**
|
||||
* 插件终端输出目录
|
||||
*/
|
||||
const PLUGIN_TERMINAL_PATH = 'runtime/install/plugin/terminal';
|
||||
|
||||
/**
|
||||
* @var string 当前执行的命令 $command 的 key
|
||||
*/
|
||||
protected string $commandKey = '';
|
||||
|
||||
/**
|
||||
* @var array proc_open 的参数
|
||||
*/
|
||||
protected array $descriptorsSpec = [];
|
||||
|
||||
/**
|
||||
* @var resource|bool proc_open 返回的 resource
|
||||
*/
|
||||
protected $process = false;
|
||||
|
||||
/**
|
||||
* @var array proc_open 的管道
|
||||
*/
|
||||
protected array $pipes = [];
|
||||
|
||||
/**
|
||||
* @var int proc执行状态:0=未执行,1=执行中,2=执行完毕
|
||||
*/
|
||||
protected int $procStatusMark = 0;
|
||||
|
||||
/**
|
||||
* @var array proc执行状态数据
|
||||
*/
|
||||
protected array $procStatusData = [];
|
||||
|
||||
/**
|
||||
* @var string 命令在前台的uuid
|
||||
*/
|
||||
protected string $uuid = '';
|
||||
|
||||
/**
|
||||
* @var string 扩展信息
|
||||
*/
|
||||
protected string $extend = '';
|
||||
|
||||
/**
|
||||
* @var string 命令执行输出文件
|
||||
*/
|
||||
protected string $outputFile = '';
|
||||
|
||||
/**
|
||||
* @var string 命令执行实时输出内容
|
||||
*/
|
||||
protected string $outputContent = '';
|
||||
|
||||
/**
|
||||
* @var array 命令变量替换
|
||||
*/
|
||||
protected array $commandVariables = [];
|
||||
|
||||
/**
|
||||
* @var array 当前执行的命令配置
|
||||
*/
|
||||
protected array $commandConfig = [];
|
||||
|
||||
/**
|
||||
* @var array 拦截器映射
|
||||
*/
|
||||
protected static array $interceptMap = [
|
||||
'install' => 'app\service\core\terminal\intercept\InstallIntercept',
|
||||
'build' => 'app\service\core\terminal\intercept\BuildIntercept',
|
||||
'common' => 'app\service\core\terminal\intercept\CommonIntercept',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var int 进程退出码
|
||||
*/
|
||||
protected int $processExitCode = 0;
|
||||
|
||||
/**
|
||||
* 私有构造函数,防止直接实例化
|
||||
*
|
||||
* @param string $uuid 会话UUID
|
||||
* @param string $extend 扩展信息
|
||||
*/
|
||||
private function __construct(string $uuid = '', string $extend = '')
|
||||
{
|
||||
$this->uuid = $uuid;
|
||||
$this->extend = $extend;
|
||||
|
||||
// 初始化命令变量(先初始化变量,再使用变量)
|
||||
$this->initCommandVariables();
|
||||
|
||||
// 初始化输出目录和文件
|
||||
$this->initOutputFile();
|
||||
|
||||
// 初始化管道描述符
|
||||
$this->initDescriptors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化输出文件
|
||||
*/
|
||||
protected function initOutputFile(): void
|
||||
{
|
||||
$config = config('terminal.execution', []);
|
||||
$outputDir = $config['output_dir'] ?? base_path(self::PLUGIN_TERMINAL_PATH);
|
||||
$outputFile = $config['output_file'] ?? 'exec.log';
|
||||
|
||||
// 替换路径中的变量
|
||||
$outputDir = $this->replaceCommandVariables($outputDir);
|
||||
|
||||
$this->outputFile = $outputDir . DIRECTORY_SEPARATOR . $outputFile;
|
||||
$filesystem = new Filesystem();
|
||||
|
||||
if (!is_dir($outputDir)) {
|
||||
$filesystem->makeDirectory($outputDir, 0755, true);
|
||||
}
|
||||
$filesystem->put($this->outputFile, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化管道描述符
|
||||
*/
|
||||
protected function initDescriptors(): void
|
||||
{
|
||||
/**
|
||||
* 命令执行结果输出到文件而不是管道
|
||||
* 因为输出到管道时有延迟,而文件虽然需要频繁读取和对比内容,但是输出实时的
|
||||
*/
|
||||
$this->descriptorsSpec = [
|
||||
0 => ['pipe', 'r'], // 标准输入
|
||||
1 => ['file', $this->outputFile, 'w'], // 标准输出
|
||||
2 => ['file', $this->outputFile, 'w'], // 标准错误
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化命令变量
|
||||
*/
|
||||
protected function initCommandVariables(): void
|
||||
{
|
||||
// 后端根目录(server目录)
|
||||
$backendRoot = base_path();
|
||||
// 项目根目录(包含server、admin、web等目录的父目录)
|
||||
$projectRoot = dirname($backendRoot);
|
||||
|
||||
// 检查是否在命令行环境下执行
|
||||
if (php_sapi_name() === 'cli') {
|
||||
// 在命令行环境下,使用默认值
|
||||
$platform = 'h5';
|
||||
$env = 'production';
|
||||
} else {
|
||||
// 在Web环境下,使用request()函数获取参数
|
||||
$platform = request()->input('platform', 'h5');
|
||||
$env = request()->input('env', 'production');
|
||||
}
|
||||
|
||||
$this->commandVariables = [
|
||||
'%PLATFORM%' => $platform, // Uni-app平台变量
|
||||
'%ENV%' => $env, // 环境变量
|
||||
'{package_manager}' => config('terminal.npm_package_manager', 'npm'), // 包管理器
|
||||
'{backend_root}' => $backendRoot, // 后端根目录
|
||||
'{project_root}' => $projectRoot, // 项目根目录
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取解析后的命令键(去掉包管理器后缀)
|
||||
*
|
||||
* @param string $key 原始命令键
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getResolvedCommandKey(string $key): string
|
||||
{
|
||||
$keyParts = explode('.', $key);
|
||||
|
||||
// 检查是否是安装或构建命令,尝试去掉包管理器后缀
|
||||
// 注意:install.server 是后端命令,不使用包管理器
|
||||
if (count($keyParts) > 2 && (in_array($keyParts[0], ['install', 'build']) && $keyParts[1] !== 'server')) {
|
||||
// 重新构建命令键,去掉最后一个部分(包管理器)
|
||||
return implode('.', array_slice($keyParts, 0, -1));
|
||||
}
|
||||
// 对于其他命令(如debug、custom等),也尝试去掉包管理器后缀
|
||||
if (count($keyParts) > 1) {
|
||||
// 检查最后一个部分是否是包管理器
|
||||
$lastPart = end($keyParts);
|
||||
$packageManagers = config('terminal.package_managers', []);
|
||||
if (isset($packageManagers[$lastPart])) {
|
||||
// 重新构建命令键,去掉最后一个部分(包管理器)
|
||||
return implode('.', array_slice($keyParts, 0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取命令配置
|
||||
*
|
||||
* @param string $key 命令key
|
||||
*
|
||||
* @return array|bool
|
||||
*/
|
||||
public static function getCommand(string $key): bool|array
|
||||
{
|
||||
if (!$key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$commands = config('terminal.commands', []);
|
||||
|
||||
// 支持多级命令解析(如:frontend.admin.npm)
|
||||
$keyParts = explode('.', $key);
|
||||
|
||||
$currentLevel = $commands;
|
||||
foreach ($keyParts as $part) {
|
||||
if (!isset($currentLevel[$part])) {
|
||||
// 检查是否是安装或构建命令,尝试去掉包管理器后缀
|
||||
// 注意:install.server 是后端命令,不使用包管理器
|
||||
if (count($keyParts) > 2 && (in_array($keyParts[0], ['install', 'build']) && $keyParts[1] !== 'server')) {
|
||||
// 重新构建命令键,去掉最后一个部分(包管理器)
|
||||
$newKey = implode('.', array_slice($keyParts, 0, -1));
|
||||
return self::getCommand($newKey);
|
||||
}
|
||||
// 对于其他命令(如debug、custom等),也尝试去掉包管理器后缀
|
||||
if (count($keyParts) > 1) {
|
||||
// 检查最后一个部分是否是包管理器
|
||||
$lastPart = end($keyParts);
|
||||
$packageManagers = config('terminal.package_managers', []);
|
||||
if (isset($packageManagers[$lastPart])) {
|
||||
// 重新构建命令键,去掉最后一个部分(包管理器)
|
||||
$newKey = implode('.', array_slice($keyParts, 0, -1));
|
||||
return self::getCommand($newKey);
|
||||
}
|
||||
}
|
||||
// 如果是test命令,尝试使用default
|
||||
if ($part === 'test' && isset($currentLevel['test']['default'])) {
|
||||
$currentLevel = $currentLevel['test']['default'];
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
$currentLevel = $currentLevel[$part];
|
||||
}
|
||||
|
||||
// 如果最终结果不是数组,则包装为命令格式
|
||||
if (!is_array($currentLevel)) {
|
||||
$command = [
|
||||
'cwd' => base_path(),
|
||||
'command' => $currentLevel,
|
||||
];
|
||||
} else {
|
||||
// 检查是否包含命令配置
|
||||
if (isset($currentLevel['cwd']) && isset($currentLevel['command'])) {
|
||||
$command = $currentLevel;
|
||||
} else {
|
||||
// 如果没有明确的命令配置,尝试使用default
|
||||
if (isset($currentLevel['default'])) {
|
||||
$command = $currentLevel['default'];
|
||||
} else {
|
||||
// 如果没有明确的命令配置,返回false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保必要的字段存在
|
||||
$command = array_merge([
|
||||
'cwd' => base_path(),
|
||||
'command' => '',
|
||||
'description' => '',
|
||||
'variables' => [],
|
||||
'before' => null,
|
||||
'after' => null,
|
||||
], $command);
|
||||
|
||||
$command['cwd'] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $command['cwd']);
|
||||
return $command;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换命令中的变量
|
||||
*
|
||||
* @param string $command
|
||||
* @param array $variables
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function replaceCommandVariables(string $command, array $variables = []): string
|
||||
{
|
||||
// 合并变量,确保 $this->commandVariables 的优先级高于 $variables
|
||||
$allVariables = $this->commandVariables;
|
||||
foreach ($variables as $key => $value) {
|
||||
// 只有当 $this->commandVariables 中不存在该键时,才使用 $variables 中的值
|
||||
if (!isset($allVariables[$key])) {
|
||||
$allVariables[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($allVariables as $variable => $value) {
|
||||
// 如果值是配置键(如 'npm_package_manager'),则解析配置值
|
||||
if (is_string($value) && config($value) !== null) {
|
||||
$value = config($value);
|
||||
}
|
||||
$command = str_replace($variable, $value, $command);
|
||||
}
|
||||
|
||||
return $command;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令并通过输出-Generator(HTTP请求方式)
|
||||
*
|
||||
* @param string|null $commandKey 命令key
|
||||
* @param bool $authentication
|
||||
*
|
||||
* @return \Generator
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function exec(?string $commandKey = null, bool $authentication = true): \Generator
|
||||
{
|
||||
$originalCommandKey = $commandKey ?? request()->input('command');
|
||||
$command = self::getCommand($originalCommandKey);
|
||||
if (!$command) {
|
||||
yield Sse::error('命令不允许执行或不存在: ' . $originalCommandKey, null, $this->uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 存储命令配置
|
||||
$this->commandConfig = $command;
|
||||
// 使用解析后的命令键(去掉包管理器后缀)
|
||||
$this->commandKey = $this->getResolvedCommandKey($originalCommandKey);
|
||||
|
||||
// 检查服务是否启用
|
||||
if (!config('terminal.enabled', false)) {
|
||||
yield Sse::error(date('Y-m-d H:i',time()).' WEB终端服务未启用', null, $this->uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->beforeExecution();
|
||||
|
||||
// 执行前阶段
|
||||
if (!empty($command['description'])) {
|
||||
yield Sse::progress($command['description'], 0, ['stage' => 'before'], $this->uuid);
|
||||
}
|
||||
|
||||
yield Sse::progress('连接成功', 5, ['stage' => 'connected'], $this->uuid);
|
||||
|
||||
// 执行前置钩子
|
||||
yield from $this->executeIntercept('before', $command);
|
||||
|
||||
// 替换命令变量
|
||||
$finalCommand = $this->replaceCommandVariables($command['command'], $command['variables'] ?? []);
|
||||
// 替换 cwd 中的变量
|
||||
$finalCwd = $this->replaceCommandVariables($command['cwd'], $command['variables'] ?? []);
|
||||
yield Sse::progress($finalCommand, 10, ['stage' => 'before', 'command' => $finalCommand], $this->uuid);
|
||||
|
||||
// 执行命令
|
||||
$success = $this->executeCommand($finalCommand, $finalCwd);
|
||||
if (!$success) {
|
||||
yield Sse::error('命令启动失败', null, $this->uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
yield Sse::progress('命令开始执行', 15, ['stage' => 'executing'], $this->uuid);
|
||||
|
||||
// 实时输出处理
|
||||
yield from $this->handleRealTimeOutput();
|
||||
|
||||
// 执行后处理
|
||||
yield from $this->afterExecution();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行拦截器
|
||||
*
|
||||
* @param string $stage 阶段(before/after)
|
||||
* @param array $command 命令配置
|
||||
*
|
||||
* @return \Generator
|
||||
*/
|
||||
protected function executeIntercept(string $stage, array $command): \Generator
|
||||
{
|
||||
// 如果配置了自定义拦截器,使用自定义拦截器
|
||||
if (isset($command[$stage])) {
|
||||
$hook = $command[$stage];
|
||||
if (is_array($hook) && isset($hook['class']) && isset($hook['method'])) {
|
||||
$class = $hook['class'];
|
||||
$method = $hook['method'];
|
||||
|
||||
if (class_exists($class)) {
|
||||
// 检查是否实现了 InterceptInterface 接口
|
||||
if (in_array('app\service\core\terminal\intercept\InterceptInterface', class_implements($class))) {
|
||||
if (method_exists($class, $method)) {
|
||||
$instance = new $class($this->uuid);
|
||||
$params = $stage === 'after' ? [$this->commandKey, $this->processExitCode ?? 0] : [];
|
||||
$generator = call_user_func_array([$instance, $method], $params);
|
||||
|
||||
foreach ($generator as $message) {
|
||||
yield $message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据命令类型选择默认拦截器
|
||||
$commandType = explode('.', $this->commandKey)[0] ?? 'common';
|
||||
$interceptClass = self::$interceptMap[$commandType] ?? self::$interceptMap['common'];
|
||||
|
||||
if (class_exists($interceptClass)) {
|
||||
// 检查是否实现了 InterceptInterface 接口
|
||||
if (in_array('app\service\core\terminal\intercept\InterceptInterface', class_implements($interceptClass))) {
|
||||
$instance = new $interceptClass($this->uuid);
|
||||
$method = $stage;
|
||||
|
||||
if (method_exists($instance, $method)) {
|
||||
$params = $stage === 'after' ? [$this->commandKey, $this->processExitCode ?? 0] : [];
|
||||
$generator = call_user_func_array([$instance, $method], $params);
|
||||
|
||||
foreach ($generator as $message) {
|
||||
yield $message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行钩子方法
|
||||
*
|
||||
* @param mixed $hook 钩子配置
|
||||
* @param array $params 参数
|
||||
*
|
||||
* @return \Generator
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function executeHook($hook, array $params = []): \Generator
|
||||
{
|
||||
try {
|
||||
if (is_callable($hook)) {
|
||||
$result = call_user_func_array($hook, $params);
|
||||
if ($result instanceof \Generator) {
|
||||
yield from $result;
|
||||
} elseif (is_string($result)) {
|
||||
yield Sse::progress($result, 8, ['stage' => 'hook'], $this->uuid);
|
||||
}
|
||||
} elseif (is_array($hook) && isset($hook['class']) && isset($hook['method'])) {
|
||||
$class = $hook['class'];
|
||||
$method = $hook['method'];
|
||||
|
||||
if (class_exists($class)) {
|
||||
if (method_exists($class, $method)) {
|
||||
if ($class === get_class($this)) {
|
||||
$result = call_user_func_array([$this, $method], $params);
|
||||
} else {
|
||||
$reflection = new \ReflectionClass($class);
|
||||
$constructor = $reflection->getConstructor();
|
||||
if ($constructor && $constructor->getNumberOfParameters() > 0) {
|
||||
$instance = new $class($this->uuid);
|
||||
} else {
|
||||
$instance = new $class();
|
||||
}
|
||||
$result = call_user_func_array([$instance, $method], $params);
|
||||
}
|
||||
if ($result instanceof \Generator) {
|
||||
yield from $result;
|
||||
} elseif (is_string($result)) {
|
||||
yield Sse::progress($result, 8, ['stage' => 'hook'], $this->uuid);
|
||||
}
|
||||
} else {
|
||||
yield Sse::progress("方法 {$method} 不存在于类 {$class} 中", 8, ['stage' => 'hook_error'], $this->uuid);
|
||||
}
|
||||
} else {
|
||||
yield Sse::progress("类 {$class} 不存在", 8, ['stage' => 'hook_error'], $this->uuid);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
yield Sse::progress("执行钩子方法时出错: " . $e->getMessage(), 8, ['stage' => 'hook_error'], $this->uuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令并返回简单结果(用于非SSE上下文)
|
||||
*
|
||||
* @param string $commandKey 命令key
|
||||
* @param array $variables 命令变量
|
||||
* @param string $sessionUuid 会话UUID
|
||||
*
|
||||
* @return array
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function executeSimple(string $commandKey, array $variables = [], string $sessionUuid = ''): array
|
||||
{
|
||||
$this->commandKey = $commandKey;
|
||||
$this->uuid = $sessionUuid ?: uniqid('terminal_', true);
|
||||
|
||||
$command = self::getCommand($this->commandKey);
|
||||
if (!$command) {
|
||||
return ['success' => false, 'message' => '错误:命令不允许执行或不存在: ' . $this->commandKey];
|
||||
}
|
||||
|
||||
// 检查服务是否启用
|
||||
if (!config('terminal.enabled', false)) {
|
||||
return ['success' => false, 'message' => 'Terminal服务未启用'];
|
||||
}
|
||||
|
||||
$this->beforeExecution();
|
||||
|
||||
// 合并命令变量
|
||||
$allVariables = array_merge($command['variables'] ?? [], $variables);
|
||||
$finalCommand = $this->replaceCommandVariables($command['command'], $allVariables);
|
||||
|
||||
// 执行命令
|
||||
if (!$this->executeCommand($finalCommand, $command['cwd'])) {
|
||||
return ['success' => false, 'message' => '执行失败'];
|
||||
}
|
||||
|
||||
// 等待命令执行完成
|
||||
$this->waitForCompletion();
|
||||
|
||||
// 获取执行结果 - 优先使用保存的状态数据中的exitcode,因为在Linux环境下proc_close可能返回-1
|
||||
$exitCode = -1;
|
||||
if (!empty($this->procStatusData) && isset($this->procStatusData['exitcode'])) {
|
||||
$exitCode = $this->procStatusData['exitcode'];
|
||||
} else if (is_resource($this->process)) {
|
||||
$exitCode = proc_close($this->process);
|
||||
$this->process = false;
|
||||
}
|
||||
|
||||
|
||||
// 清理资源
|
||||
if (is_resource($this->process)) {
|
||||
proc_close($this->process);
|
||||
$this->process = false;
|
||||
}
|
||||
$this->pipes = [];
|
||||
$this->procStatusMark = 0;
|
||||
$this->procStatusData = [];
|
||||
|
||||
return [
|
||||
'success' => $exitCode === 0,
|
||||
'message' => $exitCode === 0 ? '命令执行成功' : '命令执行失败,代码: ' . $exitCode,
|
||||
'exitCode' => $exitCode
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待命令执行完成
|
||||
*/
|
||||
protected function waitForCompletion(): void
|
||||
{
|
||||
while ($this->getProcStatus()) {
|
||||
usleep(100000); // 100ms
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
*
|
||||
* @param string $command
|
||||
* @param string $cwd
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function executeCommand(string $command, string $cwd): bool
|
||||
{
|
||||
// 在 Windows 环境下,需要使用 cmd.exe /c 来执行命令
|
||||
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
||||
$command = 'cmd.exe /c ' . $command;
|
||||
}
|
||||
$this->process = proc_open($command, $this->descriptorsSpec, $this->pipes, $cwd);
|
||||
return is_resource($this->process);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理实时输出
|
||||
*
|
||||
* @return \Generator
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function handleRealTimeOutput(): \Generator
|
||||
{
|
||||
$config = config('terminal.execution', []);
|
||||
$pollInterval = $config['poll_interval'] ?? 500000;
|
||||
|
||||
$this->outputContent = file_get_contents($this->outputFile);
|
||||
$lineCount = 0;
|
||||
while ($this->getProcStatus()) {
|
||||
$contents = file_get_contents($this->outputFile);
|
||||
if ($contents !== $this->outputContent) {
|
||||
$newOutput = substr($contents, strlen($this->outputContent));
|
||||
// 只要有新输出就发送事件,不管是否包含换行符
|
||||
if (!empty($newOutput)) {
|
||||
$lineCount++;
|
||||
// 进度 15-90 之间
|
||||
$progress = min(90, 15 + (int)($lineCount * 2));
|
||||
yield Sse::progress($newOutput, $progress, ['stage' => 'output'], $this->uuid);
|
||||
$this->outputContent = $contents;
|
||||
}
|
||||
}
|
||||
usleep($pollInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行后处理
|
||||
*
|
||||
* @return \Generator
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function afterExecution(): \Generator
|
||||
{
|
||||
// 发送剩余的输出
|
||||
$contents = file_get_contents($this->outputFile);
|
||||
if ($contents !== $this->outputContent) {
|
||||
$newOutput = substr($contents, strlen($this->outputContent));
|
||||
if (!empty($newOutput)) {
|
||||
yield Sse::progress($newOutput, 90, ['stage' => 'output'], $this->uuid);
|
||||
}
|
||||
}
|
||||
|
||||
// 优先使用保存的状态数据中的exitcode,因为在Linux环境下proc_close可能返回-1
|
||||
$exitCode = -1;
|
||||
if (!empty($this->procStatusData) && isset($this->procStatusData['exitcode'])) {
|
||||
$exitCode = $this->procStatusData['exitcode'];
|
||||
} else if (is_resource($this->process)) {
|
||||
$exitCode = proc_close($this->process);
|
||||
$this->process = false;
|
||||
}
|
||||
|
||||
yield Sse::progress('退出代码: ' . $exitCode, 95, ['stage' => 'after', 'exitCode' => $exitCode], $this->uuid);
|
||||
|
||||
$success = $exitCode === 0;
|
||||
|
||||
// 保存退出码供拦截器使用
|
||||
$this->processExitCode = $exitCode;
|
||||
|
||||
// 执行后置方法(无论成功失败都执行)
|
||||
yield from $this->executeIntercept('after', $this->commandConfig);
|
||||
|
||||
if ($success) {
|
||||
if ($this->successCallback()) {
|
||||
yield Sse::progress('命令执行成功', 99, ['stage' => 'after'], $this->uuid);
|
||||
} else {
|
||||
yield Sse::error('回调执行失败', ['exitCode' => $exitCode], $this->uuid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理资源
|
||||
if (is_resource($this->process)) {
|
||||
proc_close($this->process);
|
||||
$this->process = false;
|
||||
}
|
||||
$this->pipes = [];
|
||||
$this->procStatusMark = 0;
|
||||
$this->procStatusData = [];
|
||||
|
||||
// 重新加载Webman(如果是特定命令)
|
||||
if ($success && in_array(explode('.', $this->commandKey)[0], ['build', 'backend.composer'])) {
|
||||
Util::reloadWebman();
|
||||
}
|
||||
|
||||
// 发送完成事件
|
||||
yield Sse::completed(
|
||||
$success ? 'Success' : '执行失败,退出代码: ' . $exitCode,
|
||||
[
|
||||
'success' => $success,
|
||||
'exitCode' => $exitCode,
|
||||
'command' => $this->commandKey,
|
||||
],
|
||||
$this->uuid
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进程状态
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getProcStatus(): bool
|
||||
{
|
||||
if (!$this->process) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = proc_get_status($this->process);
|
||||
if (!$status['running']) {
|
||||
$this->procStatusMark = 2;
|
||||
$this->procStatusData = $status; // 保存完整的状态数据,包括exitcode
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->procStatusMark = 1;
|
||||
$this->procStatusData = $status;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取输出文件路径
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getOutputFile(): string
|
||||
{
|
||||
return $this->outputFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进程退出代码
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getProcessExitCode(): int
|
||||
{
|
||||
if ($this->process) {
|
||||
$status = proc_get_status($this->process);
|
||||
if (isset($status['exitcode'])) {
|
||||
return $status['exitcode'];
|
||||
}
|
||||
}
|
||||
return $this->processExitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功回调
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function successCallback(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行前处理
|
||||
*/
|
||||
protected function beforeExecution(): void
|
||||
{
|
||||
// 初始化命令变量
|
||||
$this->initCommandVariables();
|
||||
// 初始化管道描述符
|
||||
$this->initDescriptors();
|
||||
// 可以在这里添加执行前的处理逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出过滤器
|
||||
*
|
||||
* @param string $output
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function outputFilter(string $output): string
|
||||
{
|
||||
// 过滤敏感信息或格式化输出
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
public function cleanupResources(): void
|
||||
{
|
||||
if (is_resource($this->process)) {
|
||||
proc_close($this->process);
|
||||
}
|
||||
$this->process = false;
|
||||
$this->pipes = [];
|
||||
$this->procStatusMark = 0;
|
||||
$this->procStatusData = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新实例(工厂方法,每次请求创建新实例)
|
||||
*
|
||||
* @param string $uuid 会话UUID
|
||||
* @param string $extend 扩展信息
|
||||
* @return Terminal
|
||||
*/
|
||||
public static function create(string $uuid = '', string $extend = ''): Terminal
|
||||
{
|
||||
return new self($uuid, $extend);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧代码的别名方法
|
||||
* @deprecated 请使用 create() 方法
|
||||
* @return Terminal
|
||||
*/
|
||||
public static function instance(): Terminal
|
||||
{
|
||||
$uuid = request()->input('uuid', '');
|
||||
$extend = request()->input('extend', '');
|
||||
return self::create($uuid, $extend);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\terminal\intercept;
|
||||
|
||||
use core\tool\Sse;
|
||||
use support\Log;
|
||||
|
||||
/**
|
||||
* 构建拦截器类
|
||||
* 提供构建相关的前置和后置方法
|
||||
*/
|
||||
class BuildIntercept implements InterceptInterface
|
||||
{
|
||||
protected string $uuid;
|
||||
|
||||
public function __construct(string $uuid = '')
|
||||
{
|
||||
$this->uuid = $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换路径中的变量
|
||||
*
|
||||
* @param string $path 路径
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function replacePathVariables(string $path): string
|
||||
{
|
||||
// 后端根目录(server目录)
|
||||
$backendRoot = base_path();
|
||||
// 项目根目录(包含server、admin、web等目录的父目录)
|
||||
$projectRoot = dirname($backendRoot);
|
||||
|
||||
$variables = [
|
||||
'{backend_root}' => $backendRoot,
|
||||
'{project_root}' => $projectRoot,
|
||||
];
|
||||
|
||||
foreach ($variables as $variable => $value) {
|
||||
$path = str_replace($variable, $value, $path);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前置处理
|
||||
*
|
||||
* @return \Generator
|
||||
*/
|
||||
public function before(): \Generator
|
||||
{
|
||||
yield Sse::progress('开始构建前端项目...', 0, [], $this->uuid);
|
||||
yield Sse::progress('检查构建环境...', 1, [], $this->uuid);
|
||||
yield Sse::progress('清理旧的构建文件...', 2, [], $this->uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后置处理
|
||||
*
|
||||
* @param string $commandKey 命令键
|
||||
* @param int $exitCode 退出码
|
||||
*
|
||||
* @return \Generator
|
||||
*/
|
||||
public function after(string $commandKey = '', int $exitCode = 0): \Generator
|
||||
{
|
||||
if ($exitCode !== 0) {
|
||||
yield Sse::progress('构建失败,跳过迁移部署', 92, [], $this->uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
yield Sse::progress('构建完成,开始迁移部署...', 92, [], $this->uuid);
|
||||
|
||||
// 解析命令键,获取前端类型
|
||||
$keyParts = explode('.', $commandKey);
|
||||
$frontendType = $keyParts[1] ?? '';
|
||||
|
||||
// 执行迁移
|
||||
if (!empty($frontendType)) {
|
||||
yield from $this->migrateFrontendBuild($frontendType, $commandKey);
|
||||
}
|
||||
|
||||
yield Sse::progress('部署完成!', 98, [], $this->uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移前端构建文件
|
||||
*
|
||||
* @param string $frontendType 前端类型(admin/web/h5/app)
|
||||
* @param string $commandKey 命令键
|
||||
*
|
||||
* @return \Generator
|
||||
*/
|
||||
protected function migrateFrontendBuild(string $frontendType, string $commandKey = ''): \Generator
|
||||
{
|
||||
$config = config('terminal.frontend_programs', []);
|
||||
|
||||
if (!isset($config[$frontendType]) || !$config[$frontendType]['enabled']) {
|
||||
yield Sse::progress("未配置 {$frontendType} 前端程序", 93, [], $this->uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理uni-app等多平台程序
|
||||
$programConfig = $config[$frontendType];
|
||||
if ($frontendType === 'uni-app') {
|
||||
// 从命令键中提取平台信息
|
||||
$keyParts = explode('.', $commandKey);
|
||||
$platform = $keyParts[2] ?? $programConfig['default_platform'] ?? 'h5';
|
||||
|
||||
if (isset($programConfig['platforms'][$platform])) {
|
||||
$programConfig = array_merge($programConfig, $programConfig['platforms'][$platform]);
|
||||
} else {
|
||||
yield Sse::progress("未配置 {$frontendType} 的 {$platform} 平台", 93, [], $this->uuid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$sourceDir = $programConfig['source_dir'] ?? '';
|
||||
$targetDir = $programConfig['target_dir'] ?? '';
|
||||
$copyMappings = $programConfig['copy_mappings'] ?? [];
|
||||
$cleanTarget = $programConfig['clean_target'] ?? true;
|
||||
$preserveFiles = $programConfig['preserve_files'] ?? [];
|
||||
$copyOptions = $programConfig['copy_options'] ?? [];
|
||||
|
||||
// 替换路径变量
|
||||
$sourceDir = $this->replacePathVariables($sourceDir);
|
||||
$targetDir = $this->replacePathVariables($targetDir);
|
||||
|
||||
if (empty($sourceDir) || empty($targetDir)) {
|
||||
yield Sse::progress("{$frontendType} 前端程序配置不完整", 93, [], $this->uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
yield Sse::progress("开始迁移 {$frontendType} 前端构建文件...", 93, [], $this->uuid);
|
||||
|
||||
try {
|
||||
// 复制构建文件
|
||||
if ($this->copyBuildFiles($sourceDir, $targetDir, $copyMappings, $cleanTarget, $preserveFiles, $copyOptions)) {
|
||||
yield Sse::progress("{$frontendType} 前端构建文件迁移成功", 95, [], $this->uuid);
|
||||
} else {
|
||||
yield Sse::progress("{$frontendType} 前端构建文件迁移失败", 95, [], $this->uuid);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
yield Sse::progress("{$frontendType} 前端构建文件迁移失败: " . $e->getMessage(), 95, [], $this->uuid);
|
||||
Log::error("Frontend build migration failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取构建输出目录
|
||||
*
|
||||
* @param string $frontendType
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getBuildOutputDir(string $frontendType): string
|
||||
{
|
||||
$config = config('terminal.frontend_programs', []);
|
||||
|
||||
if (isset($config[$frontendType]) && isset($config[$frontendType]['source_dir'])) {
|
||||
return $this->replacePathVariables($config[$frontendType]['source_dir']);
|
||||
}
|
||||
|
||||
// 兼容旧配置
|
||||
$projectRoot = dirname(base_path());
|
||||
|
||||
$outputDirs = [
|
||||
'admin' => $projectRoot . '/admin/dist',
|
||||
'web' => $projectRoot . '/web/.output/public',
|
||||
'h5' => $projectRoot . '/uni-app/dist/build/h5',
|
||||
'app' => $projectRoot . '/uni-app/dist/build/app',
|
||||
];
|
||||
|
||||
return $outputDirs[$frontendType] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标目录
|
||||
*
|
||||
* @param string $frontendType
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getTargetDir(string $frontendType): string
|
||||
{
|
||||
$config = config('terminal.frontend_programs', []);
|
||||
|
||||
if (isset($config[$frontendType]) && isset($config[$frontendType]['target_dir'])) {
|
||||
return $this->replacePathVariables($config[$frontendType]['target_dir']);
|
||||
}
|
||||
|
||||
// 兼容旧配置
|
||||
$backendRoot = base_path();
|
||||
|
||||
$targetDirs = [
|
||||
'admin' => $backendRoot . '/public/admin',
|
||||
'web' => $backendRoot . '/public',
|
||||
'h5' => $backendRoot . '/public/h5',
|
||||
'app' => $backendRoot . '/public/app',
|
||||
];
|
||||
|
||||
return $targetDirs[$frontendType] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制构建文件
|
||||
*
|
||||
* @param string $sourceDir 源目录
|
||||
* @param string $targetDir 目标目录
|
||||
* @param array $copyMappings 复制映射
|
||||
* @param bool $cleanTarget 是否清理目标目录
|
||||
* @param array $preserveFiles 保留的文件
|
||||
* @param array $copyOptions 复制选项
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function copyBuildFiles(string $sourceDir, string $targetDir, array $copyMappings = [], bool $cleanTarget = true, array $preserveFiles = [], array $copyOptions = []): bool
|
||||
{
|
||||
if (!is_dir($sourceDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 确保目标目录存在
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
// 清理目标目录(如果需要)
|
||||
if ($cleanTarget && is_dir($targetDir)) {
|
||||
$this->cleanDirectory($targetDir, $preserveFiles);
|
||||
}
|
||||
|
||||
// 如果没有配置复制映射,默认复制所有文件
|
||||
if (empty($copyMappings)) {
|
||||
$copyMappings = ['*' => '.'];
|
||||
}
|
||||
|
||||
// 执行复制
|
||||
foreach ($copyMappings as $source => $target) {
|
||||
$sourcePath = $sourceDir . DIRECTORY_SEPARATOR . $source;
|
||||
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $target;
|
||||
|
||||
// 确保目标路径存在
|
||||
if (!is_dir($targetPath)) {
|
||||
mkdir($targetPath, 0755, true);
|
||||
}
|
||||
|
||||
// 处理通配符复制
|
||||
if (str_contains($source, '*')) {
|
||||
// 转换路径分隔符为正斜杠,确保 glob 函数在 Windows 上正常工作
|
||||
$globPath = str_replace(DIRECTORY_SEPARATOR, '/', $sourcePath);
|
||||
$files = glob($globPath);
|
||||
if (!empty($files)) {
|
||||
foreach ($files as $file) {
|
||||
$destFile = $targetPath . DIRECTORY_SEPARATOR . basename($file);
|
||||
if (is_dir($file)) {
|
||||
// 递归复制目录
|
||||
$this->copyDirectory($file, $destFile);
|
||||
} else {
|
||||
// 复制文件
|
||||
copy($file, $destFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif (is_dir($sourcePath)) {
|
||||
// 复制目录
|
||||
$this->copyDirectory($sourcePath, $targetPath);
|
||||
} else {
|
||||
// 复制单个文件
|
||||
if (file_exists($sourcePath)) {
|
||||
copy($sourcePath, $targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制目录
|
||||
*
|
||||
* @param string $source 源目录
|
||||
* @param string $target 目标目录
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function copyDirectory(string $source, string $target): bool
|
||||
{
|
||||
if (!is_dir($source)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建目标目录
|
||||
if (!is_dir($target)) {
|
||||
mkdir($target, 0755, true);
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $item) {
|
||||
$sourcePath = $item->getPathname();
|
||||
$targetPath = $target . DIRECTORY_SEPARATOR . $iterator->getSubPathName();
|
||||
|
||||
if ($item->isDir()) {
|
||||
if (!is_dir($targetPath)) {
|
||||
mkdir($targetPath, 0755, true);
|
||||
}
|
||||
} else {
|
||||
copy($sourcePath, $targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理目录
|
||||
*
|
||||
* @param string $directory 目录路径
|
||||
* @param array $preserveFiles 保留的文件
|
||||
*/
|
||||
protected function cleanDirectory(string $directory, array $preserveFiles = []): void
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = scandir($directory);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
if (in_array($file, $preserveFiles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_dir($path)) {
|
||||
$this->deleteDirectory($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目录
|
||||
*
|
||||
* @param string $directory 目录路径
|
||||
*/
|
||||
protected function deleteDirectory(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = scandir($directory);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
if (is_dir($path)) {
|
||||
$this->deleteDirectory($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($directory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\terminal\intercept;
|
||||
|
||||
use core\tool\Sse;
|
||||
|
||||
/**
|
||||
* 通用拦截器类
|
||||
* 提供默认的前置和后置方法
|
||||
*/
|
||||
class CommonIntercept implements InterceptInterface
|
||||
{
|
||||
protected string $uuid;
|
||||
|
||||
public function __construct(string $uuid = '')
|
||||
{
|
||||
$this->uuid = $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前置处理
|
||||
*
|
||||
* @return \Generator
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function before(): \Generator
|
||||
{
|
||||
yield Sse::progress('默认拦截前置处理....', 0, [], $this->uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后置处理
|
||||
*
|
||||
* @param string $commandKey 命令键
|
||||
* @param int $exitCode 退出码
|
||||
*
|
||||
* @return \Generator
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function after(string $commandKey = '', int $exitCode = 0): \Generator
|
||||
{
|
||||
if ($exitCode === 0) {
|
||||
yield Sse::progress('默认拦截后置处理', 98, [], $this->uuid);
|
||||
} else {
|
||||
yield Sse::progress('默认拦截后置处理-失败,退出代码: ' . $exitCode, 98, [], $this->uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\terminal\intercept;
|
||||
|
||||
use core\tool\Sse;
|
||||
|
||||
/**自定义示例拦截类
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class CustomIntercept implements InterceptInterface
|
||||
{
|
||||
protected string $uuid;
|
||||
|
||||
public function __construct(string $uuid = '')
|
||||
{
|
||||
$this->uuid = $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前置处理
|
||||
*
|
||||
* @return \Generator
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function before(): \Generator
|
||||
{
|
||||
yield Sse::progress('自定义拦截前置处理....', 0, [], $this->uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后置处理
|
||||
*
|
||||
* @param string $commandKey 命令键
|
||||
* @param int $exitCode 退出码
|
||||
*
|
||||
* @return \Generator
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function after(string $commandKey = '', int $exitCode = 0): \Generator
|
||||
{
|
||||
if ($exitCode === 0) {
|
||||
yield Sse::progress('自定义拦截后置处理-成功', 98, [], $this->uuid);
|
||||
} else {
|
||||
yield Sse::progress('自定义拦截后置处理失败,退出代码: ' . $exitCode, 98, [], $this->uuid);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\terminal\intercept;
|
||||
|
||||
use core\tool\Sse;
|
||||
|
||||
/**
|
||||
* 安装依赖拦截器类
|
||||
*/
|
||||
class InstallIntercept implements InterceptInterface
|
||||
{
|
||||
protected string $uuid;
|
||||
|
||||
public function __construct(string $uuid = '')
|
||||
{
|
||||
$this->uuid = $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前置处理
|
||||
*
|
||||
* @return \Generator
|
||||
*/
|
||||
public function before(): \Generator
|
||||
{
|
||||
yield Sse::progress('开始安装依赖...', 0, [], $this->uuid);
|
||||
yield Sse::progress('检查包管理器...', 1, [], $this->uuid);
|
||||
yield Sse::progress('清理旧的依赖文件...', 2, [], $this->uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后置处理
|
||||
*
|
||||
* @param string $commandKey 命令键
|
||||
* @param int $exitCode 退出码
|
||||
*
|
||||
* @return \Generator
|
||||
*/
|
||||
public function after(string $commandKey = '', int $exitCode = 0): \Generator
|
||||
{
|
||||
if ($exitCode === 0) {
|
||||
yield Sse::progress('依赖安装成功', 98, [], $this->uuid);
|
||||
} else {
|
||||
yield Sse::progress('依赖安装失败,退出代码: ' . $exitCode, 98, [], $this->uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\core\terminal\intercept;
|
||||
|
||||
/**
|
||||
* 拦截器接口
|
||||
* 定义拦截器的标准方法
|
||||
*/
|
||||
interface InterceptInterface
|
||||
{
|
||||
/**
|
||||
* 前置处理
|
||||
*
|
||||
* @return \Generator
|
||||
*/
|
||||
public function before(): \Generator;
|
||||
|
||||
/**
|
||||
* 后置处理
|
||||
*
|
||||
* @param string $commandKey 命令键
|
||||
* @param int $exitCode 退出码
|
||||
*
|
||||
* @return \Generator
|
||||
*/
|
||||
public function after(string $commandKey = '', int $exitCode = 0): \Generator;
|
||||
}
|
||||
Reference in New Issue
Block a user