444 lines
15 KiB
PHP
444 lines
15 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Channel;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Process;
|
||
use RuntimeException;
|
||
|
||
class ChannelProjectService
|
||
{
|
||
public const SUPPORT_TEMPLATES = ['test', 'blank'];
|
||
|
||
public const DEFAULT_SUPPORT_TEMPLATE = 'test';
|
||
|
||
public const BUILDER_OLD = Channel::BUILDER_OLD;
|
||
|
||
public const BUILDER_NEW = Channel::BUILDER_NEW;
|
||
|
||
public const RESULT_MARKER = 'CORUNA_BUILD_RESULT ';
|
||
|
||
/**
|
||
* Build channel web assets via the old or new channel builder.
|
||
*
|
||
* @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<string>, reporting: list<string>},
|
||
* 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) {
|
||
// Shared /weifile + /details must not be wiped when a DB channel row is removed.
|
||
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<string>, reporting: list<string>},
|
||
* 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<string>, reporting: list<string>},
|
||
* 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 {
|
||
unset($channelName); // shared /weifile layout; no per-channel folder
|
||
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-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 = (string) ($result['weifile_path'] ?? '/weifile/weifile.html');
|
||
|
||
return [
|
||
'channel_id' => $channelId,
|
||
'builder_type' => self::BUILDER_NEW,
|
||
'channel_name' => null,
|
||
'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' => (string) ($result['details_path'] ?? '/details/'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $cmd
|
||
* @return array<string, mixed>
|
||
*/
|
||
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<string, mixed>
|
||
*/
|
||
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];
|
||
}
|
||
}
|