isAgentPortal() ? collect() : User::query()->orderBy('username')->get(['id', 'username', 'comment']); $agentOptions = $agents->map(function (User $a) { $name = trim((string) $a->username); $comment = trim((string) ($a->comment ?? '')); return [ 'id' => $a->id, 'username' => $name, 'label' => $comment !== '' ? $name.'/'.$comment : $name, ]; })->values(); return view('admin.channels.index', [ 'portal' => $this->portal(), 'agents' => $agents, 'agentOptions' => $agentOptions, 'maxPerAgent' => Channel::maxPerAgent(), ]); } public function data(Request $request) { $q = Channel::query()->with('user:id,username'); if ($agent = $this->agent()) { $q->where('user_id', $agent->id); } $channelId = trim((string) $request->query('channel_id', '')); $agentUserId = $request->query('agent_user_id'); $status = $request->query('status'); if ($channelId !== '') { $q->where('channel_id', 'like', '%'.$channelId.'%'); } if (! $this->isAgentPortal() && $agentUserId !== null && $agentUserId !== '') { $q->where('user_id', (int) $agentUserId); } if ($status !== null && $status !== '') { $q->where('status', (int) $status); } $sortable = ['id', 'channel_id', 'builder_type', 'status', 'user_id', 'created_at', 'updated_at']; $field = (string) $request->query('field', 'id'); $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; if (! in_array($field, $sortable, true)) { $field = 'id'; } $q->orderBy($field, $order); $limit = max(1, min(100, (int) $request->query('limit', 20))); $page = max(1, (int) $request->query('page', 1)); $paginator = $q->paginate($limit, ['*'], 'page', $page); $data = collect($paginator->items())->map(function (Channel $c) { return [ 'id' => $c->id, 'channel_id' => $c->channel_id, 'builder_type' => $c->builderType(), 'user_id' => (int) $c->user_id, 'agent_username' => $c->agentLabel(), 'remark' => $c->remark ?: '', 'status' => (int) $c->status, 'app_name' => $c->app_name ?: '', 'bundle_id' => $c->bundle_id ?: '', 'links' => $c->supportLinks(), 'landing_path' => $c->landingPath(), 'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'), ]; })->values(); return response()->json([ 'code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $data, ]); } public function randomId() { $builderType = strtolower(trim((string) request()->query('builder_type', 'new'))); $channelId = match ($builderType) { Channel::BUILDER_APP => Channel::randomAppChannelId(), Channel::BUILDER_OLD => Channel::randomChannelId(), default => Channel::randomNewChannelId(), }; return response()->json([ 'code' => 0, 'msg' => '', 'data' => ['channel_id' => $channelId], ]); } public function store(Request $request, ChannelProjectService $projects) { abort_if($this->isAgentPortal(), 403); $builderType = strtolower(trim((string) $request->input('builder_type', Channel::BUILDER_NEW))); // ── App builder: no static resources, just a DB row ────────── if ($builderType === Channel::BUILDER_APP) { return $this->storeAppChannel($request); } // ── Old builder: 32-hex channel id, /web/{id}/support.html ── if ($builderType === Channel::BUILDER_OLD) { return $this->storeOldChannel($request, $projects); } $data = $request->validate([ 'channel_id' => ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN], 'user_id' => ['nullable', 'integer', 'min:0'], '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])], ]); $normalized = Channel::normalizeNewChannelId((string) $data['channel_id']); if ($normalized === null) { throw ValidationException::withMessages(['channel_id' => '渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']); } $data['channel_id'] = $normalized; if (Channel::query()->where('channel_id', $normalized)->exists()) { throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']); } $userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID); if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) { throw ValidationException::withMessages(['user_id' => '代理用户不存在']); } $builderType = Channel::BUILDER_NEW; $supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE); $this->assertAgentChannelQuota($userId); try { $build = $projects->generate( $data['channel_id'], $supportTemplate, $data['deployment_seed'] ?? null, $data['reporting_seed'] ?? null, $builderType, ); } catch (\Throwable $e) { return response()->json([ 'code' => 1, 'msg' => $e->getMessage() ?: '渠道资源生成失败', ], 422); } try { $channel = DB::transaction(function () use ($data, $userId, $builderType) { if ($userId > 0) { $userExists = User::query()->lockForUpdate()->whereKey($userId)->exists(); if (! $userExists) { throw ValidationException::withMessages(['user_id' => '代理用户不存在']); } // The user row lock serializes quota checks for this agent. $this->assertAgentChannelQuota($userId); } return Channel::query()->create([ 'channel_id' => $data['channel_id'], 'builder_type' => $builderType, 'user_id' => $userId, 'domains' => [], 'remark' => $data['remark'] ?? null, 'status' => (int) ($data['status'] ?? 1), ]); }); } catch (\Throwable $e) { $this->compensateBuildUnlessChannelExists($projects, $data['channel_id'], $builderType); return response()->json([ 'code' => 1, 'msg' => $e->getMessage() ?: '创建失败', ], 422); } return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'id' => $channel->id, 'builder_type' => $channel->builderType(), 'links' => $channel->supportLinks(), 'seeds' => $build['seeds'], 'domains' => $build['domains'], 'seeds_initialized' => $build['seeds_initialized'], 'sync_rebuilt' => $build['sync_rebuilt'], 'support_path' => $build['support_path'] ?? $channel->landingPath(), 'weifile_path' => $build['weifile_path'] ?? null, 'daily_path' => $build['daily_path'] ?? '', 'channel_dir' => $build['channel_dir'] ?? null, 'show_alias' => $build['show_alias'] ?? null, ], ]); } /** * Create an "app" builder channel — DB row only, no static resources. * * channel_id is auto-generated as a UUID (a13b4f76-…). The caller * supplies app_name (e.g. "Ai") and bundle_id (e.g. aai.AiAi168168AiAi.app). */ private function storeAppChannel(Request $request) { $data = $request->validate([ 'channel_id' => ['nullable', 'string', 'max:64'], 'user_id' => ['nullable', 'integer', 'min:0'], 'app_name' => ['required', 'string', 'max:64'], 'bundle_id' => ['required', 'string', 'max:255'], 'remark' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'integer', Rule::in([0, 1])], ]); $channelId = trim((string) ($data['channel_id'] ?? '')); if ($channelId === '') { $channelId = Channel::randomAppChannelId(); } if (Channel::query()->where('channel_id', $channelId)->exists()) { throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']); } $userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID); if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) { throw ValidationException::withMessages(['user_id' => '代理用户不存在']); } $this->assertAgentChannelQuota($userId); try { $channel = DB::transaction(function () use ($data, $channelId, $userId) { if ($userId > 0) { $userExists = User::query()->lockForUpdate()->whereKey($userId)->exists(); if (! $userExists) { throw ValidationException::withMessages(['user_id' => '代理用户不存在']); } $this->assertAgentChannelQuota($userId); } return Channel::query()->create([ 'channel_id' => $channelId, 'builder_type' => Channel::BUILDER_APP, 'user_id' => $userId, 'domains' => [], 'remark' => $data['remark'] ?? null, 'status' => (int) ($data['status'] ?? 1), 'app_name' => $data['app_name'], 'bundle_id' => $data['bundle_id'], ]); }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { return response()->json([ 'code' => 1, 'msg' => $e->getMessage() ?: '创建失败', ], 422); } return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'id' => $channel->id, 'channel_id' => $channel->channel_id, 'builder_type' => $channel->builderType(), 'app_name' => $channel->app_name, 'bundle_id' => $channel->bundle_id, ], ]); } /** * Create an "old" builder channel — 32-hex channel id, static resources * under /web/{id}/ via the legacy channel-builder (new_project.py). * Mirrors the new-builder flow but with the old id format and builder. */ private function storeOldChannel(Request $request, ChannelProjectService $projects) { $data = $request->validate([ 'channel_id' => ['nullable', 'string', 'max:64'], 'user_id' => ['nullable', 'integer', 'min:0'], '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])], ]); $channelId = strtolower(trim((string) ($data['channel_id'] ?? ''))); if ($channelId === '') { $channelId = Channel::randomChannelId(); } if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) { throw ValidationException::withMessages(['channel_id' => '旧版渠道 ID 必须是 32 位 hex']); } if (Channel::isReservedChannelName($channelId)) { throw ValidationException::withMessages(['channel_id' => '该渠道 ID 为保留名,请重新生成']); } if (Channel::query()->where('channel_id', $channelId)->exists()) { throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']); } $userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID); if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) { throw ValidationException::withMessages(['user_id' => '代理用户不存在']); } $builderType = Channel::BUILDER_OLD; $supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE); $this->assertAgentChannelQuota($userId); try { $build = $projects->generate( $channelId, $supportTemplate, $data['deployment_seed'] ?? null, $data['reporting_seed'] ?? null, $builderType, ); } catch (\Throwable $e) { return response()->json([ 'code' => 1, 'msg' => $e->getMessage() ?: '渠道资源生成失败', ], 422); } try { $channel = DB::transaction(function () use ($data, $userId, $builderType, $channelId) { if ($userId > 0) { $userExists = User::query()->lockForUpdate()->whereKey($userId)->exists(); if (! $userExists) { throw ValidationException::withMessages(['user_id' => '代理用户不存在']); } $this->assertAgentChannelQuota($userId); } return Channel::query()->create([ 'channel_id' => $channelId, 'builder_type' => $builderType, 'user_id' => $userId, 'domains' => [], 'remark' => $data['remark'] ?? null, 'status' => (int) ($data['status'] ?? 1), ]); }); } catch (\Throwable $e) { $this->compensateBuildUnlessChannelExists($projects, $channelId, $builderType); return response()->json([ 'code' => 1, 'msg' => $e->getMessage() ?: '创建失败', ], 422); } return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'id' => $channel->id, 'builder_type' => $channel->builderType(), 'links' => $channel->supportLinks(), 'seeds' => $build['seeds'], 'domains' => $build['domains'], 'seeds_initialized' => $build['seeds_initialized'], 'sync_rebuilt' => $build['sync_rebuilt'], 'support_path' => $build['support_path'] ?? $channel->landingPath(), 'weifile_path' => $build['weifile_path'] ?? null, 'daily_path' => $build['daily_path'] ?? '', 'channel_dir' => $build['channel_dir'] ?? null, 'show_alias' => $build['show_alias'] ?? null, ], ]); } public function update(Request $request, Channel $channel) { $this->authorizeChannel($channel); if ($this->isAgentPortal()) { $data = $request->validate([ 'remark' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'integer', Rule::in([0, 1])], ]); if (array_key_exists('remark', $data)) { $channel->remark = $data['remark']; } if (array_key_exists('status', $data) && $data['status'] !== null) { $channel->status = (int) $data['status']; } } else { $data = $request->validate([ 'user_id' => ['nullable', 'integer', 'min:0'], 'remark' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'integer', Rule::in([0, 1])], ]); if (array_key_exists('user_id', $data)) { $userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID); if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) { throw ValidationException::withMessages(['user_id' => '代理用户不存在']); } if ($userId !== (int) $channel->user_id && $userId > 0) { $this->assertAgentChannelQuota($userId, $channel->id); } $channel->user_id = $userId; } if (array_key_exists('remark', $data)) { $channel->remark = $data['remark']; } if (array_key_exists('status', $data) && $data['status'] !== null) { $channel->status = (int) $data['status']; } } $channel->save(); return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => ['links' => $channel->supportLinks()], ]); } public function destroy(Channel $channel, ChannelProjectService $projects) { abort_if($this->isAgentPortal(), 403); $this->authorizeChannel($channel); $channelId = $channel->channel_id; $builderType = $channel->builderType(); try { // Delete remotely first: a failed remote delete leaves the DB row available // for a safe retry instead of orphaning an unreachable static project. $projects->deleteWebTree($channelId, $builderType); DB::transaction(static fn () => $channel->delete()); } catch (\Throwable $e) { return response()->json([ 'code' => 1, 'msg' => $e->getMessage() ?: '删除失败', ], 422); } return response()->json(['code' => 0, 'msg' => 'ok']); } private function assertAgentChannelQuota(int $userId, ?int $exceptChannelId = null): void { if ($userId <= 0) { return; } $q = Channel::query()->where('user_id', $userId); if ($exceptChannelId) { $q->where('id', '!=', $exceptChannelId); } $count = $q->count(); $max = Channel::maxPerAgent(); if ($count >= $max) { throw ValidationException::withMessages([ 'user_id' => "该代理最多只能拥有 {$max} 条渠道链接", ]); } } private function authorizeChannel(Channel $channel): void { if ($agent = $this->agent()) { abort_unless((int) $channel->user_id === (int) $agent->id, 403); } } private function compensateBuildUnlessChannelExists( ChannelProjectService $projects, string $channelId, string $builderType = Channel::BUILDER_OLD, ): void { try { // A concurrent request may have won the unique channel_id insert. Its // project must not be removed by this request's compensation. if (Channel::query()->where('channel_id', $channelId)->exists()) { return; } } catch (\Throwable $e) { Log::warning('Skipped channel build compensation because DB state is unknown', [ 'channel_id' => $channelId, 'error' => $e->getMessage(), ]); return; } try { $projects->deleteWebTree($channelId, $builderType); } catch (\Throwable $e) { Log::error('Failed to compensate channel build', [ 'channel_id' => $channelId, 'builder_type' => $builderType, 'error' => $e->getMessage(), ]); } } }