Vodiči

Laravel: Auto-Brand New Client Workspaces with AI Extracted Logo, Colors, Fonts

Laravel: Automatski izradite potpuno nova radna okruženja klijenata s logotipom, bojama i fontovima izdvojenima pomoću AI-ja

Prazan radni prostor klijenta odmah stvara poteškoće. Netko mora pronaći ispravan logotip, prekopirati boje s web-stranice, utvrditi fontove i sve to pretvoriti u upotrebljive postavke prije nego što stvarni rad može započeti.

Ova Laravel implementacija uklanja taj trošak postavljanja. Stvaranje radnog prostora odmah vraća upotrebljiv zapis, a zatim zadatak u redu šalje javnu web-stranicu klijenta API-ju Brand Kit Extractor. Rezultat se validira na granici aplikacije i pohranjuje kao strukturirani podaci o logotipu, bojama, fontovima, slikama, društvenim profilima i CSS varijablama.

Važan detalj za produkciju jest da je ekstrakcija asinkrona. Udaljena web-stranica može biti spora, nedostupna ili ograničena stopom zahtjeva; nijedan od tih uvjeta ne bi trebao učiniti stvaranje radnog prostora neispravnim.

Dobijte pristup i izradite servisni token

Najprije se registrirajte za račun ili upotrijebite stranicu za prijavu ako ga već imate.

  1. Otvorite stranicu usluge Brand Kit Extractor.
  2. Odaberite dostupni paket Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
  5. Pohranite ga u upravitelj lozinki i spremište tajni platforme za implementaciju.

Ova usluga zahtijeva autentifikaciju; nije krajnja točka bez tokena. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer token jer je vjerojatnije da će se parametri upita pojaviti u zapisnicima proxyja i pristupa.

Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao operaciju implementacije: ažurirajte svako okruženje koje koristi uslugu, implementirajte ili ponovno učitajte konfiguraciju, a tek zatim provjerite ekstrakciju.

Potvrdite točan API poziv

Zahtjev je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit, s JSON tijelom koje sadrži url. Prije pisanja Laravel koda pošaljite jedan minimalan zahtjev iz pouzdanog terminala:

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"}'

Nemojte lijepiti dobiveni token ili odgovor u kontrolu izvornog koda. Stavite vjerodajnicu u Laravelovu lokalnu datoteku .env i u produkciji upotrijebite konfiguraciju okruženja platforme za implementaciju:

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

Arhitektura i struktura projekta

Ova značajka ima dvije zasebne transakcije. HTTP zahtjev stvara radni prostor sa statusom pending. Nakon potvrde transakcije baze podataka, zadatak u redu obavlja ekstrakciju i mijenja status u ready, retrying ili failed.

To razdvajanje održava predvidivom latenciju koju vidi korisnik i daje prolaznim neuspjesima kontrolirani put ponovnog pokušaja. Kompromis je eventualna konzistentnost: radni prostor postoji prije podataka o robnoj marki, pa sučelje mora prikazivati trenutačni status i provjeravati ga ili osvježavati dok ekstrakcija ne završi.

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

Potrebni su vam PHP 8.3 ili noviji, postojeća Laravel aplikacija, podržana baza podataka i konfigurirani pozadinski sustav reda. Red temeljen na bazi podataka dovoljan je za malu instalaciju; izradite njegovu tablicu ako je aplikacija već nema:

php artisan make:queue-table
php artisan migrate

Konfigurirajte Laravel i trajno pohranite eksplicitna stanja

Dodajte jedan unos podržan varijablama okruženja u config/services.php. Mogućnost konfiguriranja krajnje točke olakšava testiranje, dok zadana vrijednost zadržava točan produkcijski 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),
],

Radni prostor pohranjuje svaki dio neovisno. To čini pristup na razini aplikacije jednostavnim i izbjegava tretiranje nevalidiranog API odgovora kao neprozirnog JSON bloba.

<?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');
    }
};

U app/Models/Workspace.php učinite polja koja unosi korisnik dodjeljivima i pretvorite ekstrahirane kolekcije:

<?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',
        ];
    }
}

Validirajte odgovor na API granici

Uspješan HTTP status ne čini udaljene podatke pouzdanima. Donji mapper zahtijeva potpuni ugovor: naziv robne marke, logotipe, boje, fontove, slike, društvene profile i CSS varijable. Također odbacuje pretjerano ugnježđivanje i odgovore veće od 512 KiB prije nego što išta dosegne bazu podataka.

<?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.');
        }
    }
}

Ova validacija namjerno čuva dokaze i metapodatke unutar kolekcija umjesto da pretpostavlja da je svaki logotip niz znakova ili da je svaka boja heksadekadska vrijednost. Normalizacija specifična za sučelje pripada drugom sloju nakon što potvrdite dokumentirane oblike koje vaše sučelje koristi.

Izradite ograničeni HTTP klijent sa selektivnim ponovnim pokušajima

Izradite malu iznimku koja redu govori je li ponovni pokušaj koristan:

<?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);
    }
}

Klijent ponovno pokušava kod neuspjeha veze, 429 i pogrešaka poslužitelja. Neuspjesi autentifikacije, neuspjesi validacije zahtjeva i neispravni uspješni odgovori ne pokušavaju se ponovno naslijepo.

<?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);
    }
}

Primijetite što nedostaje u zapisniku: token, tijelo odgovora, puni URL i ekstrahirani društveni podaci. Naziv hosta, broj pokušaja, kategorija statusa i identifikator radnog prostora obično su dovoljni za istraživanje operativnih neuspjeha.

Izradite radni prostor i pošaljite ekstrakciju

Kontroler odbacuje ne-HTTP sheme, lokalne nazive hostova i izričito privatne IP adrese. Za strože proizvode dodajte politiku odobrenih domena ili provjeru vlasništva umjesto prihvaćanja proizvoljnog unosa korisnika.

<?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.',
            ]);
        }
    }
}

Zaštitite obje rute uobičajenim pravilima autentifikacije i autorizacije radnog prostora vaše aplikacije:

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']);
});

Pokrenite idempotentni zadatak reda

<?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',
        ]);
    }
}

Testirajte bez pozivanja stvarne usluge

Http::fake() čini API granicu determinističkom i sprječava trošenje kvote u testovima. Ovaj test provjerava autentifikaciju, oblik zahtjeva, mapiranje odgovora i pohranu putem zadatka.

<?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')
        );
    }
}

Dodajte zasebne slučajeve za 401, 422, 429, neispravan JSON, nedostajuća polja i iscrpljeni neuspjeh veze. Potvrdite da trajne pogreške završavaju kao failed, dok se prolazne pogreške ponovno bacaju za odgođeni ponovni pokušaj reda.

Sigurnost, operacije i implementacija

  • Ekstrahirani sadržaj tretirajte kao nepouzdan. Izbjegavajte nazive i metapodatke pri prikazivanju. Ne umećite vraćene CSS varijable izravno u globalni stilski list; dopustite samo nazive svojstava i formate vrijednosti koje vaš proizvod izričito podržava.
  • Pažljivo postupajte s udaljenim resursima. URL-ovi logotipa i slika mogu se promijeniti ili pratiti posjetitelje. Primijenite odgovarajuću politiku sigurnosti sadržaja ili dohvatite i validirajte resurse kroz kontrolirani postupak unosa prije njihova posluživanja kao medija radnog prostora.
  • Minimizirajte pohranjene pogreške. Pohranite stabilne kategorije kao što su authentication ili request_validation, a ne uzvodna tijela odgovora koja mogu sadržavati osjetljive informacije.
  • Pratite ishode. Pratite broj i starost radnih prostora u stanjima pending, retrying, ready i failed. Stari zapis pending korisniji je od generičkog upozorenja o dubini reda.
  • Sigurno rotirajte. Budući da ponovno generiranje opoziva stari servisni token, uskladite zamjenu tajne s ponovnim učitavanjem konfiguracije i ponovnim pokretanjem radnika reda.

Implementirajte migraciju i predmemoriranu konfiguraciju prije ponovnog pokretanja radnika:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --tries=4 --timeout=120

Osigurajte da retry_after veze reda premašuje vremensko ograničenje radnika kako drugi radnik ne bi preuzeo isti zadatak dok je još pokrenut. U nadziranom produkcijskom procesu upravitelj procesa treba pokretati queue:work; gornja naredba prikazuje oblik radnika, a nije zamjena za nadzor.

Uobičajeni neuspjesi i završna provjera

401 ili 403 obično znači da token nedostaje, pogrešno je upisan, opozvan je ili nije dostupan jer je konfiguracija predmemorirana prije promjene okruženja. 422 označava da je poslani URL odbijen i treba ga ispraviti, a ne ponavljati zahtjev. Ponovljeni odgovori 429 upućuju na kvotu ili ograničenja stope; zadržite odgodu i pregledajte aktivni paket umjesto povećanja konkurentnosti.

Ako radni prostori ostaju u stanju pending, potvrdite da radnik reda radi na istoj vezi reda kao i web-aplikacija. Ako ekstrakcija uspije, ali sučelje ostane prazno, pregledajte pohranjena polja i mapiranje polja na frontend strani prije slabljenja validacije granice.

  • Račun i paket Free, Plus ili Pro su aktivni.
  • Token ograničen na uslugu prisutan je u konfiguraciji podržanoj varijablama okruženja.
  • Ručni POST na točnu krajnju točku uspijeva s JSON url.
  • Stvaranje radnog prostora vraća HTTP 202 bez čekanja na ekstrakciju.
  • Potvrđena transakcija šalje točno jedan zadatak u red.
  • Status napreduje od pending do processing, a zatim do ready.
  • Naziv robne marke, logotipi, boje, fontovi, slike, društveni profili i CSS varijable validiraju se prije pohrane.
  • Neuspjesi autentifikacije i validacije ne pokušavaju se ponovno.
  • Ograničenja stope, neuspjesi poslužitelja i neuspjesi veze koriste ograničene ponovne pokušaje i odgodu.
  • Zapisnici i testni primjeri ne sadrže stvarni servisni token ni osjetljivo tijelo odgovora.

Ugladeni rezultat nije samo API poziv. To je radni prostor koji se pojavljuje odmah, sigurno se obogaćuje u pozadini, iskreno prijavljuje neuspjeh i korisnicima daje praktično polazište umjesto praznog platna. To je razlika između priključivanja AI usluge i pretvaranja nje u pouzdanu značajku proizvoda.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.