Browse Source

feat(system): 添加系统扩展模块- 创建系统扩展的基础结构和功能
- 实现扩展管理、配置、安装、卸载等功能
- 添加数据库迁移文件
- 创建控制器和模型
- 实现命令行扩展管理命令

runphp 7 tháng trước cách đây
mục cha
commit
d844a30f95

+ 1 - 6
.gitignore

@@ -1,6 +1 @@
-composer.phar
-/vendor/
-
-# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
-# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
-# composer.lock
+.idea/

+ 38 - 0
README.md

@@ -0,0 +1,38 @@
+# System Extension
+
+扩展管理系统
+
+这是用来管理扩展的扩展,主要是展示扩展列表,安装扩展,卸载扩展,更新扩展,扩展配置等功能。
+
+## 功能说明
+1. **展示扩展列表**:列出所有已安装的扩展及其基本信息。
+2. **安装扩展**:支持从本地或远程仓库安装新的扩展。
+3. **卸载扩展**:移除不再需要的扩展。
+4. **更新扩展**:检查并更新已安装扩展到最新版本。
+5. **扩展配置**:为每个扩展提供独立的配置选项。
+
+## 使用方法
+1. **访问扩展管理页面**:
+   - 登录系统后,导航至“扩展管理”模块。
+2. **操作扩展**:
+   - 点击“安装”按钮以添加新扩展。
+   - 点击“卸载”按钮以移除现有扩展。
+   - 点击“更新”按钮以升级扩展版本。
+   - 点击“配置”按钮以调整扩展设置。
+
+## 注意事项
+1. **权限要求**:仅管理员用户可进行扩展的安装、卸载和更新操作。
+2. **备份数据**:在执行卸载或更新操作前,请确保已备份相关数据。
+3. **兼容性检查**:安装新扩展时,请确认其与当前系统的兼容性。
+
+
+## HOOKS
+1. **after_read_extension_config**: 在读取扩展配置后触发
+1. **before_install_extension**: 在安装扩展前触发
+1. **after_install_extension**: 在安装扩展后触发
+1. **before_uninstall_extension**: 在卸载扩展前触发
+1. **before_uninstall_\<module_name\>_extension**: 在卸载扩展前触发
+1. **after_uninstall_extension**: 在卸载扩展后触发
+1. **after_uninstall_\<module_name\>_extension**: 在卸载扩展后触发
+1. **after_enable_extension**: 在启用扩展后触发
+1. **after_disable_extension**: 在禁用扩展后触发

+ 16 - 0
command.php

@@ -0,0 +1,16 @@
+<?php
+declare(strict_types=1);
+
+use SixShop\System\Command\CoreExtensionConfigCommand;
+use SixShop\System\Command\CrontabCommand;
+use SixShop\System\Command\ExtensionManagementCommand;
+use SixShop\System\Command\ModelPropertyCommand;
+use SixShop\System\Command\ExtensionScaffoldMakeCommand;
+
+return [
+    'amp:property' => ModelPropertyCommand::class,
+    'core:config' => CoreExtensionConfigCommand::class,
+    'extension:manage' => ExtensionManagementCommand::class,
+    'extension:make' => ExtensionScaffoldMakeCommand::class,
+    'crontab' => CrontabCommand::class,
+];

+ 31 - 0
composer.json

@@ -0,0 +1,31 @@
+{
+  "name": "six-shop/system",
+  "description": "系统扩展",
+  "type": "sixshop-extension",
+  "keywords": [
+    "sixshop",
+    "thinkphp"
+  ],
+  "require": {
+    "php": ">=8.3",
+    "six-shop/core": ">=0.4 <1.0"
+  },
+  "authors": [
+    {
+      "name": "hui he",
+      "email": "runphp@qq.com"
+    }
+  ],
+  "license": "MIT",
+  "autoload": {
+    "psr-4": {
+      "SixShop\\System\\": "src"
+    }
+  },
+  "extra": {
+    "sixshop": {
+      "id": "system",
+      "class": "SixShop\\System\\Extension"
+    }
+  }
+}

+ 119 - 0
config.php

@@ -0,0 +1,119 @@
+<?php
+declare(strict_types=1);
+
+return [
+    [
+        'type' => 'switch',
+        'field' => 'is_cache',
+        'title' => '缓存开关',
+        'info' => '是否开启缓存',
+        '$required' => false,
+        'props' => [
+            'activeValue' => true,
+            'inactiveValue' => false
+        ],
+        '_fc_id' => 'id_Fjzbmcdo513lahc',
+        'name' => 'ref_Fzt4mcdo513laic',
+        'display' => true,
+        'hidden' => false,
+        '_fc_drag_tag' => 'switch'
+    ],
+    [
+        'type' => 'checkbox',
+        'field' => 'normal_module_list',
+        'title' => '普通模块加载列表',
+        'info' => '选择状态的模块才会加载',
+        'effect' => [
+            'fetch' => [
+                'action' => '{{API_BASE_URL}}/admin/system/extension/normal',
+                'method' => 'GET',
+                'dataType' => 'json',
+                'headers' => [
+                    "Authorization" => "Bearer {{API_TOKEN}}"
+                ],
+                'query' => [],
+                'data' => [],
+                'parse' => '',
+                'beforeFetch' => '',
+                'onError' => '',
+                'to' => 'options'
+            ]
+        ],
+        '$required' => false,
+        'props' => [
+            '_optionType' => 1
+        ],
+        '_fc_id' => 'id_Ffunmdtoraneacc',
+        'name' => 'ref_Fq8pmdtoraneadc',
+        'display' => true,
+        'hidden' => true,
+        '_fc_drag_tag' => 'checkbox'
+    ],
+    [
+        'type' => 'group',
+        'field' => 'category',
+        'value' => [
+            [
+                'text' => '核心扩展',
+                'code' => 'core'
+            ],
+            [
+                'text' => '支付',
+                'code' => 'pay'
+            ],
+            [
+                'text' => '定制',
+                'code' => 'custom'
+            ],
+            [
+                'text' => '其他扩展',
+                'code' => 'other'
+            ],
+        ],
+        'title' => '分类',
+        'info' => '',
+        '$required' => false,
+        'props' => [
+            'expand' => 1,
+            'rule' => [
+                [
+                    'type' => 'fcRow',
+                    'children' => [
+                        [
+                            'type' => 'col',
+                            'props' => [
+                                'span' => 12
+                            ],
+                            'children' => [
+                                [
+                                    'type' => 'input',
+                                    'field' => 'text',
+                                    'title' => '分类名',
+                                    'info' => '',
+                                    '$required' => false,
+                                    '_fc_id' => 'id_Ffmvmclvt0mfauc',
+                                    'name' => 'ref_Fwifmclvt0mfavc',
+                                    'display' => true,
+                                    'hidden' => false,
+                                    '_fc_drag_tag' => 'input'
+                                ],
+                                [
+                                    'type' => 'input',
+                                    'field' => 'code',
+                                    'title' => '分类编码',
+                                    'info' => '',
+                                    '$required' => false,
+                                    '_fc_id' => 'id_F217mclvt3a5axc',
+                                    'name' => 'ref_Flwvmclvt3a5ayc',
+                                    'display' => true,
+                                    'hidden' => false,
+                                    '_fc_drag_tag' => 'input'
+                                ]
+                            ]
+                        ]
+                    ]
+                ]
+            ]
+        ]
+    ]
+];

+ 58 - 0
database/migrations/20250624125157_extension_config.php

@@ -0,0 +1,58 @@
+<?php
+
+use think\migration\Migrator;
+use think\migration\db\Column;
+
+class ExtensionConfig extends Migrator
+{
+    /**
+     * Change Method.
+     */
+    public function change(): void
+    {
+        $table = $this->table('extension_config', [
+            'id' => false,
+            'primary_key' => 'id'
+        ]);
+        
+        $table->addColumn('id', 'integer', [
+                'identity' => true,
+                'signed' => false,
+                'comment' => '主键'
+            ])
+            ->addColumn('extension_id', 'string', [
+                'limit' => 32,
+                'comment' => '模块ID,关联extension表'
+            ])
+            ->addColumn('key', 'string', [
+                'limit' => 64,
+                'comment' => '配置项名称'
+            ])
+            ->addColumn('value', 'json', [
+                'null' => true,
+                'comment' => '配置值,JSON格式存储'
+            ])
+            ->addColumn('type', 'string', [
+                'limit' => 32,
+                'default' => 'text',
+                'comment' => '配置类型:input, radio, select等'
+            ])
+            ->addColumn('title', 'string', [
+                'limit' => 32,
+                'default' => 'text',
+                'comment' => '配置名称'
+            ])
+            ->addColumn('create_time', 'timestamp', [
+                'default' => 'CURRENT_TIMESTAMP',
+                'comment' => '创建时间'
+            ])
+            ->addColumn('update_time', 'timestamp', [
+                'default' => 'CURRENT_TIMESTAMP',
+                'update' => 'CURRENT_TIMESTAMP',
+                'comment' => '更新时间'
+            ])
+            ->addIndex(['extension_id'], ['name' => 'extension_id'])
+            ->addIndex(['extension_id', 'key'], ['unique' => true, 'name' => 'uniq_extension_id_key']) // 复合唯一索引
+            ->create();
+    }
+}

+ 100 - 0
database/migrations/20250627061219_extension.php

@@ -0,0 +1,100 @@
+<?php
+
+use think\migration\Migrator;
+use think\migration\db\Column;
+
+class Extension extends Migrator
+{
+    /**
+     * Change Method.
+     *
+     * Write your reversible migrations using this method.
+     *
+     * More information on writing migrations is available here:
+     * http://docs.phinx.org/en/latest/migrations.html#the-abstractmigration-class
+     *
+     * The following commands can be used in this method and Phinx will
+     * automatically reverse them when rolling back:
+     *
+     *    createTable
+     *    renameTable
+     *    addColumn
+     *    renameColumn
+     *    addIndex
+     *    addForeignKey
+     *
+     * Remember to call "create()" or "update()" and NOT "save()" when working
+     * with the Table class.
+     */
+    public function change()
+    {
+        $table = $this->table('extension', [
+            'comment' => '扩展管理表',
+            'engine' => 'InnoDB',
+            'collation' => 'utf8mb4_general_ci',
+            'id' => false,
+            'primary_key' => 'id'
+        ]);
+
+        $table->addColumn('id', 'string', [
+            'limit' => 50,
+            'null' => false,
+            'comment' => '扩展唯一标识符'
+        ])->addColumn('name', 'string', [
+            'limit' => 100,
+            'null' => false,
+            'comment' => '扩展名称'
+        ])->addColumn('is_core', 'boolean', [
+            'default' => false,
+            'comment' => '是否核心扩展'
+        ])->addColumn('description', 'text', [
+            'null' => true,
+            'comment' => '扩展描述'
+        ])->addColumn('version', 'string', [
+            'limit' => 20,
+            'null' => false,
+            'default' => '1.0.0',
+            'comment' => '扩展版本'
+        ])->addColumn('core_version', 'string', [
+            'limit' => 20,
+            'null' => false,
+            'comment' => '支持的核心版本'
+        ])->addColumn('author', 'string', [
+            'limit' => 100,
+            'null' => false,
+            'comment' => '作者'
+        ])->addColumn('email', 'string', [
+            'limit' => 100,
+            'null' => true,
+            'comment' => '作者邮箱'
+        ])->addColumn('website', 'string', [
+            'limit' => 255,
+            'null' => true,
+            'comment' => '扩展地址'
+        ])->addColumn('image', 'string', [
+            'limit' => 255,
+            'null' => true,
+            'comment' => '扩展图片地址'
+        ])->addColumn('license', 'string', [
+            'limit' => 50,
+            'null' => true,
+            'comment' => '开源协议'
+        ])->addColumn('status', 'integer', [
+            'limit' => 1,
+            'default' => 0,
+            'comment' => '状态(1:未安装,2:安装,3:启用,4:禁用)'
+        ])->addColumn('create_time', 'datetime', [
+            'null' => true,
+            'comment' => '创建时间'
+        ])->addColumn('update_time', 'datetime', [
+            'null' => true,
+            'comment' => '更新时间'
+        ])->addColumn('delete_time', 'datetime', [
+            'null' => true,
+            'comment' => '删除时间'
+        ])->addIndex(['id'], [
+            'unique' => true,
+            'name' => 'uniq_id'
+        ])->create();
+    }
+}

+ 35 - 0
database/migrations/20250702110812_extension_add_category.php

@@ -0,0 +1,35 @@
+<?php
+
+use think\migration\Migrator;
+use think\migration\db\Column;
+
+class ExtensionAddCategory extends Migrator
+{
+    /**
+     * Change Method.
+     *
+     * Write your reversible migrations using this method.
+     *
+     * More information on writing migrations is available here:
+     * http://docs.phinx.org/en/latest/migrations.html#the-abstractmigration-class
+     *
+     * The following commands can be used in this method and Phinx will
+     * automatically reverse them when rolling back:
+     *
+     *    createTable
+     *    renameTable
+     *    addColumn
+     *    renameColumn
+     *    addIndex
+     *    addForeignKey
+     *
+     * Remember to call "create()" or "update()" and NOT "save()" when working
+     * with the Table class.
+     */
+    public function change(): void
+    {
+        $table = $this->table('extension');
+        $table->addColumn('category', 'string', ['limit' => 255, 'default' => '', 'comment' => '分类', 'after' => 'is_core'])
+            ->update();
+    }
+}

+ 18 - 0
info.php

@@ -0,0 +1,18 @@
+<?php
+declare(strict_types=1);
+
+return [
+    'id' => 'system', # 扩展的唯一标识符
+    'name' => '扩展管理系统', # 扩展的名称
+    'is_core' => true, # 是否核心扩展'
+    'category' => 'core', # 扩展的分类 core:核心扩展,other:其他扩展
+    'description' => '这是用来管理扩展的扩展,主要是展示扩展列表,安装扩展,卸载扩展,更新扩展,扩展配置等功能。', # 扩展的描述
+    'version' => '1.0.0',  # 扩展的版本
+    'core_version' => '^1.0',  # 支持的核心版本
+    'author' => 'runphp', # 作者
+    'email' => 'runphp@qq.com', # 作者的邮箱
+    'website' => '', # 扩展的地址,可以是扩展的仓库地址,帮助用户寻找扩展,安装扩展等网络地址
+    'image' => '', # 扩展的图片,用于展示扩展的图标,或者是扩展的截图等图片地址
+    'license' => 'MIT', # 扩展的开源协议
+    'weight' => 101, # 扩展的权重,用于控制加载先后顺序, 普通扩展请使用>=10000
+];

+ 35 - 0
route/admin.php

@@ -0,0 +1,35 @@
+<?php
+declare(strict_types=1);
+
+use SixShop\System\Controller\{ExtensionConfigController, ExtensionController};
+use think\facade\Route;
+
+Route::group('extension', function () {
+    Route::get('normal', [ExtensionController::class, 'normal'])->option(['name' => 'system:extension:normal', 'description' => '获取普通扩展列表']);
+    Route::resource('', ExtensionController::class, function () {
+        Route::post('install', [ExtensionController::class, 'install'])->option(['name' => 'system:extension:install', 'description' => '安装扩展']);
+        Route::post('uninstall', [ExtensionController::class, 'uninstall'])->option(['name' => 'system:extension:uninstall', 'description' => '卸载扩展']);
+        Route::post('enable', [ExtensionController::class, 'enable'])->option(['name' => 'system:extension:enable', 'description' => '启用扩展']);
+        Route::post('disable', [ExtensionController::class, 'disable'])->option(['name' => 'system:extension:disable', 'description' => '禁用扩展']);
+    })->only([
+        'index',
+        'read',
+    ]);
+})->option([
+    'name' => 'system:extension',
+    'description' => '扩展'
+])->middleware([
+    'auth'
+]);
+
+Route::resource('extension_config', ExtensionConfigController::class)->only([
+    'read',
+    'edit',
+    'update'
+])->option([
+    'name' => 'system:extension_config',
+    'description' => '扩展配置'
+])->middleware([
+    'auth'
+]);
+

+ 77 - 0
src/Command/CoreExtensionConfigCommand.php

@@ -0,0 +1,77 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System\Command;
+
+use SixShop\Core\Helper;
+use SixShop\System\ExtensionManager;
+use SixShop\System\Migrate;
+use SixShop\System\Model\ExtensionModel;
+use think\console\Command;
+use think\db\exception\PDOException;
+
+class CoreExtensionConfigCommand extends Command
+{
+    public function configure(): void
+    {
+        $this->setName('core:config')
+            ->setDescription('Set the core extension default configuration')
+            ->addOption('force', 'f', null, 'Force update the core extension default configuration');
+    }
+
+    public function handle(): void
+    {
+        $start = microtime(true);
+        $force = $this->input->getOption('force');
+        $extensionManager = $this->app->make(ExtensionManager::class);
+        // 确保系统扩展迁移已安装
+        $moduleList = array_diff(Helper::extension_name_list(), ['system']);
+        array_unshift($moduleList, 'system');
+        try {
+            $installModuleList = ExtensionModel::where(['is_core' => 0])
+                ->where('status', '>', 1)
+                ->column('id');
+        } catch (PDOException $e) {
+            $installModuleList = [];
+        }
+        foreach ($moduleList as $moduleName) {
+            $extension = $extensionManager->getExtension($moduleName);
+            $info = $extension->getInfo();
+            try {
+                $config = $extensionManager->getExtensionConfig($moduleName);
+            } catch (PDOException $e) {
+                $config = [];
+            }
+            if ((isset($info['is_core']) && $info['is_core'] == 1) || in_array($moduleName, $installModuleList)) {
+                $migrate = app(Migrate::class, [$this->app, $moduleName], true);
+                if ($force) {
+                    $migrate->uninstall();
+                    $this->output->writeln("Uninstall extension migration for module: $moduleName");
+                }
+                $installVersions = $migrate->install();
+                foreach ($installVersions as $version) {
+                    $this->output->writeln("Install extension migration for module: $moduleName, version: $version");
+                }
+                if (empty($config) || $force) {
+                    $updateData = [];
+                    $formConfig = $extension->getConfig();
+                    foreach ($formConfig as $item) {
+                        if (isset($item['value'])) {
+                            $updateData[$item['field']] = $item['value'];
+                            $value = is_array($item['value']) ? json_encode($item['value'], JSON_UNESCAPED_UNICODE) : $item['value'];
+                            $this->output->writeln("Set extension default configuration for module: $moduleName, field: {$item['field']}, value: $value");
+                        }
+                    }
+                    if (!empty($updateData)) {
+                        $extensionManager->saveConfig($moduleName, $updateData);
+                    }
+                }
+            }
+        }
+
+        $end = microtime(true);
+
+        $this->output->writeln('');
+        $this->output->writeln('<comment>All Done. Took ' . sprintf('%.4fs', $end - $start) . '</comment>');
+    }
+}

+ 61 - 0
src/Command/CrontabCommand.php

@@ -0,0 +1,61 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System\Command;
+
+use SixShop\System\Event\CrontabWorkerStartEvent;
+use think\App;
+use think\console\Command;
+use think\console\Input;
+use think\console\input\Argument;
+use think\console\input\Option;
+use think\console\Output;
+use think\facade\Event;
+use Workerman\Worker;
+
+class CrontabCommand extends Command
+{
+    public function configure(): void
+    {
+        $this->setName('crontab')
+            ->addArgument('action', Argument::REQUIRED, 'action')
+            ->addOption('daemon', 'd', Option::VALUE_NONE, 'daemon mode')
+            ->addOption('grace', 'g', Option::VALUE_NONE, 'graceful shutdown')
+            ->setDescription('Crontab command');
+    }
+
+    protected function execute(Input $input, Output $output): void
+    {
+        $argv = [$input->getArgument('action')];
+        $daemon = $input->getOption('daemon');
+        if ($daemon) {
+            $argv[] = '-d';
+        }
+        $grace = $input->getOption('grace');
+        if ($grace) {
+            $argv[] = '-g';
+        }
+        $worker = new class ($argv, $this->app) extends Worker {
+            private static array $argv;
+
+            public function __construct(array $argv, private readonly App $app, ?string $socketName = null, array $socketContext = [])
+            {
+                parent::__construct($socketName, $socketContext);
+                self::$argv = $argv;
+                self::$pidFile = $app->getRootPath() . 'runtime/crontab.pid';
+                self::$logFile = $app->getRootPath() . 'runtime/crontab.log';
+                self::$statisticsFile = $app->getRootPath() . 'runtime/crontab.statistics.php';
+            }
+
+            public function setOnWorkerStart(callable $worker): void
+            {
+                $this->onWorkerStart = $worker;
+            }
+        };
+
+        $worker->setOnWorkerStart(function () {
+            Event::trigger(CrontabWorkerStartEvent::class);
+        });
+        Worker::runAll();
+    }
+}

+ 137 - 0
src/Command/ExtensionManagementCommand.php

@@ -0,0 +1,137 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System\Command;
+
+use SixShop\Core\Helper;
+use SixShop\System\ExtensionManager;
+use think\console\Command;
+use think\console\Input;
+use think\console\input\Argument;
+use think\console\Output;
+use think\console\Table;
+
+class ExtensionManagementCommand extends Command
+{
+    const int FAILURE = 1;
+    const int SUCCESS = 0;
+
+    protected function configure(): void
+    {
+        $this->setName('extension:manage')
+            ->setDescription('扩展管理命令:创建、安装、启用、禁用、卸载、列表')
+            ->addArgument('action', Argument::REQUIRED, '操作类型: create|list|install|enable|disable|uninstall')
+            ->addArgument('module', Argument::OPTIONAL, '扩展模块名(create、install、enable、disable、uninstall 需指定)');
+    }
+
+    protected function execute(Input $input, Output $output): int
+    {
+        $action = $input->getArgument('action');
+        $module = $input->getArgument('module');
+        $extensionManager = $this->app->make(ExtensionManager::class);
+
+
+        switch ($action) {
+            case 'list':
+                $list = $extensionManager->getExtensionList();
+                $table = new Table();
+                $table->setHeader([
+                    'ID', '名称', '状态', '分类', '版本', '作者', '描述'
+                ]);
+                $rows = [];
+                foreach ($list as $ext) {
+                    $rows[] = [
+                        $ext['id'],
+                        $ext['name'],
+                        $ext['status_text'] ?? $ext['status'],
+                        $ext['category_text'] ?? ($ext['category'] ?? ''),
+                        $ext['version'] ?? '',
+                        $ext['author'] ?? '',
+                        mb_strimwidth($ext['description'] ?? '', 0, 32, '...')
+                    ];
+                }
+                $table->setRows($rows);
+                $table->setStyle('box-double');
+                $output->writeln("<info>扩展列表:</info>");
+                $output->writeln($table->render());
+                break;
+            case 'create':
+                if (!$module) {
+                    $output->error('请指定要创建的扩展模块名');
+                    return self::FAILURE;
+                }
+                $basePath = Helper::extension_path($module);
+                if (is_dir($basePath)) {
+                    $output->error("扩展 {$module} 已存在");
+                    return self::FAILURE;
+                }
+                // 创建目录结构
+                @mkdir($basePath . 'src', 0777, true);
+                @mkdir($basePath . 'database/migrations', 0777, true);
+                // info.php
+                $info = [
+                    'id' => $module,
+                    'name' => $module,
+                    'is_core' => false,
+                    'category' => 'other',
+                    'description' => $module . ' 扩展',
+                    'version' => '1.0.0',
+                    'core_version' => '^1.0',
+                    'author' => 'yourname',
+                    'email' => '',
+                    'website' => '',
+                    'image' => '',
+                    'license' => 'MIT',
+                ];
+                $infoExport = var_export($info, true);
+                $infoExport = str_replace(['array (', ')'], ['[', ']'], $infoExport);
+                file_put_contents($basePath . 'info.php', "<?php\ndeclare(strict_types=1);\nreturn " . $infoExport . ";\n");
+                // Extension.php
+                $extClass = "<?php\ndeclare(strict_types=1);\n\nnamespace SixShop\\Extension\\{$module};\n\nuse SixShop\\Extension\\core\\ExtensionAbstract;\n\nclass Extension extends ExtensionAbstract\n{\n    protected function getBaseDir(): string\n    {\n        return dirname(__DIR__);\n    }\n}\n";
+                file_put_contents($basePath . 'src/Extension.php', $extClass);
+                // README.md
+                file_put_contents($basePath . 'README.md', "# $module\n\n扩展说明\n");
+                // config.php
+                file_put_contents($basePath . 'config.php', "<?php\ndeclare(strict_types=1);\n\nreturn [];\n");
+                $output->warning("扩展 {$module} 创建成功,目录:$basePath");
+                break;
+            case 'install':
+                if (!$module) {
+                    $output->error('请指定要安装的扩展模块名');
+                    return self::FAILURE;
+                }
+                $extensionManager->install($module);
+                $output->warning("扩展 {$module} 安装成功");
+                break;
+            case 'enable':
+                if (!$module) {
+                    $output->error('请指定要启用的扩展模块名');
+                    return self::FAILURE;
+                }
+                $extensionManager->enable($module);
+                $output->warning("扩展 {$module} 启用成功");
+                break;
+            case 'disable':
+                if (!$module) {
+                    $output->error('请指定要禁用的扩展模块名');
+                    return self::FAILURE;
+                }
+                $extensionManager->disable($module);
+                $output->warning("扩展 {$module} 禁用成功");
+                break;
+            case 'uninstall':
+                if (!$module) {
+                    $output->error('请指定要卸载的扩展模块名');
+                    return self::FAILURE;
+                }
+                $extensionManager->uninstall($module);
+                $output->warning("扩展 {$module} 卸载成功");
+                break;
+            default:
+                $output->error('不支持的操作类型: ' . $action);
+                return self::FAILURE;
+        }
+
+        return self::SUCCESS;
+    }
+}

+ 540 - 0
src/Command/ExtensionScaffoldMakeCommand.php

@@ -0,0 +1,540 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System\Command;
+
+use SixShop\Core\Helper;
+use Throwable;
+use think\console\Command;
+use think\console\Input;
+use think\console\input\Argument;
+use think\console\input\Option;
+use think\console\Output;
+
+class ExtensionScaffoldMakeCommand extends Command
+{
+    protected function configure(): void
+    {
+        $this->setName('extension:make')
+            ->setDescription('生成扩展脚手架骨架(后端+前端,可选 Service/Entity/FFI/Frontend)')
+            ->addArgument('module', Argument::REQUIRED, '扩展模块名(目录名,建议小写下划线)')
+            ->addOption('with-api', null, Option::VALUE_NONE, '生成 API 路由与控制器')
+            ->addOption('with-admin', null, Option::VALUE_NONE, '生成 Admin 路由与控制器')
+            ->addOption('with-service', null, Option::VALUE_NONE, '生成 Service 层')
+            ->addOption('with-entity', null, Option::VALUE_NONE, '生成 Entity 层')
+            ->addOption('with-migration', null, Option::VALUE_NONE, '生成迁移与安装/卸载 SQL 样板')
+            ->addOption('with-frontend', null, Option::VALUE_NONE, '生成前端 Admin 模板')
+            ->addOption('with-ffi', null, Option::VALUE_NONE, '生成 FFI 目录与构建脚本样板')
+            ->addOption('adapter', null, Option::VALUE_REQUIRED, '服务适配默认实现 php|ffi|auto', 'php')
+            ->addOption('desc', null, Option::VALUE_REQUIRED, 'info.php 描述', '')
+            ->addOption('author', null, Option::VALUE_REQUIRED, '作者', 'yourname')
+            ->addOption('dry-run', null, Option::VALUE_NONE, '仅预览将要创建的目录与文件,不实际写入')
+            ->addOption('force', null, Option::VALUE_NONE, '允许在已存在的模块目录内覆盖写入文件');
+    }
+
+    protected function execute(Input $input, Output $output): int
+    {
+        $module = (string)$input->getArgument('module');
+        $withApi = (bool)$input->getOption('with-api');
+        $withAdmin = (bool)$input->getOption('with-admin');
+        $withService = (bool)$input->getOption('with-service');
+        $withEntity = (bool)$input->getOption('with-entity');
+        $withMigration = (bool)$input->getOption('with-migration');
+        $withFrontend = (bool)$input->getOption('with-frontend');
+        $withFFI = (bool)$input->getOption('with-ffi');
+        $adapter = (string)$input->getOption('adapter');
+        $desc = (string)$input->getOption('desc');
+        $author = (string)$input->getOption('author');
+        $dryRun = (bool)$input->getOption('dry-run');
+        $force = (bool)$input->getOption('force');
+
+        if (!$module) {
+            $output->error('模块名不能为空');
+            return 1;
+        }
+
+        // 默认行为:如果用户未显式指定任何 with-* 选项,则默认生成“完整插件”(除 FFI)
+        $anySpecified = $withApi || $withAdmin || $withService || $withEntity || $withMigration || $withFrontend || $withFFI;
+        if (!$anySpecified) {
+            $withApi = $withAdmin = $withService = $withEntity = $withMigration = $withFrontend = true;
+            // $withFFI 默认为 false,避免环境未开启 FFI 导致构建失败
+        }
+
+        $base = rtrim(Helper::extension_path($module), '/');
+        if (is_dir($base) && !$force) {
+            $output->error("扩展 {$module} 已存在:{$base},可使用 --force 覆盖写入");
+            return 1;
+        }
+
+        // 目录结构
+        $dirs = [
+            "$base/src/Controller/Api",
+            "$base/src/Controller/Admin",
+            "$base/src/Service",
+            "$base/src/Entity",
+            "$base/src/Hook",
+            "$base/database/migrations",
+            "$base/database/seeds",
+            "$base/route",
+            "$base/config",
+        ];
+        // 计划文件(用于 dry-run 展示)
+        $ns = "SixShop\\\\Extension\\\\{$module}";
+        $studly = str_replace(['-', '_'], '', ucwords($module, '-_'));
+        $planFiles = [
+            "$base/info.php",
+            "$base/config.php",
+            "$base/README.md",
+            "$base/src/Extension.php",
+            "$base/src/Hook/{$studly}Hook.php",
+        ];
+        if ($withApi) {
+            $planFiles[] = "$base/route/api.php";
+            $planFiles[] = "$base/src/Controller/Api/HelloController.php";
+            $planFiles[] = "$base/src/Controller/Api/ItemController.php";
+        }
+        if ($withAdmin) {
+            $planFiles[] = "$base/route/admin.php";
+            $planFiles[] = "$base/src/Controller/Admin/DashboardController.php";
+            $planFiles[] = "$base/src/Controller/Admin/ManageController.php";
+            $planFiles[] = "$base/src/Controller/Admin/ItemController.php";
+            $planFiles[] = "$base/src/Controller/Admin/UploadController.php";
+        }
+        if ($withService) $planFiles[] = "$base/src/Service/{$studly}Service.php";
+        if ($withEntity) $planFiles[] = "$base/src/Entity/{$studly}.php";
+        if ($withMigration) {
+            $planFiles[] = "$base/config/install.sql";
+            $planFiles[] = "$base/config/uninstall.sql";
+        }
+
+        if ($dryRun) {
+            $output->writeln("[DRY-RUN] 将创建以下目录:");
+            foreach ($dirs as $d) { $output->writeln("  - $d"); }
+            $output->writeln("[DRY-RUN] 将创建以下关键文件(部分):");
+            foreach ($planFiles as $f) { $output->writeln("  - $f"); }
+            return 0;
+        }
+
+        foreach ($dirs as $d) @mkdir($d, 0777, true);
+
+        // info.php
+        // 生成完整 info.php(参考 guimi)
+        $info = [
+            'id' => $module,
+            'name' => $module,
+            // 分类:core|content|shop|other|custom,默认 custom
+            'category' => 'custom',
+            'description' => $desc ?: ($module . ' 扩展模块'),
+            'version' => '0.1.0',
+            'core_version' => '^1.0',
+            'author' => $author ?: 'sixshop',
+            'email' => '',
+            'website' => '',
+            'image' => '',
+            'license' => 'MIT',
+            'keywords' => [],
+            'dependencies' => [],
+            'conflicts' => [],
+            'requires' => [
+                'php' => '>=8.0.0',
+                'extensions' => ['json', 'pdo'],
+            ],
+        ];
+        $infoExport = var_export($info, true);
+        $infoExport = str_replace(['array (', ')'], ['[', ']'], $infoExport);
+        file_put_contents("$base/info.php", "<?php\ndeclare(strict_types=1);\n\nreturn " . $infoExport . ";\n");
+
+        // Extension.php(使用 Nowdoc + sprintf 注入命名空间)
+        $ns = 'SixShop\\Extension\\' . $module;
+        $extClass = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+namespace %s;
+
+use SixShop\Core\ExtensionAbstract;
+use think\facade\Db;
+
+class Extension extends ExtensionAbstract
+{
+    protected function getBaseDir(): string
+    {
+        return dirname(__DIR__);
+    }
+
+    public function install(): void
+    {
+        $sqlFile = __DIR__ . '/../config/install.sql';
+        if (is_file($sqlFile)) {
+            $sql = file_get_contents($sqlFile);
+            if ($sql) { Db::execute($sql); }
+        }
+    }
+
+    public function uninstall(): void
+    {
+        $sqlFile = __DIR__ . '/../config/uninstall.sql';
+        if (is_file($sqlFile)) {
+            $sql = file_get_contents($sqlFile);
+            if ($sql) { Db::execute($sql); }
+        }
+    }
+}
+PHP, $ns);
+        file_put_contents("$base/src/Extension.php", $extClass);
+
+        // config.php(form-create 占位)
+        $configPhp = <<<PHP
+<?php
+declare(strict_types=1);
+
+return [
+    'form' => [
+        [
+            'type' => 'input',
+            'field' => 'title',
+            'title' => '标题',
+            'value' => '',
+            'props' => ['placeholder' => '请输入标题'],
+        ],
+    ],
+];
+PHP;
+        file_put_contents("$base/config.php", $configPhp);
+
+        // README
+        file_put_contents("$base/README.md", "# {$module}\n\n自动生成的扩展骨架。\n");
+
+        // 上面已生成 Extension.php,这里不再重复生成
+
+        // Hook 占位(下面统一生成一次)
+
+        // 安装/卸载 SQL 样板
+        if ($withMigration) {
+            $install = "-- 安装 SQL 示例\n";
+            $uninstall = "-- 卸载 SQL 示例\n";
+            file_put_contents("$base/config/install.sql", $install);
+            file_put_contents("$base/config/uninstall.sql", $uninstall);
+        }
+
+        // 路由(注意:系统会自动加 /api/{$module} 或 /admin/{$module} 前缀,这里不需要再包一层模块分组)
+        $apiRoute = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+use SixShop\Extension\%s\Controller\Api\ItemController;
+use think\facade\Route;
+
+// 注意:前缀由系统自动添加,这里只写相对路径
+
+// 健康检查
+Route::get('ping', fn() => json(['code' => 0, 'msg' => 'ok', 'data' => ['pong' => true]]))->middleware(['auth']);
+
+// 示例:业务分组-具体动作(放在资源路由之前,避免 :id 冲突)
+Route::group('item', function () {
+    Route::get('info', [ItemController::class, 'info']);
+    Route::post('check', [ItemController::class, 'check']);
+})->middleware(['auth']);
+
+PHP, $module);
+        $adminRoute = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+use think\facade\Route;
+use SixShop\Extension\%s\Controller\Admin\DashboardController;
+use SixShop\Extension\%s\Controller\Admin\ItemController;
+use SixShop\Extension\%s\Controller\Admin\UploadController;
+
+// 首页/仪表盘控制器路由(对齐 guimi 写法)
+Route::get('dashboard/stats', [DashboardController::class, 'stats'])->middleware(['auth']);
+// 可按需继续追加:relation-trend / verification-trend / redemption-trend / latest 等
+
+// 通用上传
+Route::post('upload', [UploadController::class, 'handle'])->middleware(['auth']);
+PHP, $module, $module, $module);
+        if ($withApi) file_put_contents("$base/route/api.php", $apiRoute);
+        if ($withAdmin) file_put_contents("$base/route/admin.php", $adminRoute);
+
+        // 控制器样板
+        if ($withApi) {
+            $apiCtrl = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+namespace %s\Controller\Api;
+
+use think\Request; use think\Response;
+
+class HelloController
+{
+    private function uid(Request $r): ?int { return $r->userID ?? ($r->adminID ?? null); }
+    public function index(Request $r): Response
+    {
+        if (!$this->uid($r)) return json(['code'=>401,'msg'=>'未登录']);
+        return json(['code'=>0,'msg'=>'ok','data'=>['now'=>date('c')]]);
+    }
+}
+PHP, $ns);
+            file_put_contents("$base/src/Controller/Api/HelloController.php", $apiCtrl);
+
+            // API 资源控制器
+            $apiItemCtrl = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+namespace %s\Controller\Api;
+
+use think\Request; use think\Response;
+
+class ItemController
+{
+    private function uid(Request $r): ?int { return $r->userID ?? ($r->adminID ?? null); }
+
+    public function index(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>['list'=>[], 'total'=>0]]); }
+    public function read(int $id): Response { return json(['code'=>0,'msg'=>'ok','data'=>['id'=>$id]]); }
+    public function save(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
+    public function update(int $id, Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
+    public function delete(int $id): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
+
+    // 具体动作示例(与路由匹配)
+    public function info(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>['info'=>[]]]); }
+    public function check(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
+}
+PHP, $ns);
+            file_put_contents("$base/src/Controller/Api/ItemController.php", $apiItemCtrl);
+        }
+        if ($withAdmin) {
+            $adminCtrl = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+namespace %s\Controller\Admin;
+
+use think\Request; use think\Response;
+
+class ManageController
+{
+    public function list(Request $r): Response
+    {
+        return json(['code'=>0,'msg'=>'ok','data'=>['list'=>[], 'total'=>0]]);
+    }
+}
+PHP, $ns);
+            file_put_contents("$base/src/Controller/Admin/ManageController.php", $adminCtrl);
+
+            // 首页/仪表盘控制器(对齐 guimi:dashboard/*)
+            $dashboardCtrl = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+namespace %s\Controller\Admin;
+
+use think\Response;
+
+class DashboardController
+{
+    public function stats(): Response
+    {
+        // 首页统计占位:可返回卡片统计与趋势入口
+        return json(['code' => 0, 'msg' => 'ok', 'data' => [
+            'cards' => [
+                ['title' => '总数', 'value' => 0],
+            ],
+        ]]);
+    }
+}
+PHP, $ns);
+            file_put_contents("$base/src/Controller/Admin/DashboardController.php", $dashboardCtrl);
+
+            // Admin 资源控制器
+            $adminItemCtrl = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+namespace %s\Controller\Admin;
+
+use think\Request; use think\Response;
+
+class ItemController
+{
+    public function index(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>['list'=>[], 'total'=>0]]); }
+    public function read(int $id): Response { return json(['code'=>0,'msg'=>'ok','data'=>['id'=>$id]]); }
+    public function save(Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
+    public function update(int $id, Request $r): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
+    public function delete(int $id): Response { return json(['code'=>0,'msg'=>'ok','data'=>true]); }
+}
+PHP, $ns);
+            file_put_contents("$base/src/Controller/Admin/ItemController.php", $adminItemCtrl);
+
+            // Admin 上传控制器
+            $uploadCtrl = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+namespace %s\Controller\Admin;
+
+use think\Request; use think\Response;
+
+class UploadController
+{
+    public function handle(Request $r): Response
+    {
+        // TODO: 接入实际存储逻辑,返回 { url, name }
+        return json(['code'=>0,'msg'=>'ok','data'=>['url'=>'','name'=>'']]);
+    }
+}
+PHP, $ns);
+            file_put_contents("$base/src/Controller/Admin/UploadController.php", $uploadCtrl);
+        }
+
+        // Service / Entity 占位
+        if ($withService) {
+            $svc = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+namespace %s\Service;
+
+class %sService
+{
+    public function ping(): array { return ['pong' => true]; }
+}
+PHP, $ns, $studly);
+            file_put_contents("$base/src/Service/{$studly}Service.php", $svc);
+        }
+        if ($withEntity) {
+            $ent = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+namespace %s\Entity;
+
+class %sEntity
+{
+    public const TABLE = 'extension_%s_item';
+}
+PHP, $ns, $studly, $module);
+            file_put_contents("$base/src/Entity/{$studly}Entity.php", $ent);
+        }
+
+        // 迁移 & 安装/卸载 SQL
+        if ($withMigration) {
+            $install = "-- 安装 SQL 示例\n" .
+                       "CREATE TABLE IF NOT EXISTS `extension_{$module}_item`(\n" .
+                       "  `id` int unsigned NOT NULL AUTO_INCREMENT,\n" .
+                       "  `title` varchar(255) NOT NULL DEFAULT '',\n" .
+                       "  `created_at` int unsigned NOT NULL DEFAULT 0,\n" .
+                       "  PRIMARY KEY (`id`)\n" .
+                       ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n";
+            $uninstall = "DROP TABLE IF EXISTS `extension_{$module}_item`;\n";
+            file_put_contents("$base/config/install.sql", $install);
+            file_put_contents("$base/config/uninstall.sql", $uninstall);
+        }
+
+        // Hook 示例(统一在此生成一次)
+        $hook = sprintf(<<<'PHP'
+<?php
+declare(strict_types=1);
+
+namespace %s\Hook;
+
+class %sHook
+{
+    /** 示例:用户登录后 */
+    public function onUserLogin(array $payload): void {}
+}
+PHP, $ns, $studly);
+        file_put_contents("$base/src/Hook/{$studly}Hook.php", $hook);
+
+        // FFI 样板
+        if ($withFFI) {
+            @mkdir("$base/ffi/model", 0777, true);
+            $gomod = sprintf("module %s\n\ngo 1.21\n", $module);
+            file_put_contents("$base/ffi/go.mod", $gomod);
+            $mainGo = <<<'GO'
+package main
+
+// TODO: 实现导出方法
+func main() {}
+GO;
+            file_put_contents("$base/ffi/main.go", $mainGo);
+            $mk = sprintf(<<<'MK'
+.PHONY: build
+build:
+    go build -buildmode=c-shared -o lib_%s.so main.go
+MK, $module);
+            file_put_contents("$base/ffi/Makefile", $mk);
+            $buildSh = <<<'SH'
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/ffi"
+make build
+cd ..
+echo "[提示] 如使用 FFI,请重启 PHP-FPM 并在 Service Adapter 中切换实现"
+SH;
+            file_put_contents("$base/build.sh", $buildSh);
+            @chmod("$base/build.sh", 0755);
+        }
+
+        // 前端 Admin 模板
+        if ($withFrontend) {
+            // 注意:root_path() 指向 backend/ 应用根;我们需要仓库根目录
+            $projectRoot = rtrim(dirname(root_path()), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+            // 视图目录名使用连字符(kebab-case),避免下划线
+            $feViewName = str_replace('_', '-', $module);
+            $feBase = $projectRoot . 'frontend/admin/src/views/' . $feViewName;
+            @mkdir($feBase . '/components', 0777, true);
+            @mkdir($feBase . '/composables', 0777, true);
+            $indexVue = sprintf(<<<'VUE'
+<template>
+  <div class="%s-page">
+    <a-tabs v-model:activeKey="tab">
+      <a-tab-pane key="dashboard" tab="仪表盘" />
+      <a-tab-pane key="list" tab="列表" />
+      <a-tab-pane key="settings" tab="设置" />
+    </a-tabs>
+    <component :is="currentComp" />
+  </div>
+</template>
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+const tab = ref('dashboard')
+const currentComp = computed(() => {
+  return tab.value === 'list' ? 'ListPanel' : (tab.value === 'settings' ? 'SettingsPanel' : 'DashboardPanel')
+})
+</script>
+VUE, $feViewName);
+            file_put_contents($feBase . '/index.vue', $indexVue);
+            $dash = "<template><div>DashboardPanel - {$feViewName}</div></template>\n";
+            $list = "<template><div>ListPanel - {$feViewName}</div></template>\n";
+            $settings = "<template><div>SettingsPanel - {$feViewName}</div></template>\n";
+            file_put_contents($feBase . '/components/DashboardPanel.vue', $dash);
+            file_put_contents($feBase . '/components/ListPanel.vue', $list);
+            file_put_contents($feBase . '/components/SettingsPanel.vue', $settings);
+            $useApi = sprintf(<<<'TS'
+import request from '@/utils/request'
+
+export function apiGet(url: string, params?: any) { return request.get(url, { params }) }
+export function apiPost(url: string, data?: any) { return request.post(url, data) }
+export const %sApi = {
+  ping: () => apiGet(`/api/%s/ping`),
+}
+TS, $module, $module);
+            file_put_contents($feBase . '/composables/useApi.ts', $useApi);
+        }
+
+        // 提示
+        $output->writeln("<info>扩展骨架已生成:</info> $base");
+        if ($withFrontend) $output->writeln("<comment>前端模板:</comment> frontend/admin/src/views/{$module}");
+        $output->writeln("<comment>下一步:</comment> 1) 根据业务完善 Service/Entity 2) 配置路由与菜单 3) 如需 FFI 执行 {$module}/build.sh");
+        return 0;
+    }
+
+    private function studly(string $value): string
+    {
+        $value = str_replace(['-', '_'], ' ', $value);
+        $value = ucwords($value);
+        return str_replace(' ', '', $value);
+    }
+}

+ 46 - 0
src/Command/ModelPropertyCommand.php

@@ -0,0 +1,46 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System\Command;
+
+use Ergebnis\Classy\Constructs;
+use SixShop\Core\Helper;
+use think\console\Command;
+use think\console\input\Argument;
+use think\console\Input\InputOption;
+
+class ModelPropertyCommand extends Command
+{
+    public function configure(): void
+    {
+        // Annotation for Model Properties
+        $this->setName('amp:property')
+            ->setDescription('Set the model property for extensions')
+            ->addArgument('module', Argument::REQUIRED, 'The module name to process')
+            ->addOption('all', 'a', Argument::OPTIONAL, 'Process all modules');
+    }
+
+    public function handle(): void
+    {
+        $modules = [];
+        if ($this->input->getOption('all')) {
+            $modules = module_name_list();
+        } else {
+            $modules[] = $this->input->getArgument('module');
+        }
+
+        foreach ($modules as $module) {
+            $modelDir = Helper::extension_path($module . '/src/Model');
+            $this->output->info("Generating model property for model directory: $modelDir");
+            if (!file_exists($modelDir)) {
+                $this->output->error("Model directory does not exist: $modelDir");
+                continue;
+            }
+            $constructs = Constructs::fromDirectory($modelDir);
+            foreach ($constructs as $construct) {
+                $this->output->info("Generating model property for model: " . $construct->name());
+                $this->getConsole()->call('ide-helper:model', ['--overwrite' => true, 'model' => $construct->name()]);
+            }
+        }
+    }
+}

+ 11 - 0
src/Config.php

@@ -0,0 +1,11 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System;
+
+use SixShop\Core\Trait\ConfigTrait;
+
+class Config
+{
+    use ConfigTrait;
+}

+ 39 - 0
src/Config/ExtensionConfig.php

@@ -0,0 +1,39 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System\Config;
+
+/**
+ * 模块配置
+ */
+class ExtensionConfig
+{
+    /**
+     * 模块共用的配置
+     * @var array
+     */
+    const array BASE = [
+        [
+            'type' => 'switch',
+            'field' => 'debug',
+            'title' => 'Debug开关',
+            'info' => '您可以开启或关闭debug模式',
+            'required' => false,
+            'props' => [
+                'activeValue' => true,
+                'inactiveValue' => false,
+                'disabled' => false,
+                'activeText' => '开启',
+                'inactiveText' => '关闭',
+                'activeColor' => '#40FB07FF',
+                'inactiveColor' => '#FF0000FF'
+            ],
+            '_fc_id' => 'id_Fakamcartp0rawc',
+            'name' => 'ref_F15kmcartp0raxc',
+            'display' => true,
+            'hidden' => false,
+            '_fc_drag_tag' => 'switch'
+        ]
+    ];
+
+}

+ 27 - 0
src/Controller/ExtensionConfigController.php

@@ -0,0 +1,27 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System\Controller;
+
+use SixShop\Core\Helper;
+use SixShop\System\ExtensionManager;
+use think\Request;
+use think\Response;
+use think\response\Json;
+
+class ExtensionConfigController
+{
+    public function read(string $id, ExtensionManager $extensionManager): Response
+    {
+        return Helper::success_response($extensionManager->getExtensionConfig($id));
+    }
+    public function edit(string $id, ExtensionManager $extensionManager): Response
+    {
+        return Helper::success_response($extensionManager->getExtensionConfigForm($id));
+    }
+
+    public function update(string $id, ExtensionManager $extensionManager, Request $request): Response
+    {
+        return Helper::success_response($extensionManager->saveConfig($id, $request->post()));
+    }
+}

+ 118 - 0
src/Controller/ExtensionController.php

@@ -0,0 +1,118 @@
+<?php
+declare(strict_types=1);
+namespace SixShop\System\Controller;
+
+use SixShop\Core\Helper;
+use SixShop\System\Enum\ExtensionStatusEnum;
+use SixShop\System\ExtensionManager;
+use SixShop\System\Migrate;
+use think\App;
+use think\facade\Event;
+use think\paginator\driver\Bootstrap;
+use think\Response;
+
+class ExtensionController
+{
+    public function index(ExtensionManager $extensionManager): Response
+    {
+        $extensionList = $extensionManager->getExtensionList();
+        $data = [
+            'total' => count($extensionList),
+            'enabled' => 0,
+            'disabled' => 0,
+            'installed'  => 0,
+            'uninstalled' => 0,
+            'category_map' => $extensionManager->getCategoryMap(),
+        ];
+        
+        // 获取ExtensionService实例,用于检查菜单状态
+        $extensionService = new \app\common\service\ExtensionService();
+        
+        foreach ($extensionList as &$extension) {
+            // 检查每个扩展是否已有菜单
+            try {
+                $extension['has_menu'] = $extensionService->hasExtensionMenu($extension['id']);
+            } catch (\Exception $e) {
+                $extension['has_menu'] = false;
+            }
+            
+            match ($extension['status']) {
+                ExtensionStatusEnum::ENABLED => $data['enabled']++,
+                ExtensionStatusEnum::DISABLED => $data['disabled']++,
+                ExtensionStatusEnum::UNINSTALLED => $data['uninstalled']++,
+                default => null,
+            };
+        }
+        $data['installed'] = $data['total'] - $data['uninstalled'];
+        return Helper::page_response(new Bootstrap(array_values($extensionList), $data['total'], 1, $data['total']), $data);
+    }
+
+    public function read(string $id, ExtensionManager $extensionManager): Response
+    {
+        $data = $extensionManager->getInfo($id);
+        $filePath = Helper::extension_path($id) . '/README.md';
+        if (file_exists($filePath)) {
+            $data['markdown'] = file_get_contents($filePath);
+        } else {
+            // 处理文件不存在的情况
+            $data['markdown'] = '无文档请补充文档,请将文档保存在扩展目录下'.$id.'/README.md';
+        }
+        $data['migrations'] = $extensionManager->migrations($id);
+        return Helper::success_response($data);
+    }
+    public function install(string $id, ExtensionManager $extensionManager): Response
+    {
+        Event::trigger('before_install_extension', $id);
+        $extensionManager->install($id);
+        Event::trigger('after_install_extension', $id);
+        return Helper::success_response();
+    }
+
+    public function uninstall(string $id, ExtensionManager $extensionManager): Response
+    {
+        Event::trigger('before_uninstall_extension', $id);
+        Event::trigger('before_uninstall_'.$id.'_extension');
+        $extensionManager->uninstall($id);
+        Event::trigger('after_uninstall_extension', $id);
+        Event::trigger('after_uninstall_'.$id.'_extension');
+        return Helper::success_response();
+    }
+
+    public function enable(string $id, ExtensionManager $extensionManager): Response
+    {
+        $extensionManager->enable($id);
+        Event::trigger('after_enable_extension', $id);
+        return Helper::success_response();
+    }
+
+    public function disable(string $id, ExtensionManager $extensionManager): Response
+    {
+        $extensionManager->disable($id);
+        Event::trigger('after_disable_extension', $id);
+        return Helper::success_response();
+    }
+
+    public function normal(App $app): Response
+    {
+        $extensionPath = Helper::extension_path();
+        $extensionDirs = array_diff(scandir($extensionPath), ['.', '..']);
+        $options = [];
+        foreach ($extensionDirs as $item) {
+            if (!is_dir($extensionPath . $item)) {
+                continue;
+            }
+            $infoFile = $extensionPath . $item . '/info.php';
+            if (is_file($infoFile)) {
+                $info = require $infoFile;
+                if (!($info['is_core']?? false)) {
+                    $options[] = [
+                        'value' => $info['id'],
+                        'label' => $info['name'],
+                    ];
+                }
+            }
+        }
+
+        return Helper::success_response($options);
+    }
+}

+ 30 - 0
src/Cron/SystemCron.php

@@ -0,0 +1,30 @@
+<?php
+declare(strict_types=1);
+namespace SixShop\System\Cron;
+
+use SixShop\Core\Attribute\Cron;
+use think\Cache;
+use Workerman\Crontab\Crontab;
+
+readonly class SystemCron
+{
+    public function __construct(private Cache $cache)
+    {
+    }
+
+    #[Cron('1 * * * * *', 'system.cron')]
+    public function onWorkerStart(): void
+    {
+        $crontabList = [];
+        foreach (Crontab::getAll() as $item) {
+            /* @var Crontab $item */
+            $crontabList[] = [
+                'rule' => $item->getRule(),
+                'name' => $item->getName(),
+                'id' => $item->getId(),
+                'time' => date('Y-m-d H:i:s'),
+            ];
+        }
+        $this->cache->set('crontab_list', $crontabList);
+    }
+}

+ 14 - 0
src/Entity/ExtensionEntity.php

@@ -0,0 +1,14 @@
+<?php
+declare(strict_types=1);
+namespace SixShop\System\Entity;
+
+use SixShop\Core\Entity\BaseEntity;
+use SixShop\System\Model\ExtensionModel;
+
+/**
+ * @mixin ExtensionModel
+ */
+class ExtensionEntity extends BaseEntity
+{
+
+}

+ 21 - 0
src/Enum/ExtensionStatusEnum.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace SixShop\System\Enum;
+
+enum ExtensionStatusEnum:int
+{
+    case UNINSTALLED = 1; // 未安装
+    case INSTALLED = 2;
+    case ENABLED = 3;
+    case DISABLED = 4;
+
+    public function toString(): string
+    {
+        return match ($this) {
+            self::UNINSTALLED => '未安装',
+            self::INSTALLED => '已安装',
+            self::ENABLED => '已启用',
+            self::DISABLED => '已禁用',
+        };
+    }
+}

+ 7 - 0
src/Event/CrontabWorkerStartEvent.php

@@ -0,0 +1,7 @@
+<?php
+declare(strict_types=1);
+namespace SixShop\System\Event;
+
+class CrontabWorkerStartEvent
+{
+}

+ 31 - 0
src/Extension.php

@@ -0,0 +1,31 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System;
+
+use SixShop\Core\ExtensionAbstract;
+use SixShop\System\Cron\SystemCron;
+use SixShop\System\Hook\GatheringCrontabEventHook;
+
+class Extension extends ExtensionAbstract
+{
+
+    public function getHooks(): array
+    {
+        return [
+            GatheringCrontabEventHook::class
+        ];
+    }
+
+    protected function getBaseDir(): string
+    {
+        return dirname(__DIR__);
+    }
+
+    public function getCronJobs(): array
+    {
+        return [
+            SystemCron::class
+        ];
+    }
+}

+ 291 - 0
src/ExtensionManager.php

@@ -0,0 +1,291 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System;
+
+use RuntimeException;
+use SixShop\Core\Contracts\ExtensionInterface;
+use SixShop\Core\Helper;
+use SixShop\Extension\payment\Contracts\PaymentExtensionInterface;
+use SixShop\System\Config\ExtensionConfig;
+use SixShop\System\Enum\ExtensionStatusEnum;
+use SixShop\System\Model\ExtensionConfigModel;
+use SixShop\System\Model\ExtensionModel;
+use think\db\Query;
+use think\exception\ValidateException;
+use think\facade\Db;
+use think\facade\Event;
+use think\facade\Log;
+use think\facade\Validate;
+use think\Service;
+
+class ExtensionManager extends Service
+{
+    /**
+     * @var array 扩展列表
+     */
+    private array $extensionList = [];
+
+    /**
+     * @var array 分类列表
+     */
+    private array $categoryMap = [];
+
+
+    /**
+     * 安装扩展
+     */
+    public function install(string $moduleName): void
+    {
+        $extensionModel = ExtensionModel::where(['id' => $moduleName])->findOrFail();
+        if ($extensionModel->status === ExtensionStatusEnum::INSTALLED) {
+            throw new RuntimeException("{$moduleName}扩展已安装");
+        }
+        $this->app->make(Migrate::class, [$this->app, $moduleName])->install();
+        $extension = $this->getExtension($moduleName);
+        $extension->install();
+        $config = $this->getExtensionConfig($moduleName);
+        if (empty($config)) {
+            $updateData = [];
+            $formConfig = $extension->getConfig();
+            foreach ($formConfig as $item) {
+                if (isset($item['value'])) {
+                    $updateData[$item['field']] = $item['value'];
+                }
+            }
+            if (!empty($updateData)) {
+                $this->saveConfig($moduleName, $updateData);
+            }
+        }
+        $extensionModel->status = ExtensionStatusEnum::INSTALLED;
+        $extensionModel->save();
+    }
+
+    public function getExtension(string $moduleName): ExtensionInterface|PaymentExtensionInterface
+    {
+        return $this->app->get('extension.' . $moduleName);
+    }
+
+    public function getExtensionConfig(string $moduleName, string $key = '', bool $onlyValue = true): mixed
+    {
+        $extensionConfig = ExtensionConfigModel::where('extension_id', $moduleName)->when($key, function (Query $query) use ($key) {
+            $query->where('key', $key);
+        })->column(['value', 'type',], 'key', true);
+
+        if (count($extensionConfig) === 0) {
+            return $key ? null : [];
+        }
+        if ($onlyValue) {
+            $extensionConfig = array_map(fn($item) => $item['value'], $extensionConfig);
+        }
+
+        return $key != '' ? $extensionConfig[$key] : $extensionConfig;
+    }
+
+    public function saveConfig(string $moduleName, array $data): bool
+    {
+        $config = array_merge(ExtensionConfig::BASE, $this->getExtension($moduleName)->getConfig());
+        $updateData = [];
+        foreach ($config as $item) {
+            if (isset($item['field'])) {
+                if (isset($data[$item['field']])) {
+                    $updateData[] = [
+                        'extension_id' => $moduleName,
+                        'key' => $item['field'],
+                        'value' => $data[$item['field']],
+                        'type' => $item['type'],
+                        'title' => $item['title']
+                    ];
+                }
+            } else {
+                if (isset($item['children'])) {
+                    foreach ($item['children'] as $childItem) {
+                        if (isset($childItem['field'], $data[$childItem['field']])) {
+                            $updateData[] = [
+                                'extension_id' => $moduleName,
+                                'key' => $childItem['field'],
+                                'value' => $data[$childItem['field']],
+                                'type' => $childItem['type'],
+                                'title' => $childItem['title']
+                            ];
+                        }
+                        if (isset($childItem['children'])) {
+                            foreach ($childItem['children'] as $grandChildItem) {
+                                if (isset($grandChildItem['field'], $data[$grandChildItem['field']])) {
+                                    $updateData[] = [
+                                        'extension_id' => $moduleName,
+                                        'key' => $grandChildItem['field'],
+                                        'value' => $data[$grandChildItem['field']],
+                                        'type' => $grandChildItem['type'],
+                                        'title' => $grandChildItem['title'],
+                                    ];
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        if (!empty($updateData)) {
+            Db::transaction(function () use ($updateData) {
+                foreach ($updateData as $item) {
+                    $configModel = ExtensionConfigModel::where([
+                        'extension_id' => $item['extension_id'],
+                        'key' => $item['key']
+                    ])->findOrEmpty();
+                    $configModel->save($item);
+                    Event::trigger('after_write_extension_config:' . $item['extension_id'] . ':' . $item['key'], $item);
+                }
+                Event::trigger('after_write_extension_config:' . $item['extension_id'], array_column($updateData, null,'key'));
+            });
+        }
+        return true;
+    }
+
+    /**
+     * 卸载扩展
+     */
+    public function uninstall(string $moduleName): void
+    {
+        $extensionModel = ExtensionModel::where(['id' => $moduleName])->findOrFail();
+        if ($extensionModel->status === ExtensionStatusEnum::UNINSTALLED) {
+            throw new RuntimeException("{$moduleName}扩展未安装");
+        }
+        $this->app->make(Migrate::class, [$this->app, $moduleName])->uninstall();
+        $this->getExtension($moduleName)->uninstall();
+        $extensionModel->status = ExtensionStatusEnum::UNINSTALLED;
+        $extensionModel->save();
+    }
+
+    /**
+     * 启用扩展
+     */
+    public function enable(string $moduleName): void
+    {
+        $extensionModel = ExtensionModel::where(['id' => $moduleName])->findOrFail();
+        match ($extensionModel->status) {
+            ExtensionStatusEnum::UNINSTALLED => throw new RuntimeException("{$moduleName}扩展未安装"),
+            ExtensionStatusEnum::ENABLED => throw new RuntimeException("{$moduleName}扩展已启用"),
+            default => null,
+        };
+        $extensionModel->status = ExtensionStatusEnum::ENABLED;
+        $extensionModel->save();
+    }
+
+    /**
+     * 禁用扩展
+     */
+    public function disable(string $moduleName): void
+    {
+        $extensionModel = ExtensionModel::where(['id' => $moduleName])->findOrFail();
+        if ($extensionModel->status != ExtensionStatusEnum::ENABLED) {
+            throw new RuntimeException("{$moduleName}扩展未启用");
+        }
+        $extensionModel->status = ExtensionStatusEnum::DISABLED;
+        $extensionModel->save();
+    }
+
+    /**
+     * 获取扩展信息
+     */
+    public function getInfo(string $name): ExtensionModel
+    {
+        return $this->extensionList[$name] ?? ($this->extensionList[$name] = $this->app->cache->remember(
+            sprintf(ExtensionModel::EXTENSION_INFO_CACHE_KEY, $name),
+            function () use ($name) {
+                return $this->initExtensionInfo($name);
+            }));
+    }
+
+    private function initExtensionInfo(string $name): ExtensionModel
+    {
+        $categoryMap = $this->getCategoryMap();
+        $extensionInfo = $this->getExtension($name)->getInfo();
+        try {
+            Validate::rule([
+                'id' => 'require|max:50',
+                'name' => 'require|max:100',
+                'is_core' => 'in:0,1',
+                'category' => 'in:' . implode(',', array_keys($categoryMap)),
+                'description' => 'max:65535',
+                'version' => 'require|max:20',
+                'core_version' => 'require|max:20',
+                'author' => 'require|max:100',
+                'email' => 'email|max:100',
+                'website' => 'url|max:255',
+                'image' => 'url|max:255',
+                'license' => 'max:50',
+            ])->failException()->check($extensionInfo);
+        } catch (ValidateException $exception) {
+            Log::warning('module(' . $name . ') info error:' . $exception->getError());
+        }
+        if (!isset($extensionInfo['id']) || $extensionInfo['id'] !== $name) {
+            throw new RuntimeException("{$name}扩展id与目录名不一致");
+        }
+        $extension = ExtensionModel::where(['id' => $name])->append(['status_text'])->findOrEmpty();
+        if ($extension->isEmpty()) {
+            $extensionInfo['status'] = 1; // 下载的扩展默认未安装
+            if (isset($extensionInfo['is_core']) && $extensionInfo['is_core'] == 1) {
+                $extensionInfo['status'] = 3; // 核心扩展默认启用
+            }
+            $extension->save($extensionInfo);
+        }
+        $extension['category_text'] = $categoryMap[$extension['category']] ?? '未知';
+        return $this->extensionList[$name] = $extension;
+    }
+
+    /**
+     * @return array
+     */
+    public function getCategoryMap(): array
+    {
+        if (empty($this->categoryMap)) {
+            $this->categoryMap = array_to_map($this->getExtensionConfig('system', 'category'), 'code', 'text');
+        }
+        return $this->categoryMap;
+    }
+
+    public function getExtensionList(): array
+    {
+        foreach (Helper::extension_name_list() as $name) {
+            $this->app->cache->set(sprintf(ExtensionModel::EXTENSION_INFO_CACHE_KEY, $name), $this->initExtensionInfo($name));
+        }
+        return $this->extensionList;
+    }
+
+    public function getExtensionConfigForm(string $moduleName): array
+    {
+        $config = array_merge(ExtensionConfig::BASE, array_values($this->getExtension($moduleName)->getConfig()));
+        $extensionConfig = ExtensionConfigModel::where('extension_id', $moduleName)->column(['value', 'type',], 'key', true);
+        foreach ($config as $key => &$item) {
+            if (isset($item['field'])) {
+                if (isset($extensionConfig[$item['field']])) {
+                    $config[$key]['value'] = $extensionConfig[$item['field']]['value'];
+                }
+            } else {
+                if (isset($item['children'])) {
+                    foreach ($item['children'] as $childKey => &$childItem) {
+                        if (isset($childItem['field'], $extensionConfig[$childItem['field']])) {
+                            $config[$key]['children'][$childKey]['value'] = $extensionConfig[$childItem['field']]['value'];
+                        }
+                        if (isset($childItem['children'])) {
+                            foreach ($childItem['children'] as $grandChildKey => $grandChildItem) {
+                                if (isset($grandChildItem['field'], $extensionConfig[$grandChildItem['field']])) {
+                                    $config[$key]['children'][$childKey]['children'][$grandChildKey]['value'] = $extensionConfig[$grandChildItem['field']]['value'];
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        Event::trigger('after_read_extension_config', [$config, $moduleName]);
+
+        return $config;
+    }
+
+    public function migrations(string $id)
+    {
+        return app(Migrate::class, [$this->app, $id])->getMigrationList();
+    }
+}

+ 16 - 0
src/ExtensionMysqlAdapter.php

@@ -0,0 +1,16 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System;
+
+use Phinx\Db\Adapter\MysqlAdapter;
+
+/**
+ * ExtensionMysqlAdapter
+ *
+ * todo 创建统一的扩展适配器
+ */
+class ExtensionMysqlAdapter extends MysqlAdapter
+{
+
+}

+ 42 - 0
src/Hook/GatheringCrontabEventHook.php

@@ -0,0 +1,42 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System\Hook;
+
+use ReflectionClass;
+use ReflectionMethod;
+use SixShop\Core\Attribute\Cron;
+use SixShop\Core\Attribute\Hook;
+use SixShop\Core\Helper;
+use SixShop\System\Event\CrontabWorkerStartEvent;
+use SixShop\System\ExtensionManager;
+use think\App;
+use Workerman\Crontab\Crontab;
+
+class GatheringCrontabEventHook
+{
+    public function __construct(private App $app)
+    {
+    }
+
+    #[Hook(CrontabWorkerStartEvent::class)]
+    public function onWorkerStart(): void
+    {
+        $extensionManager = $this->app->make(ExtensionManager::class);
+        foreach (Helper::extension_name_list() as $extensionName) {
+            $extension = $extensionManager->getExtension($extensionName);
+            $cronJobs = $extension->getCronJobs();
+            foreach ($cronJobs as $cronJobClass) {
+                $ref = new ReflectionClass($cronJobClass);
+                foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+                    $attributes = $method->getAttributes(Cron::class);
+                    foreach ($attributes as $attribute) {
+                        $cronInstance = $attribute->newInstance();
+                        $name = $cronInstance->name ?: $cronJobClass . '@' . $method->getName();
+                        new Crontab($cronInstance->rule, [$this->app->make($cronJobClass), $method->getName()], $name);
+                    }
+                }
+            }
+        }
+    }
+}

+ 15 - 0
src/Job/ClosureJob.php

@@ -0,0 +1,15 @@
+<?php
+declare(strict_types=1);
+namespace SixShop\System\Job;
+
+use SixShop\Core\Job\BaseJob;
+use Closure;
+class ClosureJob extends BaseJob
+{
+
+    protected bool $isClosure = true;
+    protected function execute(Closure $data)
+    {
+        return value($data);
+    }
+}

+ 290 - 0
src/Migrate.php

@@ -0,0 +1,290 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System;
+
+use Phinx\Config\Config;
+use Phinx\Db\Adapter\AdapterFactory;
+use Phinx\Db\Adapter\AdapterInterface;
+use Phinx\Migration\AbstractMigration;
+use Phinx\Migration\MigrationInterface;
+use Phinx\Util\Util;
+use SixShop\Core\Helper;
+use SixShop\System\Model\MigrationsModel;
+use think\App;
+
+class Migrate
+{
+    private string $moduleName;
+
+    private string $path;
+    protected ?array $migrations = null;
+    private $input;
+    private $output;
+
+    protected AdapterInterface $adapter;
+
+    protected App $app;
+
+    public function __construct(App $app, string $moduleName)
+    {
+        $this->app = $app;
+        $this->moduleName = $moduleName;
+        $this->path = Helper::extension_path($this->moduleName) . 'database' . DIRECTORY_SEPARATOR . 'migrations';
+        $this->migrations = $this->getMigrations();
+        $this->input = null;
+        $this->output = null;
+    }
+
+    public function install(): array
+    {
+        $migrations = $this->getMigrations();
+        $versions = $this->getVersions();
+        $currentVersion = $this->getCurrentVersion();
+        if (empty($versions) && empty($migrations)) {
+            return [];
+        }
+        ksort($migrations);
+        $installVersions = [];
+        foreach ($migrations as $migration) {
+            if ($migration->getVersion() <= $currentVersion) {
+                continue;
+            }
+            if (!in_array($migration->getVersion(), $versions)) {
+                $installVersions[] = $migration->getVersion();
+                $this->executeMigration($migration);
+            }
+        }
+        return $installVersions;
+    }
+
+    public function uninstall(): void
+    {
+        $migrations = $this->getMigrations();
+        $versionLog = $this->getVersionLog();
+        $versions = array_keys($versionLog);
+
+        ksort($migrations);
+        sort($versions);
+        if (empty($versions)) {
+            return;
+        }
+        krsort($migrations);
+        foreach ($migrations as $migration) {
+            if (in_array($migration->getVersion(), $versions)) {
+                if (isset($versionLog[$migration->getVersion()]) && 0 != $versionLog[$migration->getVersion()]['breakpoint']) {
+                    break;
+                }
+                $this->executeMigration($migration, MigrationInterface::DOWN);
+            }
+        }
+    }
+
+    public function getMigrationList(): array
+    {
+        $migrations = $this->getMigrations();
+        MigrationsModel::maker(function (MigrationsModel $model) {
+            $model->setOption('suffix', $this->moduleName);
+        });
+        $versionLog =  MigrationsModel::column('*', 'version');
+        foreach ($migrations as $key => $migration) {
+            $migrations[$key] = $versionLog[$key] ?? ['version'=> $key];
+        }
+        return array_values($migrations);
+    }
+
+    protected function getMigrations(): ?array
+    {
+        if (null === $this->migrations) {
+            if (!is_dir($this->path)) {
+                return [];
+            }
+            $allFiles = array_diff(scandir($this->path), ['.', '..']);
+            $phpFiles = [];
+            foreach ($allFiles as $file) {
+                if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
+                    $phpFiles[] = $this->path . DIRECTORY_SEPARATOR . $file;
+                }
+            }
+
+            // filter the files to only get the ones that match our naming scheme
+            $fileNames = [];
+            /** @var Migrator[] $versions */
+            $versions = [];
+
+            foreach ($phpFiles as $filePath) {
+                if (Util::isValidMigrationFileName(basename($filePath))) {
+                    $version = Util::getVersionFromFileName(basename($filePath));
+
+                    if (isset($versions[$version])) {
+                        throw new \InvalidArgumentException(sprintf('Duplicate migration - "%s" has the same version as "%s"', $filePath, $versions[$version]->getVersion()));
+                    }
+
+                    // convert the filename to a class name
+                    $class = Util::mapFileNameToClassName(basename($filePath));
+
+                    if (isset($fileNames[$class])) {
+                        throw new \InvalidArgumentException(sprintf('Migration "%s" has the same name as "%s"', basename($filePath), $fileNames[$class]));
+                    }
+
+                    $fileNames[$class] = basename($filePath);
+
+                    // load the migration file
+                    /** @noinspection PhpIncludeInspection */
+                    require_once $filePath;
+                    if (!class_exists($class)) {
+                        throw new \InvalidArgumentException(sprintf('Could not find class "%s" in file "%s"', $class, $filePath));
+                    }
+
+                    // instantiate it
+                    $migration = new $class('default', $version, $this->input, $this->output);
+
+                    if (!($migration instanceof AbstractMigration)) {
+                        throw new \InvalidArgumentException(sprintf('The class "%s" in file "%s" must extend \Phinx\Migration\AbstractMigration', $class, $filePath));
+                    }
+
+                    $versions[$version] = $migration;
+                }
+            }
+
+            ksort($versions);
+            $this->migrations = $versions;
+        }
+
+        return $this->migrations;
+    }
+
+    protected function getVersions()
+    {
+        return $this->getAdapter()->getVersions();
+    }
+
+    protected function getVersionLog()
+    {
+        return $this->getAdapter()->getVersionLog();
+    }
+
+    public function getAdapter()
+    {
+        if (isset($this->adapter)) {
+            return $this->adapter;
+        }
+
+        $options = $this->getDbConfig();
+
+        $adapterFactory = AdapterFactory::instance();
+        $adapterFactory->registerAdapter('mysql', ExtensionMysqlAdapter::class);
+        $adapter = $adapterFactory->getAdapter($options['adapter'], $options);
+
+        if ($adapter->hasOption('table_prefix') || $adapter->hasOption('table_suffix')) {
+            $adapter = $adapterFactory->getWrapper('prefix', $adapter);
+        }
+
+
+        $this->adapter = $adapter;
+
+        return $adapter;
+    }
+
+    protected function getDbConfig(): array
+    {
+        $default = $this->app->config->get('database.default');
+
+        $config = $this->app->config->get("database.connections.{$default}");
+
+        if (0 == $config['deploy']) {
+            $dbConfig = [
+                'adapter' => $config['type'],
+                'host' => $config['hostname'],
+                'name' => $config['database'],
+                'user' => $config['username'],
+                'pass' => $config['password'],
+                'port' => $config['hostport'],
+                'charset' => $config['charset'],
+                'suffix' => $config['suffix'] ?? '',
+                'table_prefix' => $config['prefix'],
+            ];
+        } else {
+            $dbConfig = [
+                'adapter' => explode(',', $config['type'])[0],
+                'host' => explode(',', $config['hostname'])[0],
+                'name' => explode(',', $config['database'])[0],
+                'user' => explode(',', $config['username'])[0],
+                'pass' => explode(',', $config['password'])[0],
+                'port' => explode(',', $config['hostport'])[0],
+                'charset' => explode(',', $config['charset'])[0],
+                'suffix' => explode(',', $config['suffix'] ?? '')[0],
+                'table_prefix' => explode(',', $config['prefix'])[0],
+            ];
+        }
+
+        $table = $this->app->config->get('database.extension_migration_table', 'migrations_' . $this->moduleName);
+
+        $dbConfig['migration_table'] = $dbConfig['table_prefix'] . $table;
+        $dbConfig['version_order'] = Config::VERSION_ORDER_CREATION_TIME;
+
+        return $dbConfig;
+    }
+
+    protected function getCurrentVersion()
+    {
+        $versions = $this->getVersions();
+        $version = 0;
+
+        if (!empty($versions)) {
+            $version = end($versions);
+        }
+
+        return $version;
+    }
+
+    protected function executeMigration(MigrationInterface $migration, $direction = MigrationInterface::UP)
+    {
+
+        $startTime = time();
+        $direction = (MigrationInterface::UP === $direction) ? MigrationInterface::UP : MigrationInterface::DOWN;
+        $migration->setMigratingUp($direction === MigrationInterface::UP);
+        $migration->setAdapter($this->getAdapter());
+
+        $migration->preFlightCheck();
+
+        if (method_exists($migration, MigrationInterface::INIT)) {
+            $migration->{MigrationInterface::INIT}();
+        }
+
+        // begin the transaction if the adapter supports it
+        if ($this->getAdapter()->hasTransactions()) {
+            $this->getAdapter()->beginTransaction();
+        }
+
+        // Run the migration
+        if (method_exists($migration, MigrationInterface::CHANGE)) {
+            if (MigrationInterface::DOWN === $direction) {
+                // Create an instance of the ProxyAdapter so we can record all
+                // of the migration commands for reverse playback
+                /** @var \Phinx\Db\Adapter\ProxyAdapter $proxyAdapter */
+                $proxyAdapter = AdapterFactory::instance()->getWrapper('proxy', $this->getAdapter());
+                $migration->setAdapter($proxyAdapter);
+                $migration->{MigrationInterface::CHANGE}();
+                $proxyAdapter->executeInvertedCommands();
+                $migration->setAdapter($this->getAdapter());
+            } else {
+                /** @noinspection PhpUndefinedMethodInspection */
+                $migration->change();
+            }
+        } else {
+            $migration->{$direction}();
+        }
+
+        // commit the transaction if the adapter supports it
+        if ($this->getAdapter()->hasTransactions()) {
+            $this->getAdapter()->commitTransaction();
+        }
+
+        $migration->postFlightCheck();
+
+        // Record it in the database
+        $this->getAdapter()
+            ->migrated($migration, $direction, date('Y-m-d H:i:s', $startTime), date('Y-m-d H:i:s', time()));
+    }
+}

+ 57 - 0
src/Model/ExtensionConfigModel.php

@@ -0,0 +1,57 @@
+<?php
+declare(strict_types=1);
+namespace SixShop\System\Model;
+
+use think\Model;
+use think\model\type\Json;
+
+/**
+ * Class SixShop\System\Model\ExtensionConfigModel
+ *
+ * @property array $value 配置值,JSON格式存储
+ * @property int $id 主键
+ * @property string $create_time 创建时间
+ * @property string $extension_id 模块ID,关联extension表
+ * @property string $key 配置项名称
+ * @property string $title 配置名称
+ * @property string $type 配置类型:input, radio, select等
+ * @property string $update_time 更新时间
+ */
+class ExtensionConfigModel extends Model
+{
+    protected $name = 'extension_config';
+    protected $pk = 'id';
+
+    protected function getOptions(): array
+    {
+        return [
+            'type' => [
+                'value' => 'json'
+            ],
+            'jsonAssoc' => true,
+        ];
+    }
+
+    public function getValueAttr(Json $value, array $data)
+    {
+        $raw = $value->value();
+
+        $firstOrSelf = function ($val) {
+            if (is_array($val)) {
+                if (empty($val)) {
+                    return '';
+                }
+                $vals = array_values($val);
+                return $vals[0] ?? '';
+            }
+            return $val;
+        };
+
+        return match ($data['type']) {
+            'radio', 'select', 'elTreeSelect', 'input' => (string)$firstOrSelf($raw),
+            'switch' => (bool)$raw,
+            'timePicker', 'colorPicker', 'datePicker', 'fcEditor' => (fn($val) => (is_array($val) && count($val) == 1) ? (array_values($val)[0] ?? '') : $val)($raw),
+            default => $raw,
+        };
+    }
+}

+ 60 - 0
src/Model/ExtensionModel.php

@@ -0,0 +1,60 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System\Model;
+
+use SixShop\System\Enum\ExtensionStatusEnum;
+use think\facade\Cache;
+use think\Model;
+use think\model\concern\SoftDelete;
+
+/**
+ * Class SixShop\System\Model\ExtensionModel
+ *
+ * @property ExtensionStatusEnum $status 状态(1:未安装,2:安装,3:启用,4:禁用)
+ * @property bool $is_core 是否核心扩展
+ * @property string $author 作者
+ * @property string $category 分类
+ * @property string $core_version 支持的核心版本
+ * @property string $create_time 创建时间
+ * @property string $delete_time 删除时间
+ * @property string $description 扩展描述
+ * @property string $email 作者邮箱
+ * @property string $id 扩展唯一标识符
+ * @property string $image 扩展图片地址
+ * @property string $license 开源协议
+ * @property string $name 扩展名称
+ * @property string $update_time 更新时间
+ * @property string $version 扩展版本
+ * @property string $website 扩展地址
+ * @property-read mixed $status_text
+ * @method static \think\db\Query onlyTrashed()
+ * @method static \think\db\Query withTrashed()
+ */
+class ExtensionModel extends Model
+{
+    public const string EXTENSION_INFO_CACHE_KEY = 'extension_info:%s';
+
+    public function getStatusTextAttr($value, $data): string
+    {
+        return $data['status']->toString();
+    }
+
+    use SoftDelete;
+
+    public function onAfterWrite($model): void
+    {
+        Cache::delete(sprintf(self::EXTENSION_INFO_CACHE_KEY, $model->id));
+    }
+
+    protected function getOptions(): array
+    {
+        return [
+            'name' => 'extension',
+            'pk' => 'id',
+            'type' => [
+                'status' => ExtensionStatusEnum::class,
+            ]
+        ];
+    }
+}

+ 16 - 0
src/Model/MigrationsModel.php

@@ -0,0 +1,16 @@
+<?php
+declare(strict_types=1);
+namespace SixShop\System\Model;
+
+use think\Model;
+
+class MigrationsModel extends Model
+{
+    protected function getOptions(): array
+    {
+        return [
+            'name' => 'migrations_',
+            'pk' => 'version',
+        ];
+    }
+}

+ 29 - 0
src/helper.php

@@ -0,0 +1,29 @@
+<?php
+declare(strict_types=1);
+
+use SixShop\System\ExtensionManager;
+
+
+if (!function_exists('extension_config')) {
+    /**
+     * 获取模块配置
+     */
+    function extension_config(string $moduleName, string $key = '', bool $onlyValue = true): mixed
+    {
+        return app(ExtensionManager::class)->getExtensionConfig($moduleName, $key, $onlyValue);
+    }
+}
+
+if (!function_exists('array_to_map')) {
+    function array_to_map(array|null $array, string $key, string $value): array
+    {
+        if (empty($array)) {
+            return [];
+        }
+        $map = [];
+        foreach ($array as $item) {
+            $map[$item[$key]] = $item[$value];
+        }
+        return $map;
+    }
+}

+ 24 - 0
test/ExtensionManagerTest.php

@@ -0,0 +1,24 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\System;
+
+use PHPUnit\Framework\TestCase;
+
+class ExtensionManagerTest extends TestCase
+{
+    public function testInstall()
+    {
+        app()->make(ExtensionManager::class)->install('hello');
+    }
+
+    public function testUninstall()
+    {
+        app()->make(ExtensionManager::class)->uninstall('hello');
+    }
+
+    public function testGetExtensionConfig()
+    {
+        $result = array_to_map(app(ExtensionManager::class)->getExtensionConfig('system', 'category'), 'code', 'text');
+    }
+}