Symfony Воведување: Увезете профили од социјални мрежи со AI решавач на идентитет и рачна проверка
Полето за профил на социјална мрежа изгледа безопасно сè додека процесот на воведување не зависи од него. Луѓето внесуваат цели URL-адреси, само кориснички имиња, врски за споделување од мобилен уред и идентификатори копирани од различни платформи. Зачувувањето на тој внес дословно остава секоја надолна функционалност повторно да открива што значи тој.
Овој туторијал гради Symfony работен тек ориентиран кон продукциска употреба кој испраќа јавна Facebook, Instagram или LinkedIn референца до Identity Resolver, го зачувува нормализираниот резултат како кандидат за преглед и бара лице да го прифати или одбие. Важната граница е намерна: нормализацијата е автоматизирана, но вашата апликација останува одговорна да одлучи дали вратениот јавен идентитет му припаѓа на корисникот во процесот на воведување.
Добијте пристап пред да пишувате код за интеграција
Почнете со официјалната документација за Identity Resolver. Тековната јавна крајна точка не бара токен за сметка или API клуч, па нема акредитив што треба да се копира пред да се направи првото барање.
- Отворете ја страницата за регистрација ако сакате сметка на порталот за идни услуги управувани преку сметка.
- Постоечките корисници можат да ја отворат страницата за најава.
- Прегледајте ја страницата за услугата и планот.
- Потврдете го тековниот договор во официјалната документација: јавниот resolver во моментов не бара токен.
Затоа, во оваа имплементација нема поле за токен, заглавие за авторизација или променлива на околината за API клуч. Не измислувајте такво. Ако подоцна се воведе автентикација, тогаш следете ја документацијата и издадениот таен клуч поставете го во складиште за тајни податоци при распоредување, наместо во изворниот код.
Точното барање е GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Прифаќа platform плус поддржан параметар username, id, identifier, profile или url. Овој проект доследно користи profile, дозволувајќи испратената вредност да биде препознатлива профилна референца или 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"
Заменете го местодржачот со јавен профил кој сте овластени да го обработувате. Успешниот одговор е нормализиран објект на јавен идентитет. Нема да претпоставуваме недокументирани имиња на полиња; апликацијата потврдува дека одговорот е JSON објект и го зачувува недопрен.
Бидејќи не постои акредитив, во .env.local чувајте само конфигурација за интеграција што не е тајна. Продукцијата треба да ја обезбеди истата променлива преку својата околина, наместо да ја зачувува таа датотека во commit.
# .env.local
IDENTITY_RESOLVER_BASE_URI=https://ai.mihajlo.mk/api/identity-resolver
Изберете архитектура со преглед на прво место
Работниот тек има четири мали дела: контролер за воведување ги прима платформата и профилната референца, посветен клиент го повикува resolver-от, Doctrine ентитет го зачувува кандидатот и нормализираниот payload, а рута за преглед го запишува прифаќањето или одбивањето.
Повикот кон resolver-от останува синхрон бидејќи воведувањето бара непосреден кандидат за преглед. Messenger би додал доцнење од редица, грижи за дупликатна испорака и уште една оперативна зависност без да ја подобри оваа кратка интеракција. Ако сообраќајот или латентноста на услугата подоцна го направат синхроното воведување неприфатливо, истиот клиент може да стои зад Messenger handler додека кандидатот започнува во состојба на чекање во редица.
Клучно, успешен API одговор не го поврзува автоматски идентитетот со сметка. Јавните податоци можат да бидат двосмислени, застарени или неточно внесени. Експлицитната состојба на преглед го штити корисникот и ѝ дава на апликацијата точка на одлучување што може да се ревидира.
Предуслови и распоред на проектот
Користете PHP 8.3 или понов, Composer, поддржано Symfony издание и база на податоци компатибилна со Doctrine. Креирајте ја апликацијата и инсталирајте ги само компонентите што му се потребни на овој работен тек:
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
Релевантната структура на проектот е намерно компактна:
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
Поврзете го основниот URI преку dependency injection. Одржувањето на коренот на крајната точка конфигурабилен овозможува тестови и контролирани промени на околината без расфрлање URL-адреси низ деловниот код.
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Identity\IdentityResolverClient:
arguments:
$baseUri: '%env(resolve:IDENTITY_RESOLVER_BASE_URI)%'
Изградете дефанзивна API граница
Клиентот ги поседува деталите за транспортот, политиката за повторни обиди, JSON валидацијата и класификацијата на неуспеси. Кодот за доменот и контролерот никогаш не треба да разбира HTTP статусни кодови.
Оваа имплементација дозволува вкупно три обиди. Повторува при транспортни неуспеси, HTTP 429 и минливите одговори 502, 503 и 504. Не повторува при одговори за валидација, автентикација, дозвола или ненаден ресурс. Повлекувањето е кратко и ограничено бидејќи задржувањето на веб-барање за неограничена низа повторни обиди е полошо од прикажување поправлива грешка при воведување.
<?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.',
);
}
}
Забележете што отсуствува од дневниците: испратената вредност на профилот, телото на одговорот, колачињата и акредитивите. Платформата, бројот на обидот, статусот и класата на исклучок се доволни за дијагностицирање на достапноста без дневниците на апликацијата да се претворат во копија на кориснички доставени податоци за идентитет.
Зачувајте кандидат, а не одобрен идентитет
Ентитетот ја зачувува оригиналната референца за преглед, целосниот нормализиран објект, временските ознаки и ограничениот статус. Користете Doctrine колона способна за JSON за интеграцијата да остане компатибилна со развојот на одговорот без да се преправа дека недокументираните клучеви се гарантирани.
<?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();
}
}
Креирајте и применете ја миграцијата откако ќе го конфигурирате DATABASE_URL за целната околина:
php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction
Поврзете увоз и рачен преглед
Контролерот ги валидира платформата и должината на внесот пред да ја повика услугата, користи CSRF заштита за двете мутации и ги ограничува кандидатите на непроѕирен клуч за воведување чуван во сесијата. Автентицирана апликација наместо тоа треба да ги ограничи записите на својот вистински идентификатор на корисникот.
<?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]);
}
}
Шаблонот за увоз има потреба од избирач на платформа, поле за профил и {{ csrf_token('social-import') }}. Шаблонот за преглед треба да ги прикаже испратената референца и нормализираниот објект, а потоа да испрати или decision=accept или decision=reject со {{ csrf_token('social-review-' ~ candidate.id) }}. Екранирајте ги вообичаените вредности; за читлив дијагностички приказ, прикажете го payload-от преку JSON кодирањето и екранирањето на Twig наместо содржината од услугата да ја означите како суров HTML.
Тестирајте повторни обиди и гранична валидација
MockHttpClient го прави транспортното однесување детерминистичко и спречува тестовите да контактираат со јавната услуга. Тестирајте најмалку успех, невалиден JSON, одговор од клиент што не се повторува, успешно закрепнување по 429 и исцрпување по минливи неуспеси.
<?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());
}
}
}
Безбедност, набљудливост и распоредување
Јавно не значи без последици. Кажете им на корисниците зошто се бара профилот, обработувајте само референци релевантни за воведувањето, ограничете го пристапот до записите за преглед и дефинирајте задржување за одбиените кандидати. Секоја вредност од одговорот третирајте ја како недоверлива содржина за приказ. Никогаш не користете нормализирани низи како HTML, SQL, shell аргументи или доказ за овластување без валидација соодветна на контекстот.
Следете ги бројките и латентноста по исход: успех, одбиено барање, ограничување на стапка, минлив неуспех на надворешната услуга и невалиден одговор. Алармирајте при трајни промени наместо при едно неуспешно барање. Дневниците треба да носат идентификатор за корелација на барањето ако пошироката апликација веќе има таков, но не треба да ги содржат испратениот профил или нормализираниот payload.
При распоредување, внесете IDENTITY_RESOLVER_BASE_URI, конфигурирајте DATABASE_URL, загрејте го продукцискиот кеш на Symfony, извршете ги миграциите еднаш и распоредете ги процесите на апликацијата само по валидација на конфигурацијата. Осигурете се дека е дозволен излезен HTTPS кон ai.mihajlo.mk. Не ја оневозможувајте TLS верификацијата за да решите проблеми со сертификат или прокси.
Чести режими на неуспех
- HTTP 400 или 422: проверете го изборот на платформата и испратениот параметар. Поправете го барањето; повторувањето на непроменет внес е залудно.
- HTTP 401 или 403: не додавајте претпоставено заглавие за авторизација. Повторно проверете ја тековната официјална документација и мрежната политика за распоредување.
- HTTP 429: почитувајте ја границата на услугата, одржувајте ги повторните обиди ограничени и дозволете му на корисникот да се обиде подоцна кога обидите ќе се исцрпат.
- Невалиден JSON или одговор што не е објект: класифицирајте го како неуспех на договорот со надворешната услуга. Не зачувувајте делумни или претпоставени полиња за идентитет.
- Дупликатни испраќања: спречете втора одлука за преглед и разгледајте политика за единственост во базата на податоци соодветна на вашиот модел на сметка.
- Страницата за преглед не е пронајдена: потврдете дека истиот автентициран корисник или сесија за воведување го поседува кандидатот; никогаш не откривајте запис на друг корисник.
Конечна контролна листа за верификација
- Документацијата за услугата е проверена и не е конфигуриран токен или API клуч за тековната јавна крајна точка.
- Апликацијата испраќа само
GETбарања до/v1/resolveсоplatformиprofile. - Времетраењето на поврзувањето и целото барање е ограничено.
- Се повторуваат само ограничувања на стапка, избрани минливи статуси и транспортни неуспеси.
- Нормализираниот одговор е валидиран како JSON објект без потпирање на недокументирани полиња.
- Кандидатите остануваат во чекање додека овластено лице не ги прифати или одбие.
- Двете форми што ја менуваат состојбата користат CSRF заштита.
- Дневниците исклучуваат профилни референци, payload-и од одговорот, идентификатори на сесии и тајни податоци.
- Тестовите со mock транспорт ги покриваат патеките на успех, повторен обид, невалиден одговор и траен неуспех.
- Продукциската конфигурација, миграциите, HTTPS излезниот сообраќај, мониторингот и правилата за задржување се подготвени.
Сигурниот увоз на идентитет не е најумниот можен парсер. Тој е тесна, набљудлива граница околу надворешна способност, проследена со човечка одлука што вашата апликација може да ја објасни. Нормализирајте со доверба, прегледувајте намерно и дозволете прифаќањето — а не самиот API успех — да биде моментот кога јавниот профил станува дел од нечија сметка.