*/ public const RESERVED_CHANNEL_NAMES = [ 'admin', 'user', 'api', 'web', 'sync', 'details', 'weifile', 'hooks', 'link', 'statistic', 'vhx', 'event', 'log', 'storage', 'build', 'hot', 'vendor', 'css', 'js', 'up', 'index', 'assets', 'static', 'source', 'channel', 'out', 't', 'nb', 'a', 'u', 'uj', 'us', 'ub', 'ba', 'result', 'favicon', 'robots', 'sitemap', 'public', 'app', 'bootstrap', 'config', 'database', 'resources', 'routes', 'tests', 'artisan', 'livewire', 'sanctum', 'telescope', 'horizon', 'pulse', ]; protected $fillable = [ 'channel_id', 'builder_type', 'user_id', 'domains', 'status', 'remark', ]; protected $attributes = [ 'builder_type' => self::BUILDER_OLD, ]; protected static function booted(): void { $bust = static function (Channel $channel): void { $svc = app(\App\Services\FilterOptionsService::class); $svc->forgetChannels(); $uids = array_unique(array_filter([ (int) $channel->user_id, (int) $channel->getOriginal('user_id'), ])); foreach ($uids as $uid) { if ($uid > 0) { $svc->forgetChannels($uid); } } }; static::saved($bust); static::deleted($bust); } protected function casts(): array { return [ 'domains' => 'array', 'status' => 'integer', 'user_id' => 'integer', ]; } public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } public function isEnabled(): bool { return (int) $this->status === 1; } public function isOfficial(): bool { return (int) $this->user_id === self::OFFICIAL_USER_ID; } public function isNewBuilder(): bool { return $this->builderType() === self::BUILDER_NEW; } public function builderType(): string { $type = strtolower(trim((string) ($this->builder_type ?? ''))); return $type === self::BUILDER_NEW ? self::BUILDER_NEW : self::BUILDER_OLD; } public function agentLabel(): string { if ($this->isOfficial()) { return '官方'; } return $this->user?->username ?: ('#'.$this->user_id); } /** * Domains for support-link display. * Channel row wins; otherwise CORUNA_LAB_CHANNEL_DOMAINS (system settings). * * @return list */ public function resolvedDomains(): array { $stored = $this->normalizeDomainList($this->domains); if ($stored !== []) { return $stored; } return $this->normalizeDomainList(config('coruna.channel_domains', [])); } /** * @param mixed $domains * @return list */ public static function normalizeDomainList(mixed $domains): array { if (! is_array($domains)) { return []; } return array_values(array_unique(array_filter(array_map(static function ($d) { if (! is_string($d)) { return ''; } $d = trim($d); $d = preg_replace('#^https?://#i', '', $d) ?? $d; return rtrim($d, '/'); }, $domains)))); } public function landingPath(): string { if ($this->isNewBuilder()) { return '/channel/'.$this->channel_id.'/weifile/weifile.html'; } return '/web/'.$this->channel_id.'/support.html'; } /** * @return list */ public function supportLinks(): array { $links = []; $path = $this->landingPath(); $scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https'; foreach ($this->resolvedDomains() as $domain) { $origin = rtrim($domain, '/'); if ($origin === '') { continue; } if (! preg_match('#^https?://#i', $origin)) { $origin = $scheme.'://'.$origin; } $links[] = $origin.$path; } return array_values(array_unique($links)); } /** * Hidden iframe snippet for embedding the landing URL (same as admin「复制推广代码」). * src is resolved at paste-time from the host page's location. */ public function promoIframeSnippet(?string $url = null): ?string { $path = $this->landingPathFromUrl($url) ?? $this->landingPath(); if ($path === '') { return null; } if (! str_starts_with($path, '/')) { $path = '/'.$path; } if (str_contains($path, '"') || str_contains($path, "'") || str_contains($path, '?')) { $path = $this->landingPath(); } // Telegram copy_text max is 256. Relative src resolves against the host page. return ''; } private function landingPathFromUrl(?string $url): ?string { $url = trim((string) $url); if ($url === '') { return null; } $path = parse_url($url, PHP_URL_PATH); return is_string($path) && $path !== '' ? $path : null; } public static function randomChannelId(): string { return bin2hex(random_bytes(16)); } public static function normalizeNewChannelId(string $channelId): ?string { $channelId = strtoupper(trim($channelId)); if (! preg_match(self::NEW_CHANNEL_ID_PATTERN, $channelId)) { return null; } return $channelId; } public static function randomNewChannelId(): string { $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; $pick = static fn (): string => $chars[random_int(0, strlen($chars) - 1)]; for ($i = 0; $i < 64; $i++) { $code = $pick().'.'.$pick().'.'.$pick().$pick(); if (self::query()->where('channel_id', $code)->exists()) { continue; } return $code; } throw new RuntimeException('无法生成唯一渠道 ID'); } public function channelPublicRelativeDir(): string { return 'channel/'.$this->channel_id; } public static function isReservedChannelName(string $name): bool { return in_array(strtolower($name), self::RESERVED_CHANNEL_NAMES, true); } public static function maxPerAgent(): int { $n = (int) config('coruna.channels.max_per_agent', 5); return max(1, $n); } }