Symfony obrasci za ponude: Obogatite podatke o tvrtki pomoću URL-ova web-stranica bez zastoja
Obrazac za ponudu trebao bi djelovati trenutačno. Ipak, URL web-mjesta koji potencijalni klijent unese može otključati koristan kontekst: identitet tvrtke, javno dostupne podatke za kontakt, telefonski broj i ključne osobe. Pogrešna implementacija prisiljava preglednik da čeka vanjski API. Dizajn prilagođen produkciji najprije prihvaća ponudu, odmah preusmjerava i obavlja obogaćivanje u pozadinskom radniku.
Ovaj vodič izrađuje takav dizajn pomoću PHP-a 8.3, Symfonyja 7.4, Doctrinea, HttpClienta i Messengera. Vanjski podaci mapiraju se na strogoj granici aplikacije, neuspjesi postaju eksplicitna stanja, a API kašnjenja nikada ne usporavaju obrazac okrenut korisniku.
Dobijte pristup i kopirajte servisni token
Počnite tako da registrirate račun ili upotrijebite stranicu za prijavu ako ga već imate.
- Otvorite stranicu usluge Website to Company data.
- Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte token ograničen na tu uslugu.
- Pohranite ga u konfiguraciju projekta podržanu varijablama okruženja, nikada u PHP ili YAML datoteke predane u repozitorij.
Ponovno generiranje tokena opoziva prethodno aktivni token, stoga uskladite rotaciju s implementacijom. Ova usluga nema način rada bez tokena: svaki zahtjev mora navesti token={serviceToken} kao parametar upita.
Točna API operacija je GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Prihvaća web-mjesto u parametru upita website. Testirajte pristup prije pisanja integracijskog koda:
curl --get 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
--data-urlencode 'website=https://example.com' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN'
Stavite stvarnu vjerodajnicu u .env.local, koji Symfony projekti obično isključuju iz kontrole verzija:
MIHAJLO_WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=company_enrichment&auto_setup=false
Arhitektura: spremi, preusmjeri, obogati
Put zahtjeva ima četiri namjerna koraka: validirajte ponudu, trajno je spremite sa stanjem obogaćivanja pending, pošaljite malu poruku koja sadrži samo njezin identifikator baze podataka i preusmjerite. Messenger radnik kasnije preuzima zapis, poziva uslugu, mapira vraćene podatke company, contact, email, phone i people, a zatim sprema rezultat.
Time se uvodi eventualna konzistentnost: podaci o tvrtki mogu se pojaviti nekoliko sekundi nakon ponude. Taj je kompromis primjeren jer obogaćivanje podržava naknadni rad; nije potrebno za potvrdu slanja. Održavanje male poruke također izbjegava dupliciranje osobnih podataka u redu čekanja.
Preduvjeti i struktura projekta
Potrebni su vam PHP 8.3 ili noviji, Composer, baza podataka koju Doctrine podržava i upravitelj procesa sposoban održati konzolnog radnika aktivnim. Izradite projekt i instalirajte samo komponente koje ovaj tijek rada koristi:
composer create-project symfony/skeleton:"7.4.*" quote-enrichment
cd quote-enrichment
composer require symfony/orm-pack symfony/form symfony/validator \
symfony/twig-bundle symfony/http-client symfony/messenger \
symfony/doctrine-messenger
composer require --dev symfony/test-pack
php bin/console doctrine:database:create
php bin/console messenger:setup-transports
Važne datoteke su:
src/Entity/QuoteRequest.phpza ponudu i stanje obogaćivanja.src/Integration/CompanyEnrichment.phpiWebsiteCompanyClient.phpza API granicu.src/Message/EnrichQuoteRequest.phpi njegov obrađivač za pozadinsko izvršavanje.src/Form/QuoteRequestType.phpisrc/Controller/QuoteController.phpza slanje obrasca.tests/Integration/WebsiteCompanyClientTest.phpza determinističke testove transporta.
Konfigurirajte ograničene zahtjeve i pažljiva ponavljanja
Klijent ograničen opsegom pruža vremensko ograničenje neaktivnosti i ograničenje ukupnog trajanja. Budući da je ovo idempotentan GET zahtjev, razumno je ponovno pokušati mali broj prolaznih odgovora. Odgovori povezani s autentikacijom i validacijom namjerno nisu na popisu za ponovno pokušavanje.
# config/packages/framework.yaml
framework:
http_client:
scoped_clients:
company_enrichment.client:
base_uri: 'https://ai.mihajlo.mk/api/website-to-company-data/'
timeout: 3
max_duration: 8
retry_failed:
http_codes: [429, 502, 503, 504]
max_retries: 2
delay: 300
multiplier: 2
max_delay: 2000
jitter: 0.2
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 2
delay: 1000
multiplier: 2
max_delay: 5000
routing:
App\Message\EnrichQuoteRequest: async
# config/services.yaml
services:
App\Integration\WebsiteCompanyClient:
arguments:
$http: '@company_enrichment.client'
$token: '%env(string:MIHAJLO_WEBSITE_COMPANY_TOKEN)%'
HTTP ponavljanja pokrivaju odgovore o kvoti i kratke prekide rada uzvodne usluge. Ako se ti pokušaji iscrpe, ponuda bilježi strukturirani neuspjeh umjesto da blokira neograničeno. Messengerova ponavljanja i dalje su korisna za neočekivane neuspjehe obrađivača ili baze podataka.
Mapirajte neizvjestan JSON na granici
Vanjski JSON ne smije izravno ulaziti u entitete ili predloške. Mapper prihvaća samo pet ugovornih polja, defenzivno primjenjuje tipove i ignorira dodatna polja. Nepostojeći ugovorni oblik tretira se kao nevaljan odgovor umjesto da se tiho pohrani.
<?php
// src/Integration/CompanyEnrichment.php
namespace App\Integration;
final readonly class CompanyEnrichment
{
public function __construct(
public ?array $company,
public ?array $contact,
public ?string $email,
public ?string $phone,
public array $people,
) {}
public static function fromPayload(array $payload): self
{
$known = ['company', 'contact', 'email', 'phone', 'people'];
if (!array_filter($known, fn (string $key) => array_key_exists($key, $payload))) {
throw new WebsiteCompanyFailure('invalid_response', 'Expected fields are absent.');
}
$people = is_array($payload['people'] ?? null)
? array_values(array_filter($payload['people'], 'is_array'))
: [];
return new self(
is_array($payload['company'] ?? null) ? $payload['company'] : null,
is_array($payload['contact'] ?? null) ? $payload['contact'] : null,
self::text($payload['email'] ?? null),
self::text($payload['phone'] ?? null),
$people,
);
}
public function toArray(): array
{
return get_object_vars($this);
}
private static function text(mixed $value): ?string
{
if (!is_string($value) || trim($value) === '') {
return null;
}
return trim($value);
}
}
// src/Integration/WebsiteCompanyFailure.php
namespace App\Integration;
final class WebsiteCompanyFailure extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Klijent koristi obavezni ugovor autentikacije putem parametra upita. Nikada ne stavlja token ili tijelo odgovora u poruke iznimki.
<?php
// src/Integration/WebsiteCompanyClient.php
namespace App\Integration;
use JsonException;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class WebsiteCompanyClient
{
public function __construct(
private HttpClientInterface $http,
private string $token,
) {}
public function extract(string $website): CompanyEnrichment
{
try {
$response = $this->http->request('GET', 'v1/extract', [
'query' => [
'website' => $website,
'token' => $this->token,
],
]);
$status = $response->getStatusCode();
if ($status === 401 || $status === 403) {
throw new WebsiteCompanyFailure('authentication', 'Service authentication failed.');
}
if (in_array($status, [400, 404, 422], true)) {
throw new WebsiteCompanyFailure('invalid_request', 'The website was rejected.');
}
if ($status === 429) {
throw new WebsiteCompanyFailure('rate_limited', 'Service quota is temporarily unavailable.');
}
if ($status >= 500) {
throw new WebsiteCompanyFailure('temporary', 'The service is temporarily unavailable.');
}
if ($status < 200 || $status >= 300) {
throw new WebsiteCompanyFailure('remote_error', 'Unexpected service response.');
}
$payload = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR,
);
if (!is_array($payload)) {
throw new WebsiteCompanyFailure('invalid_response', 'Response is not a JSON object.');
}
return CompanyEnrichment::fromPayload($payload);
} catch (WebsiteCompanyFailure $failure) {
throw $failure;
} catch (JsonException $exception) {
throw new WebsiteCompanyFailure('invalid_response', 'Response is not valid JSON.', $exception);
} catch (TransportExceptionInterface $exception) {
throw new WebsiteCompanyFailure('temporary', 'Service request could not complete.', $exception);
}
}
}
Trajno spremite eksplicitno stanje obogaćivanja
Entitet QuoteRequest trebao bi sadržavati uobičajena polja ponude kao što su customerEmail, website i summary, uz enrichmentStatus, nullable JSON enrichmentData i nullable enrichmentError. Inicijalizirajte stanje na pending i dodajte ove metode prijelaza:
<?php
// Relevant methods in src/Entity/QuoteRequest.php
public function enrichmentSucceeded(CompanyEnrichment $result): void
{
$this->enrichmentStatus = 'succeeded';
$this->enrichmentData = $result->toArray();
$this->enrichmentError = null;
}
public function enrichmentFailed(string $kind): void
{
$this->enrichmentStatus = 'failed';
$this->enrichmentData = null;
$this->enrichmentError = $kind;
}
public function getId(): ?int { return $this->id; }
public function getWebsite(): string { return $this->website; }
Upotrijebite eksplicitne nazive Doctrine stupaca za enrichment_status, enrichment_data i enrichment_error. Generirajte i pregledajte migraciju:
php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction
Pokrenite obogaćivanje u Messengeru
Poruka sadrži samo ID ponude. Atomsko ažuriranje sprječava da dvostruke isporuke obogate isti zapis na čekanju dva puta. Obrađivač zapisuje identifikatore i kategorije neuspjeha, ali ne web-mjesta, podatke za kontakt, tijela odgovora ni tokene.
<?php
// src/Message/EnrichQuoteRequest.php
namespace App\Message;
final readonly class EnrichQuoteRequest
{
public function __construct(public int $quoteId) {}
}
// src/MessageHandler/EnrichQuoteRequestHandler.php
namespace App\MessageHandler;
use App\Entity\QuoteRequest;
use App\Integration\WebsiteCompanyClient;
use App\Integration\WebsiteCompanyFailure;
use App\Message\EnrichQuoteRequest;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class EnrichQuoteRequestHandler
{
public function __construct(
private Connection $connection,
private EntityManagerInterface $entityManager,
private WebsiteCompanyClient $client,
private LoggerInterface $logger,
) {}
public function __invoke(EnrichQuoteRequest $message): void
{
$claimed = $this->connection->executeStatement(
'UPDATE quote_request
SET enrichment_status = :processing
WHERE id = :id AND enrichment_status = :pending',
['processing' => 'processing', 'pending' => 'pending', 'id' => $message->quoteId],
);
if ($claimed !== 1) {
return;
}
$quote = $this->entityManager->find(QuoteRequest::class, $message->quoteId);
if (!$quote) {
return;
}
try {
$quote->enrichmentSucceeded($this->client->extract($quote->getWebsite()));
$this->logger->info('Quote enrichment succeeded.', ['quote_id' => $message->quoteId]);
} catch (WebsiteCompanyFailure $failure) {
$quote->enrichmentFailed($failure->kind);
$this->logger->warning('Quote enrichment failed.', [
'quote_id' => $message->quoteId,
'failure_kind' => $failure->kind,
]);
}
$this->entityManager->flush();
}
}
Neka kontroler bude brz
Primijenite Symfonyjevo ograničenje Url samo s HTTP i HTTPS protokolima, razumnim ograničenjem duljine i uobičajenim rukovanjem obrascem zaštićenim CSRF-om. Kontroler mora izvršiti flush prije slanja poruke kako radnik ne bi mogao vidjeti nedostajući zapis baze podataka.
<?php
// src/Controller/QuoteController.php
namespace App\Controller;
use App\Entity\QuoteRequest;
use App\Form\QuoteRequestType;
use App\Message\EnrichQuoteRequest;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
final class QuoteController extends AbstractController
{
#[Route('/quote', name: 'quote_new', methods: ['GET', 'POST'])]
public function new(
Request $request,
EntityManagerInterface $entityManager,
MessageBusInterface $bus,
): Response {
$quote = new QuoteRequest();
$form = $this->createForm(QuoteRequestType::class, $quote);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($quote);
$entityManager->flush();
$bus->dispatch(new EnrichQuoteRequest($quote->getId()));
return $this->redirectToRoute('quote_received');
}
return $this->render('quote/new.html.twig', ['form' => $form]);
}
#[Route('/quote/received', name: 'quote_received', methods: ['GET'])]
public function received(): Response
{
return new Response('<p>Your quote request has been received.</p>');
}
}
Ako slanje u red čekanja ne uspije nakon što je ponuda predana, prijava i dalje postoji u stanju pending. Zakazana naredba za usklađivanje može ponovno poslati stare retke na čekanju; zadržite tu operaciju idempotentnom oslanjajući se na atomsko preuzimanje u obrađivaču.
Testirajte bez pozivanja aktivne usluge
MockHttpClient čini ponašanje transporta determinističkim i provjerava točnu metodu, putanju i parametre upita.
<?php
// tests/Integration/WebsiteCompanyClientTest.php
namespace App\Tests\Integration;
use App\Integration\WebsiteCompanyClient;
use App\Integration\WebsiteCompanyFailure;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class WebsiteCompanyClientTest extends TestCase
{
public function testMapsContractFields(): void
{
$http = new MockHttpClient(function (string $method, string $url): MockResponse {
self::assertSame('GET', $method);
self::assertSame('/api/website-to-company-data/v1/extract', parse_url($url, PHP_URL_PATH));
parse_str(parse_url($url, PHP_URL_QUERY) ?? '', $query);
self::assertSame('https://example.com', $query['website']);
self::assertSame('test-token', $query['token']);
return new MockResponse(json_encode([
'company' => ['name' => 'Example Company'],
'contact' => ['city' => 'Example City'],
'email' => '[email protected]',
'phone' => '+1 555 0100',
'people' => [['name' => 'Alex Example']],
], JSON_THROW_ON_ERROR));
}, 'https://ai.mihajlo.mk/');
$result = (new WebsiteCompanyClient($http, 'test-token'))
->extract('https://example.com');
self::assertSame('Example Company', $result->company['name']);
self::assertSame('[email protected]', $result->email);
self::assertCount(1, $result->people);
}
public function testRejectsUnknownJsonShape(): void
{
$client = new WebsiteCompanyClient(
new MockHttpClient(new MockResponse('{"unexpected":true}')),
'test-token',
);
$this->expectException(WebsiteCompanyFailure::class);
$client->extract('https://example.com');
}
public function testClassifiesAuthenticationFailure(): void
{
$client = new WebsiteCompanyClient(
new MockHttpClient(new MockResponse('', ['http_code' => 401])),
'test-token',
);
try {
$client->extract('https://example.com');
self::fail('Expected authentication failure.');
} catch (WebsiteCompanyFailure $failure) {
self::assertSame('authentication', $failure->kind);
}
}
}
Sigurnost, operacije i česti neuspjesi
Autentikacija putem parametra upita zahtijeva posebnu pažnju jer URL-ove mogu zabilježiti proxyji, profileri ili HTTP zapisnici. Nikada ne zapisujte potpuni URL zahtjeva. Onemogućite profiliranje u produkciji, ograničite pristup infrastrukturnim zapisnicima, redigirajte vrijednosti upita token na svakom obrnutom proxyju i odmah rotirajte token ako je izložen.
Vraćene podatke o tvrtki i ljudima tretirajte kao nepouzdan unos. Escapeajte ih u predlošcima, autorizirajte pristup administrativnim zaslonima ponuda, definirajte pravilo zadržavanja i izbjegavajte kopiranje JSON-a obogaćivanja u analitiku ili izvješća o iznimkama. Validacija obrasca trebala bi odbiti ne-HTTP sheme, dok ograničavanje brzine na razini aplikacije i CSRF zaštita smanjuju zlouporabu kvote.
Implementirajte migracije i Messenger transport prije pokretanja radnika. Zatim nadzirite potrošača pomoću upravitelja usluga:
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console messenger:setup-transports
php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M
Ponovno pokrenite radnike pri svakom izdanju kako bi učitali novi kod. Pratite starost reda čekanja, zapise na čekanju, broj uspjeha i neuspjeha po kategoriji neuspjeha, izlaze radnika i trajanje obogaćivanja. Upotrijebite php bin/console messenger:failed:show za pregled iscrpljenih Messenger isporuka, a messenger:failed:retry tek nakon otklanjanja uzroka.
- Neuspjesi autentikacije: potvrdite implementiranu tajnu i zapamtite da je ponovno generiranje opozvalo stari token. Nemojte ponavljati odgovore 401 ili 403.
- Neuspjesi nevaljanog web-mjesta: zadržite ponudu, zabilježite
invalid_requesti omogućite članu osoblja da je ispravi i ponovno pošalje. - Ograničenja brzine: dopustite ograničenim HTTP ponavljanjima da riješe kratkotrajni pritisak, zatim zabilježite
rate_limited. Nemojte stvarati neograničenu petlju ponavljanja. - Neispravan JSON: klasificirajte ga kao
invalid_response; nikada ne nagađajte novu strukturu odgovora unutar domenskog koda. - Rastući red čekanja: provjerite radi li radnik, pregledajte neuspjele poruke i usporedite stopu dolaska s kapacitetom obrade prije dodavanja potrošača.
Završni kontrolni popis provjere
- Preglednik se preusmjerava nakon trajnog spremanja u bazu podataka i slanja poruke, bez čekanja na obogaćivanje.
- Odlazni zahtjev je točno GET prema navedenom krajnjem odredištu za ekstrakciju s parametrima upita
websiteitoken. - Servisni token postoji samo u konfiguraciji podržanoj varijablama okruženja i redigiran je u zapisnicima.
- Podaci o tvrtki, kontaktu, e-pošti, telefonu i ljudima prolaze kroz defenzivnu granicu aplikacije.
- Vremenska ograničenja i ponavljanja su ograničeni; neuspjesi validacije i autentikacije ne ponavljaju se naslijepo.
- Duplicirane poruke ne mogu obraditi već preuzetu ponudu.
- Testovi prolaze s
php bin/phpunit, a radnik ažurira stvarnu ponudu na čekanju u staging okruženju.
Važan rezultat nisu samo bogatiji podaci o ponudama. To je obrazac koji ostaje pouzdan kada je usluga obogaćivanja spora, ograničena kvotom, privremeno nedostupna ili vrati nešto neočekivano. Najprije prihvatite namjeru korisnika; obogatite je prema vlastitom operativnom ritmu. To razdvajanje pretvara praktični API poziv u produkcijsku integraciju.