fix: ios 16

This commit is contained in:
hashbro
2026-08-09 21:57:08 +08:00
parent 6bf33e2761
commit 6bc172c570
179 changed files with 6246 additions and 400 deletions
@@ -7,7 +7,6 @@ use App\Http\Controllers\Controller;
use App\Models\Channel;
use App\Models\User;
use App\Services\ChannelProjectService;
use App\Services\SettingsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -66,15 +65,11 @@ class ChannelController extends Controller
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (Channel $c) {
$domains = Channel::normalizeDomainList($c->domains);
return [
'id' => $c->id,
'channel_id' => $c->channel_id,
'user_id' => (int) $c->user_id,
'agent_username' => $c->agentLabel(),
'domains' => $domains,
'domains_text' => implode("\n", $domains),
'remark' => $c->remark ?: '',
'status' => (int) $c->status,
'links' => $c->supportLinks(),
@@ -107,8 +102,9 @@ class ChannelController extends Controller
$data = $request->validate([
'channel_id' => ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')],
'user_id' => ['nullable', 'integer', 'min:0'],
'domains' => ['nullable', 'string', 'max:4000'],
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
'reporting_seed' => ['nullable', 'string', 'min:1', 'max:32'],
'remark' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
@@ -118,12 +114,16 @@ class ChannelController extends Controller
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
}
$domains = SettingsService::parseDomains($data['domains'] ?? null);
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
$this->assertAgentChannelQuota($userId);
try {
$projects->generate($data['channel_id'], $domains, $supportTemplate);
$build = $projects->generate(
$data['channel_id'],
$supportTemplate,
$data['deployment_seed'] ?? null,
$data['reporting_seed'] ?? null,
);
} catch (\Throwable $e) {
return response()->json([
'code' => 1,
@@ -132,7 +132,7 @@ class ChannelController extends Controller
}
try {
$channel = DB::transaction(function () use ($data, $userId, $domains) {
$channel = DB::transaction(function () use ($data, $userId) {
if ($userId > 0) {
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
if (! $userExists) {
@@ -145,7 +145,7 @@ class ChannelController extends Controller
return Channel::query()->create([
'channel_id' => $data['channel_id'],
'user_id' => $userId,
'domains' => $domains,
'domains' => [],
'remark' => $data['remark'] ?? null,
'status' => (int) ($data['status'] ?? 1),
]);
@@ -165,6 +165,12 @@ class ChannelController extends Controller
'data' => [
'id' => $channel->id,
'links' => $channel->supportLinks(),
'seeds' => $build['seeds'],
'domains' => $build['domains'],
'seeds_initialized' => $build['seeds_initialized'],
'sync_rebuilt' => $build['sync_rebuilt'],
'support_path' => $build['support_path'],
'daily_path' => $build['daily_path'],
],
]);
}
@@ -187,7 +193,6 @@ class ChannelController extends Controller
} else {
$data = $request->validate([
'user_id' => ['nullable', 'integer', 'min:0'],
'domains' => ['nullable', 'string', 'max:4000'],
'remark' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
@@ -201,9 +206,6 @@ class ChannelController extends Controller
}
$channel->user_id = $userId;
}
if (array_key_exists('domains', $data)) {
$channel->domains = SettingsService::parseDomains($data['domains']);
}
if (array_key_exists('remark', $data)) {
$channel->remark = $data['remark'];
}
+1 -1
View File
@@ -89,7 +89,7 @@ class Channel extends Model
public function supportLinks(): array
{
$links = [];
$path = '/channel/'.$this->channel_id.'/web/support.html';
$path = '/web/'.$this->channel_id.'/support.html';
$baseUrl = rtrim((string) config('coruna.static_site.base_url', ''), '/');
if ($baseUrl !== '') {
$links[] = $baseUrl.$path;
+171 -90
View File
@@ -2,10 +2,8 @@
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use RuntimeException;
class ChannelProjectService
@@ -14,36 +12,70 @@ class ChannelProjectService
public const DEFAULT_SUPPORT_TEMPLATE = 'test';
public const RESULT_MARKER = 'CORUNA_BUILD_RESULT ';
/**
* Ask the standalone builder to materialize a channel project.
* Build channel web assets (and shared sync on first/changed seeds) via channel-builder.
*
* @param list<string>|null $deploymentDomains Channel-configured hosts; empty → CORUNA_LAB_CHANNEL_DOMAINS
* @return array{
* channel_id: string,
* seeds: array{deployment_seed: string, reporting_seed: string},
* domains: array{deployment: list<string>, reporting: list<string>},
* seeds_initialized: bool,
* sync_rebuilt: bool,
* support_path: string,
* daily_path: string,
* support_template?: string,
* }
*/
public function generate(
string $channelId,
?array $deploymentDomains = null,
string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE,
): void {
?string $deploymentSeed = null,
?string $reportingSeed = null,
): array {
$channelId = $this->normalizeChannelId($channelId);
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
$deploymentDomains = $this->normalizeDomains($deploymentDomains)
?: $this->domains('coruna.deployment_domains');
$reportingDomains = $this->domains('coruna.reporting_domains');
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds($deploymentSeed, $reportingSeed);
if ($deploymentDomains === []) {
throw new RuntimeException('投放域名未配置(CORUNA_LAB_CHANNEL_DOMAINS 或渠道 domains)');
}
if ($reportingDomains === []) {
throw new RuntimeException('上报域名未配置(CORUNA_REPORTING_DOMAINS)');
$cmd = [
$this->pythonBinary(),
$this->builderScript('new_project.py'),
'--artifact-root',
$this->artifactRoot(),
'--state-root',
$this->stateRoot(),
'--channel-id',
$channelId,
'--support-template',
$supportTemplate,
'--force',
];
if ($deploymentSeed !== null && $reportingSeed !== null) {
$cmd[] = '--deployment-seed';
$cmd[] = $deploymentSeed;
$cmd[] = '--reporting-seed';
$cmd[] = $reportingSeed;
}
$response = $this->request('post', $channelId, [
'deployment_domains' => $deploymentDomains,
'reporting_domains' => $reportingDomains,
'support_template' => $supportTemplate,
]);
$result = $this->runBuilder($cmd, '生成渠道资源失败');
$this->ensureSuccessful($response, '生成渠道资源失败');
return [
'channel_id' => (string) ($result['channel_id'] ?? $channelId),
'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' => (string) ($result['support_path'] ?? '/web/'.$channelId.'/support.html'),
'daily_path' => (string) ($result['daily_path'] ?? '/sync/daily.html'),
'support_template' => (string) ($result['support_template'] ?? $supportTemplate),
];
}
public function deleteWebTree(string $channelId): void
@@ -54,71 +86,119 @@ class ChannelProjectService
return;
}
$response = $this->request('delete', $channelId);
if ($response->status() === 404) {
return;
}
$cmd = [
$this->pythonBinary(),
$this->builderScript('delete_channel_web.py'),
'--artifact-root',
$this->artifactRoot(),
'--channel-id',
$channelId,
];
$this->ensureSuccessful($response, '删除渠道资源失败');
$this->runBuilder($cmd, '删除渠道资源失败');
}
private function request(string $method, string $channelId, array $payload = []): Response
/**
* @param list<string> $cmd
* @return array<string, mixed>
*/
private function runBuilder(array $cmd, string $errorPrefix): array
{
$url = rtrim((string) config('coruna.build_service.url', ''), '/');
$token = trim((string) config('coruna.build_service.token', ''));
if ($url === '') {
throw new RuntimeException('渠道构建服务 URL 未配置');
}
if ($token === '') {
throw new RuntimeException('渠道构建服务令牌未配置');
}
$timeout = (float) config('coruna.channel_builder.timeout', 600);
$process = Process::timeout((int) max(1, $timeout))
->path(base_path('channel-builder'))
->run($cmd);
$method = strtoupper($method);
$path = $method === 'POST'
? '/v1/channels/'.$channelId.'/build'
: '/v1/channels/'.$channelId;
try {
$pending = Http::baseUrl($url)
->withToken($token)
->acceptJson()
->connectTimeout((float) config('coruna.build_service.connect_timeout', 5))
->timeout((float) config('coruna.build_service.timeout', 600));
if ($method === 'POST') {
return $pending->asJson()->post($path, $payload);
}
return $pending->send($method, $path);
} catch (ConnectionException $e) {
Log::error('Channel build service connection failed', [
'channel_id' => $channelId,
'operation' => $method,
'error' => $e->getMessage(),
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('渠道构建服务连接失败: '.$e->getMessage(), 0, $e);
throw new RuntimeException("{$errorPrefix}: {$detail}");
}
return $this->parseResultMarker($process->output(), $errorPrefix);
}
private function ensureSuccessful(Response $response, string $message): void
/**
* @return array<string, mixed>
*/
private function parseResultMarker(string $output, string $errorPrefix): array
{
if ($response->successful()) {
return;
$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;
}
$detail = $response->json('message');
$detail = is_string($detail) && trim($detail) !== ''
? trim($detail)
: trim($response->body());
$detail = mb_substr($detail !== '' ? $detail : 'unknown error', 0, 500);
throw new RuntimeException("{$errorPrefix}: missing builder result marker");
}
Log::error('Channel build service request failed', [
'status' => $response->status(),
'detail' => $detail,
]);
private function pythonBinary(): string
{
$configured = trim((string) config('coruna.channel_builder.python', ''));
if ($configured !== '') {
return $configured;
}
throw new RuntimeException("{$message} ({$response->status()}): {$detail}");
$venv = base_path('channel-builder/.venv/bin/python');
if (is_file($venv)) {
return $venv;
}
return 'python3';
}
private function builderScript(string $name): string
{
$path = base_path('channel-builder/tools/'.$name);
if (! is_file($path)) {
throw new RuntimeException("渠道构建脚本不存在: {$path}");
}
return $path;
}
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
{
$root = trim((string) config('coruna.channel_builder.state_root', ''));
if ($root === '') {
$root = 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 normalizeChannelId(string $channelId): string
@@ -146,27 +226,28 @@ class ChannelProjectService
return $supportTemplate;
}
/** @return list<string> */
private function domains(string $key): array
/**
* @return array{0: ?string, 1: ?string}
*/
private function normalizeOptionalSeeds(?string $deploymentSeed, ?string $reportingSeed): array
{
return $this->normalizeDomains(config($key, []));
}
/** @return list<string> */
private function normalizeDomains(mixed $domains): array
{
if (! is_array($domains)) {
return [];
$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 array_values(array_unique(array_filter(array_map(static function ($domain) {
if (! is_string($domain)) {
return '';
}
$domain = trim($domain);
$domain = preg_replace('#^https?://#i', '', $domain) ?? $domain;
return rtrim($domain, '/');
}, $domains))));
return [$deploymentSeed, $reportingSeed];
}
}