setName('plugin:manifest-check') ->setDescription('检查 plugin.json、复合身份和 Installer 元数据一致性'); } protected function execute(Input $input, Output $output): int { try { $repository = new PluginManifestRepository(app()->getRootPath()); $manifests = $repository->all(); /** @var ModuleInstaller $manager */ $manager = app()->make(ModuleInstaller::class); $installers = $manager->getInstallers(); $dependencyErrors = (new PluginDependencyResolver($repository))->validateGraph(); } catch (\Throwable $e) { $output->writeln('插件清单检查失败:' . $e->getMessage() . ''); return 1; } $errors = []; array_push($errors, ...$dependencyErrors); array_push($errors, ...$this->validateDiyComponents($manifests, app()->getRootPath())); foreach ($installers as $id => $installer) { $manifest = $manifests[$id] ?? null; if (!$manifest) { $errors[] = "{$id}: Installer 没有对应清单"; continue; } $expectedInstallerKey = $manifest->identity->kind === 'driver' ? $manifest->identity->canonicalId() : $manifest->identity->legacyKey; if ($installer->getKey() !== $expectedInstallerKey) { $errors[] = sprintf( '%s: Installer key=%s,清单 key=%s', $id, $installer->getKey(), $expectedInstallerKey ); } if ($installer->getType() !== $manifest->type) { $errors[] = sprintf( '%s: Installer type=%s,清单 type=%s', $id, $installer->getType(), $manifest->type ); } } $output->writeln(sprintf( '插件清单:%d(模块 %d / 驱动 %d),Installer:%d', count($manifests), count($repository->modules()), count($repository->drivers()), count($installers) )); if ($errors !== []) { foreach ($errors as $error) { $output->writeln(' ✗ ' . $error . ''); } return 1; } $output->writeln('清单、复合身份和 Installer 元数据一致'); return 0; } /** * 校验清单 diy.components 声明的 DIY 组件存在于前端组件源码 * * 清单是组件可用性判定的唯一真源(C 端按不可用清单过滤,管理端编辑器按可用白名单 * 放行),若清单声明了前端不存在的组件名,会导致该声明永不生效。校验规则: * 管理端组件按 defineOptions 的 defaultName 匹配,移动端组件按静态 name 匹配; * 管理端组件还须带 pluginComponent: true 标记(编辑器据此识别插件归属并按白名单 * 放行),缺标记的组件被视为核心组件恒显示,插件不可用时不会隐藏。 * 前端目录缺失(纯后端部署)时跳过校验。 * * @param array $manifests * @return string[] */ private function validateDiyComponents(array $manifests, string $rootPath): array { $declaredComponents = []; foreach ($manifests as $manifest) { foreach ($manifest->meta['diy']['components'] ?? [] as $component) { $name = is_array($component) ? (string)($component['name'] ?? '') : ''; if ($name !== '') { $declaredComponents[$manifest->identity->legacyKey][$name] = true; } } } if ($declaredComponents === []) { return []; } $componentFiles = $this->diyComponentFiles($rootPath); if ($componentFiles === []) { // 组件源码目录不可达(纯后端部署 / 未挂载前端),无法比对,跳过该校验 return []; } // 收集前端组件源码中的组件名(管理端 defaultName / 移动端 name), // 管理端同时记录是否带 pluginComponent 标记(插件归属组件必须标记) $componentNames = []; $adminMarkers = []; foreach ($componentFiles as $file => $isAdmin) { $content = (string)file_get_contents($file); if ($isAdmin) { $name = $this->matchOptionValue($content, 'defaultName'); if ($name !== '') { $adminMarkers[$name] = ($adminMarkers[$name] ?? false) || $this->matchPluginComponentFlag($content); } } else { $name = $this->matchComponentName($content); } if ($name !== '') { $componentNames[$name] = true; } } $errors = []; foreach ($declaredComponents as $pluginKey => $names) { foreach (array_keys($names) as $name) { if (!isset($componentNames[$name])) { $errors[] = sprintf( '清单 %s 声明的 DIY 组件 %s 不存在于前端组件源码(管理端 defaultName / 移动端 name 均未命中)', $pluginKey, $name ); continue; } // 管理端编辑器按 pluginComponent 标记识别插件归属组件,缺标记则恒显示、白名单不生效 if (isset($adminMarkers[$name]) && !$adminMarkers[$name]) { $errors[] = sprintf( '清单 %s 声明的 DIY 组件 %s 对应的管理端组件缺少 pluginComponent: true 标记,插件不可用时编辑器不会隐藏该组件', $pluginKey, $name ); } } } return $errors; } /** * 收集 DIY 组件源码文件,值为是否属于管理端组件目录 * * @return array */ private function diyComponentFiles(string $rootPath): array { $repoRoot = dirname(rtrim($rootPath, DIRECTORY_SEPARATOR)); $directories = [ $repoRoot . '/frontend/admin/src/diy/components/mobilePage' => true, $repoRoot . '/frontend/uni-app/src/subpackage/diyComponents' => false, $repoRoot . '/frontend/uni-app/src/subpackage/diyComponents/productSections' => false, ]; $files = []; foreach ($directories as $directory => $isAdmin) { if (!is_dir($directory)) { continue; } foreach (glob($directory . '/*.vue') ?: [] as $file) { $files[$file] = $isAdmin; } } return $files; } private function matchOptionValue(string $content, string $key): string { return preg_match('/' . $key . '\s*:\s*[\'"]([^\'"]+)[\'"]/', $content, $matched) ? $matched[1] : ''; } /** * 管理端组件 defineOptions 是否带 pluginComponent: true(插件归属组件标记) */ private function matchPluginComponentFlag(string $content): bool { return (bool)preg_match('/pluginComponent\s*:\s*true/', $content); } /** * 移动端组件的静态 name 位于 defineOptions 或 export default 选项块内 */ private function matchComponentName(string $content): string { if (preg_match('/defineOptions\s*\(\s*\{[\s\S]{0,300}?name\s*:\s*[\'"]([^\'"]+)[\'"]/', $content, $matched)) { return $matched[1]; } if (preg_match('/export\s+default\s*\{[\s\S]{0,300}?name\s*:\s*[\'"]([^\'"]+)[\'"]/', $content, $matched)) { return $matched[1]; } return ''; } }