From 89a8ea13d8571e78882426e7c2c7246b19b54eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=98=8A=E5=A4=A9?= <442384644@qq.com> Date: Mon, 26 Oct 2026 15:17:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=AE=89=E5=85=A8=E8=A7=A3=E5=8E=8B):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20SafeZip=EF=BC=8C=E4=B8=BB=E9=A2=98/?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=20ZIP=20=E9=98=B2=E8=B7=AF=E5=BE=84=E7=A9=BF?= =?UTF-8?q?=E8=B6=8A=EF=BC=9B=E7=99=BB=E8=AE=B0=E4=B8=BB=E9=A2=98=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 商品 Excel 导入空单元格统一为空串,并补齐 product_type 列映射。 Co-authored-by: Cursor --- .../controller/diy/ThemeController.php | 74 +++-- .../controller/plugin/PluginController.php | 73 +---- .../system/admin/SystemRoleServices.php | 10 +- backend/crmeb/utils/File.php | 93 ++++--- backend/crmeb/utils/SafeZip.php | 254 ++++++++++++++++++ .../public/install/data/system_menus_data.sql | 3 +- 6 files changed, 379 insertions(+), 128 deletions(-) create mode 100644 backend/crmeb/utils/SafeZip.php diff --git a/backend/app/adminapi/controller/diy/ThemeController.php b/backend/app/adminapi/controller/diy/ThemeController.php index 668d3d486..3d5cdd40d 100644 --- a/backend/app/adminapi/controller/diy/ThemeController.php +++ b/backend/app/adminapi/controller/diy/ThemeController.php @@ -18,6 +18,7 @@ use app\services\diy\ThemeDownloadServices; use app\services\diy\ThemeServices; use app\services\product\CenterProductServices; use app\services\system\module\ModuleInstaller; +use crmeb\utils\SafeZip; use SplFileInfo; use think\facade\App; @@ -352,16 +353,21 @@ class ThemeController extends AuthController */ public function importTheme() { - // 1 获取文件 + // 1 获取文件:url 必须落在 public/uploads 内,禁止路径穿越读取任意文件 [$importUrl] = $this->request->postMore([ ['url', ''], ], true); - $realPath = public_path() . $importUrl; - if (!file_exists($realPath)) return app('json')->fail('文件不存在'); + try { + $realPath = $this->resolveThemeImportZip((string)$importUrl); + } catch (\Throwable $e) { + return app('json')->fail($e->getMessage() ?: '文件不存在或路径非法'); + } - // 2. 解压文件到 theme/import/ 目录 - $dir = 'theme/import/'; - if (!is_dir($dir)) mkdir($dir, 0755, true); + // 2. 解压到 public/theme/import/(绝对路径,禁止 CWD 相对解压) + $dir = rtrim(public_path('theme' . DIRECTORY_SEPARATOR . 'import'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { + return app('json')->fail('无法创建主题导入目录'); + } // 清理导入目录,防止数据污染 $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), @@ -375,10 +381,11 @@ class ThemeController extends AuthController @unlink($fileInfo->getRealPath()); } } - $zip = new \ZipArchive(); - $zip->open($realPath); - $zip->extractTo($dir); - $zip->close(); + try { + SafeZip::extract($realPath, $dir); + } catch (\Throwable $e) { + return app('json')->fail('主题包解压失败: ' . $e->getMessage()); + } // 3. 读取解压后的 config.json 文件 $configPath = $dir . 'config.json'; @@ -389,8 +396,11 @@ class ThemeController extends AuthController // 4. 处理图片资源迁移 // 将压缩包里面的所有图片,移动到 uploads/theme/{时间戳}/ 文件夹下 $timestamp = date('YmdHis'); - $themeDir = 'uploads/theme/' . $timestamp . '/'; - if (!is_dir($themeDir)) mkdir($themeDir, 0755, true); + $themeDirRel = 'uploads/theme/' . $timestamp . '/'; + $themeDirAbs = rtrim(public_path('uploads' . DIRECTORY_SEPARATOR . 'theme' . DIRECTORY_SEPARATOR . $timestamp), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + if (!is_dir($themeDirAbs) && !mkdir($themeDirAbs, 0755, true) && !is_dir($themeDirAbs)) { + return app('json')->fail('无法创建主题图片目录'); + } $rootPath = realpath($dir); $imageMap = []; // 记录相对路径到新上传路径的映射 @@ -411,11 +421,11 @@ class ThemeController extends AuthController $relative = ltrim(str_replace($rootPath, '', $filePath), DIRECTORY_SEPARATOR); $basename = basename($filePath); - // 目标路径 - $target = $themeDir . $basename; - // 移动/复制文件 - if (@copy($filePath, $target)) { - $imageMap[$relative] = $target; // 记录映射:包内相对路径 => 新系统路径 + // 目标路径(磁盘绝对路径写入,映射仍用站点相对路径) + $targetAbs = $themeDirAbs . $basename; + $targetRel = $themeDirRel . $basename; + if (@copy($filePath, $targetAbs)) { + $imageMap[$relative] = $targetRel; } } @@ -575,4 +585,34 @@ class ThemeController extends AuthController $data = $this->services->getMicroPageList(); return app('json')->success($data); } + + /** + * 将主题导入 url(完整 URL /uploads 相对路径)解析为 uploads 目录内的本地 ZIP 路径。 + */ + protected function resolveThemeImportZip(string $importUrl): string + { + $importUrl = trim($importUrl); + if ($importUrl === '') { + throw new \RuntimeException('请先上传主题包'); + } + + $path = $importUrl; + if (preg_match('#^https?://#i', $importUrl)) { + $parts = parse_url($importUrl); + $path = (string)($parts['path'] ?? ''); + } + $path = str_replace('\\', '/', $path); + $path = ltrim($path, '/'); + if (str_starts_with($path, 'uploads/')) { + $path = substr($path, strlen('uploads/')); + } + if ($path === '' || str_contains($path, '..')) { + throw new \RuntimeException('文件不存在或路径非法'); + } + if (!preg_match('/\.zip$/i', $path)) { + throw new \RuntimeException('仅支持 ZIP 主题包'); + } + + return SafeZip::resolveUnder(public_path('uploads'), $path); + } } diff --git a/backend/app/adminapi/controller/plugin/PluginController.php b/backend/app/adminapi/controller/plugin/PluginController.php index 3c21bf2ac..121391fe7 100644 --- a/backend/app/adminapi/controller/plugin/PluginController.php +++ b/backend/app/adminapi/controller/plugin/PluginController.php @@ -17,6 +17,7 @@ use app\services\system\module\ModuleInstaller; use app\services\system\module\CreateModuleInstallLogTable; use crmeb\plugin\PluginManager; use crmeb\plugin\PluginManifest; +use crmeb\utils\SafeZip; use think\facade\App; /** @@ -141,26 +142,22 @@ class PluginController extends AuthController } $targetDir = app()->getRootPath(); + $tmpDir = ''; try { - // 临时目录放到 public/uploads/ 下,方便统一管理 - $tmpDir = $targetDir . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . '.plugin_tmp_' . uniqid() . DIRECTORY_SEPARATOR; - if (!is_dir($tmpDir)) { - mkdir($tmpDir, 0755, true); + // 临时目录放在 runtime(Web 根外),避免解压物被 HTTP 直接访问执行 + $tmpRoot = $targetDir . 'runtime' . DIRECTORY_SEPARATOR . 'plugin_upload' . DIRECTORY_SEPARATOR; + if (!is_dir($tmpRoot) && !mkdir($tmpRoot, 0755, true) && !is_dir($tmpRoot)) { + return app('json')->fail('无法创建插件上传临时目录'); + } + $tmpDir = $tmpRoot . '.plugin_tmp_' . uniqid('', true) . DIRECTORY_SEPARATOR; + if (!mkdir($tmpDir, 0755, true) && !is_dir($tmpDir)) { + return app('json')->fail('无法创建插件上传临时目录'); } $zipPath = $tmpDir . 'package.zip'; $file->move($tmpDir, 'package.zip'); - - // 解压到临时目录 - $zip = new \ZipArchive(); - if ($zip->open($zipPath) !== true) { - $this->cleanTempDir($tmpDir); - return app('json')->fail('无法打开 ZIP 文件,可能已损坏'); - } - $this->validateZipEntries($zip); - $zip->extractTo($tmpDir); - $zip->close(); + SafeZip::extract($zipPath, $tmpDir, ['require_plugin_json' => true]); // 查找 plugin.json(可能在根目录或一级子目录) $pluginJson = $tmpDir . 'plugin.json'; @@ -322,54 +319,6 @@ class PluginController extends AuthController } } - /** - * 解压前验证所有 ZIP 条目,阻止目录穿越、绝对路径、符号链接和解压炸弹。 - */ - protected function validateZipEntries(\ZipArchive $zip): void - { - if ($zip->numFiles < 1 || $zip->numFiles > 2000) { - throw new \RuntimeException('ZIP 文件数量必须在 1 到 2000 之间'); - } - - $totalSize = 0; - $manifestCount = 0; - for ($i = 0; $i < $zip->numFiles; $i++) { - $stat = $zip->statIndex($i); - if (!is_array($stat) || !isset($stat['name'])) { - throw new \RuntimeException('ZIP 条目信息损坏'); - } - $name = str_replace('\\', '/', (string) $stat['name']); - $segments = explode('/', $name); - if ($name === '' || str_contains($name, "\0") || str_starts_with($name, '/') - || preg_match('/^[A-Za-z]:\//', $name) || in_array('..', $segments, true)) { - throw new \RuntimeException("ZIP 包含非法路径: {$name}"); - } - if (basename($name) === 'plugin.json') { - $manifestCount++; - if (count($segments) > 2) { - throw new \RuntimeException('plugin.json 只能位于 ZIP 根目录或一级插件目录'); - } - } - - $totalSize += (int) ($stat['size'] ?? 0); - if ($totalSize > 150 * 1024 * 1024) { - throw new \RuntimeException('ZIP 解压后总体积不能超过 150MB'); - } - - $attributes = 0; - $opsys = 0; - if ($zip->getExternalAttributesIndex($i, $opsys, $attributes)) { - $fileType = ($attributes >> 16) & 0170000; - if ($fileType === 0120000) { - throw new \RuntimeException("ZIP 不允许包含符号链接: {$name}"); - } - } - } - if ($manifestCount !== 1) { - throw new \RuntimeException('ZIP 包必须且只能包含一个 plugin.json'); - } - } - /** * 递归复制目录 */ diff --git a/backend/app/services/system/admin/SystemRoleServices.php b/backend/app/services/system/admin/SystemRoleServices.php index 43e872eb3..dabf3c202 100644 --- a/backend/app/services/system/admin/SystemRoleServices.php +++ b/backend/app/services/system/admin/SystemRoleServices.php @@ -127,7 +127,15 @@ class SystemRoleServices extends BaseService // AI Agent 接口可借内置 MCP 读取全站业务数据,同样 fail-closed: // 路由未登记权限节点时一律按无权限处理,禁止默认放行 $requiresAiAgentAuth = str_starts_with($rule, 'aiagent/'); - if (!$requiresPrinterAuth && !$requiresAiAgentAuth && !in_array($rule, $allAuth[$method] ?? [], true)) return true; + // ZIP 落盘写接口:未登记权限节点时不得默认放行(plugin 写接口已在菜单登记) + $requiresZipWriteAuth = in_array($rule, [ + 'theme/import', + 'plugin/refresh_notices', + ], true); + if (!$requiresPrinterAuth && !$requiresAiAgentAuth && !$requiresZipWriteAuth + && !in_array($rule, $allAuth[$method] ?? [], true)) { + return true; + } // 如果是crud接口放行 if (strpos($rule, 'crud/') === 0) return true; diff --git a/backend/crmeb/utils/File.php b/backend/crmeb/utils/File.php index 92ce7f028..bc77c4954 100644 --- a/backend/crmeb/utils/File.php +++ b/backend/crmeb/utils/File.php @@ -905,24 +905,12 @@ class File */ public static function zipOpen(string $filename, string $savename) { - $zip = new \ZipArchive; - $zipfile = $filename; - $res = $zip->open($zipfile); - $toDir = $savename; - if (!file_exists($toDir)) mkdir($toDir, 0777); - $docnum = $zip->numFiles; - for ($i = 0; $i < $docnum; $i++) { - $statInfo = $zip->statIndex($i); - if ($statInfo['crc'] == 0 && $statInfo['comp_size'] != 2) { - //新建目录 - mkdir($toDir . '/' . substr($statInfo['name'], 0, -1), 0777); - } else { - //拷贝文件 - copy('zip://' . $zipfile . '#' . $statInfo['name'], $toDir . '/' . $statInfo['name']); - } + try { + SafeZip::extract($filename, $savename); + return true; + } catch (\Throwable $e) { + return false; } - $zip->close(); - return true; } /** @@ -1054,34 +1042,42 @@ class File throw new AdminException('数据结构不正确'); } } else { + // 空单元格统一成空串,避免 null !== '' 触发条码重复误判、分类名异常等 + $cell = static function ($v): string { + if ($v === null) { + return ''; + } + return is_scalar($v) ? trim((string)$v) : ''; + }; $data[] = [ - 'id' => $t1, - 'store_name' => $t2, - 'cate_name_one' => $t4, - 'cate_name_two' => $t5, - 'unit_name' => $t6, - 'slider_image' => $t7, - 'video_link' => $t8, - 'description' => $t9, - 'ficti' => $t10, - 'min_qty' => $t11, - 'spec_type' => $t12, - 'sku_type_value' => $t13, - 'sku_name' => $t14, - 'sku_value' => $t15, - 'pic' => $t16, - 'price' => $t17, - 'ot_price' => $t18, - 'cost' => $t19, - 'stock' => $t20, - 'weight' => $t21, - 'volume' => $t22, - 'bar_code' => $t23, - 'bar_code_number' => $t24, - 'store_info' => $t25, - 'keyword' => $t26, - 'command_word' => $t27, - 'give_integral' => $t28, + 'id' => $cell($t1), + 'store_name' => $cell($t2), + 'product_type' => $cell($t3), + 'cate_name_one' => $cell($t4), + 'cate_name_two' => $cell($t5), + 'unit_name' => $cell($t6), + 'slider_image' => $cell($t7), + 'video_link' => $cell($t8), + 'description' => $cell($t9), + 'ficti' => $cell($t10), + 'min_qty' => $cell($t11), + 'spec_type' => $cell($t12), + 'sku_type_value' => $cell($t13), + 'sku_name' => $cell($t14), + 'sku_value' => $cell($t15), + 'pic' => $cell($t16), + 'price' => $cell($t17), + 'ot_price' => $cell($t18), + 'cost' => $cell($t19), + 'stock' => $cell($t20), + 'weight' => $cell($t21), + 'volume' => $cell($t22), + 'bar_code' => $cell($t23), + 'bar_code_number' => $cell($t24), + 'store_info' => $cell($t25), + 'keyword' => $cell($t26), + 'command_word' => $cell($t27), + 'give_integral' => $cell($t28), ]; } @@ -1152,9 +1148,12 @@ class File return false; } - $zip = new \ZipArchive; - $zip->open($source); - return $zip->extractTo($folder); + try { + SafeZip::extract($source, $folder); + return true; + } catch (\Throwable $e) { + return false; + } } /** diff --git a/backend/crmeb/utils/SafeZip.php b/backend/crmeb/utils/SafeZip.php new file mode 100644 index 000000000..b4f0d32b6 --- /dev/null +++ b/backend/crmeb/utils/SafeZip.php @@ -0,0 +1,254 @@ +numFiles < 1 || $zip->numFiles > $maxFiles) { + throw new \RuntimeException("ZIP 文件数量必须在 1 到 {$maxFiles} 之间"); + } + + $totalSize = 0; + $manifestCount = 0; + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + if (!is_array($stat) || !isset($stat['name'])) { + throw new \RuntimeException('ZIP 条目信息损坏'); + } + $name = str_replace('\\', '/', (string)$stat['name']); + self::assertSafeEntryName($name); + + if (basename($name) === 'plugin.json') { + $manifestCount++; + $segments = explode('/', rtrim($name, '/')); + if (count($segments) > 2) { + throw new \RuntimeException('plugin.json 只能位于 ZIP 根目录或一级插件目录'); + } + } + + $totalSize += (int)($stat['size'] ?? 0); + if ($totalSize > $maxBytes) { + throw new \RuntimeException('ZIP 解压后总体积超过限制'); + } + + $attributes = 0; + $opsys = 0; + if ($zip->getExternalAttributesIndex($i, $opsys, $attributes)) { + // Unix 外部属性:高 16 位为 mode;0120000 = symlink + if ($opsys === \ZipArchive::OPSYS_UNIX) { + $fileType = ($attributes >> 16) & 0170000; + if ($fileType === 0120000) { + throw new \RuntimeException("ZIP 不允许包含符号链接: {$name}"); + } + } + } + } + + if ($requirePluginJson && $manifestCount !== 1) { + throw new \RuntimeException('ZIP 包必须且只能包含一个 plugin.json'); + } + } + + /** + * 安全解压到目标目录(逐条写出,禁止 extractTo 直落)。 + * + * @param array{max_files?:int,max_bytes?:int,require_plugin_json?:bool} $options + */ + public static function extract(string $zipPath, string $destDir, array $options = []): void + { + self::assertMagic($zipPath); + + $destDir = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $destDir), DIRECTORY_SEPARATOR); + if ($destDir === '') { + throw new \RuntimeException('解压目标目录无效'); + } + if (!is_dir($destDir) && !mkdir($destDir, 0755, true) && !is_dir($destDir)) { + throw new \RuntimeException('无法创建解压目录'); + } + $destReal = realpath($destDir); + if ($destReal === false) { + throw new \RuntimeException('无法解析解压目录'); + } + + $zip = new \ZipArchive(); + if ($zip->open($zipPath) !== true) { + throw new \RuntimeException('无法打开 ZIP 文件,可能已损坏'); + } + + try { + self::validateEntries($zip, $options); + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $name = str_replace('\\', '/', (string)$stat['name']); + self::assertSafeEntryName($name); + + $relative = str_replace('/', DIRECTORY_SEPARATOR, $name); + $target = $destReal . DIRECTORY_SEPARATOR . $relative; + $parent = dirname($target); + self::assertPathContained($destReal, $parent); + + if (str_ends_with($name, '/')) { + if (!is_dir($target) && !mkdir($target, 0755, true) && !is_dir($target)) { + throw new \RuntimeException("无法创建目录: {$name}"); + } + continue; + } + + if (!is_dir($parent) && !mkdir($parent, 0755, true) && !is_dir($parent)) { + throw new \RuntimeException("无法创建目录: {$name}"); + } + + $stream = $zip->getStream($name); + if ($stream === false) { + // 部分目录条目无内容流,跳过即可 + if (($stat['size'] ?? 0) === 0) { + continue; + } + throw new \RuntimeException("无法读取 ZIP 条目: {$name}"); + } + $out = fopen($target, 'wb'); + if ($out === false) { + fclose($stream); + throw new \RuntimeException("无法写入文件: {$name}"); + } + try { + stream_copy_to_stream($stream, $out); + } finally { + fclose($out); + fclose($stream); + } + self::assertPathContained($destReal, $target); + } + self::assertExtractedTree($destReal); + } finally { + $zip->close(); + } + } + + /** + * 将相对路径解析为 base 目录下的真实文件路径(防穿越)。 + */ + public static function resolveUnder(string $baseDir, string $relative): string + { + $baseReal = realpath($baseDir); + if ($baseReal === false || !is_dir($baseReal)) { + throw new \RuntimeException('基准目录不存在'); + } + + $relative = str_replace('\\', '/', $relative); + $relative = ltrim($relative, '/'); + if ($relative === '' || str_contains($relative, "\0") || str_contains($relative, '..') + || preg_match('#^[A-Za-z]:/#', $relative)) { + throw new \RuntimeException('非法文件路径'); + } + + $candidate = $baseReal . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative); + $real = realpath($candidate); + if ($real === false || !is_file($real)) { + throw new \RuntimeException('文件不存在'); + } + self::assertPathContained($baseReal, $real); + return $real; + } + + /** + * 解压后遍历:拒绝软链,且每个文件必须落在目标目录内。 + */ + public static function assertExtractedTree(string $destDir): void + { + $root = realpath($destDir); + if ($root === false) { + throw new \RuntimeException('解压目录无效'); + } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS) + ); + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + $pathname = $file->getPathname(); + if (is_link($pathname)) { + throw new \RuntimeException('解压结果不允许包含符号链接: ' . $file->getFilename()); + } + $real = realpath($pathname); + if ($real === false) { + throw new \RuntimeException('解压结果路径无效'); + } + self::assertPathContained($root, $real); + } + } + + protected static function assertSafeEntryName(string $name): void + { + $normalized = rtrim(str_replace('\\', '/', $name), '/'); + $segments = $normalized === '' ? [] : explode('/', $normalized); + if ($name === '' || str_contains($name, "\0") || str_starts_with($name, '/') + || preg_match('/^[A-Za-z]:\//', $name) || str_contains($name, '//') + || in_array('..', $segments, true) || in_array('.', $segments, true)) { + throw new \RuntimeException("ZIP 包含非法路径: {$name}"); + } + } + + protected static function assertPathContained(string $root, string $path): void + { + $root = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR); + $pathNorm = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); + // 目录尚未创建时用字符串前缀;已存在则用 realpath + if (file_exists($pathNorm)) { + $real = realpath($pathNorm); + if ($real === false) { + throw new \RuntimeException('路径解析失败'); + } + $pathNorm = $real; + } else { + // 规范化 .. 已在条目名阶段拒绝;此处再挡一层 + if (str_contains($pathNorm, '..' . DIRECTORY_SEPARATOR) || str_ends_with($pathNorm, '..')) { + throw new \RuntimeException('路径越界'); + } + } + if ($pathNorm !== $root && !str_starts_with($pathNorm, $root . DIRECTORY_SEPARATOR)) { + throw new \RuntimeException('路径越界,拒绝写出到目标目录之外'); + } + } +} diff --git a/backend/public/install/data/system_menus_data.sql b/backend/public/install/data/system_menus_data.sql index 165229d69..59a719a10 100755 --- a/backend/public/install/data/system_menus_data.sql +++ b/backend/public/install/data/system_menus_data.sql @@ -740,4 +740,5 @@ INSERT INTO `eb_system_menus` (`id`, `pid`, `icon`, `menu_name`, `module`, `cont (6656, 6647, '', '设置默认AI模型', 'admin', '', '', 'ai/model/set_default/', 'PUT', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-default', 'core:setting-ai-model-default', 0, '设置默认AI模型接口权限'), (6657, 6647, '', 'AI模型排序', 'admin', '', '', 'ai/model/sort', 'PUT', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-sort', 'core:setting-ai-model-sort', 0, 'AI模型排序接口权限'), (6658, 6647, '', 'AI模型连通性测试', 'admin', '', '', 'ai/model/test', 'POST', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-test', 'core:setting-ai-model-test', 0, 'AI模型连通性测试接口权限'), -(6659, 6647, '', '拉取厂商可用模型列表', 'admin', '', '', 'ai/model/remote_models', 'POST', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-remote-list', 'core:setting-ai-model-remote-list', 0, '拉取厂商可用模型列表接口权限'); +(6659, 6647, '', '拉取厂商可用模型列表', 'admin', '', '', 'ai/model/remote_models', 'POST', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-remote-list', 'core:setting-ai-model-remote-list', 0, '拉取厂商可用模型列表接口权限'), +(6825, 3460, '', '导入主题', 'admin', '', '', 'theme/import', 'POST', '[]', 1, 1, 0, 1, '', '', 2, '', 0, 'diy-theme-import', 'core:diy-theme-import', 0, '导入主题 ZIP 包');