resolveUnitId($user); $requestedToken = $data['registration_draft_token'] ?? null; unset($data['registration_draft_token']); return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use ( $user, $unitId, $requestedToken, $data, ): array { $existingDraft = null; // reaproveita apenas um rascunho valido do mesmo usuario e unidade if ($requestedToken !== null) { $existingDraft = $this->findOwnedDraft($user, $requestedToken, $unitId); if ($existingDraft === null) { $this->invalidToken(); } } $token = $requestedToken ?? (string) Str::uuid(); $expiresAt = now()->addMinutes(config('students.registration_draft_ttl_minutes', 15)); $oldReservations = $existingDraft['reservations'] ?? []; $newReservations = $this->reservationKeys($data); $acquiredReservations = []; // impede conflito com alunos ja cadastrados no banco $this->validateDatabaseUniques($data); try { // reserva os campos unicos enquanto o cadastro estiver em andamento foreach ($newReservations as $field => $key) { if (($oldReservations[$field] ?? null) === $key) { if (Cache::get($key) !== $token) { $this->invalidToken(); } continue; } if (! Cache::add($key, $token, $expiresAt) && Cache::get($key) !== $token) { $this->uniqueReservationConflict($field); } $acquiredReservations[$field] = $key; } } catch (ValidationException $exception) { // desfaz somente as reservas obtidas nesta tentativa $this->releaseReservations($acquiredReservations, $token); throw $exception; } $removedReservations = array_diff($oldReservations, $newReservations); // libera valores que deixaram de fazer parte do rascunho $this->releaseReservations($removedReservations, $token); foreach ($newReservations as $key) { Cache::put($key, $token, $expiresAt); } $draft = [ 'user_id' => $user->id, 'unit_id' => $unitId, 'data' => $data, 'reservations' => $newReservations, 'expires_at' => $expiresAt->toIso8601String(), ]; Cache::put($this->draftKey($token), $draft, $expiresAt); // mantem um indice para permitir a limpeza em qualquer cache $this->registerToken($token); return [ 'registration_draft_token' => $token, 'expires_at' => $draft['expires_at'], ]; }); } // conclui um cadastro salvo como rascunho public function consume(User $user, string $token, Closure $callback): mixed { $unitId = $this->resolveUnitId($user); return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use ( $user, $token, $unitId, $callback, ): mixed { $draft = $this->findOwnedDraft($user, $token, $unitId); // confirma a posse do rascunho e de todas as reservas if ($draft === null || ! $this->ownsAllReservations($draft['reservations'], $token)) { $this->invalidToken(); } $this->validateDatabaseUniques($draft['data']); $result = $callback($draft['data'], $unitId); // remove o rascunho e suas reservas apos concluir o cadastro $this->releaseReservations($draft['reservations'], $token); Cache::forget($this->draftKey($token)); $this->unregisterToken($token); return $result; }); } public function executeWithoutDraft(User $user, array $data, Closure $callback): mixed { $unitId = $this->resolveUnitId($user); return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use ( $data, $unitId, $callback, ): mixed { $this->validateDatabaseUniques($data); // bloqueia valores reservados por cadastros ainda nao concluidos foreach ($this->reservationKeys($data) as $field => $key) { if (Cache::has($key)) { $this->uniqueReservationConflict($field); } } return $callback($data, $unitId); }); } public function findOwnedDraft(User $user, string $token, ?int $unitId = null): ?array { // aceita somente rascunhos do usuario e da unidade informados $draft = Cache::get($this->draftKey($token)); if (! is_array($draft)) { return null; } $unitId ??= $this->resolveUnitId($user); if ($draft['user_id'] !== $user->id || $draft['unit_id'] !== $unitId) { return null; } return $draft; } // remove todos os rascunhos e reservas public function clearAll(): int { return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function (): int { // usa exclusao direta quando o cache esta no banco de dados $databaseStoreResult = $this->clearDatabaseStore(); if ($databaseStoreResult !== null) { return $databaseStoreResult; } $clearedDrafts = 0; // nos demais caches percorre o indice de tokens registrados foreach ($this->registeredTokens() as $token) { $draft = Cache::get($this->draftKey($token)); if (is_array($draft)) { $this->releaseReservations($draft['reservations'] ?? [], $token); $clearedDrafts++; } Cache::forget($this->draftKey($token)); } Cache::forget(self::TOKENS_KEY); return $clearedDrafts; }); } private function clearDatabaseStore(): ?int { $store = Cache::getStore(); if (!$store instanceof DatabaseStore) { return null; } $table = config('cache.stores.'.config('cache.default').'.table', 'cache'); $keyPrefix = $store->getPrefix().self::CACHE_PREFIX; // seleciona apenas as chaves pertencentes a este fluxo $keys = $store->getConnection() ->table($table) ->where('key', 'like', $keyPrefix.'%') ->pluck('key') ->filter(fn (string $key): bool => str_starts_with($key, $keyPrefix)); $clearedDrafts = $keys ->map(fn (string $key): string => substr($key, strlen($keyPrefix))) ->filter(fn (string $key): bool => Str::isUuid($key)) ->count(); if ($keys->isNotEmpty()) { $store->getConnection()->table($table)->whereIn('key', $keys)->delete(); } return $clearedDrafts; } // controla a posse das reservas temporarias private function ownsAllReservations(array $reservations, string $token): bool { foreach ($reservations as $key) { if (Cache::get($key) !== $token) { return false; } } return true; } private function releaseReservations(array $reservations, string $token): void { // libera apenas reservas que ainda pertencem ao token foreach ($reservations as $key) { if (Cache::get($key) === $token) { Cache::forget($key); } } } private function registeredTokens(): array { $tokens = Cache::get(self::TOKENS_KEY, []); return is_array($tokens) ? $tokens : []; } private function reservationKeys(array $data): array { $keys = []; foreach (self::UNIQUE_FIELDS as $field) { $value = $data[$field] ?? null; if (! is_string($value) || $value === '') { continue; } $normalizedValue = Str::lower(trim($value)); // o hash evita armazenar dados pessoais na chave do cache $keys[$field] = self::CACHE_PREFIX."reservation:{$field}:".hash('sha256', $normalizedValue); } return $keys; } private function validateDatabaseUniques(array $data): void { Validator::make($data, [ 'document_number' => 'sometimes|nullable|unique:students,document_number', 'email' => 'sometimes|nullable|unique:students,email', ])->validate(); } private function uniqueReservationConflict(string $field): never { throw ValidationException::withMessages([ $field => __('validation.unique', [ 'attribute' => __("validation.attributes.{$field}"), ]), ]); } // resolve e valida a unidade usada no cadastro private function resolveUnitId(User $user): int { $activeUnitId = request()->input('active_unit_id'); if ($activeUnitId) { $unit = $user->units()->where('units.id', $activeUnitId)->first(); abort_if(! $unit, 403, 'Unidade não autorizada para este usuário.'); return $unit->id; } $unit = $user->units()->first(); abort_if(! $unit, 403, 'Usuário sem unidade associada.'); return $unit->id; } // mantem o indice de rascunhos ativos private function registerToken(string $token): void { $tokens = array_values(array_filter( $this->registeredTokens(), fn (string $registeredToken): bool => Cache::has($this->draftKey($registeredToken)), )); $tokens[] = $token; Cache::forever(self::TOKENS_KEY, array_values(array_unique($tokens))); } private function unregisterToken(string $token): void { $tokens = array_values(array_filter( $this->registeredTokens(), fn (string $registeredToken): bool => $registeredToken !== $token, )); if ($tokens === []) { Cache::forget(self::TOKENS_KEY); return; } Cache::forever(self::TOKENS_KEY, $tokens); } // monta chaves e erros do fluxo private function draftKey(string $token): string { return self::CACHE_PREFIX.$token; } private function invalidToken(): never { throw ValidationException::withMessages([ 'registration_draft_token' => __('validation.registration_draft_invalid'), ]); } }