Symfony форми за понуди: Збогатете ги податоците за компаниите од URL-адреси на веб-страници без доцнење
Формуларот за понуда треба да делува моментално. Сепак, URL-адресата на веб-страницата што ја внесува потенцијален клиент може да отклучи корисен контекст: идентитетот на компанијата, јавни контакт-детали, телефонски број и клучни лица. Погрешната имплементација го тера прелистувачот да чека надворешен API. Дизајнот погоден за продукција прво ја прифаќа понудата, веднаш пренасочува и врши збогатување во работник во заднина.
Ова упатство го гради тој дизајн со PHP 8.3, Symfony 7.4, Doctrine, HttpClient и Messenger. Надворешните податоци се мапираат на строга граница на апликацијата, неуспесите стануваат експлицитни состојби, а API-доцнењата никогаш не го одложуваат формуларот што го гледа клиентот.
Добијте пристап и копирајте го сервисниот токен
Започнете со регистрација на сметка, или користете ја страницата за најава ако веќе имате сметка.
- Отворете ја страницата на услугата Website to Company data.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Најдете го панелот Service token и копирајте го токенот ограничен на услугата.
- Чувајте го во конфигурација на проектот поддржана од околински променливи, никогаш во комитирани PHP или YAML-датотеки.
Повторното генерирање на токенот го повлекува претходно активниот токен, затоа координирајте ја ротацијата со распоредувањето. Оваа услуга нема режим без токен: секое барање мора да достави token={serviceToken} како параметар во query-низата.
Точната API-операција е GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Таа ја прифаќа веб-страницата во параметарот website на query-низата. Тестирајте го пристапот пред да напишете код за интеграција:
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'
Ставете ги вистинските акредитиви во .env.local, кој Symfony-проектите обично го исклучуваат од контрола на верзии:
MIHAJLO_WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=company_enrichment&auto_setup=false
Архитектура: зачувај, пренасочи, збогати
Патеката на барањето има четири намерни чекори: валидирајте ја понудата, зачувајте ја со состојба на збогатување pending, испратете мала порака што го содржи само нејзиниот идентификатор во базата на податоци и пренасочете. Работник на Messenger подоцна го презема записот, ја повикува услугата, ги мапира вратените податоци company, contact, email, phone и people, а потоа го зачувува резултатот.
Ова воведува евентуална конзистентност: деталите за компанијата може да се појават неколку секунди по понудата. Тој компромис е соодветен бидејќи збогатувањето поддржува последователна работа; не е потребно за потврдување на поднесувањето. Одржувањето на пораката мала исто така избегнува дуплирање лични информации во редицата.
Предуслови и структура на проектот
Ви треба PHP 8.3 или понов, Composer, база на податоци поддржана од Doctrine и управувач со процеси способен да одржува активен console worker. Создадете го проектот и инсталирајте ги само компонентите што ги користи овој тек на работа:
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
Важните датотеки се:
src/Entity/QuoteRequest.phpза понудата и состојбата на збогатување.src/Integration/CompanyEnrichment.phpиWebsiteCompanyClient.phpза API-границата.src/Message/EnrichQuoteRequest.phpи неговиот обработувач за извршување во заднина.src/Form/QuoteRequestType.phpиsrc/Controller/QuoteController.phpза поднесување на формуларот.tests/Integration/WebsiteCompanyClientTest.phpза детерминистички тестови на транспортот.
Конфигурирајте ограничени барања и внимателни повторни обиди
Клиент со ограничен опсег обезбедува timeout за неактивност и ограничување на целокупното времетраење. Бидејќи ова е идемпотентно GET-барање, разумно е повторно да се обидете мал број минливи одговори. Одговорите за автентикација и валидација намерно не се на списокот за повторни обиди.
# 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-повторните обиди покриваат одговори за квота и кратки прекини кај upstream-услугата. Ако тие обиди се исцрпат, понудата бележи структурирано откажување наместо да блокира неодредено време. Повторните обиди на Messenger остануваат корисни за неочекувани неуспеси на обработувачот или базата на податоци.
Мапирајте неизвесен JSON на границата
Надворешниот JSON не смее да тече директно во ентитети или шаблони. Маперот ги прифаќа само петте договорни полиња, внимателно применува типови и игнорира дополнителни полиња. Отсутна договорна структура се третира како невалиден одговор наместо тивко да се зачува.
<?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);
}
}
Клиентот го користи задолжителниот договор за автентикација преку параметар во query-низата. Тој никогаш не ги става токенот или телото на одговорот во пораки за исклучоци.
<?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);
}
}
}
Зачувајте експлицитна состојба на збогатување
Ентитетот QuoteRequest треба да содржи вообичаени полиња за понуда како customerEmail, website и summary, како и enrichmentStatus, JSON-поле enrichmentData што може да биде null и enrichmentError што може да биде null. Иницијализирајте ја состојбата на pending и додајте ги овие методи за премин:
<?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; }
Користете експлицитни имиња на Doctrine-колони за enrichment_status, enrichment_data и enrichment_error. Генерирајте и прегледајте ја миграцијата:
php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction
Извршете збогатување во Messenger
Пораката го содржи само ID-то на понудата. Атомското ажурирање спречува дупликатните испораки двапати да го збогатат истиот запис што е во чекање. Обработувачот евидентира идентификатори и категории на неуспех, но не веб-страници, контакт-податоци, тела на одговори или токени.
<?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();
}
}
Одржувајте го контролерот брз
Применете го Symfony-ограничувањето Url само со HTTP и HTTPS протоколи, разумно ограничување на должината и вообичаено ракување со формулар заштитен со CSRF. Контролерот мора да изврши flush пред испраќањето, за работникот да не може да забележи запис што недостига во базата на податоци.
<?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>');
}
}
Ако испраќањето во редицата не успее откако понудата е комитирана, поднесувањето сè уште постои во состојба pending. Закажана команда за усогласување може повторно да испрати стари записи во чекање; одржувајте ја таа операција идемпотентна потпирајќи се на атомското преземање од обработувачот.
Тестирајте без да ја повикате активната услуга
MockHttpClient го прави однесувањето на транспортот детерминистичко и ги проверува точниот метод, патека и параметри на query-низата.
<?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);
}
}
}
Безбедност, операции и вообичаени неуспеси
Автентикацијата со параметар во query-низата заслужува посебно внимание бидејќи URL-адресите може да бидат зачувани од проксија, профајлери или HTTP-дневници. Никогаш не ја евидентирајте целосната URL-адреса на барањето. Оневозможете го профилирањето во продукција, ограничете го пристапот до инфраструктурните дневници, редактирајте ги вредностите на query-параметарот token на кој било reverse proxy и веднаш ротирајте го токенот ако биде изложен.
Третирајте ги вратените податоци за компанијата и лицата како недоверлив влез. Ескепирајте ги во шаблоните, овластете пристап до административните екрани за понуди, дефинирајте политика за задржување и избегнувајте копирање JSON за збогатување во аналитика или извештаи за исклучоци. Валидацијата на формуларот треба да одбива не-HTTP шеми, додека ограничувањето на стапката на ниво на апликација и CSRF-заштитата ја намалуваат злоупотребата на квотата.
Распоредете ги миграциите и транспортот на Messenger пред да стартувате работници. Потоа надгледувајте го потрошувачот со управувач на услуги:
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
Рестартирајте ги работниците при секое издание за да вчитаат нов код. Следете ја староста на редицата, записите во чекање, бројот на успеси и неуспеси по категорија на неуспех, излезите на работниците и времетраењето на збогатувањето. Користете php bin/console messenger:failed:show за да ги прегледате исцрпените испораки на Messenger и messenger:failed:retry само откако ќе ја исправите причината.
- Неуспеси при автентикација: потврдете ја распоредената тајна и запомнете дека повторното генерирање го повлече стариот токен. Не обидувајте се повторно со одговори 401 или 403.
- Неуспеси со невалидна веб-страница: задржете ја понудата, забележете
invalid_requestи дозволете член на персоналот да ја исправи и повторно испрати. - Ограничувања на стапката: дозволете ограничените HTTP-повторни обиди да се справат со краткотраен притисок, потоа забележете
rate_limited. Не создавајте неограничена јамка за повторни обиди. - Неправилно форматиран JSON: класифицирајте го како
invalid_response; никогаш не претпоставувајте нова структура на одговор во доменскиот код. - Растечка редица: потврдете дека работникот работи, прегледајте ги неуспешните пораки и споредете ја стапката на пристигнување со капацитетот за обработка пред да додадете потрошувачи.
Конечна контролна листа за проверка
- Прелистувачот се пренасочува по зачувувањето во базата на податоци и испраќањето на пораката, без да чека на збогатувањето.
- Појдовното барање е точно GET до доставената крајна точка за екстракција со query-параметрите
websiteиtoken. - Сервисниот токен постои само во конфигурација поддржана од околински променливи и е редактиран од дневниците.
- Податоците за компанија, контакт, е-пошта, телефон и лица минуваат низ заштитна граница на апликацијата.
- Timeout-ите и повторните обиди се ограничени; неуспесите при валидација и автентикација не се повторуваат слепо.
- Дупликатните пораки не можат да обработат веќе преземена понуда.
- Тестовите поминуваат со
php bin/phpunit, а работникот ажурира вистинска понуда во чекање во staging-околина.
Важниот резултат не се само побогати податоци за понудата. Тоа е формулар што останува сигурен кога услугата за збогатување е бавна, ограничена по стапка, привремено недостапна или враќа нешто неочекувано. Прво прифатете ја намерата на клиентот; збогатете ја според вашиот оперативен распоред. Тоа раздвојување е она што го претвора практичниот API-повик во продукциска интеграција.