| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404 |
- <?php
- namespace App\Services;
- use App\Models\User;
- use Closure;
- use Illuminate\Cache\DatabaseStore;
- use Illuminate\Support\Facades\Cache;
- use Illuminate\Support\Facades\Validator;
- use Illuminate\Support\Str;
- use Illuminate\Validation\ValidationException;
- class StudentRegistrationDraftService
- {
- private const CACHE_PREFIX = 'student-registration-draft:';
- private const LOCK_KEY = 'student-registration-drafts:lock';
- private const LOCK_SECONDS = 60;
- private const TOKENS_KEY = self::CACHE_PREFIX.'tokens';
- private const UNIQUE_FIELDS = [
- 'document_number',
- 'email',
- ];
- public function store(User $user, array $data): array
- {
- // vincula o rascunho ao usuario e a unidade ativa
- $unitId = $this->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'),
- ]);
- }
- }
|