Туториали

Native PHP 8.3: Streamline Onboarding with Public Social Profile Imports and Manual Review

Native PHP 8.3: Поедноставете го воведувањето со увоз на јавни профили од социјални мрежи и рачна проверка

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

Ова упатство го гради тој работен тек во нативен PHP 8.3. Мал крајна точка за воведување го повикува Identity Resolver, го складира вратениот јавен идентитет како увоз што чека обработка и ја остава конечната одлука за одобрување или одбивање на ограничена команда. Имплементацијата користи нативен cURL, SQLite, PHPUnit, ограничени повторни обиди, дефанзивно мапирање на одговори и структурирани состојби на неуспех.

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

Започнете со официјалната страница на услугата Identity Resolver, а потоа прочитајте ја официјалната документација. Услугата ги нормализира јавните референци од Facebook, Instagram и LinkedIn во стабилен објект на идентитет.

Тековната јавна крајна точка не бара токен за сметка ниту API клуч. Следствено, официјалните барања за регистрација и барања за најава за оваа крајна точка се и двете „не се потребни“. Нема чекор за избор на план, екран за ингеренции или поле за копирање токен пред првото барање. Не измислувајте заглавие за авторизација ниту не ставајте неповрзана ингеренција за платформа во апликацијата.

Точниот договор е GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Испратете platform заедно со еден поддржан параметар за референца: username, id, identifier, profile или url.

Направете минимално тест-барање, заменувајќи го примерното корисничко име со јавна референца што ви е дозволено да ја обработувате:

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

Нативна PHP апликација нема универзална конвенција за датотека со околински променливи, па овој проект користи .env.local за локален развој и вистински променливи на процесната околина при распоредување. Јасно забележете ја состојбата на автентикацијата. Вредноста на токенот е празна бидејќи во моментов нема од каде да се копира:

IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
IDENTITY_RESOLVER_TOKEN=
DATABASE_PATH=var/app.sqlite

Не испраќајте IDENTITY_RESOLVER_TOKEN. Задржувањето на празната поставка го документира тековниот договор, а истовремено обезбедува очигледна конфигурациска точка ако автентикацијата подоцна се воведе и документира.

Архитектура: увезено не значи одобрено

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

Потоа прегледувачот го проверува складираниот објект и го одобрува или одбива преку команда достапна само за овластени оператори. Одобрувањето е заштитена транзиција на состојба: само pending_review може да стане approved или rejected.

  • API граница: го гради фиксното HTTPS барање и ги класифицира неуспесите во надворешната услуга.
  • Доменска граница: прифаќа JSON објект без да претпоставува недокументирани полиња на одговорот.
  • Репозиториум: го складира нормализираниот товар и еднонасочен хеш на поднесената референца.
  • Команда за преглед: ја одвојува човечката проценка од автоматското разрешување.

SQLite одговара на мала апликација со еден домаќин. За повеќе инстанци на апликацијата, заменете го со споделена трансакциска база на податоци, при што ќе го зачувате условното ажурирање на прегледот.

Создадете го PHP 8.3 проектот

Ви требаат PHP 8.3 или понов, Composer, екстензијата cURL, PDO SQLite и JSON поддршка. Создадете ја оваа структура:

profile-onboarding/
├── bin/review-import.php
├── public/index.php
├── src/IdentityResolver.php
├── src/ImportRepository.php
├── tests/IdentityResolverTest.php
├── var/
├── .env.local
└── composer.json

Користете PHPUnit 11 за детерминистички тестови на границите:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer install
set -a
. ./.env.local
set +a
mkdir -p var
php -S 127.0.0.1:8080 -t public

Не ја ставајте во комит .env.local ниту SQLite базата на податоци. Во продукција, вбризгајте ги истите променливи преку управувачот со процеси или платформата за распоредување, наместо да вчитувате датотека.

Изградете дефанзивен клиент за разрешувачот

Транспортот е интерфејс, па тестовите никогаш не контактираат со мрежата. cURL е ограничен на HTTPS, пренасочувањата се оневозможени, проверката на сертификатот останува овозможена, а и времето за поврзување и вкупното време се ограничени.

<?php
// src/IdentityResolver.php
namespace App;

use Closure;
use JsonException;
use RuntimeException;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public string $body,
        public ?int $retryAfterSeconds = null,
    ) {}
}

interface Transport
{
    public function get(string $url, int $connectTimeout, int $timeout): HttpResponse;
}

final class CurlTransport implements Transport
{
    public function get(string $url, int $connectTimeout, int $timeout): HttpResponse
    {
        $handle = curl_init($url);
        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER => true,
            CURLOPT_CONNECTTIMEOUT => $connectTimeout,
            CURLOPT_TIMEOUT => $timeout,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
        ]);

        $raw = curl_exec($handle);
        if ($raw === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new RuntimeException('Transport failure: ' . $message);
        }

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

        $headers = substr($raw, 0, $headerSize);
        $body = substr($raw, $headerSize);
        $retryAfter = preg_match('/^Retry-After:\s*(\d+)/mi', $headers, $match)
            ? (int) $match[1]
            : null;

        return new HttpResponse($status, $body, $retryAfter);
    }
}

final class ResolverFailure extends RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly bool $retryable,
        public readonly ?int $upstreamStatus = null,
    ) {
        parent::__construct($reason);
    }
}

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

    public static function fromJson(string $json): self
    {
        try {
            $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            throw new ResolverFailure('malformed_response', false);
        }

        if (!is_array($data) || array_is_list($data)) {
            throw new ResolverFailure('malformed_response', false);
        }

        return new self($data);
    }
}

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

    public function __construct(
        private readonly Transport $transport,
        private readonly string $endpoint,
        private readonly Closure $sleep,
        private readonly Closure $log,
    ) {}

    public function resolve(
        string $platform,
        string $parameter,
        string $reference,
    ): NormalizedIdentity {
        $platform = strtolower(trim($platform));
        $reference = trim($reference);

        if (!in_array($platform, self::PLATFORMS, true)
            || !in_array($parameter, self::PARAMETERS, true)
            || $reference === ''
            || strlen($reference) > 500) {
            throw new ResolverFailure('invalid_input', false);
        }

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

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get($url, 3, 10);
            } catch (RuntimeException) {
                if ($attempt === 3) {
                    throw new ResolverFailure('upstream_unavailable', true);
                }
                $this->pause($attempt, null, 'transport');
                continue;
            }

            if ($response->status === 200) {
                return NormalizedIdentity::fromJson($response->body);
            }

            if ($response->status === 429 || $response->status >= 500) {
                if ($attempt === 3) {
                    $reason = $response->status === 429
                        ? 'rate_limited'
                        : 'upstream_unavailable';
                    throw new ResolverFailure($reason, true, $response->status);
                }
                $this->pause(
                    $attempt,
                    $response->retryAfterSeconds,
                    (string) $response->status,
                );
                continue;
            }

            if (in_array($response->status, [400, 404, 422], true)) {
                throw new ResolverFailure('reference_rejected', false, $response->status);
            }

            if (in_array($response->status, [401, 403], true)) {
                throw new ResolverFailure('authentication_configuration', false, $response->status);
            }

            throw new ResolverFailure('unexpected_upstream_response', false, $response->status);
        }

        throw new ResolverFailure('upstream_unavailable', true);
    }

    private function pause(int $attempt, ?int $retryAfter, string $cause): void
    {
        $milliseconds = $retryAfter === null
            ? 150 * (2 ** ($attempt - 1))
            : min(max($retryAfter, 0), 2) * 1000;

        ($this->log)('identity_resolver_retry', [
            'attempt' => $attempt,
            'cause' => $cause,
            'delay_ms' => $milliseconds,
        ]);
        ($this->sleep)($milliseconds);
    }
}

Само транспортните грешки, 429 и грешките на серверот се повторуваат. Валидациските, автентикациските и другите клиентски грешки веднаш не успеваат. Максималното доцнење за повторен обид е ограничено, спречувајќи недоверлива вредност Retry-After да блокира PHP worker на неопределено време.

Зачувајте го увозот што чека обработка

Репозиториумот го складира целиот нормализиран објект бидејќи неговите имиња на полиња тука не се претпоставуваат. Хешот ја задржува вредноста за корелација без да ги дуплира поднесеното корисничко име или URL.

<?php
// src/ImportRepository.php
namespace App;

use PDO;
use RuntimeException;

final class ImportRepository
{
    public function __construct(private readonly PDO $pdo)
    {
        $this->pdo->exec(
            'CREATE TABLE IF NOT EXISTS profile_imports (
                id TEXT PRIMARY KEY,
                platform TEXT NOT NULL,
                reference_type TEXT NOT NULL,
                reference_hash TEXT NOT NULL,
                normalized_json TEXT NOT NULL,
                status TEXT NOT NULL CHECK (
                    status IN ("pending_review", "approved", "rejected")
                ),
                reviewer TEXT,
                created_at TEXT NOT NULL,
                reviewed_at TEXT
            )'
        );
    }

    public function create(
        string $platform,
        string $type,
        string $reference,
        NormalizedIdentity $identity,
    ): string {
        $id = bin2hex(random_bytes(16));
        $statement = $this->pdo->prepare(
            'INSERT INTO profile_imports
             (id, platform, reference_type, reference_hash,
              normalized_json, status, created_at)
             VALUES (?, ?, ?, ?, ?, "pending_review", ?)'
        );
        $statement->execute([
            $id,
            $platform,
            $type,
            hash('sha256', $reference),
            json_encode($identity->data, JSON_THROW_ON_ERROR),
            gmdate('c'),
        ]);

        return $id;
    }

    public function find(string $id): ?array
    {
        $statement = $this->pdo->prepare(
            'SELECT * FROM profile_imports WHERE id = ?'
        );
        $statement->execute([$id]);
        return $statement->fetch(PDO::FETCH_ASSOC) ?: null;
    }

    public function review(string $id, string $decision, string $reviewer): void
    {
        if (!in_array($decision, ['approved', 'rejected'], true)
            || trim($reviewer) === '') {
            throw new RuntimeException('Invalid review decision');
        }

        $statement = $this->pdo->prepare(
            'UPDATE profile_imports
             SET status = ?, reviewer = ?, reviewed_at = ?
             WHERE id = ? AND status = "pending_review"'
        );
        $statement->execute([$decision, $reviewer, gmdate('c'), $id]);

        if ($statement->rowCount() !== 1) {
            throw new RuntimeException('Import missing or already reviewed');
        }
    }
}

Изложете ја крајната точка за воведување

Контролерот прифаќа JSON и враќа јавен идентификатор на работниот тек, а не одговорот од надворешната услуга. Постоечка апликација треба да го поврзе тој идентификатор со својата автентицирана сесија за воведување и да спроведе CSRF заштита кога се вклучени колачиња на прелистувачот.

<?php
// public/index.php
use App\CurlTransport;
use App\IdentityResolver;
use App\ImportRepository;
use App\ResolverFailure;

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

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

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

try {
    $input = json_decode(
        file_get_contents('php://input'),
        true,
        32,
        JSON_THROW_ON_ERROR,
    );

    if (!is_array($input)) {
        throw new JsonException();
    }

    $logger = static function (string $event, array $context): void {
        error_log(json_encode(
            ['event' => $event, 'context' => $context],
            JSON_THROW_ON_ERROR,
        ));
    };

    $resolver = new IdentityResolver(
        new CurlTransport(),
        getenv('IDENTITY_RESOLVER_ENDPOINT')
            ?: 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
        static fn (int $milliseconds) => usleep($milliseconds * 1000),
        $logger,
    );

    $identity = $resolver->resolve(
        (string) ($input['platform'] ?? ''),
        (string) ($input['reference_type'] ?? ''),
        (string) ($input['reference'] ?? ''),
    );

    $database = getenv('DATABASE_PATH') ?: dirname(__DIR__) . '/var/app.sqlite';
    $pdo = new PDO('sqlite:' . $database, null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    ]);
    $repository = new ImportRepository($pdo);
    $id = $repository->create(
        strtolower(trim((string) $input['platform'])),
        (string) $input['reference_type'],
        (string) $input['reference'],
        $identity,
    );

    http_response_code(202);
    echo json_encode(['import_id' => $id, 'status' => 'pending_review']);
} catch (ResolverFailure $failure) {
    $status = $failure->reason === 'invalid_input'
        || $failure->reason === 'reference_rejected' ? 422 : 502;
    http_response_code($status);
    echo json_encode([
        'error' => $failure->reason,
        'retryable' => $failure->retryable,
    ]);
} catch (JsonException) {
    http_response_code(400);
    echo json_encode(['error' => 'invalid_json']);
} catch (Throwable $error) {
    error_log(json_encode(['event' => 'profile_import_failed']));
    http_response_code(500);
    echo json_encode(['error' => 'internal_error']);
}

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

Направете го рачниот преглед експлицитен

Командата за преглед најпрво поддржува инспекција, а потоа посебна одлука. Пристапот до оперативниот систем или контејнерот станува граница на авторизација, затоа не ја изложувајте оваа скрипта преку веб-серверот.

<?php
// bin/review-import.php
use App\ImportRepository;

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

[$script, $action, $id, $reviewer] = array_pad($argv, 4, null);

$database = getenv('DATABASE_PATH') ?: dirname(__DIR__) . '/var/app.sqlite';
$pdo = new PDO('sqlite:' . $database, null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$repository = new ImportRepository($pdo);

if ($action === 'show' && $id !== null) {
    $record = $repository->find($id);
    if ($record === null) {
        fwrite(STDERR, "Import not found\n");
        exit(1);
    }

    echo json_encode([
        'id' => $record['id'],
        'platform' => $record['platform'],
        'status' => $record['status'],
        'identity' => json_decode(
            $record['normalized_json'],
            true,
            512,
            JSON_THROW_ON_ERROR,
        ),
    ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
    exit;
}

if (in_array($action, ['approved', 'rejected'], true)
    && $id !== null
    && $reviewer !== null) {
    $repository->review($id, $action, $reviewer);
    echo "Review recorded\n";
    exit;
}

fwrite(
    STDERR,
    "Usage: php bin/review-import.php show ID\n"
    . "   or: php bin/review-import.php approved|rejected ID REVIEWER\n"
);
exit(2);

Прво извршете инспекција, споредете го нормализираниот јавен идентитет со информациите доставени при воведувањето и запишете одлука:

php bin/review-import.php show IMPORT_ID
php bin/review-import.php approved IMPORT_ID [email protected]

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

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

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

<?php
// tests/IdentityResolverTest.php
use App\HttpResponse;
use App\IdentityResolver;
use App\ResolverFailure;
use App\Transport;
use PHPUnit\Framework\TestCase;

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

    public function __construct(private array $responses) {}

    public function get(string $url, int $connectTimeout, int $timeout): HttpResponse
    {
        $this->urls[] = $url;
        $next = array_shift($this->responses);
        if ($next instanceof Throwable) {
            throw $next;
        }
        return $next;
    }
}

final class IdentityResolverTest extends TestCase
{
    private function resolver(FakeTransport $transport): IdentityResolver
    {
        return new IdentityResolver(
            $transport,
            'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
            static fn (int $milliseconds) => null,
            static fn (string $event, array $context) => null,
        );
    }

    public function testMapsAnObjectWithoutAssumingItsFields(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(200, '{"fixture_value":true}'),
        ]);

        $identity = $this->resolver($fake)
            ->resolve('instagram', 'username', 'example');

        self::assertTrue($identity->data['fixture_value']);
        self::assertStringContainsString(
            'username=example',
            $fake->urls[0],
        );
    }

    public function testRetriesRateLimitThenSucceeds(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(429, '', 1),
            new HttpResponse(200, '{"fixture_value":true}'),
        ]);

        $this->resolver($fake)
            ->resolve('linkedin', 'url', 'https://example.test/profile');

        self::assertCount(2, $fake->urls);
    }

    public function testDoesNotRetryValidationFailure(): void
    {
        $fake = new FakeTransport([new HttpResponse(400, '{}')]);

        try {
            $this->resolver($fake)
                ->resolve('facebook', 'id', '123');
            self::fail('Expected ResolverFailure');
        } catch (ResolverFailure $failure) {
            self::assertSame('reference_rejected', $failure->reason);
            self::assertFalse($failure->retryable);
            self::assertCount(1, $fake->urls);
        }
    }

    public function testRejectsNonObjectJson(): void
    {
        $fake = new FakeTransport([new HttpResponse(200, '[]')]);

        $this->expectException(ResolverFailure::class);
        $this->resolver($fake)
            ->resolve('instagram', 'identifier', 'example');
    }
}
vendor/bin/phpunit tests

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

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

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

Распоредете зад вистински веб-сервер со TLS, ограничувања на големината на барањата, автентицирани сесии за воведување, CSRF заштита и ограничување на ниво на апликација. Осигурете се PHP worker да може да пишува само во наменетиот SQLite директориум. Извршувајте создавање на базата на податоци или миграции при распоредувањето, а не истовремено во многу worker-и. Крајната точка за здравствена проверка треба да ги тестира апликацијата и базата на податоци без да го повикува надворешниот разрешувач при секоја проверка.

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

  • Неочекуван 401 или 403: не продолжувајте да се обидувате повторно. На јавната крајна точка во моментов не ѝ треба токен, затоа проверете го документираниот договор, крајната точка, однесувањето на проксито и секое случајно вбризгано заглавие за авторизација.
  • Повторени одговори 429: зачувајте ја состојбата на воведување што чека обработка, почитувајте го ограниченото повлекување и побарајте од корисникот да се обиде подоцна наместо да создавате неограничена јамка на барања.
  • Валиден JSON со неочекувана структура: класифицирајте го како malformed_response. Никогаш не погодувајте имиња на полиња ниту делумно не одобрувајте го увозот.
  • Дупликат обиди за преглед: условното ажурирање ја одбива втората одлука, спречувајќи одобрување тивко да презапише одбивање.
  • Заклучување на SQLite под оптоварување: скратете ги трансакциите или преместете го репозиториумот во споделена база на податоци пред да додадете повеќе веб-инстанци.

Конечна контролна листа за проверка

  • Барањето користи точно GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve.
  • Не се испраќа токен ниту API клуч за тековната јавна крајна точка.
  • platform и точно еден поддржан параметар за референца се URL-кодирани.
  • Времињата за поврзување и вкупното време се ограничени.
  • Се повторуваат само транспортни неуспеси, ограничувања на стапка и серверски неуспеси.
  • Непознатите полиња на одговорот се зачувуваат, додека неправилните податоци на највисоко ниво се одбиваат.
  • Увезениот идентитет останува pending_review додека не постапи овластено лице.
  • Суровите референци и нормализираните одговори не се присутни во оперативните дневници.
  • PHPUnit пакетот со лажен транспорт поминува без мрежен пристап.
  • Одобрувањето е идемпотентно на границата на базата на податоци и не ги презапишува автоматски податоците на корисникот.

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

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

Mihajlo

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