Laravel: Обединете ги социјалните врски со AI решавач на идентитети за директориуми на заедницата
Директориумот на заедницата ретко добива чисти податоци од социјалните мрежи. Еден член става целосна Facebook URL-адреса, друг испраќа Instagram корисничко име претставено како врска, а трет копира URL-адреса на LinkedIn профил со параметри за следење. Ако тие вредности одат директно во вашата база на податоци, откривањето дупликати, прикажувањето профили и идните миграции стануваат потешки.
Овој туторијал создава продукциска Laravel функционалност што прифаќа врски до Facebook, Instagram и LinkedIn профили, ја испраќа секоја референца до Identity Resolver и го складира добиениот јавен идентитет во стабилна структура на ниво на апликација. Разрешувањето се извршува во редица, неуспесите остануваат видливи наместо да исчезнат, а тестовите никогаш не контактираат со вистинската услуга.
Добијте пристап и направете го првото барање
Започнете со официјалната документација за Identity Resolver. Крајната точка моментално е јавна: не бара сметка, bearer токен или API клуч.
- Прегледајте ја страницата за услугата и плановите за да потврдите дека Identity Resolver ги поддржува платформите што ги прифаќа вашиот директориум.
- Проверете ги условите за регистрација. Регистрацијата не е дел од тековниот процес за јавната крајна точка.
- Проверете ја истата официјална документација за барањата за најава. Не треба да се најавите пред тестирањето.
- Не барајте екран за копирање токен: моментално нема токен или API клуч за копирање. Ако подоцна се воведе автентикација, следете ја документацијата наместо да претпоставувате authorization заглавие.
Точната интеграција е HTTP GET барање до https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Испратете platform заедно со поддржан параметар username, id, identifier, profile или url. Овој проект користи url бидејќи тоа го испраќаат членовите на директориумот.
curl --get \
--header "Accept: application/json" \
--data-urlencode "platform=instagram" \
--data-urlencode "url=https://www.instagram.com/example/" \
"https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"
Извршете го тоа барање пред да пишувате апликациски код. Успешниот одговор треба да биде JSON што го претставува нормализираниот јавен идентитет. Документираниот договор не оправдува поврзување на апликацијата со шпекулативни имиња на полиња, па границата подолу го валидира одговорот како JSON објект и ја зачувува неговата содржина.
Нема акредитив што треба да се стави во .env. Наместо тоа, складирајте ги таму крајната точка и оперативните поставки; додавањето измислен токен би создало погрешна безбедносна зависност.
IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
IDENTITY_RESOLVER_CONNECT_TIMEOUT=3
IDENTITY_RESOLVER_TIMEOUT=8
IDENTITY_RESOLVER_ATTEMPTS=3
IDENTITY_RESOLVER_RETRY_BASE_MS=500
Предуслови и структура на проектот
Ви треба PHP 8.3 или понов, Composer, поддржана Laravel апликација, конфигурирана база на податоци и backend за редица. Редица во база на податоци е доволна за мал директориум; Redis е корисен кога тоа го оправдуваат пропусноста или изолацијата на редиците.
composer create-project laravel/laravel community-directory
cd community-directory
php artisan make:model DirectoryEntry -m
php artisan make:request StoreDirectoryEntryRequest
php artisan make:controller DirectoryEntryController
php artisan make:job ResolveDirectoryIdentities
php artisan make:test IdentityResolverTest
php artisan queue:table
php artisan migrate
Релевантната структура на апликацијата намерно е мала:
app/Services/IdentityResolver.phpе сопственик на далечинската HTTP граница.app/Data/ResolvedIdentity.phpја дефинира стабилната доменска претстава на директориумот.app/Jobs/ResolveDirectoryIdentities.phpго извршува потенцијално бавното разрешување.app/Http/Requests/StoreDirectoryEntryRequest.phpго ограничува корисничкиот влез.app/Http/Controllers/DirectoryEntryController.phpсоздава записи и испраќа работа.
Асинхроното разрешување држи три надворешни повици надвор од буџетот за латентност на поднесувањето на формуларот. Компромисот е евентуална конзистентност: новосоздадениот запис започнува како pending. Интерфејсот треба да ја прикажува таа состојба наместо да се преправа дека нормализацијата е моментална.
Конфигурирајте Laravel без да измислувате автентикација
Додајте ја услугата во config/services.php. Laravel конфигурацијата станува единственото место што чита променливи од околината.
'identity_resolver' => [
'url' => env(
'IDENTITY_RESOLVER_URL',
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
),
'connect_timeout' => (int) env('IDENTITY_RESOLVER_CONNECT_TIMEOUT', 3),
'timeout' => (int) env('IDENTITY_RESOLVER_TIMEOUT', 8),
'attempts' => (int) env('IDENTITY_RESOLVER_ATTEMPTS', 3),
'retry_base_ms' => (int) env('IDENTITY_RESOLVER_RETRY_BASE_MS', 500),
],
Не додавајте празно authorization заглавие. „Не е потребен токен“ е договор за автентикација, а не покана за испраќање замени.
Зачувајте ги влезовите, резултатите и структурираните неуспеси
Директориумот треба да ги задржи испратените врски за ревизија и повторна обработка, додека нормализираните идентитети и неуспесите се наоѓаат во одделни JSON колони. Создадете ја миграцијата вака:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('directory_entries', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->json('social_links');
$table->json('resolved_identities')->nullable();
$table->json('resolution_failures')->nullable();
$table->string('resolution_status')->default('pending');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('directory_entries');
}
};
Во DirectoryEntry, направете ги овие атрибути достапни за масовно доделување и претворете ги трите JSON колони во array. Зачувувањето на одговорот од давателот недопрен избегнува тивко отфрлање на идни документирани полиња.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class DirectoryEntry extends Model
{
protected $fillable = [
'name',
'social_links',
'resolved_identities',
'resolution_failures',
'resolution_status',
];
protected function casts(): array
{
return [
'social_links' => 'array',
'resolved_identities' => 'array',
'resolution_failures' => 'array',
];
}
}
Валидирајте платформи и хостови пред разрешување
Листа на дозволени вредности спречува печатни грешки, неочекувани платформи, URL-адреси што не се HTTPS и измамнички имиња на хостови. Таа исто така го штити интегритетот на директориумот, иако самиот Laravel не ја презема испратената URL-адреса.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
final class StoreDirectoryEntryRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Replace with the directory's real authorization policy.
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
'profiles' => ['required', 'array', 'min:1', 'max:3'],
'profiles.*.platform' => [
'required',
'distinct',
Rule::in(['facebook', 'instagram', 'linkedin']),
],
'profiles.*.url' => ['required', 'url:https', 'max:2048'],
];
}
public function after(): array
{
return [
function (Validator $validator): void {
$domains = [
'facebook' => 'facebook.com',
'instagram' => 'instagram.com',
'linkedin' => 'linkedin.com',
];
foreach ($this->input('profiles', []) as $index => $profile) {
$platform = $profile['platform'] ?? '';
$host = Str::lower(parse_url($profile['url'] ?? '', PHP_URL_HOST) ?: '');
$domain = $domains[$platform] ?? null;
if ($domain === null ||
($host !== $domain && ! Str::endsWith($host, '.'.$domain))) {
$validator->errors()->add(
"profiles.$index.url",
'The URL host does not match the selected platform.'
);
}
}
},
];
}
}
Изградете одбранбена API граница
Доменскиот објект додава полиња во сопственост на нашата апликација, додека payload-от на услугата го третира како непрозирни податоци за јавен идентитет.
<?php
namespace App\Data;
use Carbon\CarbonImmutable;
final readonly class ResolvedIdentity
{
public function __construct(
public string $platform,
public array $publicIdentity,
public CarbonImmutable $resolvedAt,
) {}
public function toArray(): array
{
return [
'platform' => $this->platform,
'public_identity' => $this->publicIdentity,
'resolved_at' => $this->resolvedAt->toIso8601String(),
];
}
}
Услугата повторува само неуспеси на поврзување, HTTP 429 одговори и грешки на серверот. Неуспесите на валидација и автентикација се конечни бидејќи повторувањето на истото барање нема да ги поправи. Телата на одговорите и испратените URL-адреси намерно се исклучени од логовите.
<?php
namespace App\Services;
use App\Data\ResolvedIdentity;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use JsonException;
use RuntimeException;
final class IdentityResolutionException extends RuntimeException
{
public function __construct(
string $message,
public readonly ?int $statusCode = null,
public readonly bool $retryable = false,
) {
parent::__construct($message);
}
}
final class IdentityResolver
{
public function resolve(string $platform, string $url): ResolvedIdentity
{
if (! in_array($platform, ['facebook', 'instagram', 'linkedin'], true)) {
throw new IdentityResolutionException('Unsupported platform.');
}
$attempts = max(1, (int) config('services.identity_resolver.attempts'));
for ($attempt = 1; $attempt <= $attempts; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(config('services.identity_resolver.connect_timeout'))
->timeout(config('services.identity_resolver.timeout'))
->get(config('services.identity_resolver.url'), [
'platform' => $platform,
'url' => $url,
]);
} catch (ConnectionException $exception) {
$failure = new IdentityResolutionException(
'Identity Resolver connection failed.',
null,
true
);
if ($attempt === $attempts) {
throw $failure;
}
$this->pause($attempt, null);
continue;
}
if ($response->successful()) {
try {
$payload = json_decode(
$response->body(),
true,
512,
JSON_THROW_ON_ERROR
);
} catch (JsonException $exception) {
throw new IdentityResolutionException(
'Identity Resolver returned invalid JSON.'
);
}
if (! is_array($payload) || array_is_list($payload)) {
throw new IdentityResolutionException(
'Identity Resolver returned an unexpected JSON shape.'
);
}
return new ResolvedIdentity(
$platform,
$payload,
CarbonImmutable::now()
);
}
$status = $response->status();
$retryable = $status === 429 || $status >= 500;
$failure = new IdentityResolutionException(
'Identity Resolver request failed.',
$status,
$retryable
);
if (! $retryable || $attempt === $attempts) {
throw $failure;
}
Log::warning('Identity resolution will be retried.', [
'platform' => $platform,
'status' => $status,
'attempt' => $attempt,
]);
$this->pause($attempt, $response->header('Retry-After'));
}
throw new IdentityResolutionException('Identity resolution failed.');
}
private function pause(int $attempt, ?string $retryAfter): void
{
$milliseconds = ctype_digit((string) $retryAfter)
? min(5000, (int) $retryAfter * 1000)
: min(
5000,
(int) config('services.identity_resolver.retry_base_ms')
* (2 ** ($attempt - 1))
);
usleep($milliseconds * 1000);
}
}
Разрешувајте профили во задача од редица
Секоја платформа добива независен исход. Еден недостапен профил треба да произведе partial, а не да ги избрише успешните идентитети од другите платформи.
<?php
namespace App\Jobs;
use App\Models\DirectoryEntry;
use App\Services\IdentityResolutionException;
use App\Services\IdentityResolver;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class ResolveDirectoryIdentities implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 120;
public function __construct(public readonly int $entryId) {}
public function handle(IdentityResolver $resolver): void
{
$entry = DirectoryEntry::findOrFail($this->entryId);
$resolved = [];
$failures = [];
foreach ($entry->social_links as $platform => $url) {
try {
$resolved[$platform] = $resolver
->resolve($platform, $url)
->toArray();
} catch (IdentityResolutionException $exception) {
$failures[$platform] = [
'status_code' => $exception->statusCode,
'retryable' => $exception->retryable,
];
}
}
$status = $failures === []
? 'complete'
: ($resolved === [] ? 'failed' : 'partial');
$entry->update([
'resolved_identities' => $resolved ?: null,
'resolution_failures' => $failures ?: null,
'resolution_status' => $status,
]);
Log::info('Directory identity resolution finished.', [
'entry_id' => $entry->id,
'status' => $status,
'resolved_count' => count($resolved),
'failure_count' => count($failures),
]);
}
}
Контролерот ја претвора валидираната листа на профили во мапа со клучеви по платформа и враќа 202 Accepted бидејќи обработката продолжува асинхроно.
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreDirectoryEntryRequest;
use App\Jobs\ResolveDirectoryIdentities;
use App\Models\DirectoryEntry;
use Illuminate\Http\JsonResponse;
final class DirectoryEntryController extends Controller
{
public function store(StoreDirectoryEntryRequest $request): JsonResponse
{
$profiles = collect($request->validated('profiles'))
->mapWithKeys(fn (array $profile): array => [
$profile['platform'] => $profile['url'],
])
->all();
$entry = DirectoryEntry::create([
'name' => $request->validated('name'),
'social_links' => $profiles,
'resolution_status' => 'pending',
]);
ResolveDirectoryIdentities::dispatch($entry->id);
return response()->json([
'id' => $entry->id,
'resolution_status' => $entry->resolution_status,
], 202);
}
}
use App\Http\Controllers\DirectoryEntryController;
use Illuminate\Support\Facades\Route;
Route::post('/directory-entries', [DirectoryEntryController::class, 'store'])
->middleware('throttle:directory-submissions');
Тестирајте без да ја допирате јавната крајна точка
Http::fake() ги прави успехот, ограничувањето на стапката и конечниот неуспех детерминистички. Формата на fixture намерно ја контролира апликацијата; тестот потврдува дека границата го зачувува JSON наместо да се потпира на недокументирани полиња од давателот.
<?php
namespace Tests\Feature;
use App\Services\IdentityResolutionException;
use App\Services\IdentityResolver;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class IdentityResolverTest extends TestCase
{
public function test_it_sends_the_documented_query_and_maps_json(): void
{
$fixture = ['fixture_identity' => ['value' => 'stable-reference']];
Http::fake([
config('services.identity_resolver.url').'*' =>
Http::response($fixture, 200),
]);
$result = app(IdentityResolver::class)->resolve(
'instagram',
'https://www.instagram.com/example/'
);
$this->assertSame($fixture, $result->publicIdentity);
Http::assertSent(fn (Request $request): bool =>
$request->method() === 'GET'
&& $request['platform'] === 'instagram'
&& $request['url'] === 'https://www.instagram.com/example/'
&& ! $request->hasHeader('Authorization')
);
}
public function test_it_retries_a_rate_limit_then_succeeds(): void
{
config(['services.identity_resolver.retry_base_ms' => 0]);
Http::fakeSequence()
->push([], 429)
->push(['fixture_identity' => []], 200);
app(IdentityResolver::class)->resolve(
'facebook',
'https://www.facebook.com/example'
);
Http::assertSentCount(2);
}
public function test_it_does_not_retry_validation_failures(): void
{
Http::fake(fn () => Http::response([], 422));
try {
app(IdentityResolver::class)->resolve(
'linkedin',
'https://www.linkedin.com/in/example'
);
$this->fail('Expected identity resolution to fail.');
} catch (IdentityResolutionException $exception) {
$this->assertFalse($exception->retryable);
$this->assertSame(422, $exception->statusCode);
}
Http::assertSentCount(1);
}
}
php artisan test --filter=IdentityResolverTest
Безбедност, набљудливост и распоредување
Заштитете ја рутата за поднесување со вистинската политика за авторизација на вашата апликација, CSRF заштита каде што е применливо и именуван ограничувач на стапка. Прикажувајте ги зачуваните вредности со вообичаено екранување. Сепак, третирајте ги јавните профили како лични податоци: ограничете го пристапот до базата на податоци, дефинирајте правила за задржување и никогаш не евидентирајте испратени URL-адреси или тела на одговори.
Следете го бројот на записи со complete, partial и failed, како и далечинските статусни кодови и староста на редицата. Алармите треба да се фокусираат на трајни обрасци на неуспех, наместо на еден невалиден профил.
При распоредување, извршете миграции пред worker-процесите да ја обработат новата задача, кеширајте ја конфигурацијата само откако вредностите од околината се присутни и рестартирајте ги worker-процесите за да го вчитаат новиот код:
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --timeout=125 --tries=1
Поставете го retry_after на врската на редицата над timeout-от на worker-процесот, на пример 150 секунди, за друг worker да не ја преземе истата задача додека сè уште се извршува.
Вообичаени неуспеси
- HTTP 400 или 422: платформата или испратената референца е невалидна. Поправете го внесот; не обидувајте се повторно.
- HTTP 401 или 403: престанете со повторување и повторно проверете ја официјалната документација. Не претпоставувајте формат на токен.
- HTTP 429: почитувајте нумеричка вредност
Retry-Afterво рамки на ограничено доцнење и намалете го притисокот од поднесувања. - HTTP 5xx или неуспех на поврзување: повторете накратко со backoff, потоа зачувајте структуриран неуспех за подоцнежна повторна обработка.
- Невалиден JSON: третирајте го одговорот како неуспех на договорот и задржете ја оригиналната испратена врска.
- Записите остануваат pending: потврдете дека worker за редица работи и ја следи конфигурираната врска.
Конечна контролна листа за верификација
- Тест-барањето користи
GETи точната документирана крајна точка. - Не е конфигуриран токен за сметка, API клуч или authorization заглавие.
- Само HTTPS Facebook, Instagram и LinkedIn хостови ја поминуваат валидацијата.
- Времињата на истекување за поврзување и одговор се ограничени.
- Се повторуваат само неуспеси на поврзување, ограничувања на стапка и неуспеси на серверот.
- JSON од давателот се валидира на границата без претпоставени полиња.
- Делумниот успех се зачувува наместо да се отфрли.
- Тестовите користат
Http::fake()и не прават надворешни барања. - Логовите содржат оперативни метаподатоци, а не испратени врски или тела на одговори.
- Timeout-от на редицата останува под
retry_afterна врската.
Важниот резултат не се само поубави врски до социјални мрежи. Директориумот сега има експлицитна граница помеѓу неуредниот човечки влез, нормализираниот јавен идентитет и податоците што ги поседува вашата апликација. Таа граница е местото каде што сигурните интеграции ја докажуваат својата вредност: таа ги апсорбира промените, го прави неуспехот проверлив и му овозможува на остатокот од производот да работи со идентитети наместо со претпоставки во облик на URL-адреси.