初始化本地仓库

This commit is contained in:
2026-08-31 11:33:59 +08:00
commit 663f74b633
2390 changed files with 402918 additions and 0 deletions
@@ -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;
}