Laravel: Автоматски целосно нови работни простори за клиенти со лого, бои и фонтови извлечени со ВИ
Празен работен простор на клиентот создава моментално триење. Некој мора да го пронајде точниот логотип, да ги преземе боите од веб-страница, да ги идентификува фонтовите и сето тоа да го претвори во употребливи поставки пред да започне вистинската работа.
Оваа Laravel имплементација го отстранува тој данок за поставување. Создавањето работен простор веднаш враќа употреблив запис, а потоа задача во редица ја испраќа јавната веб-страница на клиентот до API-то Brand Kit Extractor. Резултатот се валидира на границата на апликацијата и се складира како структурирани податоци за логотип, боја, фонт, слики, профили на социјални мрежи и CSS-променливи.
Важниот продукциски детал е дека извлекувањето е асинхроно. Оддалечената веб-страница може да биде бавна, недостапна или ограничена по стапка; ниту една од овие состојби не треба да направи создавањето работен простор да изгледа расипано.
Добијте пристап и создајте сервисен токен
Прво, регистрирајте сметка, или користете ја страницата за најава ако веќе имате сметка.
- Отворете ја страницата на услугата Brand Kit Extractor.
- Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
- Зачувајте го во вашиот менаџер за лозинки и во складиштето за тајни на платформата за распоредување.
Оваа услуга бара автентикација; не е крајна точка без токен. Прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Ќе користиме Bearer токен бидејќи параметрите за пребарување почесто се појавуваат во прокси и пристапни дневници.
Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Третирајте ја ротацијата како операција за распоредување: ажурирајте ја секоја околина што ја користи услугата, распоредете или повторно вчитајте ја конфигурацијата, и дури потоа проверете го извлекувањето.
Потврдете го точниот API повик
Барањето е POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit, со JSON тело што содржи url. Пред да напишете Laravel код, направете едно минимално барање од доверлив терминал:
curl --request POST \
--url https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Accept: application/json" \
--header "Content-Type: application/json" \
--data '{"url":"https://example.com"}'
Не го вметнувајте добиениот токен или одговор во контрола на изворниот код. Ставете го акредитивот во локалната датотека .env на Laravel и користете ја конфигурацијата на околината на платформата за распоредување во продукција:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_ENDPOINT=https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit
BRAND_KIT_CONNECT_TIMEOUT=5
BRAND_KIT_TIMEOUT=25
QUEUE_CONNECTION=database
Архитектура и структура на проектот
Оваа функционалност има две одделни трансакции. HTTP барањето го создава работниот простор со статус pending. Откако ќе се потврди трансакцијата со базата на податоци, задача во редица го извршува извлекувањето и го менува статусот во ready, retrying или failed.
Тоа раздвојување ја одржува предвидлива латентноста видлива за корисникот и им дава на привремените неуспеси контролиран пат за повторен обид. Компромисот е евентуална конзистентност: работниот простор постои пред да постојат неговите податоци за брендот, па интерфејсот мора да го прикаже тековниот статус и да анкетира или освежува додека не заврши извлекувањето.
app/
Data/BrandKit.php
Exceptions/BrandKitException.php
Http/Controllers/WorkspaceController.php
Jobs/ExtractBrandKit.php
Models/Workspace.php
Services/BrandKitClient.php
config/services.php
database/migrations/..._create_workspaces_table.php
routes/api.php
tests/Feature/BrandKitClientTest.php
tests/Feature/ExtractBrandKitTest.php
Потребен ви е PHP 8.3 или понов, постоечка Laravel апликација, поддржана база на податоци и конфигуриран backend за редици. Редица во база на податоци е доволна за мала инсталација; создадете ја нејзината табела кога апликацијата веќе нема таква:
php artisan make:queue-table
php artisan migrate
Конфигурирајте Laravel и зачувајте експлицитни состојби
Додајте еден запис поддржан од околината во config/services.php. Задржувањето на крајната точка конфигурабилна помага при тестирање, додека стандардната вредност ја зачувува точната продукциска URL-адреса.
'brand_kit' => [
'endpoint' => env(
'BRAND_KIT_ENDPOINT',
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'
),
'token' => env('BRAND_KIT_TOKEN'),
'connect_timeout' => (int) env('BRAND_KIT_CONNECT_TIMEOUT', 5),
'timeout' => (int) env('BRAND_KIT_TIMEOUT', 25),
],
Работниот простор го складира секој дел независно. Тоа го прави пристапот на ниво на апликација едноставен и избегнува нетранспарентен JSON-објект да се третира како невалидизиран API одговор.
<?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('workspaces', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('website_url', 2048);
$table->string('brand_status')->default('pending');
$table->string('brand_name')->nullable();
$table->json('brand_logos')->nullable();
$table->json('brand_colors')->nullable();
$table->json('brand_fonts')->nullable();
$table->json('brand_imagery')->nullable();
$table->json('brand_social_profiles')->nullable();
$table->json('brand_css_variables')->nullable();
$table->text('brand_error')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('workspaces');
}
};
Во app/Models/Workspace.php, направете ги полињата внесени од корисникот доделиви и претворете ги извлечените колекции:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class Workspace extends Model
{
protected $fillable = ['name', 'website_url'];
protected function casts(): array
{
return [
'brand_logos' => 'array',
'brand_colors' => 'array',
'brand_fonts' => 'array',
'brand_imagery' => 'array',
'brand_social_profiles' => 'array',
'brand_css_variables' => 'array',
];
}
}
Валидирајте го одговорот на границата на API-то
Успешен HTTP статус не ги прави оддалечените податоци доверливи. Маперот подолу го бара целосниот договор: име на бренд, логотипи, бои, фонтови, слики, профили на социјални мрежи и CSS-променливи. Исто така, одбива прекумерно вгнездување и одговори поголеми од 512 KiB пред нешто да стигне до базата на податоци.
<?php
namespace App\Data;
use JsonException;
use UnexpectedValueException;
final readonly class BrandKit
{
public function __construct(
public string $name,
public array $logos,
public array $colors,
public array $fonts,
public array $imagery,
public array $socialProfiles,
public array $cssVariables,
) {}
public static function fromApi(array $data): self
{
$name = $data['brand_name'] ?? null;
if (! is_string($name) || trim($name) === '') {
throw new UnexpectedValueException('Invalid brand_name.');
}
$fields = [
'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
];
foreach ($fields as $field) {
if (! array_key_exists($field, $data) || ! is_array($data[$field])) {
throw new UnexpectedValueException("Invalid {$field}.");
}
self::assertJsonTree($data[$field]);
}
try {
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new UnexpectedValueException('Response is not valid JSON data.', 0, $e);
}
if (strlen($encoded) > 524288) {
throw new UnexpectedValueException('Brand kit response is too large.');
}
return new self(
trim($name),
$data['logos'],
$data['colors'],
$data['fonts'],
$data['imagery'],
$data['social_profiles'],
$data['css_variables'],
);
}
private static function assertJsonTree(mixed $value, int $depth = 0): void
{
if ($depth > 6) {
throw new UnexpectedValueException('Response nesting is too deep.');
}
if (is_array($value)) {
foreach ($value as $child) {
self::assertJsonTree($child, $depth + 1);
}
return;
}
if (! is_null($value) && ! is_scalar($value)) {
throw new UnexpectedValueException('Unsupported response value.');
}
}
}
Оваа валидација намерно ги зачувува доказите и метаподатоците во колекциите, наместо да претпоставува дека секој логотип е низа или секоја боја е хексадецимална вредност. Нормализацијата специфична за UI припаѓа во втор слој, откако ќе ги потврдите документираните облици што ги користи вашиот интерфејс.
Изградете ограничен HTTP клиент со селективни повторни обиди
Создадете мал исклучок што ѝ кажува на редицата дали повторниот обид е корисен:
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
final class BrandKitException extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly bool $retryable,
string $message,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Клиентот повторува при неуспеси на поврзување, 429 и серверски грешки. Неуспесите на автентикација, неуспесите при валидација на барањето и неисправните успешни одговори не се повторуваат наслепо.
<?php
namespace App\Services;
use App\Data\BrandKit;
use App\Exceptions\BrandKitException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class BrandKitClient
{
public function extract(string $url): BrandKit
{
$endpoint = (string) config('services.brand_kit.endpoint');
$token = (string) config('services.brand_kit.token');
if ($token === '') {
throw new BrandKitException(
'configuration',
false,
'Brand Kit service token is not configured.'
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::withToken($token)
->acceptJson()
->asJson()
->connectTimeout((int) config('services.brand_kit.connect_timeout'))
->timeout((int) config('services.brand_kit.timeout'))
->post($endpoint, ['url' => $url]);
} catch (ConnectionException $e) {
if ($attempt === 3) {
throw new BrandKitException(
'connection',
true,
'Brand Kit service could not be reached.',
$e
);
}
$this->pause($attempt, null, $url);
continue;
}
if ($response->successful()) {
$payload = $response->json();
if (! is_array($payload)) {
throw new BrandKitException(
'invalid_response',
false,
'Brand Kit service returned invalid JSON.'
);
}
try {
return BrandKit::fromApi($payload);
} catch (Throwable $e) {
throw new BrandKitException(
'invalid_response',
false,
'Brand Kit response failed schema validation.',
$e
);
}
}
if (in_array($response->status(), [401, 403], true)) {
throw new BrandKitException(
'authentication',
false,
'Brand Kit service rejected its token.'
);
}
if ($response->status() === 422) {
throw new BrandKitException(
'request_validation',
false,
'Brand Kit service rejected the website URL.'
);
}
if ($response->status() === 429 || $response->serverError()) {
if ($attempt < 3) {
$this->pause($attempt, $response->header('Retry-After'), $url);
continue;
}
throw new BrandKitException(
'upstream_transient',
true,
'Brand Kit service remained unavailable or rate-limited.'
);
}
throw new BrandKitException(
'upstream_rejection',
false,
"Brand Kit service returned HTTP {$response->status()}."
);
}
throw new BrandKitException('unexpected', true, 'Extraction did not complete.');
}
private function pause(int $attempt, ?string $retryAfter, string $url): void
{
$seconds = ctype_digit((string) $retryAfter)
? min(4, max(1, (int) $retryAfter))
: min(4, 2 ** ($attempt - 1));
Log::warning('Brand kit request will be retried.', [
'attempt' => $attempt,
'host' => parse_url($url, PHP_URL_HOST),
'delay_seconds' => $seconds,
]);
usleep($seconds * 1_000_000);
}
}
Забележете што отсуствува од дневникот: токенот, телото на одговорот, целосната URL-адреса и извлечените податоци од социјалните мрежи. Име на домаќин, број на обид, категорија на статус и идентификатор на работниот простор обично се доволни за истражување на оперативни неуспеси.
Создадете го работниот простор и испратете извлекување
Контролерот одбива шеми што не се HTTP, локални имиња на домаќини и изречно приватни IP-адреси. За построги производи, додајте политика за одобрени домени или потврда на сопственост наместо да прифаќате произволен внес од клиентите.
<?php
namespace App\Http\Controllers;
use App\Jobs\ExtractBrandKit;
use App\Models\Workspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class WorkspaceController extends Controller
{
public function store(Request $request): JsonResponse
{
$input = $request->validate([
'name' => ['required', 'string', 'max:255'],
'website_url' => ['required', 'url', 'max:2048'],
]);
$this->assertPublicUrl($input['website_url']);
$workspace = DB::transaction(function () use ($input): Workspace {
$workspace = Workspace::create($input);
ExtractBrandKit::dispatch($workspace->id)->afterCommit();
return $workspace;
});
return response()->json($workspace, 202);
}
public function show(Workspace $workspace): JsonResponse
{
return response()->json($workspace);
}
private function assertPublicUrl(string $url): void
{
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
$host = strtolower((string) parse_url($url, PHP_URL_HOST));
$invalidHost = $host === ''
|| $host === 'localhost'
|| str_ends_with($host, '.local');
if (filter_var($host, FILTER_VALIDATE_IP)) {
$invalidHost = ! filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
}
if (! in_array($scheme, ['http', 'https'], true) || $invalidHost) {
throw ValidationException::withMessages([
'website_url' => 'Enter a public HTTP or HTTPS website.',
]);
}
}
}
Заштитете ги двете рути со вообичаените политики на вашата апликација за автентикација и авторизација на работен простор:
use App\Http\Controllers\WorkspaceController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function (): void {
Route::post('/workspaces', [WorkspaceController::class, 'store']);
Route::get('/workspaces/{workspace}', [WorkspaceController::class, 'show']);
});
Извршете ја идемпотентната задача во редица
<?php
namespace App\Jobs;
use App\Exceptions\BrandKitException;
use App\Models\Workspace;
use App\Services\BrandKitClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
final class ExtractBrandKit implements ShouldQueue
{
use Queueable;
public int $tries = 4;
public int $timeout = 100;
public array $backoff = [60, 300, 900];
public function __construct(public readonly int $workspaceId) {}
public function handle(BrandKitClient $client): void
{
$workspace = Workspace::find($this->workspaceId);
if (! $workspace || $workspace->brand_status === 'ready') {
return;
}
$workspace->forceFill([
'brand_status' => 'processing',
'brand_error' => null,
])->save();
try {
$kit = $client->extract($workspace->website_url);
} catch (BrandKitException $e) {
$workspace->forceFill([
'brand_status' => $e->retryable ? 'retrying' : 'failed',
'brand_error' => $e->kind,
])->save();
if ($e->retryable) {
throw $e;
}
return;
}
$workspace->forceFill([
'brand_status' => 'ready',
'brand_name' => $kit->name,
'brand_logos' => $kit->logos,
'brand_colors' => $kit->colors,
'brand_fonts' => $kit->fonts,
'brand_imagery' => $kit->imagery,
'brand_social_profiles' => $kit->socialProfiles,
'brand_css_variables' => $kit->cssVariables,
'brand_error' => null,
])->save();
}
public function failed(Throwable $exception): void
{
Workspace::whereKey($this->workspaceId)->update([
'brand_status' => 'failed',
'brand_error' => 'retries_exhausted',
]);
}
}
Тестирајте без повикување на вистинската услуга
Http::fake() ја прави API границата детерминистичка и спречува тестовите да трошат квота. Овој тест ги проверува автентикацијата, обликот на барањето, мапирањето на одговорот и складирањето преку задачата.
<?php
namespace Tests\Feature;
use App\Jobs\ExtractBrandKit;
use App\Models\Workspace;
use App\Services\BrandKitClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class ExtractBrandKitTest extends TestCase
{
use RefreshDatabase;
public function test_it_prefills_workspace_brand_data(): void
{
config()->set('services.brand_kit.endpoint', 'https://brand.test/extract');
config()->set('services.brand_kit.token', 'test-token');
Http::preventStrayRequests();
Http::fake([
'https://brand.test/extract' => Http::response([
'brand_name' => 'Example',
'logos' => [['url' => 'https://example.com/logo.svg']],
'colors' => [['value' => '#112233']],
'fonts' => [['family' => 'Example Sans']],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--brand-primary' => '#112233'],
], 200),
]);
$workspace = Workspace::create([
'name' => 'Example workspace',
'website_url' => 'https://example.com',
]);
(new ExtractBrandKit($workspace->id))
->handle(app(BrandKitClient::class));
$workspace->refresh();
$this->assertSame('ready', $workspace->brand_status);
$this->assertSame('Example', $workspace->brand_name);
$this->assertSame('#112233', $workspace->brand_colors[0]['value']);
Http::assertSent(fn ($request) =>
$request->url() === 'https://brand.test/extract'
&& $request['url'] === 'https://example.com'
&& $request->hasHeader('Authorization', 'Bearer test-token')
);
}
}
Додајте одделни случаи за 401, 422, 429, неисправен JSON, полиња што недостигаат и исцрпен неуспех на поврзување. Потврдете дека трајните грешки завршуваат како failed, додека привремените грешки повторно се фрлаат за одложениот повторен обид на редицата.
Безбедност, операции и распоредување
- Третирајте ја извлечената содржина како недоверлива. Екранирајте ги имињата и метаподатоците при прикажување. Не вметнувајте ги вратените CSS-променливи директно во глобален stylesheet; дозволете само имиња на својства и формати на вредности што вашиот производ изречно ги поддржува.
- Внимателно ракувајте со оддалечените средства. URL-адресите на логотипи и слики може да се променат или да ги следат посетителите. Применете соодветна Content Security Policy или преземете и валидирајте ги средствата преку контролиран процес на внесување пред да ги послужувате како медиуми на работниот простор.
- Минимизирајте ги складираните грешки. Складирајте стабилни категории како
authenticationилиrequest_validation, а не тела од upstream одговори што може да содржат чувствителни информации. - Следете ги исходите. Следете го бројот и староста на работните простори во pending, retrying, ready и failed состојба. Стар pending запис е поупотреблив од генеричко предупредување за длабочина на редицата.
- Ротирајте безбедно. Бидејќи повторното генерирање го поништува стариот сервисен токен, координирајте ја замената на тајната со повторно вчитување на конфигурацијата и рестартирање на работниците во редицата.
Распоредете ја миграцијата и кешираната конфигурација пред да ги рестартирате работниците:
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --tries=4 --timeout=120
Осигурете се дека retry_after на конекцијата на редицата го надминува временското ограничување на работникот, за друг работник да не ја преземе истата задача додека сè уште се извршува. Во надгледуван продукциски процес, менаџерот на процеси треба да извршува queue:work; командата погоре е обликот на работникот, а не замена за надзор.
Вообичаени неуспеси и конечна проверка
401 или 403 обично значи дека токенот недостига, е погрешно внесен, поништен или недостапен бидејќи конфигурацијата била кеширана пред да се промени околината. 422 покажува дека поднесената URL-адреса била одбиена и треба да се поправи, а не да се повтори. Повторените одговори 429 укажуваат на квота или ограничувања на стапката; задржете го backoff-от и прегледајте го активниот план наместо да ја зголемувате конкурентноста.
Ако работните простори остануваат во pending, потврдете дека работник во редица работи со истата конекција на редицата како веб-апликацијата. Ако извлекувањето успее, но интерфејсот остане празен, проверете ги складираните низи и мапирањето на полињата во frontend-от пред да ја ослабите валидацијата на границата.
- Сметката и Free, Plus или Pro планот се активни.
- Токенот ограничен на услугата е присутен во конфигурацијата поддржана од околината.
- Рачен POST до точната крајна точка успева со JSON
url. - Создавањето работен простор враќа HTTP
202без да чека на извлекувањето. - Потврдената трансакција испраќа точно една задача во редица.
- Статусот напредува од
pendingдоprocessing, а потоа доready. - Името на брендот, логотипите, боите, фонтовите, сликите, профилите на социјалните мрежи и CSS-променливите се валидираат пред складирање.
- Неуспесите на автентикација и валидација не се повторуваат.
- Ограничувањата на стапката, серверските неуспеси и неуспесите на поврзување користат ограничени повторни обиди и backoff.
- Дневниците и тестовите не содржат вистински сервисен токен или чувствително тело на одговор.
Исполираниот резултат не е само API повик. Тоа е работен простор што се појавува веднаш, безбедно се збогатува во заднина, чесно известува за неуспех и им дава на корисниците практична почетна точка наместо празно платно. Тоа е разликата помеѓу прикачување AI услуга и нејзино претворање во сигурна функционалност на производот.