isAgentPortal() ? collect() : User::query()->orderBy('username')->get(['id', 'username']); return view('admin.channels.index', [ 'portal' => $this->portal(), 'agents' => $agents, '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', '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', 15))); $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, 'user_id' => (int) $c->user_id, 'agent_username' => $c->agentLabel(), 'remark' => $c->remark ?: '', 'status' => (int) $c->status, 'links' => $c->supportLinks(), '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() { return response()->json([ 'code' => 0, 'msg' => '', 'data' => ['channel_id' => Channel::randomChannelId()], ]); } public function store(Request $request, ChannelProjectService $projects) { abort_if($this->isAgentPortal(), 403); $data = $request->validate([ 'channel_id' => ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')], '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])], ]); $userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID); if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) { throw ValidationException::withMessages(['user_id' => '代理用户不存在']); } $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, ); } catch (\Throwable $e) { return response()->json([ 'code' => 1, 'msg' => $e->getMessage() ?: '渠道资源生成失败', ], 422); } try { $channel = DB::transaction(function () use ($data, $userId) { 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'], 'user_id' => $userId, 'domains' => [], 'remark' => $data['remark'] ?? null, 'status' => (int) ($data['status'] ?? 1), ]); }); } catch (\Throwable $e) { $this->compensateBuildUnlessChannelExists($projects, $data['channel_id']); return response()->json([ 'code' => 1, 'msg' => $e->getMessage() ?: '创建失败', ], 422); } return response()->json([ 'code' => 0, 'msg' => 'ok', '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'], ], ]); } 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; 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); 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 ): 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); } catch (\Throwable $e) { Log::error('Failed to compensate channel build', [ 'channel_id' => $channelId, 'error' => $e->getMessage(), ]); } } }