, reporting: list}, * seeds_initialized: bool, * sync_rebuilt: bool, * support_path: string, * weifile_path: ?string, * daily_path: string, * support_template?: string, * } */ public function generate( string $channelId, string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE, ?string $deploymentSeed = null, ?string $reportingSeed = null, string $builderType = self::BUILDER_OLD, ?string $channelName = null, ): array { $builderType = $this->normalizeBuilderType($builderType); $channelId = $this->normalizeChannelId($channelId); [$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds( $deploymentSeed, $reportingSeed, ); if ($builderType === self::BUILDER_NEW) { return $this->generateNew( $channelId, $channelName, $deploymentSeed, $reportingSeed, ); } return $this->generateOld( $channelId, $supportTemplate, $deploymentSeed, $reportingSeed, ); } public function deleteWebTree( string $channelId, string $builderType = self::BUILDER_OLD, ?string $channelName = null, ): void { $builderType = $this->normalizeBuilderType($builderType); if ($builderType === self::BUILDER_NEW) { $name = strtolower(trim((string) $channelName)); if ($name === '') { return; } $cmd = [ $this->pythonBinary($builderType), $this->builderScript('delete_channel.py', $builderType), '--artifact-root', $this->artifactRoot(), '--channel-name', $name, ]; $this->runBuilder($cmd, '删除渠道资源失败', $this->builderCwd($builderType)); return; } try { $channelId = $this->normalizeChannelId($channelId); } catch (RuntimeException) { return; } $cmd = [ $this->pythonBinary($builderType), $this->builderScript('delete_channel_web.py', $builderType), '--artifact-root', $this->artifactRoot(), '--channel-id', $channelId, ]; $this->runBuilder($cmd, '删除渠道资源失败', $this->builderCwd($builderType)); } /** * @return array{ * channel_id: string, * builder_type: string, * channel_name: ?string, * seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string}, * domains: array{deployment: list, reporting: list}, * seeds_initialized: bool, * sync_rebuilt: bool, * support_path: string, * weifile_path: ?string, * daily_path: string, * support_template?: string, * } */ private function generateOld( string $channelId, string $supportTemplate, ?string $deploymentSeed, ?string $reportingSeed, ): array { $supportTemplate = $this->normalizeSupportTemplate($supportTemplate); $cmd = [ $this->pythonBinary(self::BUILDER_OLD), $this->builderScript('new_project.py', self::BUILDER_OLD), '--artifact-root', $this->artifactRoot(), '--state-root', $this->stateRoot(self::BUILDER_OLD), '--channel-id', $channelId, '--support-template', $supportTemplate, '--force', ]; if ($deploymentSeed !== null && $reportingSeed !== null) { $cmd[] = '--deployment-seed'; $cmd[] = $deploymentSeed; $cmd[] = '--reporting-seed'; $cmd[] = $reportingSeed; } $result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_OLD)); $supportPath = (string) ($result['support_path'] ?? '/web/'.$channelId.'/support.html'); return [ 'channel_id' => (string) ($result['channel_id'] ?? $channelId), 'builder_type' => self::BUILDER_OLD, 'channel_name' => null, 'seeds' => [ 'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''), 'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''), ], 'domains' => [ 'deployment' => array_values((array) data_get($result, 'domains.deployment', [])), 'reporting' => array_values((array) data_get($result, 'domains.reporting', [])), ], 'seeds_initialized' => (bool) ($result['seeds_initialized'] ?? false), 'sync_rebuilt' => (bool) ($result['sync_rebuilt'] ?? false), 'support_path' => $supportPath, 'weifile_path' => null, 'daily_path' => (string) ($result['daily_path'] ?? '/sync/daily.html'), 'support_template' => (string) ($result['support_template'] ?? $supportTemplate), ]; } /** * @return array{ * channel_id: string, * builder_type: string, * channel_name: ?string, * seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string}, * domains: array{deployment: list, reporting: list}, * seeds_initialized: bool, * sync_rebuilt: bool, * support_path: string, * weifile_path: ?string, * daily_path: string, * support_template?: string, * } */ private function generateNew( string $channelId, ?string $channelName, ?string $deploymentSeed, ?string $reportingSeed, ): array { $channelName = $this->normalizeChannelName((string) $channelName); if ($channelId === '202800cfb1ad3de68e11239dcc26c30b') { throw new RuntimeException('channel_id 不能与 7z 密码槽相同'); } $scheme = strtolower((string) config('coruna.channel_builder_new.c2_scheme', 'http')); if (! in_array($scheme, ['http', 'https'], true)) { throw new RuntimeException('CORUNA_XXBB_C2_SCHEME 必须是 http 或 https'); } $cmd = [ $this->pythonBinary(self::BUILDER_NEW), $this->builderScript('build.py', self::BUILDER_NEW), '--artifact-root', $this->artifactRoot(), '--state-root', $this->stateRoot(self::BUILDER_NEW), '--channel-name', $channelName, '--channel-c', $channelId, '--scheme', $scheme, '--apply', '--force', ]; if ($deploymentSeed !== null && $reportingSeed !== null) { $cmd[] = '--deployment-seed'; $cmd[] = $deploymentSeed; $cmd[] = '--reporting-seed'; $cmd[] = $reportingSeed; } $result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_NEW)); $weifilePath = '/source/'.$channelName.'/index.html'; return [ 'channel_id' => $channelId, 'builder_type' => self::BUILDER_NEW, 'channel_name' => $channelName, 'seeds' => [ 'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''), 'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''), 'channel_c' => (string) data_get($result, 'seeds.channel_c', $channelId), ], 'domains' => [ 'deployment' => array_values((array) data_get($result, 'domains.deployment', [])), 'reporting' => array_values((array) data_get($result, 'domains.reporting', [])), ], 'seeds_initialized' => (bool) ($result['seeds_initialized'] ?? false), 'sync_rebuilt' => (bool) ($result['sync_rebuilt'] ?? false), 'support_path' => (string) ($result['support_path'] ?? $weifilePath), 'weifile_path' => $weifilePath, 'daily_path' => '', ]; } /** * @param list $cmd * @return array */ private function runBuilder(array $cmd, string $errorPrefix, string $cwd): array { $timeout = (float) config('coruna.channel_builder.timeout', 600); $process = Process::timeout((int) max(1, $timeout)) ->path($cwd) ->run($cmd); if (! $process->successful()) { $detail = trim($process->errorOutput() ?: $process->output()); $detail = mb_substr($detail !== '' ? $detail : 'builder exited '.$process->exitCode(), 0, 800); Log::error('Channel builder failed', [ 'exit_code' => $process->exitCode(), 'detail' => $detail, 'cmd' => $cmd, ]); throw new RuntimeException("{$errorPrefix}: {$detail}"); } return $this->parseResultMarker($process->output(), $errorPrefix); } /** * @return array */ private function parseResultMarker(string $output, string $errorPrefix): array { $lines = preg_split("/\r\n|\n|\r/", $output) ?: []; for ($i = count($lines) - 1; $i >= 0; $i--) { $line = $lines[$i]; if (! str_starts_with($line, self::RESULT_MARKER)) { continue; } $json = substr($line, strlen(self::RESULT_MARKER)); $decoded = json_decode($json, true); if (! is_array($decoded)) { throw new RuntimeException("{$errorPrefix}: invalid builder JSON"); } return $decoded; } throw new RuntimeException("{$errorPrefix}: missing builder result marker"); } private function pythonBinary(string $builderType): string { $configKey = $builderType === self::BUILDER_NEW ? 'coruna.channel_builder_new.python' : 'coruna.channel_builder.python'; $configured = trim((string) config($configKey, '')); if ($configured !== '') { return $configured; } $venv = $this->builderCwd($builderType).'/.venv/bin/python'; if (is_file($venv)) { return $venv; } if ($builderType === self::BUILDER_NEW) { $fallback = base_path('channel-builder/.venv/bin/python'); if (is_file($fallback)) { return $fallback; } } return 'python3'; } private function builderScript(string $name, string $builderType): string { $path = $this->builderCwd($builderType).'/tools/'.$name; if (! is_file($path)) { throw new RuntimeException("渠道构建脚本不存在: {$path}"); } return $path; } private function builderCwd(string $builderType): string { return $builderType === self::BUILDER_NEW ? base_path('channel-builder-new') : base_path('channel-builder'); } private function artifactRoot(): string { $root = trim((string) config('coruna.channel_builder.artifact_root', '')); if ($root === '') { $root = public_path(); } return $this->ensureDirectory($root, '产物目录'); } private function stateRoot(string $builderType): string { $key = $builderType === self::BUILDER_NEW ? 'coruna.channel_builder_new.state_root' : 'coruna.channel_builder.state_root'; $root = trim((string) config($key, '')); if ($root === '') { $root = $builderType === self::BUILDER_NEW ? storage_path('app/channel-builder-new') : storage_path('app/channel-builder'); } return $this->ensureDirectory($root, '构建状态目录'); } private function ensureDirectory(string $root, string $label): string { if (! is_dir($root) && ! mkdir($root, 0775, true) && ! is_dir($root)) { throw new RuntimeException("无法创建{$label}: {$root}"); } return $root; } private function normalizeBuilderType(string $builderType): string { $builderType = strtolower(trim($builderType)); if ($builderType === '') { return self::BUILDER_OLD; } if (! in_array($builderType, [self::BUILDER_OLD, self::BUILDER_NEW], true)) { throw new RuntimeException('无效的渠道类型(支持: old, new)'); } return $builderType; } private function normalizeChannelId(string $channelId): string { $channelId = strtolower(trim($channelId)); if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) { throw new RuntimeException('Invalid channel id'); } return $channelId; } private function normalizeChannelName(string $channelName): string { $channelName = strtolower(trim($channelName)); if (! preg_match('/^[a-z0-9]{8,32}$/', $channelName)) { throw new RuntimeException('Invalid channel name'); } if (Channel::isReservedChannelName($channelName)) { throw new RuntimeException('channel_name 与保留路径冲突'); } return $channelName; } private function normalizeSupportTemplate(string $supportTemplate): string { $supportTemplate = strtolower(trim($supportTemplate)); if ($supportTemplate === '') { return self::DEFAULT_SUPPORT_TEMPLATE; } if (! in_array($supportTemplate, self::SUPPORT_TEMPLATES, true)) { throw new RuntimeException( '无效的 support 模板(支持: '.implode(', ', self::SUPPORT_TEMPLATES).')' ); } return $supportTemplate; } /** * @return array{0: ?string, 1: ?string} */ private function normalizeOptionalSeeds( ?string $deploymentSeed, ?string $reportingSeed, ): array { $deploymentSeed = $deploymentSeed !== null ? trim($deploymentSeed) : null; $reportingSeed = $reportingSeed !== null ? trim($reportingSeed) : null; if (($deploymentSeed === null || $deploymentSeed === '') && ($reportingSeed === null || $reportingSeed === '')) { return [null, null]; } if ($deploymentSeed === null || $deploymentSeed === '' || $reportingSeed === null || $reportingSeed === '') { throw new RuntimeException('deployment_seed 与 reporting_seed 必须同时提供'); } foreach (['deployment_seed' => $deploymentSeed, 'reporting_seed' => $reportingSeed] as $name => $value) { if (! preg_match('/^[ -~]{1,32}$/', $value)) { throw new RuntimeException("无效的 {$name}(需 1–32 位 ASCII)"); } } if ($deploymentSeed !== $reportingSeed) { throw new RuntimeException('deployment_seed 与 reporting_seed 必须相同'); } return [$deploymentSeed, $reportingSeed]; } }