Vodiči

Laravel: Unify Social Links with AI Identity Resolver for Community Directories

Laravel: Ujedinite društvene poveznice s AI razrješavačem identiteta za direktorije zajednice

Direktorij zajednice rijetko prima čiste podatke s društvenih mreža. Jedan član zalijepi puni Facebook URL, drugi pošalje Instagram korisničko ime prikriveno kao poveznicu, a treći kopira URL LinkedIn profila s parametrima za praćenje. Ako te vrijednosti izravno uđu u vašu bazu podataka, otkrivanje duplikata, prikaz profila i buduće migracije postaju teži.

Ovaj vodič izrađuje produkcijsku Laravel značajku koja prihvaća poveznice na Facebook, Instagram i LinkedIn profile, šalje svaku referencu u Identity Resolver i pohranjuje dobiveni javni identitet u stabilnu strukturu na razini aplikacije. Razrješavanje se izvršava u redu čekanja, neuspjesi ostaju vidljivi umjesto da nestanu, a testovi nikada ne kontaktiraju stvarnu uslugu.

Dobijte pristup i uputite prvi zahtjev

Započnite sa službenom dokumentacijom za Identity Resolver. Krajnja točka trenutačno je javna: ne zahtijeva račun, bearer token ni API ključ.

  1. Pregledajte stranicu usluge i plana kako biste potvrdili da Identity Resolver podržava platforme koje vaš direktorij prihvaća.
  2. Provjerite zahtjeve za registraciju. Registracija nije dio trenutačnog tijeka za javnu krajnju točku.
  3. U istoj službenoj dokumentaciji provjerite zahtjeve za prijavu. Ne morate se prijaviti prije testiranja.
  4. Nemojte tražiti zaslon za kopiranje tokena: trenutačno ne postoji token ni API ključ za kopiranje. Ako se autentifikacija uvede kasnije, slijedite dokumentaciju umjesto da nagađate zaglavlje za autorizaciju.

Točna integracija jest HTTP GET zahtjev prema https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Pošaljite platform zajedno s podržanim parametrom username, id, identifier, profile ili url. Ovaj projekt koristi url jer to šalju članovi direktorija.

curl --get \
  --header "Accept: application/json" \
  --data-urlencode "platform=instagram" \
  --data-urlencode "url=https://www.instagram.com/example/" \
  "https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"

Pokrenite taj zahtjev prije pisanja aplikacijskog koda. Uspješan odgovor trebao bi biti JSON koji predstavlja normalizirani javni identitet. Dokumentirani ugovor ne opravdava povezivanje aplikacije sa spekulativnim nazivima polja, stoga granica u nastavku validira odgovor kao JSON objekt i čuva njegov sadržaj.

Nema vjerodajnice koju treba staviti u .env. Umjesto toga ondje pohranite krajnju točku i operativne postavke; dodavanje izmišljenog tokena stvorilo bi obmanjujuću sigurnosnu ovisnost.

IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
IDENTITY_RESOLVER_CONNECT_TIMEOUT=3
IDENTITY_RESOLVER_TIMEOUT=8
IDENTITY_RESOLVER_ATTEMPTS=3
IDENTITY_RESOLVER_RETRY_BASE_MS=500

Preduvjeti i struktura projekta

Trebate PHP 8.3 ili noviji, Composer, podržanu Laravel aplikaciju, konfiguriranu bazu podataka i pozadinu za red čekanja. Red čekanja u bazi podataka dovoljan je za mali direktorij; Redis je koristan kada to opravdavaju propusnost ili izolacija redova čekanja.

composer create-project laravel/laravel community-directory
cd community-directory
php artisan make:model DirectoryEntry -m
php artisan make:request StoreDirectoryEntryRequest
php artisan make:controller DirectoryEntryController
php artisan make:job ResolveDirectoryIdentities
php artisan make:test IdentityResolverTest
php artisan queue:table
php artisan migrate

Relevantna struktura aplikacije namjerno je mala:

  • app/Services/IdentityResolver.php upravlja granicom udaljenog HTTP-a.
  • app/Data/ResolvedIdentity.php definira stabilan domenski prikaz direktorija.
  • app/Jobs/ResolveDirectoryIdentities.php izvršava potencijalno sporo razrješavanje.
  • app/Http/Requests/StoreDirectoryEntryRequest.php ograničava korisnički unos.
  • app/Http/Controllers/DirectoryEntryController.php stvara unose i šalje posao na izvršavanje.

Asinkrono razrješavanje drži tri vanjska poziva izvan proračuna latencije za slanje obrasca. Kompromis je konačna dosljednost: novostvoreni unos započinje kao pending. Sučelje bi trebalo prikazivati to stanje umjesto da se pretvara da je normalizacija trenutačna.

Konfigurirajte Laravel bez izmišljanja autentifikacije

Dodajte uslugu u config/services.php. Laravel konfiguracija postaje jedino mjesto koje čita varijable okruženja.

'identity_resolver' => [
    'url' => env(
        'IDENTITY_RESOLVER_URL',
        'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
    ),
    'connect_timeout' => (int) env('IDENTITY_RESOLVER_CONNECT_TIMEOUT', 3),
    'timeout' => (int) env('IDENTITY_RESOLVER_TIMEOUT', 8),
    'attempts' => (int) env('IDENTITY_RESOLVER_ATTEMPTS', 3),
    'retry_base_ms' => (int) env('IDENTITY_RESOLVER_RETRY_BASE_MS', 500),
],

Nemojte dodavati prazno zaglavlje za autorizaciju. „Token nije potreban” jest ugovor o autentifikaciji, a ne poziv da šaljete rezervirana mjesta.

Pohranite unose, rezultate i strukturirane neuspjehe

Direktorij bi trebao zadržati poslane poveznice radi revizije i ponovne obrade, dok normalizirani identiteti i neuspjesi žive u zasebnim JSON stupcima. Izradite migraciju ovako:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('directory_entries', function (Blueprint $table): void {
            $table->id();
            $table->string('name');
            $table->json('social_links');
            $table->json('resolved_identities')->nullable();
            $table->json('resolution_failures')->nullable();
            $table->string('resolution_status')->default('pending');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('directory_entries');
    }
};

U DirectoryEntry, učinite ove atribute popunjivima i pretvorite tri JSON stupca u array. Zadržavanje odgovora pružatelja netaknutim izbjegava tiho odbacivanje budućih dokumentiranih polja.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class DirectoryEntry extends Model
{
    protected $fillable = [
        'name',
        'social_links',
        'resolved_identities',
        'resolution_failures',
        'resolution_status',
    ];

    protected function casts(): array
    {
        return [
            'social_links' => 'array',
            'resolved_identities' => 'array',
            'resolution_failures' => 'array',
        ];
    }
}

Validirajte platforme i hostove prije razrješavanja

Popis dopuštenih vrijednosti sprječava tipografske pogreške, neočekivane platforme, URL-ove koji nisu HTTPS i obmanjujuća imena hostova. Također štiti integritet direktorija iako Laravel sam ne dohvaća poslani URL.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;

final class StoreDirectoryEntryRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // Replace with the directory's real authorization policy.
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:120'],
            'profiles' => ['required', 'array', 'min:1', 'max:3'],
            'profiles.*.platform' => [
                'required',
                'distinct',
                Rule::in(['facebook', 'instagram', 'linkedin']),
            ],
            'profiles.*.url' => ['required', 'url:https', 'max:2048'],
        ];
    }

    public function after(): array
    {
        return [
            function (Validator $validator): void {
                $domains = [
                    'facebook' => 'facebook.com',
                    'instagram' => 'instagram.com',
                    'linkedin' => 'linkedin.com',
                ];

                foreach ($this->input('profiles', []) as $index => $profile) {
                    $platform = $profile['platform'] ?? '';
                    $host = Str::lower(parse_url($profile['url'] ?? '', PHP_URL_HOST) ?: '');
                    $domain = $domains[$platform] ?? null;

                    if ($domain === null ||
                        ($host !== $domain && ! Str::endsWith($host, '.'.$domain))) {
                        $validator->errors()->add(
                            "profiles.$index.url",
                            'The URL host does not match the selected platform.'
                        );
                    }
                }
            },
        ];
    }
}

Izradite obrambenu API granicu

დომenski objekt dodaje polja u vlasništvu naše aplikacije, dok teret podataka usluge tretira kao neprozirne podatke o javnom identitetu.

<?php

namespace App\Data;

use Carbon\CarbonImmutable;

final readonly class ResolvedIdentity
{
    public function __construct(
        public string $platform,
        public array $publicIdentity,
        public CarbonImmutable $resolvedAt,
    ) {}

    public function toArray(): array
    {
        return [
            'platform' => $this->platform,
            'public_identity' => $this->publicIdentity,
            'resolved_at' => $this->resolvedAt->toIso8601String(),
        ];
    }
}

Usluga ponovno pokušava samo kod neuspjeha veze, HTTP 429 odgovora i pogrešaka poslužitelja. Neuspjesi validacije i autentifikacije završni su jer ponavljanje istog zahtjeva neće ih popraviti. Tijela odgovora i poslani URL-ovi namjerno su isključeni iz zapisnika.

<?php

namespace App\Services;

use App\Data\ResolvedIdentity;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use JsonException;
use RuntimeException;

final class IdentityResolutionException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $statusCode = null,
        public readonly bool $retryable = false,
    ) {
        parent::__construct($message);
    }
}

final class IdentityResolver
{
    public function resolve(string $platform, string $url): ResolvedIdentity
    {
        if (! in_array($platform, ['facebook', 'instagram', 'linkedin'], true)) {
            throw new IdentityResolutionException('Unsupported platform.');
        }

        $attempts = max(1, (int) config('services.identity_resolver.attempts'));

        for ($attempt = 1; $attempt <= $attempts; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->connectTimeout(config('services.identity_resolver.connect_timeout'))
                    ->timeout(config('services.identity_resolver.timeout'))
                    ->get(config('services.identity_resolver.url'), [
                        'platform' => $platform,
                        'url' => $url,
                    ]);
            } catch (ConnectionException $exception) {
                $failure = new IdentityResolutionException(
                    'Identity Resolver connection failed.',
                    null,
                    true
                );

                if ($attempt === $attempts) {
                    throw $failure;
                }

                $this->pause($attempt, null);
                continue;
            }

            if ($response->successful()) {
                try {
                    $payload = json_decode(
                        $response->body(),
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (JsonException $exception) {
                    throw new IdentityResolutionException(
                        'Identity Resolver returned invalid JSON.'
                    );
                }

                if (! is_array($payload) || array_is_list($payload)) {
                    throw new IdentityResolutionException(
                        'Identity Resolver returned an unexpected JSON shape.'
                    );
                }

                return new ResolvedIdentity(
                    $platform,
                    $payload,
                    CarbonImmutable::now()
                );
            }

            $status = $response->status();
            $retryable = $status === 429 || $status >= 500;
            $failure = new IdentityResolutionException(
                'Identity Resolver request failed.',
                $status,
                $retryable
            );

            if (! $retryable || $attempt === $attempts) {
                throw $failure;
            }

            Log::warning('Identity resolution will be retried.', [
                'platform' => $platform,
                'status' => $status,
                'attempt' => $attempt,
            ]);

            $this->pause($attempt, $response->header('Retry-After'));
        }

        throw new IdentityResolutionException('Identity resolution failed.');
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $milliseconds = ctype_digit((string) $retryAfter)
            ? min(5000, (int) $retryAfter * 1000)
            : min(
                5000,
                (int) config('services.identity_resolver.retry_base_ms')
                    * (2 ** ($attempt - 1))
            );

        usleep($milliseconds * 1000);
    }
}

Razriješite profile u zadatku reda čekanja

Svaka platforma prima neovisan ishod. Jedan nedostupan profil trebao bi proizvesti partial, a ne izbrisati uspješne identitete s drugih platformi.

<?php

namespace App\Jobs;

use App\Models\DirectoryEntry;
use App\Services\IdentityResolutionException;
use App\Services\IdentityResolver;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

final class ResolveDirectoryIdentities implements ShouldQueue
{
    use Queueable;

    public int $tries = 1;
    public int $timeout = 120;

    public function __construct(public readonly int $entryId) {}

    public function handle(IdentityResolver $resolver): void
    {
        $entry = DirectoryEntry::findOrFail($this->entryId);
        $resolved = [];
        $failures = [];

        foreach ($entry->social_links as $platform => $url) {
            try {
                $resolved[$platform] = $resolver
                    ->resolve($platform, $url)
                    ->toArray();
            } catch (IdentityResolutionException $exception) {
                $failures[$platform] = [
                    'status_code' => $exception->statusCode,
                    'retryable' => $exception->retryable,
                ];
            }
        }

        $status = $failures === []
            ? 'complete'
            : ($resolved === [] ? 'failed' : 'partial');

        $entry->update([
            'resolved_identities' => $resolved ?: null,
            'resolution_failures' => $failures ?: null,
            'resolution_status' => $status,
        ]);

        Log::info('Directory identity resolution finished.', [
            'entry_id' => $entry->id,
            'status' => $status,
            'resolved_count' => count($resolved),
            'failure_count' => count($failures),
        ]);
    }
}

Kontroler pretvara validirani popis profila u mapu s ključevima platformi i vraća 202 Accepted jer se obrada nastavlja asinkrono.

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreDirectoryEntryRequest;
use App\Jobs\ResolveDirectoryIdentities;
use App\Models\DirectoryEntry;
use Illuminate\Http\JsonResponse;

final class DirectoryEntryController extends Controller
{
    public function store(StoreDirectoryEntryRequest $request): JsonResponse
    {
        $profiles = collect($request->validated('profiles'))
            ->mapWithKeys(fn (array $profile): array => [
                $profile['platform'] => $profile['url'],
            ])
            ->all();

        $entry = DirectoryEntry::create([
            'name' => $request->validated('name'),
            'social_links' => $profiles,
            'resolution_status' => 'pending',
        ]);

        ResolveDirectoryIdentities::dispatch($entry->id);

        return response()->json([
            'id' => $entry->id,
            'resolution_status' => $entry->resolution_status,
        ], 202);
    }
}
use App\Http\Controllers\DirectoryEntryController;
use Illuminate\Support\Facades\Route;

Route::post('/directory-entries', [DirectoryEntryController::class, 'store'])
    ->middleware('throttle:directory-submissions');

Testirajte bez dodirivanja javne krajnje točke

Http::fake() čini uspjeh, ograničavanje stope i završni neuspjeh determinističkima. Oblik fixturea namjerno kontrolira aplikacija; test provjerava da granica čuva JSON umjesto da se oslanja na nedokumentirana polja pružatelja.

<?php

namespace Tests\Feature;

use App\Services\IdentityResolutionException;
use App\Services\IdentityResolver;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class IdentityResolverTest extends TestCase
{
    public function test_it_sends_the_documented_query_and_maps_json(): void
    {
        $fixture = ['fixture_identity' => ['value' => 'stable-reference']];

        Http::fake([
            config('services.identity_resolver.url').'*' =>
                Http::response($fixture, 200),
        ]);

        $result = app(IdentityResolver::class)->resolve(
            'instagram',
            'https://www.instagram.com/example/'
        );

        $this->assertSame($fixture, $result->publicIdentity);

        Http::assertSent(fn (Request $request): bool =>
            $request->method() === 'GET'
            && $request['platform'] === 'instagram'
            && $request['url'] === 'https://www.instagram.com/example/'
            && ! $request->hasHeader('Authorization')
        );
    }

    public function test_it_retries_a_rate_limit_then_succeeds(): void
    {
        config(['services.identity_resolver.retry_base_ms' => 0]);

        Http::fakeSequence()
            ->push([], 429)
            ->push(['fixture_identity' => []], 200);

        app(IdentityResolver::class)->resolve(
            'facebook',
            'https://www.facebook.com/example'
        );

        Http::assertSentCount(2);
    }

    public function test_it_does_not_retry_validation_failures(): void
    {
        Http::fake(fn () => Http::response([], 422));

        try {
            app(IdentityResolver::class)->resolve(
                'linkedin',
                'https://www.linkedin.com/in/example'
            );
            $this->fail('Expected identity resolution to fail.');
        } catch (IdentityResolutionException $exception) {
            $this->assertFalse($exception->retryable);
            $this->assertSame(422, $exception->statusCode);
        }

        Http::assertSentCount(1);
    }
}
php artisan test --filter=IdentityResolverTest

Sigurnost, vidljivost i implementacija

Zaštitite rutu za slanje stvarnom autorizacijskom politikom svoje aplikacije, CSRF zaštitom gdje je primjenjivo i imenovanim ograničivačem stope. Prikazujte pohranjene vrijednosti uz uobičajeno escapiranje. Javne profile ipak tretirajte kao osobne podatke: ograničite pristup bazi podataka, definirajte pravila zadržavanja i nikada ne zapisujte poslane URL-ove ni tijela odgovora.

Pratite broj unosa complete, partial i failed, kao i udaljene statusne kodove i starost reda čekanja. Upozorenja bi se trebala usredotočiti na trajne obrasce neuspjeha, a ne na jedan nevaljan profil.

Tijekom implementacije pokrenite migracije prije nego što radnici obrade novi zadatak, predmemorirajte konfiguraciju tek nakon što su vrijednosti okruženja prisutne i ponovno pokrenite radnike kako bi učitali novi kod:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --timeout=125 --tries=1

Postavite retry_after veze reda čekanja iznad vremenskog ograničenja radnika, primjerice na 150 sekundi, kako drugi radnik ne bi preuzeo isti zadatak dok se još izvršava.

Česti neuspjesi

  • HTTP 400 ili 422: platforma ili poslana referenca nije valjana. Ispravite unos; nemojte ga ponovno pokušavati.
  • HTTP 401 ili 403: prestanite s ponovnim pokušajima i ponovno provjerite službenu dokumentaciju. Nemojte pretpostavljati format tokena.
  • HTTP 429: poštujte numeričku vrijednost Retry-After unutar ograničenog odgađanja i smanjite pritisak slanja.
  • HTTP 5xx ili neuspjeh veze: kratko pokušajte ponovno s odgodom, zatim sačuvajte strukturirani neuspjeh za kasniju ponovnu obradu.
  • Nevaljan JSON: tretirajte odgovor kao neuspjeh ugovora i zadržite izvornu poslanu poveznicu.
  • Unosi ostaju pending: potvrdite da radnik reda čekanja radi i prati konfiguriranu vezu.

Završni kontrolni popis za provjeru

  • Testni zahtjev koristi GET i točnu dokumentiranu krajnju točku.
  • Nisu konfigurirani token računa, API ključ ni zaglavlje za autorizaciju.
  • Validaciju prolaze samo HTTPS hostovi Facebooka, Instagrama i LinkedIna.
  • Vremenska ograničenja veze i odgovora su ograničena.
  • Ponovno se pokušavaju samo neuspjesi veze, ograničenja stope i neuspjesi poslužitelja.
  • JSON pružatelja validira se na granici bez pretpostavljenih polja.
  • Djelomični uspjeh se pohranjuje umjesto da se odbaci.
  • Testovi koriste Http::fake() i ne upućuju vanjske zahtjeve.
  • Zapisnici sadrže operativne metapodatke, a ne poslane poveznice ili tijela odgovora.
  • Vremensko ograničenje reda čekanja ostaje ispod retry_after veze.

Važan rezultat nisu samo ljepše poveznice na društvene mreže. Direktorij sada ima izričitu granicu između neurednog ljudskog unosa, normaliziranog javnog identiteta i podataka u vlasništvu vaše aplikacije. Ta je granica mjesto na kojem pouzdane integracije pokazuju svoju vrijednost: apsorbira promjene, čini neuspjehe preglednima i ostatku proizvoda omogućuje rad s identitetima umjesto s nagađanjima u obliku URL-ova.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.