diff --git a/plugin/think-plugs-helper/composer.json b/plugin/think-plugs-helper/composer.json
index 8f21740c0..263f62526 100644
--- a/plugin/think-plugs-helper/composer.json
+++ b/plugin/think-plugs-helper/composer.json
@@ -11,8 +11,10 @@
}
],
"require": {
- "php": ">7.1",
+ "php": "^8.1",
"ext-json": "*",
+ "ext-zlib": "*",
+ "doctrine/dbal": "^4.2",
"topthink/think-orm": "^2.0|^3.0|^4.0",
"topthink/think-ide-helper": "*"
},
diff --git a/plugin/think-plugs-helper/src/DbBackupStruct.php b/plugin/think-plugs-helper/src/DbBackupStruct.php
new file mode 100644
index 000000000..6b83d55a4
--- /dev/null
+++ b/plugin/think-plugs-helper/src/DbBackupStruct.php
@@ -0,0 +1,162 @@
+
+// +----------------------------------------------------------------------
+// | 官方网站: https://thinkadmin.top
+// +----------------------------------------------------------------------
+// | 开源协议 ( https://mit-license.org )
+// | 免责声明 ( https://thinkadmin.top/disclaimer )
+// +----------------------------------------------------------------------
+// | gitee 代码仓库:https://gitee.com/zoujingli/think-plugs-helper
+// | github 代码仓库:https://github.com/zoujingli/think-plugs-helper
+// +----------------------------------------------------------------------
+
+declare (strict_types=1);
+
+namespace plugin\helper;
+
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\DriverManager;
+use think\admin\Library;
+use think\admin\service\SystemService;
+use think\console\Command;
+use think\console\input\Option;
+
+/**
+ * 数据库备份.
+ */
+class DbBackupStruct extends Command
+{
+ /**
+ * 配置命令参数。
+ */
+ public function configure(): void
+ {
+ $this->setName("xadmin:helper:backup");
+ $this->addOption('all', 'a', Option::VALUE_NONE, 'Backup All Tables');
+ $this->setDescription("恢复数据前是否强制清空所有表数据");
+ }
+
+ /**
+ * 指令执行入口.
+ */
+ public function handle(): void
+ {
+ $this->backupSchema() && $this->backupTables();
+ }
+
+ /**
+ * 检查是否允许执行任务
+ * @return bool 返回true表示允许执行
+ */
+ public function isEnabled(): bool
+ {
+ return SystemService::isDebug();
+ }
+
+ /**
+ * 备份数据库结构,使用 gzip 压缩保存.
+ */
+ protected function backupSchema(): bool
+ {
+ try {
+ $outputFile = $this->getSchemaPath();
+ if (!($gz = gzopen($outputFile, 'w9'))) {
+ $this->output->error("❌ 无法打开压缩文件写入数据库结构:{$outputFile}");
+ return false;
+ }
+ $schema = $this->makeConnect()->createSchemaManager();
+ gzwrite($gz, serialize($schema->introspectSchema()));
+ gzclose($gz);
+ $this->output->info("✅ 数据库结构已压缩保存至:{$outputFile}");
+ return true;
+ } catch (\Throwable $throwable) {
+ $this->output->error("❌ 数据库结构导出失败:{$throwable->getMessage()}");
+ return false;
+ }
+ }
+
+ /**
+ * 备份数据表数据,gzip 压缩写入.
+ */
+ protected function backupTables(): bool
+ {
+ $backupPath = $this->getBackupPath();
+ is_dir(dirname($backupPath)) || mkdir(dirname($backupPath), 0755, true);
+ if (!($gz = gzopen($backupPath, 'w9'))) {
+ $this->output->error("❌ 无法打开压缩文件写入数据表数据:{$backupPath}");
+ return false;
+ }
+ $force = (bool)$this->input->getOption('all');
+ foreach ($this->getBkTables($force) as $table) {
+ $total = 0;
+ if (!empty($fields = $this->app->db->getFields($table))) {
+ $query = $this->app->db->table($table)->order(in_array('id', $fields) ? 'id' : array_values($fields)[0]);
+ in_array('ssid', $fields) && $query = $query->where('ssid', '0');
+ in_array('deleted_at', $fields) && $query = $query->whereNull('deleted_at');
+ $query->chunk(10000, function ($rows) use ($gz, $table, &$total) {
+ foreach ($rows as $row) {
+ $record = ['table' => $table, 'data' => (array)$row];
+ gzwrite($gz, json_encode($record, JSON_UNESCAPED_UNICODE) . "\n");
+ ++$total;
+ }
+ });
+ }
+ $this->output->writeln("✅ 表 {$table} 备份完成,共 {$total} 行");
+ }
+
+ gzclose($gz);
+ $this->output->info("📂 表数据已压缩写入:{$backupPath}");
+ return true;
+ }
+
+ /**
+ * 获取需要备份的表.
+ * @return array
+ */
+ protected function getBkTables(bool $all = true): array
+ {
+ // 接收指定打包数据表
+ if ($all) {
+ [$tables] = SystemService::getTables();
+ } elseif (empty($tables = Library::$sapp->config->get('phinx.tables', []))) {
+ $this->output->error("❌ 配置文件未定义数据表列表,请检查配置项:phinx.tables");
+ return [];
+ }
+ return $tables;
+ }
+
+ /**
+ * 创建连接对接.
+ */
+ protected function makeConnect(): Connection
+ {
+ $config = $this->app->db->connect()->getConfig();
+ $config['host'] = $config['hostname'] ?? '';
+ $config['user'] = $config['username'] ?? '';
+ $config['dbname'] = $config['database'] ?? '';
+ if (in_array($config['type'], ['mysql', 'sqlite', 'oci'])) {
+ $config['driver'] = 'pdo_' . $config['type'];
+ }
+ return DriverManager::getConnection($config);
+ }
+
+ /**
+ * 结构文件路径,压缩格式.
+ */
+ protected function getSchemaPath(): string
+ {
+ return syspath("database/backup.schema.gz");
+ }
+
+ /**
+ * 数据备份文件路径,压缩格式.
+ */
+ protected function getBackupPath(): string
+ {
+ return syspath("database/backup.data.gz");
+ }
+}
diff --git a/plugin/think-plugs-helper/src/DbIndexStruct.php b/plugin/think-plugs-helper/src/DbIndexStruct.php
new file mode 100644
index 000000000..b4b073478
--- /dev/null
+++ b/plugin/think-plugs-helper/src/DbIndexStruct.php
@@ -0,0 +1,84 @@
+
+// +----------------------------------------------------------------------
+// | 官方网站: https://thinkadmin.top
+// +----------------------------------------------------------------------
+// | 开源协议 ( https://mit-license.org )
+// | 免责声明 ( https://thinkadmin.top/disclaimer )
+// +----------------------------------------------------------------------
+// | gitee 代码仓库:https://gitee.com/zoujingli/think-plugs-helper
+// | github 代码仓库:https://github.com/zoujingli/think-plugs-helper
+// +----------------------------------------------------------------------
+
+declare (strict_types=1);
+
+namespace plugin\helper;
+
+use think\admin\service\SystemService;
+use think\console\Command;
+use think\facade\Db;
+
+class DbIndexStruct extends Command
+{
+
+ /**
+ * 配置命令参数。
+ */
+ public function configure(): void
+ {
+ $this->setName("xadmin:helper:index");
+ $this->setDescription("刷新数据库的结构索引");
+ }
+
+ /**
+ * 检查是否允许执行任务
+ * @return bool 返回true表示允许执行
+ */
+ public function isEnabled(): bool
+ {
+ return SystemService::isDebug();
+ }
+
+ /**
+ * 命令执行入口
+ * 遍历数据库表并重命名索引.
+ */
+ public function handle(): void
+ {
+ [$tables, $total, $count] = SystemService::getTables();
+ foreach ($tables as $table) {
+ $this->output->writeln(sprintf("[%s/%s] 开始处理表 {$table}", $count++, $total));
+ foreach (Db::query(sprintf('SHOW INDEX FROM `%s`', $table)) as $index) {
+ $keyName = $index['Key_name'] ?? '';
+ if ($keyName === 'PRIMARY') {
+ continue;
+ }
+ $newName = $this->genIndexName($table, (array)$index);
+ if ($keyName === $newName) {
+ continue;
+ }
+ Db::execute(sprintf('ALTER TABLE `%s` RENAME INDEX `%s` TO `%s`', $table, $keyName, $newName));
+ ++$count;
+ }
+ }
+ $this->output->writeln("✅ 完成 {$count} 个索引重命名");
+ }
+
+ /**
+ * 生成索引名称.
+ * @param string $table 表名
+ * @param array $index 索引信息
+ * @return string 生成的索引名称
+ */
+ private function genIndexName(string $table, array $index): string
+ {
+ $abbr = implode('', array_map(function ($word) {
+ return $word[0];
+ }, explode('_', $table)));
+ return ($index['Non_unique'] ? 'idx_' : 'uni_') . $abbr . '_' . substr(md5($table), -4) . '_' . $index['Column_name'];
+ }
+}
diff --git a/plugin/think-plugs-helper/src/ModelGen.php b/plugin/think-plugs-helper/src/DbModelStruct.php
similarity index 98%
rename from plugin/think-plugs-helper/src/ModelGen.php
rename to plugin/think-plugs-helper/src/DbModelStruct.php
index 8d407883c..7cc67ade5 100644
--- a/plugin/think-plugs-helper/src/ModelGen.php
+++ b/plugin/think-plugs-helper/src/DbModelStruct.php
@@ -30,7 +30,7 @@ use think\ide\console\ModelCommand;
* @class ModelGen
* @package plugin\helper
*/
-class ModelGen extends ModelCommand
+class DbModelStruct extends ModelCommand
{
protected function configure()
{
diff --git a/plugin/think-plugs-helper/src/DbRestoreStruct.php b/plugin/think-plugs-helper/src/DbRestoreStruct.php
new file mode 100644
index 000000000..ac3b34c87
--- /dev/null
+++ b/plugin/think-plugs-helper/src/DbRestoreStruct.php
@@ -0,0 +1,250 @@
+
+// +----------------------------------------------------------------------
+// | 官方网站: https://thinkadmin.top
+// +----------------------------------------------------------------------
+// | 开源协议 ( https://mit-license.org )
+// | 免责声明 ( https://thinkadmin.top/disclaimer )
+// +----------------------------------------------------------------------
+// | gitee 代码仓库:https://gitee.com/zoujingli/think-plugs-helper
+// | github 代码仓库:https://github.com/zoujingli/think-plugs-helper
+// +----------------------------------------------------------------------
+
+declare (strict_types=1);
+
+namespace plugin\helper;
+
+use Doctrine\DBAL\Exception;
+use Doctrine\DBAL\Schema\Comparator;
+use Doctrine\DBAL\Schema\Schema;
+use think\admin\model\SystemUser;
+use think\admin\service\RuntimeService;
+use think\admin\service\SystemService;
+use think\console\input\Option;
+
+/**
+ * 数据库结构与数据恢复命令。
+ * 支持恢复 Doctrine Schema 结构及按行压缩备份的数据。
+ */
+class DbRestoreStruct extends DbBackupStruct
+{
+
+ /**
+ * 配置命令参数。
+ */
+ public function configure(): void
+ {
+ $this->setName("xadmin:helper:restore");
+ $this->addOption('force', 'f', Option::VALUE_NONE, 'Force All Update');
+ $this->setDescription("恢复数据前是否强制清空所有表数据");
+ }
+
+ /**
+ * 命令执行入口。
+ * @throws Exception
+ */
+ public function handle(): void
+ {
+ $this->restoreSchema() && $this->restoreBackup();
+ }
+
+ /**
+ * 检查是否允许执行任务
+ * @return bool 返回true表示允许执行
+ */
+ public function isEnabled(): bool
+ {
+ return SystemService::isDebug();
+ }
+
+ /**
+ * 恢复数据库结构。
+ * @throws Exception
+ */
+ protected function restoreSchema(): bool
+ {
+ if (!is_file($gzPath = self::getSchemaPath())) {
+ $this->output->error("❌ 结构文件不存在:{$gzPath}");
+ return false;
+ }
+
+ $content = @gzdecode(file_get_contents($gzPath));
+ if (empty($content) || !($backupSchema = @unserialize($content)) instanceof Schema) {
+ $this->output->error("❌ 解压或反序列化失败:{$gzPath}");
+ return false;
+ }
+
+ $connect = self::makeConnect();
+ $platform = $connect->getDatabasePlatform();
+ $diff = (new Comparator($platform))->compareSchemas(
+ $connect->createSchemaManager()->introspectSchema(),
+ $backupSchema
+ );
+
+ $sqls = [];
+ foreach ($diff->getCreatedTables() as $t) {
+ $sqls = [...$sqls, ...$platform->getCreateTableSQL($t)];
+ }
+ foreach ($diff->getAlteredTables() as $t) {
+ $sqls = [...$sqls, ...$platform->getAlterTableSQL($t)];
+ }
+ foreach ($diff->getDroppedTables() as $t) {
+ $sqls[] = $platform->getDropTableSQL($t->getName());
+ }
+
+ if (!$sqls) {
+ $this->output->info('✅ 数据库结构已一致,无需变更。');
+ return true;
+ }
+
+ try {
+ foreach ($sqls as $sql) {
+ $this->output->writeln("🔧 执行 SQL:{$sql}");
+ $connect->executeStatement($sql);
+ }
+ $this->output->info('✅ 数据库结构同步完成。');
+ return true;
+ } catch (\Throwable $throwable) {
+ $this->output->error('❌ 结构同步失败:' . $throwable->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * 恢复业务数据。
+ * @throws Exception
+ */
+ protected function restoreBackup(): bool
+ {
+ $force = (bool)$this->input->getOption('force');
+ if (empty($tables = $this->getBkTables($force))) {
+ $this->output->error('❌ 未定义需恢复的数据表');
+ return false;
+ }
+
+ if (!file_exists($path = $this->getBackupPath())) {
+ $this->output->error("❌ 备份数据文件不存在:{$path}");
+ return false;
+ }
+
+ copy($path, $tmp = syspath('runtime/backup_data_tmp.gz'));
+ $schemaManager = $this->makeConnect()->createSchemaManager();
+
+ // 先清空 forceCleanTables 表,无需 count 检查
+ $forceCleanTables = ['system_menu', 'system_dict_data', 'system_dict_type'];
+ foreach (array_intersect($forceCleanTables, $tables) as $table) {
+ _query($table)->empty();
+ $this->output->writeln("🧹 强制清空表:{$table}");
+ }
+
+ if ($force) {
+ // 如果是强制恢复,需要清空所有表(非 forceCleanTables 表)
+ foreach ($schemaManager->listTableNames() as $table) {
+ if (!in_array($table, $forceCleanTables, true)) {
+ _query($table)->empty();
+ $this->output->writeln("✅ 已经清空表:{$table}");
+ }
+ }
+ }
+
+ // 计算需要恢复的数据表
+ $restoreTableFlags = [];
+ foreach ($tables as $table) {
+ $restoreTableFlags[$table] = $force || in_array($table, $forceCleanTables, true) || $this->app->db->table($table)->count() === 0;
+ }
+
+ if (!($fp = gzopen($tmp, 'rb'))) {
+ $this->output->writeln("❌ 无法打开备份文件:{$tmp}");
+ return false;
+ }
+
+ $totalLines = 0;
+ $currentLine = 0;
+ $batchInsert = [];
+ $insertCount = array_fill_keys($tables, 0);
+
+ try {
+ while (!gzeof($fp)) {
+ ++$totalLines;
+ $row = json_decode(trim(gzgets($fp)), true);
+ $table = $row['table'] ?? '-';
+
+ // 判断是否跳过恢复,非强制恢复时,根据 tableFlags 决定是否插入
+ if (empty($restoreTableFlags[$table]) || empty($row['data']) || !is_array($row['data'])) {
+ continue;
+ }
+
+ ++$currentLine;
+ ++$insertCount[$table];
+ $batchInsert[$table][] = $row['data'];
+ if (count($batchInsert[$table]) >= 1000) {
+ $this->flushBatchInsert($table, $batchInsert[$table]);
+ $this->output->writeln("📥 表 {$table} 批量插入 1000 行,已读取 {$totalLines} 行");
+ }
+ }
+
+ // 插入剩余数据
+ foreach ($batchInsert as $table => $rows) {
+ if ($count = count($rows)) {
+ $this->flushBatchInsert($table, $rows);
+ $this->output->writeln("📥 表 {$table} 批量插入 {$count} 行,已读取 {$totalLines} 行");
+ }
+ }
+ $this->output->writeln("✅ 数据恢复完成,共插入 {$currentLine} 行(读取 {$totalLines} 行)");
+ foreach ($insertCount as $table => $count) {
+ $count > 0 && $this->output->writeln("✅ 表 {$table} 插入 {$count} 行");
+ }
+ @unlink($tmp);
+
+ // 恢复管理员数据
+ $this->insertSuperUser();
+
+ // 清理系统运行缓存
+ return RuntimeService::clear(false);
+ } catch (\Throwable $throwable) {
+ trace_file($throwable);
+ $this->output->error('❌ 数据恢复失败:' . $throwable->getMessage());
+ return false;
+ } finally {
+ gzclose($fp);
+ }
+ }
+
+ /**
+ * 批量插入数据。
+ */
+ private function flushBatchInsert(string $table, array &$rows): void
+ {
+ if (!$rows) {
+ return;
+ }
+
+ try {
+ $this->app->db->table($table)->insertAll($rows);
+ } catch (\Throwable $throwable) {
+ $this->output->writeln("⚠️ 表 {$table} 插入失败:{$throwable->getMessage()}");
+ } finally {
+ $rows = [];
+ }
+ }
+
+ /**
+ * 插入默认管理员。
+ */
+ private function insertSuperUser(): void
+ {
+ $model = SystemUser::mk()->whereRaw('1=1')->findOrEmpty();
+ $model->isEmpty() && $model->save([
+ 'id' => '10000',
+ 'username' => 'admin',
+ 'nickname' => '超级管理员',
+ 'password' => '21232f297a57a5a743894a0e4a801fc3',
+ 'headimg' => 'https://thinkadmin.top/static/img/head.png',
+ ], true);
+ $this->output->writeln('✅ 管理员账号恢复成功');
+ }
+}
\ No newline at end of file
diff --git a/plugin/think-plugs-helper/src/Service.php b/plugin/think-plugs-helper/src/Service.php
index 0d2766fc0..2e0b2ec8b 100644
--- a/plugin/think-plugs-helper/src/Service.php
+++ b/plugin/think-plugs-helper/src/Service.php
@@ -22,6 +22,11 @@ class Service extends \think\Service
{
public function boot()
{
- $this->commands([ModelGen::class]);
+ $this->commands([
+ DbModelStruct::class,
+ DbIndexStruct::class,
+ DbBackupStruct::class,
+ DbRestoreStruct::class,
+ ]);
}
}