Laravel: Ujedinite društvene poveznice s AI razrješavačem identiteta za direktorije zajednice
Direktorij zajednice rijetko prima čiste podatke s društvenih mreža. Jedan član zalijepi puni Facebook URL, drugi pošalje Instagram korisničko ime prikriveno kao poveznicu, a treći kopira URL LinkedIn profila s parametrima za praćenje. Ako te vrijednosti izravno uđu u vašu bazu podataka, otkrivanje duplikata, prikaz profila i buduće migracije postaju teži.
Ovaj vodič izrađuje produkcijsku Laravel značajku koja prihvaća poveznice na Facebook, Instagram i LinkedIn profile, šalje svaku referencu u Identity Resolver i pohranjuje dobiveni javni identitet u stabilnu strukturu na razini aplikacije. Razrješavanje se izvršava u redu čekanja, neuspjesi ostaju vidljivi umjesto da nestanu, a testovi nikada ne kontaktiraju stvarnu uslugu.
Dobijte pristup i uputite prvi zahtjev
Započnite sa službenom dokumentacijom za Identity Resolver. Krajnja točka trenutačno je javna: ne zahtijeva račun, bearer token ni API ključ.
- Pregledajte stranicu usluge i plana kako biste potvrdili da Identity Resolver podržava platforme koje vaš direktorij prihvaća.
- Provjerite zahtjeve za registraciju. Registracija nije dio trenutačnog tijeka za javnu krajnju točku.
- U istoj službenoj dokumentaciji provjerite zahtjeve za prijavu. Ne morate se prijaviti prije testiranja.
- Nemojte tražiti zaslon za kopiranje tokena: trenutačno ne postoji token ni API ključ za kopiranje. Ako se autentifikacija uvede kasnije, slijedite dokumentaciju umjesto da nagađate zaglavlje za autorizaciju.
Točna integracija jest HTTP GET zahtjev prema https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Pošaljite platform zajedno s podržanim parametrom username, id, identifier, profile ili url. Ovaj projekt koristi url jer to šalju članovi direktorija.
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"
Pokrenite taj zahtjev prije pisanja aplikacijskog koda. Uspješan odgovor trebao bi biti JSON koji predstavlja normalizirani javni identitet. Dokumentirani ugovor ne opravdava povezivanje aplikacije sa spekulativnim nazivima polja, stoga granica u nastavku validira odgovor kao JSON objekt i čuva njegov sadržaj.
Nema vjerodajnice koju treba staviti u .env. Umjesto toga ondje pohranite krajnju točku i operativne postavke; dodavanje izmišljenog tokena stvorilo bi obmanjujuću sigurnosnu ovisnost.
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
Preduvjeti i struktura projekta
Trebate PHP 8.3 ili noviji, Composer, podržanu Laravel aplikaciju, konfiguriranu bazu podataka i pozadinu za red čekanja. Red čekanja u bazi podataka dovoljan je za mali direktorij; Redis je koristan kada to opravdavaju propusnost ili izolacija redova čekanja.
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
Relevantna struktura aplikacije namjerno je mala:
app/Services/IdentityResolver.phpupravlja granicom udaljenog HTTP-a.app/Data/ResolvedIdentity.phpdefinira stabilan domenski prikaz direktorija.app/Jobs/ResolveDirectoryIdentities.phpizvršava potencijalno sporo razrješavanje.app/Http/Requests/StoreDirectoryEntryRequest.phpograničava korisnički unos.app/Http/Controllers/DirectoryEntryController.phpstvara unose i šalje posao na izvršavanje.
Asinkrono razrješavanje drži tri vanjska poziva izvan proračuna latencije za slanje obrasca. Kompromis je konačna dosljednost: novostvoreni unos započinje kao pending. Sučelje bi trebalo prikazivati to stanje umjesto da se pretvara da je normalizacija trenutačna.
Konfigurirajte Laravel bez izmišljanja autentifikacije
Dodajte uslugu u config/services.php. Laravel konfiguracija postaje jedino mjesto koje čita varijable okruženja.
'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),
],
Nemojte dodavati prazno zaglavlje za autorizaciju. „Token nije potreban” jest ugovor o autentifikaciji, a ne poziv da šaljete rezervirana mjesta.
Pohranite unose, rezultate i strukturirane neuspjehe
Direktorij bi trebao zadržati poslane poveznice radi revizije i ponovne obrade, dok normalizirani identiteti i neuspjesi žive u zasebnim JSON stupcima. Izradite migraciju ovako:
<?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');
}
};
U DirectoryEntry, učinite ove atribute popunjivima i pretvorite tri JSON stupca u array. Zadržavanje odgovora pružatelja netaknutim izbjegava tiho odbacivanje budućih dokumentiranih polja.
<?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',
];
}
}
Validirajte platforme i hostove prije razrješavanja
Popis dopuštenih vrijednosti sprječava tipografske pogreške, neočekivane platforme, URL-ove koji nisu HTTPS i obmanjujuća imena hostova. Također štiti integritet direktorija iako Laravel sam ne dohvaća poslani 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.'
);
}
}
},
];
}
}
Izradite obrambenu API granicu
დომenski objekt dodaje polja u vlasništvu naše aplikacije, dok teret podataka usluge tretira kao neprozirne podatke o javnom identitetu.
<?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(),
];
}
}
Usluga ponovno pokušava samo kod neuspjeha veze, HTTP 429 odgovora i pogrešaka poslužitelja. Neuspjesi validacije i autentifikacije završni su jer ponavljanje istog zahtjeva neće ih popraviti. Tijela odgovora i poslani URL-ovi namjerno su isključeni iz zapisnika.
<?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);
}
}
Razriješite profile u zadatku reda čekanja
Svaka platforma prima neovisan ishod. Jedan nedostupan profil trebao bi proizvesti partial, a ne izbrisati uspješne identitete s drugih platformi.
<?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),
]);
}
}
Kontroler pretvara validirani popis profila u mapu s ključevima platformi i vraća 202 Accepted jer se obrada nastavlja asinkrono.
<?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');
Testirajte bez dodirivanja javne krajnje točke
Http::fake() čini uspjeh, ograničavanje stope i završni neuspjeh determinističkima. Oblik fixturea namjerno kontrolira aplikacija; test provjerava da granica čuva JSON umjesto da se oslanja na nedokumentirana polja pružatelja.
<?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
Sigurnost, vidljivost i implementacija
Zaštitite rutu za slanje stvarnom autorizacijskom politikom svoje aplikacije, CSRF zaštitom gdje je primjenjivo i imenovanim ograničivačem stope. Prikazujte pohranjene vrijednosti uz uobičajeno escapiranje. Javne profile ipak tretirajte kao osobne podatke: ograničite pristup bazi podataka, definirajte pravila zadržavanja i nikada ne zapisujte poslane URL-ove ni tijela odgovora.
Pratite broj unosa complete, partial i failed, kao i udaljene statusne kodove i starost reda čekanja. Upozorenja bi se trebala usredotočiti na trajne obrasce neuspjeha, a ne na jedan nevaljan profil.
Tijekom implementacije pokrenite migracije prije nego što radnici obrade novi zadatak, predmemorirajte konfiguraciju tek nakon što su vrijednosti okruženja prisutne i ponovno pokrenite radnike kako bi učitali novi kod:
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --timeout=125 --tries=1
Postavite retry_after veze reda čekanja iznad vremenskog ograničenja radnika, primjerice na 150 sekundi, kako drugi radnik ne bi preuzeo isti zadatak dok se još izvršava.
Česti neuspjesi
- HTTP 400 ili 422: platforma ili poslana referenca nije valjana. Ispravite unos; nemojte ga ponovno pokušavati.
- HTTP 401 ili 403: prestanite s ponovnim pokušajima i ponovno provjerite službenu dokumentaciju. Nemojte pretpostavljati format tokena.
- HTTP 429: poštujte numeričku vrijednost
Retry-Afterunutar ograničenog odgađanja i smanjite pritisak slanja. - HTTP 5xx ili neuspjeh veze: kratko pokušajte ponovno s odgodom, zatim sačuvajte strukturirani neuspjeh za kasniju ponovnu obradu.
- Nevaljan JSON: tretirajte odgovor kao neuspjeh ugovora i zadržite izvornu poslanu poveznicu.
- Unosi ostaju pending: potvrdite da radnik reda čekanja radi i prati konfiguriranu vezu.
Završni kontrolni popis za provjeru
- Testni zahtjev koristi
GETi točnu dokumentiranu krajnju točku. - Nisu konfigurirani token računa, API ključ ni zaglavlje za autorizaciju.
- Validaciju prolaze samo HTTPS hostovi Facebooka, Instagrama i LinkedIna.
- Vremenska ograničenja veze i odgovora su ograničena.
- Ponovno se pokušavaju samo neuspjesi veze, ograničenja stope i neuspjesi poslužitelja.
- JSON pružatelja validira se na granici bez pretpostavljenih polja.
- Djelomični uspjeh se pohranjuje umjesto da se odbaci.
- Testovi koriste
Http::fake()i ne upućuju vanjske zahtjeve. - Zapisnici sadrže operativne metapodatke, a ne poslane poveznice ili tijela odgovora.
- Vremensko ograničenje reda čekanja ostaje ispod
retry_afterveze.
Važan rezultat nisu samo ljepše poveznice na društvene mreže. Direktorij sada ima izričitu granicu između neurednog ljudskog unosa, normaliziranog javnog identiteta i podataka u vlasništvu vaše aplikacije. Ta je granica mjesto na kojem pouzdane integracije pokazuju svoju vrijednost: apsorbira promjene, čini neuspjehe preglednima i ostatku proizvoda omogućuje rad s identitetima umjesto s nagađanjima u obliku URL-ova.