Туториали

Build Creator Profiles: Native PHP Integrates Social Links with AI Identity Resolver

Изградете профили на креатори: нативниот PHP интегрира социјални врски со AI-разрешувач на идентитет

Менаџерот за контакти на креатори обично започнува со поле со безопасен изглед наречено „социјална врска“. Набрзо, тоа поле содржи целосни URL-адреси, кориснички имиња, копирани патеки до профили, нумерички идентификатори и неколку начини на пишување на истата платформа. Потоа интерфејсот ја пренесува таа недоследност во резултатите од пребарување, извозите и картичките на профили.

Овој туторијал гради интеграција во Native PHP 8.3 што ги испраќа тие референци до Identity Resolver и го претвора нормализираниот одговор во еден предвидлив модел на картичка за профил. Границата останува намерно строга: resolver-от поседува нормализација на јавниот идентитет, додека нашата апликација поседува ID-ја на контакти, презентација, складирање и политика за неуспеси.

Добијте пристап пред да пишувате код за интеграција

Започнете со страницата на услугата Identity Resolver, па потоа прочитајте ја официјалната документација. Тековната јавна крајна точка не бара токен за сметка ниту API-клуч.

Следствено, регистрацијата и најавувањето не се чекори во овој тек на воведување. Користете ја официјалната документација за да го потврдите тековниот статус на регистрација и статус на најавување, наместо да нагаѓате недокументирани URL-адреси за сметки. Нема екран за акредитиви и нема што да копирате. Ако подоцна се воведе автентикација, третирајте ја документацијата како извор на вистината пред да ја промените продукциската конфигурација.

Точното барање е GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Испратете platform плус точно еден поддржан параметар username, id, identifier, profile или url. Поддржаните платформи се Facebook, Instagram и LinkedIn.

Направете го првото барање со референца за тестирање што може да се отфрли:

curl --fail-with-body --get \
  --connect-timeout 3 \
  --max-time 10 \
  --data-urlencode "platform=instagram" \
  --data-urlencode "username=YOUR_USERNAME" \
  "https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"

Во тоа барање не припаѓа заглавие Authorization. Сепак, не толкувајте „јавно“ како „неограничено“: клиентите сè уште имаат потреба од ограничени временски ограничувања, конзервативни повторни обиди и експлицитно справување со ограничувањето на стапката.

Native PHP не вчитува автоматски датотеки .env. За локален развој ќе вчитаме една преку школката; продукцијата треба да ги внесе истите променливи преку својот менаџер на процеси или систем за управување со тајни. Празниот запис за токен го бележи сегашниот договор за автентикација и никогаш не се пренесува:

# .env
IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
IDENTITY_RESOLVER_TOKEN=
APP_ENV=development

# Install the only third-party development dependency.
composer require --dev phpunit/phpunit:^11.0

# Load local variables, then start the application.
set -a
. ./.env
set +a
php -S 127.0.0.1:8080 -t public

Архитектура: задржете ги неизвесните податоци на границата

Услугата ветува нормализиран објект за јавен идентитет, но овој туторијал не претпоставува недокументирани имиња на полиња, како што се прикажано име, аватар или канонска URL-адреса. Наместо тоа, границата проверува дали одговорот е JSON-објект и го зачувува под identity. Подоцнежен слој за презентација може да мапира документирани полиња без да го спојува транспортниот код со претпоставки.

Договорот за картичката на менаџерот за контакти е стабилен без оглед на платформата:

  • contact_id е клучот на записот во сопственост на апликацијата.
  • platform и source ја опишуваат поднесената референца.
  • identity го содржи нормализираниот јавен објект на resolver-от.
  • resolved_at ја бележи свежината без да се претставува како податок за идентитет.

Мало синхроно барање е соодветно кога корисник изречно додава или освежува еден контакт. Масовните увози треба да ја повикуваат истата сервисна класа од worker, за бавен добавувач да не може да го зафати секој веб-процес.

Користете ја оваа структура:

creator-contacts/
├── composer.json
├── public/
│   └── index.php
├── src/
│   ├── HttpTransport.php
│   ├── CurlTransport.php
│   ├── IdentityResolverClient.php
│   └── ProfileCard.php
└── tests/
    └── IdentityResolverClientTest.php

Конфигурирајте го Composer autoloading со "CreatorContacts\\": "src/" и "CreatorContacts\\Tests\\": "tests/", па извршете composer dump-autoload.

Изградете ограничен cURL транспорт и клиент за resolver

Транспортот изложува одговори наместо да фрла исклучоци за HTTP статусни кодови. Тоа му овозможува на клиентот да разликува неуспех на добавувач што може повторно да се проба од трајно невалидно барање.

<?php
// src/HttpTransport.php
namespace CreatorContacts;

interface HttpTransport
{
    public function get(string $url): TransportResponse;
}

final readonly class TransportResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

// src/CurlTransport.php
namespace CreatorContacts;

use RuntimeException;

final class CurlTransport implements HttpTransport
{
    public function get(string $url): TransportResponse
    {
        $headers = [];
        $handle = curl_init($url);

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => 3000,
            CURLOPT_TIMEOUT_MS => 10000,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);

                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }

                return $length;
            },
        ]);

        $body = curl_exec($handle);

        if ($body === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new RuntimeException('Identity Resolver transport error: ' . $message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new TransportResponse($status, $headers, $body);
    }
}

Клиентот валидира локално, го URL-кодира прашалникот, повторува само за минливи исходи и емитува метаподатоци наместо лични референци или тела на одговори. Неговиот sleeper може да се вметне, што ги прави тестовите за повторни обиди моментални и детерминистички.

<?php
// src/IdentityResolverClient.php
namespace CreatorContacts;

use JsonException;
use RuntimeException;

final class ResolverException extends RuntimeException
{
    public function __construct(public readonly string $category, string $message)
    {
        parent::__construct($message);
    }
}

final class IdentityResolverClient
{
    private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
    private const REFERENCES = ['username', 'id', 'identifier', 'profile', 'url'];

    public function __construct(
        private readonly HttpTransport $transport,
        private readonly string $endpoint,
        private readonly mixed $sleeper = null,
        private readonly mixed $logger = null,
    ) {}

    public function resolve(string $platform, string $kind, string $value): array
    {
        $platform = strtolower(trim($platform));
        $value = trim($value);

        if (!in_array($platform, self::PLATFORMS, true)
            || !in_array($kind, self::REFERENCES, true)
            || $value === '') {
            throw new ResolverException('validation', 'Unsupported or empty identity reference.');
        }

        if ($kind === 'url') {
            $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
            if (!in_array($scheme, ['http', 'https'], true)) {
                throw new ResolverException('validation', 'Profile URL must use HTTP or HTTPS.');
            }
        }

        $url = $this->endpoint . '?' . http_build_query(
            ['platform' => $platform, $kind => $value],
            '',
            '&',
            PHP_QUERY_RFC3986,
        );

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get($url);
            } catch (RuntimeException $error) {
                $this->log($platform, $attempt, null, 'transport_failure');

                if ($attempt === 3) {
                    throw new ResolverException('unavailable', 'Resolver transport failed.');
                }

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

            $this->log($platform, $attempt, $response->status, 'response');

            if ($response->status === 200) {
                try {
                    $object = json_decode($response->body, false, 512, JSON_THROW_ON_ERROR);
                    $identity = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
                } catch (JsonException) {
                    throw new ResolverException('malformed_response', 'Resolver returned invalid JSON.');
                }

                if (!is_object($object) || !is_array($identity)) {
                    throw new ResolverException(
                        'malformed_response',
                        'Resolver response must be a JSON object.',
                    );
                }

                return $identity;
            }

            $retryable = $response->status === 429
                || in_array($response->status, [502, 503, 504], true);

            if ($retryable && $attempt < 3) {
                $this->pause($attempt, $response->headers['retry-after'] ?? null);
                continue;
            }

            $category = match (true) {
                $response->status === 429 => 'rate_limited',
                $response->status >= 500 => 'unavailable',
                default => 'rejected',
            };

            throw new ResolverException($category, 'Resolver request was not accepted.');
        }

        throw new ResolverException('unavailable', 'Resolver attempts exhausted.');
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $microseconds = min(2_000_000, 200_000 * (2 ** ($attempt - 1)));

        if ($retryAfter !== null && ctype_digit($retryAfter)) {
            $microseconds = min(2_000_000, (int) $retryAfter * 1_000_000);
        }

        ($this->sleeper ?? usleep(...))($microseconds);
    }

    private function log(string $platform, int $attempt, ?int $status, string $event): void
    {
        ($this->logger ?? error_log(...))(json_encode([
            'event' => 'identity_resolver.' . $event,
            'platform' => $platform,
            'attempt' => $attempt,
            'status' => $status,
        ], JSON_THROW_ON_ERROR));
    }
}

Ограничувањето од две секунди за Retry-After е намерно за интерактивно барање. Подолгите квотни периоди треба брзо да не успеат како rate_limited; апликацијата може да закаже подоцнежно освежување наместо да држи PHP worker отворен. Неуспесите на валидација, другите 4xx одговори и неправилно форматираните успешни одговори никогаш не се повторуваат.

Мапирајте го идентитетот во картичка за профил

Доменскиот објект ги раздвојува податоците на resolver-от од метаподатоците на апликацијата. Складиштето може да го серијализира овој модел за приказ како JSON или да го подели во колони во база на податоци според потребите на менаџерот за контакти.

<?php
// src/ProfileCard.php
namespace CreatorContacts;

use DateTimeImmutable;

final readonly class ProfileCard
{
    public function __construct(
        public string $contactId,
        public string $platform,
        public array $source,
        public array $identity,
        public string $resolvedAt,
    ) {}

    public static function create(
        string $contactId,
        string $platform,
        string $kind,
        string $value,
        array $identity,
    ): self {
        return new self(
            $contactId,
            $platform,
            [$kind => $value],
            $identity,
            (new DateTimeImmutable())->format(DATE_ATOM),
        );
    }

    public function toArray(): array
    {
        return [
            'contact_id' => $this->contactId,
            'platform' => $this->platform,
            'source' => $this->source,
            'identity' => $this->identity,
            'resolved_at' => $this->resolvedAt,
        ];
    }
}

Предниот контролер прифаќа ID на контакт, платформа и точно една поддржана референца. Тој ги претвора очекуваните неуспеси во структурирани состојби без да изложува тела од надворешниот систем или траги од исклучоци.

<?php
// public/index.php
declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use CreatorContacts\CurlTransport;
use CreatorContacts\IdentityResolverClient;
use CreatorContacts\ProfileCard;
use CreatorContacts\ResolverException;

header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST'
    || parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/profile-cards/resolve') {
    http_response_code(404);
    echo json_encode(['error' => ['code' => 'not_found']]);
    exit;
}

try {
    $input = json_decode(file_get_contents('php://input'), true, 512, JSON_THROW_ON_ERROR);
    $contactId = $input['contact_id'] ?? '';
    $platform = $input['platform'] ?? '';
    $references = array_intersect_key(
        $input,
        array_fill_keys(['username', 'id', 'identifier', 'profile', 'url'], true),
    );

    if (!is_string($contactId)
        || preg_match('/^[A-Za-z0-9_-]{1,64}$/', $contactId) !== 1
        || count($references) !== 1) {
        throw new ResolverException('validation', 'Invalid contact or reference.');
    }

    $kind = (string) array_key_first($references);
    $value = $references[$kind];

    if (!is_string($platform) || !is_string($value)) {
        throw new ResolverException('validation', 'Platform and reference must be strings.');
    }

    $endpoint = getenv('IDENTITY_RESOLVER_URL');
    if ($endpoint === false || $endpoint === '') {
        throw new RuntimeException('IDENTITY_RESOLVER_URL is not configured.');
    }

    $client = new IdentityResolverClient(new CurlTransport(), $endpoint);
    $identity = $client->resolve($platform, $kind, $value);
    $card = ProfileCard::create($contactId, strtolower($platform), $kind, $value, $identity);

    echo json_encode(['data' => $card->toArray()], JSON_THROW_ON_ERROR);
} catch (ResolverException $error) {
    $status = match ($error->category) {
        'validation' => 422,
        'rate_limited' => 429,
        'rejected' => 400,
        default => 503,
    };

    http_response_code($status);
    echo json_encode(['error' => [
        'code' => $error->category,
        'message' => $error->getMessage(),
    ]], JSON_THROW_ON_ERROR);
} catch (Throwable) {
    http_response_code(500);
    echo json_encode(['error' => ['code' => 'internal_error']]);
}

Тестирајте повторни обиди и валидација на границата

Лажен транспорт е попожелен од тестови со жива мрежа: тој ги докажува градењето на прашалникот и политиката за повторни обиди без да троши капацитет на добавувачот или да зависи од променливи јавни профили.

<?php
// tests/IdentityResolverClientTest.php
namespace CreatorContacts\Tests;

use CreatorContacts\HttpTransport;
use CreatorContacts\IdentityResolverClient;
use CreatorContacts\ResolverException;
use CreatorContacts\TransportResponse;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    public array $urls = [];

    public function __construct(private array $responses) {}

    public function get(string $url): TransportResponse
    {
        $this->urls[] = $url;
        return array_shift($this->responses);
    }
}

final class IdentityResolverClientTest extends TestCase
{
    public function testItBuildsTheRequestAndPreservesTheIdentityObject(): void
    {
        $transport = new FakeTransport([
            new TransportResponse(200, [], '{"public":{"label":"Creator"}}'),
        ]);

        $client = new IdentityResolverClient(
            $transport,
            'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
            static fn (int $microseconds) => null,
            static fn (string $message) => null,
        );

        $identity = $client->resolve('instagram', 'username', 'sample_creator');

        self::assertSame(['public' => ['label' => 'Creator']], $identity);
        self::assertStringContainsString('platform=instagram', $transport->urls[0]);
        self::assertStringContainsString('username=sample_creator', $transport->urls[0]);
    }

    public function testItRetriesRateLimitingThenSucceeds(): void
    {
        $transport = new FakeTransport([
            new TransportResponse(429, ['retry-after' => '1'], '{}'),
            new TransportResponse(200, [], '{"normalized":true}'),
        ]);

        $client = new IdentityResolverClient(
            $transport,
            'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
            static fn (int $microseconds) => null,
            static fn (string $message) => null,
        );

        self::assertSame(['normalized' => true], $client->resolve(
            'linkedin',
            'url',
            'https://www.linkedin.com/in/sample',
        ));
        self::assertCount(2, $transport->urls);
    }

    public function testItRejectsUnsupportedInputBeforeTransport(): void
    {
        $transport = new FakeTransport([]);
        $client = new IdentityResolverClient($transport, 'https://example.invalid');

        $this->expectException(ResolverException::class);
        $client->resolve('unknown', 'username', 'sample');
    }
}

Извршете vendor/bin/phpunit tests. Објектите на одговори во овие фикстури се намерно синтетички податоци на границата, а не тврдења за недокументирани продукциски полиња.

Безбедност, набљудливост и распоредување

Поставете го контролерот зад постоечката политика за автентикација и CSRF на менаџерот за контакти; примерот се фокусира на границата со resolver-от, наместо да измислува систем за најава во апликацијата. Дозволете ги само петте клучеви за референца, ограничете ја големината на телото на барањето на веб-серверот и escape-ирајте ја секоја вредност кога прелистувачот ја прикажува вратената картичка. Јавниот социјален профил сè уште може да содржи злонамерен текст.

Не евидентирајте поднесени кориснички имиња, URL-адреси, нормализирани товари или тела од добавувачот. Структурираните настани веќе ги изложуваат корисните оперативни димензии: настан, платформа, обид и статус. Поставете предупредување за одржливи стапки на identity_resolver.unavailable и набљудувајте го rate_limited одделно, бидејќи тие состојби бараат различни одговори.

Распоредете со овозможени PHP-екстензии cURL и JSON. Извршете composer install --no-dev --classmap-authoritative, внесете IDENTITY_RESOLVER_URL во околината на PHP-FPM или контејнерот и рестартирајте ги worker-ите за да ја наследат. Оставете ја овозможена верификацијата на излезниот HTTPS; оваа имплементација никогаш не ги оневозможува проверките на сертификати. Health checks треба да ја проверуваат самата апликација, а не постојано да ја повикуваат надворешната услуга.

Вообичаени неуспеси

  • HTTP 422 локално: платформата, ID-то на контактот, URL-шемата или бројот на референци не ја поминаа валидацијата.
  • Одбиено барање: потврдете дека избраниот параметар е поддржан и консултирајте ја официјалната документација пред да го промените договорот.
  • HTTP 429: зачувајте ја постоечката картичка, означете го освежувањето како одложено и обидете се подоцна наместо да создадете бура од повторни обиди.
  • HTTP 503: добавувачот го пречекори времето, врати минлив серверски статус или произведе неупотреблив товар по ограничените обиди.
  • Празна вредност на околината: извезете IDENTITY_RESOLVER_URL во вистинската околина на PHP worker-от, не само во интерактивна школка.

Контролна листа за конечна верификација

  1. Потврдете дека крајната точка останува јавна и без токен во официјалната документација.
  2. Извршете го минималното cURL барање без заглавие за авторизација.
  3. Извршете PHPUnit и потврдете дека лажниот транспорт прави точно два повика во тестот за ограничување на стапката.
  4. Поднесувајте една референца одеднаш до POST /profile-cards/resolve.
  5. Потврдете дека резултатите од Facebook, Instagram и LinkedIn имаат иста форма на картичка на ниво на апликацијата.
  6. Потврдете дека дневниците содржат метаподатоци за статусот, но не и товар од јавен идентитет или поднесена референца.
  7. Поминете низ патеките за 429, 5xx, неправилно форматиран JSON, timeout и валидација пред распоредувањето.

Најтрајниот дел од оваа интеграција не е cURL повикот. Тоа е границата: променлива јавна референца влегува од едната страна, валидиран објект за идентитет излегува од другата, а секој неуспех станува експлицитна состојба. Таа дисциплина ги одржува картичките на креаторите доследни денес и му дава простор на менаџерот за контакти да еволуира без чистењето на социјалните врски да го претвори во трајна сложеност на апликацијата.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.