Vodiči

Symfony Onboarding: Import Social Profiles with AI Identity Resolver & Manual Review

Symfony uvođenje: Uvoz društvenih profila uz AI alat za razlučivanje identiteta i ručnu provjeru

Polje društvenog profila izgleda bezazleno sve dok o njemu ne ovisi uvođenje korisnika. Ljudi lijepe pune URL-ove, sama korisnička imena, mobilne poveznice za dijeljenje i identifikatore kopirane s različitih platformi. Spremanje tog unosa doslovno ostavlja svakoj funkciji nizvodno da ponovno otkriva što on znači.

Ovaj vodič izrađuje Symfony tijek rada usmjeren na produkciju koji šalje javnu Facebook, Instagram ili LinkedIn referencu u Identity Resolver, sprema normalizirani rezultat kao kandidata za pregled i zahtijeva da ga osoba prihvati ili odbije. Važna je granica namjerna: normalizacija je automatizirana, ali vaša aplikacija ostaje odgovorna za odlučivanje pripada li vraćeni javni identitet korisniku koji prolazi uvođenje.

Dobijte pristup prije pisanja integracijskog koda

Započnite sa službenom dokumentacijom Identity Resolvera. Trenutačni javni endpoint ne zahtijeva token računa ni API ključ, pa nema vjerodajnice koju treba kopirati prije prvog zahtjeva.

  1. Otvorite stranicu za registraciju ako želite račun na portalu za buduće usluge kojima se upravlja putem računa.
  2. Postojeći korisnici mogu otvoriti stranicu za prijavu.
  3. Pregledajte stranicu usluge i paketa.
  4. Potvrdite trenutačni ugovor u službenoj dokumentaciji: javni resolver trenutačno ne treba token.

Stoga u ovoj implementaciji nema polja tokena, autorizacijskog zaglavlja ni varijable okruženja za API ključ. Nemojte ga izmišljati. Ako se autentikacija uvede kasnije, tada slijedite dokumentaciju i izdanu tajnu smjestite u spremište tajni za implementaciju, a ne u kontrolu izvornog koda.

Točan zahtjev je GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Prihvaća platform te podržani parametar username, id, identifier, profile ili url. Ovaj projekt dosljedno koristi profile, dopuštajući da poslana vrijednost bude prepoznatljiva referenca profila ili URL.

curl --get \
  --data-urlencode "platform=linkedin" \
  --data-urlencode "profile=https://www.linkedin.com/in/EXAMPLE_PUBLIC_PROFILE" \
  "https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"

Zamijenite rezervirano mjesto javnim profilom koji ste ovlašteni obrađivati. Uspješan odgovor je normalizirani objekt javnog identiteta. Nećemo pretpostavljati nedokumentirana imena polja; aplikacija provjerava je li odgovor JSON objekt i pohranjuje ga netaknutog.

Budući da vjerodajnica ne postoji, u .env.local spremajte samo integracijsku konfiguraciju koja nije tajna. Produkcija bi istu varijablu trebala pružiti putem svojeg okruženja, umjesto da se ta datoteka predaje u repozitorij.

# .env.local
IDENTITY_RESOLVER_BASE_URI=https://ai.mihajlo.mk/api/identity-resolver

Odaberite arhitekturu u kojoj pregled dolazi prvi

Tijek rada ima četiri mala dijela: kontroler za uvođenje prima platformu i referencu profila, namjenski klijent poziva resolver, Doctrine entitet sprema kandidata i normalizirani payload, a ruta za pregled bilježi prihvaćanje ili odbijanje.

Poziv resolvera ostaje sinkron jer uvođenju treba neposredan kandidat za pregled. Messenger bi dodao latenciju reda, pitanja isporuke duplikata i još jednu operativnu ovisnost bez poboljšanja ove kratke interakcije. Ako promet ili latencija usluge kasnije učine sinkrono uvođenje neprihvatljivim, isti klijent može stajati iza Messenger handlera, dok kandidat započinje u stanju na čekanju u redu.

Ključno je da uspješan API odgovor ne povezuje automatski identitet s računom. Javni podaci mogu biti dvosmisleni, zastarjeli ili pogrešno uneseni. Izričito stanje pregleda štiti korisnika i aplikaciji daje provjerljivu točku odlučivanja.

Preduvjeti i raspored projekta

Koristite PHP 8.3 ili noviji, Composer, podržano izdanje Symfonya i bazu podataka kompatibilnu s Doctrineom. Izradite aplikaciju i instalirajte samo komponente koje su potrebne ovom tijeku rada:

composer create-project symfony/skeleton social-onboarding
cd social-onboarding
composer require symfony/http-client symfony/orm-pack symfony/twig-bundle \
  symfony/security-csrf symfony/validator
composer require --dev symfony/maker-bundle symfony/test-pack

Relevantna struktura projekta namjerno je sažeta:

src/
  Controller/SocialOnboardingController.php
  Entity/SocialProfileCandidate.php
  Enum/ReviewStatus.php
  Identity/IdentityResolverClient.php
  Identity/NormalizedIdentity.php
  Identity/ResolverFailure.php
templates/onboarding/
  social.html.twig
  review.html.twig
tests/Identity/
  IdentityResolverClientTest.php
config/services.yaml

Povežite osnovni URI putem ubrizgavanja ovisnosti. Održavanje korijena endpointa podesivim omogućuje testove i kontrolirane promjene okruženja bez raspršivanja URL-ova kroz poslovni kod.

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\Identity\IdentityResolverClient:
        arguments:
            $baseUri: '%env(resolve:IDENTITY_RESOLVER_BASE_URI)%'

Izgradite obrambenu API granicu

Klijent upravlja detaljima transporta, pravilima ponavljanja pokušaja, provjerom JSON-a i klasifikacijom neuspjeha. Kod domene i kontrolera nikada ne bi trebao morati razumjeti HTTP statusne kodove.

Ova implementacija dopušta ukupno tri pokušaja. Ponovno pokušava kod neuspjeha transporta, HTTP 429 i prolaznih odgovora 502, 503 i 504. Ne ponavlja pokušaje kod odgovora za validaciju, autentikaciju, dozvole ili nenalaženje. Pauza između pokušaja kratka je i ograničena jer je zadržavanje web zahtjeva zbog neograničenog niza ponavljanja gore od prikazivanja oporavljive pogreške pri uvođenju.

<?php
// src/Identity/NormalizedIdentity.php
namespace App\Identity;

final readonly class NormalizedIdentity
{
    public function __construct(public array $payload)
    {
    }
}

// src/Identity/ResolverFailure.php
namespace App\Identity;

final class ResolverFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        public readonly ?int $status = null,
    ) {
        parent::__construct($message);
    }
}

// src/Identity/IdentityResolverClient.php
namespace App\Identity;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class IdentityResolverClient
{
    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $baseUri,
    ) {
    }

    public function resolve(string $platform, string $profile): NormalizedIdentity
    {
        $retryableStatuses = [429, 502, 503, 504];
        $delays = [150_000, 350_000];

        for ($attempt = 1; $attempt <= 3; ++$attempt) {
            try {
                $response = $this->http->request('GET', $this->baseUri.'/v1/resolve', [
                    'query' => [
                        'platform' => $platform,
                        'profile' => $profile,
                    ],
                    'timeout' => 3.0,
                    'max_duration' => 8.0,
                    'headers' => ['Accept' => 'application/json'],
                ]);

                $status = $response->getStatusCode();
                $body = $response->getContent(false);

                if ($status >= 200 && $status < 300) {
                    try {
                        $payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
                    } catch (\JsonException $e) {
                        throw new ResolverFailure(
                            'invalid_response',
                            'Resolver returned invalid JSON.',
                            $status,
                        );
                    }

                    if (!is_array($payload) || array_is_list($payload)) {
                        throw new ResolverFailure(
                            'invalid_response',
                            'Resolver response was not a JSON object.',
                            $status,
                        );
                    }

                    return new NormalizedIdentity($payload);
                }

                if (!in_array($status, $retryableStatuses, true)) {
                    throw new ResolverFailure(
                        'rejected_request',
                        'Resolver rejected the request.',
                        $status,
                    );
                }

                $this->logger->warning('Identity resolution will be retried.', [
                    'attempt' => $attempt,
                    'status' => $status,
                    'platform' => $platform,
                ]);
            } catch (TransportExceptionInterface $e) {
                $this->logger->warning('Identity resolver transport failure.', [
                    'attempt' => $attempt,
                    'platform' => $platform,
                    'exception_class' => $e::class,
                ]);
            }

            if ($attempt < 3) {
                usleep($delays[$attempt - 1]);
            }
        }

        throw new ResolverFailure(
            'temporarily_unavailable',
            'Resolver remained unavailable after bounded retries.',
        );
    }
}

Primijetite što nedostaje u zapisima: poslana vrijednost profila, tijelo odgovora, kolačići i vjerodajnice. Platforma, broj pokušaja, status i klasa iznimke dovoljni su za dijagnosticiranje dostupnosti bez pretvaranja zapisnika aplikacije u kopiju korisnički poslanih identitetskih podataka.

Pohranite kandidata, a ne odobreni identitet

Entitet sprema izvornu referencu za pregled, potpuni normalizirani objekt, vremenske oznake i ograničeni status. Koristite Doctrine stupac koji podržava JSON kako bi integracija ostala kompatibilna s evolucijom odgovora, bez pretvaranja da su nedokumentirani ključevi zajamčeni.

<?php
// src/Enum/ReviewStatus.php
namespace App\Enum;

enum ReviewStatus: string
{
    case Pending = 'pending';
    case Accepted = 'accepted';
    case Rejected = 'rejected';
}

// src/Entity/SocialProfileCandidate.php
namespace App\Entity;

use App\Enum\ReviewStatus;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
final class SocialProfileCandidate
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\Column(length: 36)]
    private string $ownerKey;

    #[ORM\Column(length: 20)]
    private string $platform;

    #[ORM\Column(length: 2048)]
    private string $submittedReference;

    #[ORM\Column(type: 'json')]
    private array $normalizedIdentity;

    #[ORM\Column(enumType: ReviewStatus::class)]
    private ReviewStatus $status;

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $reviewedAt = null;

    public function __construct(
        string $ownerKey,
        string $platform,
        string $submittedReference,
        array $normalizedIdentity,
    ) {
        $this->id = Uuid::v7();
        $this->ownerKey = $ownerKey;
        $this->platform = $platform;
        $this->submittedReference = $submittedReference;
        $this->normalizedIdentity = $normalizedIdentity;
        $this->status = ReviewStatus::Pending;
        $this->createdAt = new \DateTimeImmutable();
    }

    public function getId(): Uuid { return $this->id; }
    public function getOwnerKey(): string { return $this->ownerKey; }
    public function getPlatform(): string { return $this->platform; }
    public function getSubmittedReference(): string { return $this->submittedReference; }
    public function getNormalizedIdentity(): array { return $this->normalizedIdentity; }
    public function getStatus(): ReviewStatus { return $this->status; }

    public function review(ReviewStatus $decision): void
    {
        if ($this->status !== ReviewStatus::Pending) {
            throw new \LogicException('Candidate has already been reviewed.');
        }
        if ($decision === ReviewStatus::Pending) {
            throw new \InvalidArgumentException('A review requires a final decision.');
        }

        $this->status = $decision;
        $this->reviewedAt = new \DateTimeImmutable();
    }
}

Izradite i primijenite migraciju nakon konfiguriranja DATABASE_URL za ciljno okruženje:

php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction

Povežite uvoz i ručni pregled

Kontroler provjerava platformu i duljinu unosa prije pozivanja usluge, koristi CSRF zaštitu za obje promjene stanja i ograničava kandidate na neprozirni ključ za uvođenje koji se čuva u sesiji. Autentificirana aplikacija trebala bi umjesto toga ograničavati zapise na stvarni identifikator korisnika.

<?php
// src/Controller/SocialOnboardingController.php
namespace App\Controller;

use App\Entity\SocialProfileCandidate;
use App\Enum\ReviewStatus;
use App\Identity\IdentityResolverClient;
use App\Identity\ResolverFailure;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Uid\Uuid;

final class SocialOnboardingController extends AbstractController
{
    #[Route('/onboarding/social', name: 'social_form', methods: ['GET'])]
    public function form(): Response
    {
        return $this->render('onboarding/social.html.twig');
    }

    #[Route('/onboarding/social', name: 'social_import', methods: ['POST'])]
    public function import(
        Request $request,
        IdentityResolverClient $resolver,
        EntityManagerInterface $em,
        CsrfTokenManagerInterface $csrf,
        LoggerInterface $logger,
    ): Response {
        $token = new CsrfToken('social-import', (string) $request->request->get('_token'));
        if (!$csrf->isTokenValid($token)) {
            throw $this->createAccessDeniedException();
        }

        $platform = strtolower(trim((string) $request->request->get('platform')));
        $profile = trim((string) $request->request->get('profile'));

        if (!in_array($platform, ['facebook', 'instagram', 'linkedin'], true)
            || $profile === ''
            || mb_strlen($profile) > 2048) {
            $this->addFlash('error', 'Choose a supported platform and enter a valid profile reference.');
            return $this->redirectToRoute('social_form');
        }

        try {
            $identity = $resolver->resolve($platform, $profile);
        } catch (ResolverFailure $e) {
            $logger->error('Social profile import failed.', [
                'kind' => $e->kind,
                'status' => $e->status,
                'platform' => $platform,
            ]);
            $this->addFlash('error', 'The profile could not be imported. Check it or try again later.');
            return $this->redirectToRoute('social_form');
        }

        $ownerKey = $request->getSession()->get('onboarding_key');
        if (!is_string($ownerKey)) {
            $ownerKey = Uuid::v7()->toRfc4122();
            $request->getSession()->set('onboarding_key', $ownerKey);
        }

        $candidate = new SocialProfileCandidate(
            $ownerKey,
            $platform,
            $profile,
            $identity->payload,
        );
        $em->persist($candidate);
        $em->flush();

        return $this->redirectToRoute('social_review', ['id' => $candidate->getId()]);
    }

    #[Route('/onboarding/social/{id}', name: 'social_review', methods: ['GET', 'POST'])]
    public function review(
        SocialProfileCandidate $candidate,
        Request $request,
        EntityManagerInterface $em,
        CsrfTokenManagerInterface $csrf,
    ): Response {
        $ownerKey = $request->getSession()->get('onboarding_key');
        if (!is_string($ownerKey) || !hash_equals($candidate->getOwnerKey(), $ownerKey)) {
            throw $this->createNotFoundException();
        }

        if ($request->isMethod('POST')) {
            $token = new CsrfToken(
                'social-review-'.$candidate->getId(),
                (string) $request->request->get('_token'),
            );
            if (!$csrf->isTokenValid($token)) {
                throw $this->createAccessDeniedException();
            }

            $decision = match ($request->request->get('decision')) {
                'accept' => ReviewStatus::Accepted,
                'reject' => ReviewStatus::Rejected,
                default => throw $this->createNotFoundException(),
            };

            $candidate->review($decision);
            $em->flush();

            return $this->redirectToRoute('social_review', ['id' => $candidate->getId()]);
        }

        return $this->render('onboarding/review.html.twig', ['candidate' => $candidate]);
    }
}

Predložak za uvoz treba selektor platforme, polje profila i {{ csrf_token('social-import') }}. Predložak za pregled trebao bi prikazati poslanu referencu i normalizirani objekt, a zatim poslati decision=accept ili decision=reject s {{ csrf_token('social-review-' ~ candidate.id) }}. Escapeajte uobičajene vrijednosti; za čitljiv dijagnostički prikaz renderirajte payload putem Twigova JSON kodiranja i escapeanja, umjesto da sadržaj usluge označite kao sirovi HTML.

Testirajte ponavljanja i validaciju granice

MockHttpClient čini ponašanje transporta determinističkim i sprječava testove da kontaktiraju javnu uslugu. Testirajte barem uspjeh, neispravan JSON, odgovor klijenta koji se ne ponavlja, uspješan oporavak nakon 429 i iscrpljivanje nakon prolaznih neuspjeha.

<?php
namespace App\Tests\Identity;

use App\Identity\IdentityResolverClient;
use App\Identity\ResolverFailure;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class IdentityResolverClientTest extends TestCase
{
    public function testMapsAJsonObjectWithoutAssumingItsFields(): void
    {
        $http = new MockHttpClient([
            new MockResponse('{"public":"value"}', ['http_code' => 200]),
        ]);
        $client = new IdentityResolverClient($http, new NullLogger(), 'https://resolver.test');

        $identity = $client->resolve('linkedin', 'example');

        self::assertSame(['public' => 'value'], $identity->payload);
    }

    public function testRetriesRateLimitThenSucceeds(): void
    {
        $http = new MockHttpClient([
            new MockResponse('limited', ['http_code' => 429]),
            new MockResponse('{"normalized":true}', ['http_code' => 200]),
        ]);
        $client = new IdentityResolverClient($http, new NullLogger(), 'https://resolver.test');

        self::assertSame(
            ['normalized' => true],
            $client->resolve('instagram', 'example')->payload,
        );
        self::assertSame(2, $http->getRequestsCount());
    }

    public function testDoesNotRetryValidationFailure(): void
    {
        $http = new MockHttpClient([
            new MockResponse('invalid', ['http_code' => 422]),
        ]);
        $client = new IdentityResolverClient($http, new NullLogger(), 'https://resolver.test');

        try {
            $client->resolve('facebook', '');
            self::fail('Expected resolver failure.');
        } catch (ResolverFailure $e) {
            self::assertSame('rejected_request', $e->kind);
            self::assertSame(422, $e->status);
            self::assertSame(1, $http->getRequestsCount());
        }
    }
}

Sigurnost, vidljivost i implementacija

Javno ne znači bez posljedica. Recite korisnicima zašto se profil traži, obrađujte samo reference relevantne za uvođenje, ograničite pristup zapisima pregleda i definirajte zadržavanje odbijenih kandidata. Svaku vrijednost odgovora tretirajte kao nepouzdan sadržaj za prikaz. Nikada ne koristite normalizirane nizove kao HTML, SQL, argumente ljuske ili dokaz autorizacije bez validacije prikladne kontekstu.

Pratite brojeve i latenciju prema ishodu: uspjeh, odbijeni zahtjev, ograničenje broja zahtjeva, prolazni neuspjeh uzvodne usluge i neispravan odgovor. Upozoravajte na trajne promjene, a ne na jedan neuspjeli zahtjev. Zapisnici trebaju sadržavati identifikator korelacije zahtjeva ako ga šira aplikacija već ima, ali ne smiju sadržavati poslani profil ni normalizirani payload.

Tijekom implementacije ubrizgajte IDENTITY_RESOLVER_BASE_URI, konfigurirajte DATABASE_URL, zagrijte Symfonyjev produkcijski predmemorijski spremnik, jednom pokrenite migracije i implementirajte procese aplikacije tek nakon validacije konfiguracije. Provjerite je li dopušten izlazni HTTPS prema ai.mihajlo.mk. Nemojte onemogućiti TLS provjeru kako biste riješili probleme s certifikatom ili proxyjem.

Uobičajeni načini neuspjeha

  • HTTP 400 ili 422: provjerite odabir platforme i poslani parametar. Ispravite zahtjev; ponavljanje nepromijenjenog unosa je rasipno.
  • HTTP 401 ili 403: nemojte dodavati pretpostavljeno autorizacijsko zaglavlje. Ponovno provjerite trenutačnu službenu dokumentaciju i mrežna pravila implementacije.
  • HTTP 429: poštujte granice usluge, zadržite ograničen broj ponavljanja i pustite korisnika da pokuša kasnije kada su pokušaji iscrpljeni.
  • Neispravan JSON ili odgovor koji nije objekt: klasificirajte ga kao neuspjeh ugovora uzvodne usluge. Nemojte spremati djelomična ili pretpostavljena polja identiteta.
  • Dvostruke prijave: spriječite drugu odluku o pregledu i razmotrite pravilo jedinstvenosti baze podataka primjereno vašem modelu računa.
  • Stranica pregleda nije pronađena: provjerite posjeduje li isti autentificirani korisnik ili sesija uvođenja kandidata; nikada ne otkrivajte zapis drugog korisnika.

Završni kontrolni popis za provjeru

  • Dokumentacija usluge je provjerena i za trenutačni javni endpoint nije konfiguriran token ni API ključ.
  • Aplikacija šalje samo GET zahtjeve na /v1/resolve s parametrima platform i profile.
  • Trajanje veze i ukupno trajanje zahtjeva ograničeni su.
  • Ponavljaju se samo ograničenja broja zahtjeva, odabrani prolazni statusi i neuspjesi transporta.
  • Normalizirani odgovor validiran je kao JSON objekt bez oslanjanja na nedokumentirana polja.
  • Kandidati ostaju na čekanju dok ih ovlaštena osoba ne prihvati ili odbije.
  • Oba obrasca koji mijenjaju stanje koriste CSRF zaštitu.
  • Zapisnici isključuju reference profila, payloadove odgovora, identifikatore sesije i tajne.
  • Testovi s lažnim transportom pokrivaju putanje uspjeha, ponavljanja, neispravnog odgovora i trajnog neuspjeha.
  • Produkcijska konfiguracija, migracije, HTTPS izlaz, nadzor i pravila zadržavanja spremni su.

Pouzdan uvoz identiteta nije najdomišljatiji mogući parser. To je uska, vidljiva granica oko vanjske mogućnosti, nakon koje slijedi ljudska odluka koju vaša aplikacija može objasniti. Normalizirajte s pouzdanjem, pregledajte promišljeno i neka prihvaćanje — a ne puki uspjeh API-ja — bude trenutak kada javni profil postaje dio nečijeg računa.

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.