Zadzwonił do mnie w piątek wieczorem. Głos spokojny, ale słyszałem w nim zmęczenie człowieka, który od tygodni walczy z problemem, na który nie ma wpływu. Prowadzi firmę usługową obsługującą klientów w czterech krajach europejskich. Zapłacił agencji za profesjonalną platformę internetową. Dostał coś, co wyglądało jak profesjonalna platforma. Pod spodem kryła się katastrofa.
Ze względu na umowę poufności nie mogę ujawnić nazwy firmy ani branży. Mogę natomiast opowiedzieć dokładnie, co zastałem, co zrobiłem i dlaczego wybrałem takie, a nie inne technologie. Ta historia to przestroga dla każdego, kto zleca budowę platformy zewnętrznej agencji.
Co zastałem po audycie platformy od agencji
Pierwszy krok to zawsze audyt. Nie oceniam, nie krytykuję. Zbieram fakty. Po trzech dniach analizy miałem pełny obraz.
Spaghetti code na PHP 5.6: agencja nie użyła żadnego frameworka. Cała platforma to monolityczny kod proceduralny w PHP 5.6 (koniec wsparcia w 2018 roku) z zapytaniami SQL wklejanymi bezpośrednio w szablony HTML. Żadnego ORM-a, żadnej warstwy abstrakcji, żadnego routera. Pliki po 3000 linii mieszające logikę biznesową z prezentacją.
// Znaleziony kod -- zapytanie SQL bezpośrednio w szablonie (zanonimizowany)
<?php
$result = mysql_query("SELECT * FROM services
WHERE category = '" . $_GET['cat'] . "'
ORDER BY id DESC");
// SQL injection -- brak jakiejkolwiek walidacji danych wejściowych
while ($row = mysql_fetch_assoc($result)) {
echo "<div class='service'>";
echo "<h2>" . $row['title'] . "</h2>"; // XSS -- brak escapowania
echo "<p>" . $row['description'] . "</p>";
echo "</div>";
}
?>
MySQL 5.5 bez indeksów: baza danych z 47 tabelami, żadna nie miała indeksów poza kluczami głównymi. Zapytanie listujące usługi z filtrami wykonywało full table scan na 200 000 rekordach, a średni czas odpowiedzi wynosił 4.7 sekundy.
jQuery 1.x + Bootstrap 3: frontend z 2014 roku. Dwanaście plików jQuery ładowanych na każdej stronie, w tym trzy różne wersje biblioteki. Brak minifikacji, brak bundlera, brak tree-shaking. Łączna waga skryptów: 2.8 MB.
FTP jako “deployment”: brak repozytorium Git, brak CI/CD, brak środowiska staging. Agencja wrzucała pliki bezpośrednio przez FTP na serwer produkcyjny. Żadnego systemu kontroli wersji. Brak testów.
Zerowe bezpieczeństwo: hasła użytkowników przechowywane w MD5 bez soli. Sesje w plikach na serwerze współdzielonym. SQL injection w 23 miejscach. XSS w formularzach. Brak CSRF tokenów. Brak HTTPS na panelu logowania.
Metryki zastane
| Metryka | Wartość | Ocena |
|---|---|---|
| PageSpeed (mobile) | 18 | Krytyczny |
| LCP | 12.4s | Krytyczny |
| INP | 1100ms | Krytyczny |
| CLS | 0.52 | Krytyczny |
| TTFB | 4.7s | Krytyczny |
| Waga strony | 11.2 MB | Nadmierna |
| Czas odpowiedzi API | 4.7s (avg) | Krytyczny |
| Ruch organiczny | Spadek 72% r/r | Krytyczny |
| Wykryte podatności | 23 SQL injection, 14 XSS | Krytyczny |
Najgorsze było to, że klient nie wiedział o żadnym z tych problemów. Agencja przez rok wysyłała mu raporty z “optymalizacji”, które nie miały pokrycia w rzeczywistości.
Dlaczego wybrałem ten stos technologiczny
Decyzja o architekturze docelowej to najważniejszy moment projektu. Klient miał uzasadnione obawy. Poprzednia agencja obiecała “nowoczesne rozwiązanie” i dostarczyła kod z 2014 roku. Musiałem wybrać technologie, które rozwiążą konkretne problemy, nie te które są akurat modne.
Przeanalizowałem wymagania i dopasowałem narzędzia do zadań:
Python + Django (backend API): klient potrzebował solidnego backendu z panelem administracyjnym, uwierzytelnianiem, walidacją danych i REST API. Django daje to wszystko z pudełka. Django REST Framework to dojrzały, stabilny ekosystem z doskonałą dokumentacją. Klient obsługuje 4 rynki europejskie. Django ma wbudowaną internacjonalizację.
PostgreSQL (baza danych): migracja z MySQL 5.5 na PostgreSQL to nie kaprys. PostgreSQL oferuje lepsze indeksy (GIN, GiST do wyszukiwania pełnotekstowego), lepsze typy danych (JSONB, arrays), dojrzałe partycjonowanie tabel i niezawodne transakcje ACID. Dla 200 000 rekordów z wielojęzycznym wyszukiwaniem pełnotekstowym to naturalny wybór.
Redis (cache i kolejki): czas odpowiedzi API z 4.7 sekundy musiał spaść poniżej 100 milisekund. Redis cachuje wyniki zapytań, przechowuje sesje użytkowników i obsługuje kolejki zadań asynchronicznych (Celery). Jedno narzędzie, trzy krytyczne funkcje.
React + TypeScript (frontend interaktywny): dashboard klienta, wyszukiwarka z filtrami, formularze wieloetapowe. To wszystko wymaga reaktywnego UI. React z TypeScript daje typowane komponenty, doskonałe narzędzia deweloperskie i ogromny ekosystem bibliotek.
Rust (mikroserwis wydajnościowy): indeksowanie wyszukiwania 200 000 rekordów w 4 wersjach językowych, przetwarzanie plików CSV/Excel od klientów, transformacje danych. Te zadania wymagały surowej wydajności. Rust przetwarza indeks wyszukiwania w 1.8 sekundy zamiast 47 sekund w starej implementacji PHP. To nie jest różnica procentowa. To różnica o rząd wielkości.
Astro (strona marketingowa): strona główna, blog, oferta, strony usług. To treści statyczne, które nie potrzebują JavaScriptu. Astro generuje czysty HTML z zerowymi kosztami runtime. Interaktywne elementy (wyszukiwarka, formularz kontaktowy) działają jako izolowane wyspy React. Jako profesjonalny programista Astro wybrałem ten framework dla strony publicznej.
Architektura docelowa:
┌─────────────────────────────────────────────────────┐
│ Cloudflare CDN │
├──────────────┬───────────────┬────────────────────────┤
│ Astro SSG │ React SPA │ Django REST API │
│ (marketing) │ (dashboard) │ (backend) │
│ HTML/CSS │ TypeScript │ Python 3.12 │
├──────────────┴───────────────┴────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ Rust service │ │
│ │ (primary DB) │ │ (cache) │ │ (search index, │ │
│ │ │ │ (queue) │ │ data processing│ │
│ └─────────────┘ └──────────┘ └──────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Python AI pipeline (content processing, NLP) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Backend API w Django REST Framework
Serce nowej platformy to Django z Django REST Framework. Zbudowałem API obsługujące wielojęzyczny katalog usług, system zapytań od klientów, uwierzytelnianie JWT i panel administracyjny.
# services/models.py -- model usługi z wielojęzycznością
from django.db import models
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
class Service(models.Model):
slug = models.SlugField(max_length=200, unique=True)
category = models.ForeignKey(
'Category', on_delete=models.PROTECT, related_name='services'
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
class ServiceTranslation(models.Model):
service = models.ForeignKey(
Service, on_delete=models.CASCADE, related_name='translations'
)
language = models.CharField(max_length=5, choices=[
('pl', 'Polski'), ('en', 'English'),
('de', 'Deutsch'), ('fr', 'Français'),
])
title = models.CharField(max_length=200)
description = models.TextField()
meta_title = models.CharField(max_length=70)
meta_description = models.CharField(max_length=160)
search_vector = SearchVectorField(null=True)
class Meta:
unique_together = ['service', 'language']
indexes = [
GinIndex(fields=['search_vector']),
models.Index(fields=['language', 'service']),
]
# services/serializers.py -- serializator z walidacją
from rest_framework import serializers
class ServiceSerializer(serializers.ModelSerializer):
translations = ServiceTranslationSerializer(many=True, read_only=True)
category_name = serializers.CharField(
source='category.name', read_only=True
)
class Meta:
model = Service
fields = [
'id', 'slug', 'category_name',
'is_active', 'translations', 'created_at',
]
# services/views.py -- widoki API z cache Redis
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
class ServiceViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Service.objects.filter(
is_active=True
).select_related(
'category'
).prefetch_related(
'translations'
)
serializer_class = ServiceSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ['category__slug']
search_fields = ['translations__title', 'translations__description']
@method_decorator(cache_page(60 * 15)) # Cache 15 minut
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
Konfiguracja Redis jako backend cache i broker kolejek Celery:
# settings.py -- konfiguracja Redis
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/0',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'SERIALIZER': 'django_redis.serializers.json.JSONSerializer',
'CONNECTION_POOL_KWARGS': {'max_connections': 50},
},
'KEY_PREFIX': 'platform',
'TIMEOUT': 900, # 15 minut domyślnie
}
}
# Sesje w Redis (szybsze niż baza danych)
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'
# Celery z Redis jako broker
CELERY_BROKER_URL = 'redis://127.0.0.1:6379/1'
CELERY_RESULT_BACKEND = 'redis://127.0.0.1:6379/2'
CELERY_TASK_SERIALIZER = 'json'
Wpływ po wdrożeniu Django + PostgreSQL + Redis: średni czas odpowiedzi API spadł z 4.7 sekundy do 45 milisekund. Zapytania z cache Redis obsługiwane w 3-5 milisekund.
Mikroserwis Rust do indeksowania wyszukiwania
Najciekawsze wyzwanie techniczne to wyszukiwarka. Klient ma katalog 200 000 rekordów w 4 wersjach językowych. Stara implementacja PHP wykonywała LIKE '%term%' na MySQL bez indeksów: 47 sekund na zapytanie. Nieużywalne.
PostgreSQL z GIN indexami i tsvector rozwiązał problem dla standardówych zapytań. Ale klient potrzebował też:
- wyszukiwania z tolerancją na literówki (fuzzy matching),
- filtrowania po wielu atrybutach jednocześnie z natychmiastowymi wynikami,
- przebudowy indeksu po imporcie danych z plików CSV/Excel.
Do tych zadań zbudowałem mikroserwis w Rust z biblioteką Tantivy (odpowiednik Apache Lucene dla Rust):
// search-service/src/indexer.rs -- indeksowanie wyszukiwania w Rust
use tantivy::{
schema::*, doc, Index, IndexWriter,
tokenizer::NgramTokenizer,
};
use serde::Deserialize;
use std::time::Instant;
#[derive(Deserialize)]
pub struct ServiceRecord {
pub id: i64,
pub slug: String,
pub title: String,
pub description: String,
pub category: String,
pub language: String,
pub attributes: Vec<String>,
}
pub struct SearchIndexer {
index: Index,
schema: Schema,
}
impl SearchIndexer {
pub fn new(index_path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let mut schema_builder = Schema::builder();
schema_builder.add_i64_field("id", STORED | INDEXED);
schema_builder.add_text_field("slug", STORED);
schema_builder.add_text_field("title", TEXT | STORED);
schema_builder.add_text_field("description", TEXT | STORED);
schema_builder.add_text_field("category", STRING | STORED);
schema_builder.add_text_field("language", STRING | STORED);
schema_builder.add_text_field("attributes", TEXT | STORED);
// N-gram field for fuzzy/partial matching
schema_builder.add_text_field("title_ngram", TEXT);
let schema = schema_builder.build();
let index = Index::create_in_dir(index_path, schema.clone())?;
// Register n-gram tokenizer for typo tolerance
let ngram_tokenizer = NgramTokenizer::new(2, 4, false)
.expect("Failed to create ngram tokenizer");
index
.tokenizers()
.register("ngram", ngram_tokenizer);
Ok(Self { index, schema })
}
pub fn build_index(
&self,
records: Vec<ServiceRecord>,
) -> Result<usize, Box<dyn std::error::Error>> {
let start = Instant::now();
let mut writer: IndexWriter = self.index.writer(128_000_000)?; // 128MB buffer
let title_field = self.schema.get_field("title").unwrap();
let description_field = self.schema.get_field("description").unwrap();
let title_ngram_field = self.schema.get_field("title_ngram").unwrap();
let count = records.len();
for record in records {
writer.add_document(doc!(
self.schema.get_field("id").unwrap() => record.id,
self.schema.get_field("slug").unwrap() => record.slug,
title_field => record.title.clone(),
description_field => record.description,
self.schema.get_field("category").unwrap() => record.category,
self.schema.get_field("language").unwrap() => record.language,
self.schema.get_field("attributes").unwrap() =>
record.attributes.join(" "),
title_ngram_field => record.title,
))?;
}
writer.commit()?;
let duration = start.elapsed();
println!(
"Indexed {} records in {:.2}s",
count,
duration.as_secs_f64()
);
Ok(count)
}
}
HTTP API w Rust z frameworkiem Actix-web:
// search-service/src/main.rs -- API wyszukiwania
use actix_web::{web, App, HttpServer, HttpResponse};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct SearchQuery {
q: String,
lang: Option<String>,
category: Option<String>,
limit: Option<usize>,
}
#[derive(Serialize)]
struct SearchResult {
id: i64,
slug: String,
title: String,
excerpt: String,
category: String,
score: f32,
}
async fn search(
query: web::Query<SearchQuery>,
indexer: web::Data<SearchIndexer>,
) -> HttpResponse {
let limit = query.limit.unwrap_or(20);
let lang = query.lang.as_deref().unwrap_or("pl");
let results = indexer.search(
&query.q, lang, query.category.as_deref(), limit
);
HttpResponse::Ok().json(results)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let indexer = SearchIndexer::new("./search_index")
.expect("Failed to create indexer");
let indexer_data = web::Data::new(indexer);
HttpServer::new(move || {
App::new()
.app_data(indexer_data.clone())
.route("/search", web::get().to(search))
.route("/health", web::get().to(|| async {
HttpResponse::Ok().body("ok")
}))
})
.bind("127.0.0.1:8081")?
.run()
.await
}
Wyniki benchmarku mikroserwisu Rust:
| Operacja | Stary PHP | Nowy Rust | Poprawa |
|---|---|---|---|
| Budowa indeksu (200k rekordów) | 47s | 1.8s | 26x szybciej |
| Wyszukiwanie proste | 4.7s | 2ms | 2350x szybciej |
| Wyszukiwanie z filtrami | 8.3s | 5ms | 1660x szybciej |
| Fuzzy matching (literówki) | Brak | 8ms | Nowa funkcja |
| Zużycie pamięci | 512 MB | 84 MB | 6x mniej |
Rust nie był wyborem “bo jest modny”. Był wyborem, bo dla tego konkretnego zadania: przetwarzania 200 000 rekordów z indeksowaniem n-gramowym i fuzzy matchingiem, daje wydajność nieosiągalną dla interpreterów.
Frontend React z interaktywnym dashboardem
Dashboard klienta wymagał reaktywnego UI: tabele z sortowaniem, filtry wielopoziomowe, formularze wieloetapowe, wykresy z danymi w czasie rzeczywistym. React z TypeScript to naturalny wybór.
// src/components/ServiceSearch.tsx -- wyszukiwarka z filtrami
import { useState, useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useDebounce } from '@/hooks/useDebounce';
interface SearchResult {
id: number;
slug: string;
title: string;
excerpt: string;
category: string;
score: number;
}
interface SearchFilters {
category: string | null;
language: string;
}
export function ServiceSearch({ locale }: { locale: string }) {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState<SearchFilters>({
category: null,
language: locale,
});
const debouncedQuery = useDebounce(query, 300);
const { data: results, isLoading } = useQuery<SearchResult[]>({
queryKey: ['search', debouncedQuery, filters],
queryFn: async () => {
const params = new URLSearchParams({
q: debouncedQuery,
lang: filters.language,
...(filters.category && { category: filters.category }),
});
const res = await fetch(`/api/search?${params}`);
return res.json();
},
enabled: debouncedQuery.length >= 2,
staleTime: 5 * 60 * 1000, // 5 minut cache
});
const handleCategoryChange = useCallback((category: string | null) => {
setFilters(prev => ({ ...prev, category }));
}, []);
return (
<div className="search-container">
<div className="relative">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={locale === 'pl' ? 'Szukaj usług...' : 'Search services...'}
className="w-full px-4 py-3 rounded-lg border border-gray-200
dark:border-gray-700 bg-white dark:bg-gray-800
focus:ring-2 focus:ring-emerald-500 focus:outline-none"
/>
{isLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<span className="animate-spin h-5 w-5 border-2
border-emerald-500 border-t-transparent rounded-full
inline-block" />
</div>
)}
</div>
{results && results.length > 0 && (
<div className="mt-4 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{results.map((result) => (
<a
key={result.id}
href={`/${locale}/${result.slug}/`}
className="block p-4 rounded-lg border border-gray-100
dark:border-gray-700 hover:border-emerald-500
transition-colors"
>
<span className="text-xs font-medium text-emerald-600
dark:text-emerald-400">
{result.category}
</span>
<h3 className="mt-1 font-semibold text-gray-900
dark:text-white">
{result.title}
</h3>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400
line-clamp-2">
{result.excerpt}
</p>
</a>
))}
</div>
)}
</div>
);
}
Komponent React jest osadzony w stronie Astro jako interaktywna wyspa:
---
// src/pages/[lang]/services.astro -- strona usług
import { ServiceSearch } from '../../components/ServiceSearch';
import Layout from '../../layouts/Layout.astro';
---
<Layout title="Usługi">
<section class="services-hero">
<h1>Nasze usługi</h1>
</section>
<!-- Wyspa React -- ładuje się przy scrollu, nie blokuje reszty strony -->
<ServiceSearch client:visible locale={Astro.params.lang} />
</Layout>
Dzięki architekturze Islands strona marketingowa ładuje 0 KB JavaScriptu domyślnie. Komponent wyszukiwarki (38 KB gzipped) ładuje się dopiero gdy użytkownik przewinie do sekcji.
Pipeline AI do przetwarzania treści
Migracja 1200 stron treści z brudnego HTML na czysty Markdown wymagała automatyzacji. Wiele stron miało niekompletne metadane SEO, brakujące opisy, nieoptymalne nagłówki. Zamiast poprawiać je ręcznie, zbudowałem pipeline w Pythonie wykorzystujący niestandardówy model językowy.
Pipeline działał w trzech fazach:
# ai_content_pipeline.py -- przetwarzanie treści z AI
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import markdownify
@dataclass
class ContentAnalysis:
entities: list[dict] = field(default_factory=list)
meta_description: str = ''
heading_issues: list[str] = field(default_factory=list)
word_count: int = 0
is_thin: bool = False
suggested_internal_links: list[str] = field(default_factory=list)
def clean_legacy_html(raw_html: str) -> str:
"""Faza 1: Czyszczenie brudnego HTML z legacy platformy."""
# Usuwanie inline styles, komentarzy warunkowych IE, pustych tagów
cleaned = re.sub(r'style="[^"]*"', '', raw_html)
cleaned = re.sub(r'<!--\[if.*?\]>.*?<!\[endif\]-->', '', cleaned,
flags=re.DOTALL)
cleaned = re.sub(r'<(div|span|p)[^>]*>\s*</\1>', '', cleaned)
cleaned = re.sub(r' ', ' ', cleaned)
return cleaned
def convert_to_markdown(html: str) -> str:
"""Faza 2: Konwersja HTML na Markdown."""
return markdownify.markdownify(
html,
heading_style="ATX",
strip=['script', 'style', 'iframe', 'object', 'embed'],
convert=['h1', 'h2', 'h3', 'h4', 'p', 'a', 'img',
'ul', 'ol', 'li', 'strong', 'em', 'table'],
)
def analyze_with_ai(content: str, model_client) -> ContentAnalysis:
"""Faza 3: Analiza treści za pomocą niestandardowego modelu AI."""
prompt = f"""Przeanalizuj poniższą treść strony internetowej.
Zwróć JSON z polami:
- entities: lista obiektów {{name, type, relevance_score}}
- meta_description: optymalny opis meta (max 155 znaków, po polsku)
- heading_issues: lista problemów że strukturą nagłówków
- word_count: liczba słów
- suggested_internal_links: sugerowane frazy do linkowania wewnętrznego
Treść:
{content[:4000]}"""
response = model_client.generate(prompt, max_tokens=1024)
data = json.loads(response.text)
return ContentAnalysis(
entities=data.get('entities', []),
meta_description=data.get('meta_description', ''),
heading_issues=data.get('heading_issues', []),
word_count=data.get('word_count', 0),
is_thin=data.get('word_count', 0) < 300,
suggested_internal_links=data.get('suggested_internal_links', []),
)
def process_batch(
content_dir: Path, model_client, max_workers: int = 4
) -> dict:
"""Przetwarzanie wsadowe z wielowątkowością."""
stats = {'processed': 0, 'thin': 0, 'entities': 0}
def process_file(md_file: Path) -> ContentAnalysis:
content = md_file.read_text(encoding='utf-8')
return analyze_with_ai(content, model_client)
files = list(content_dir.glob('*.md'))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
results = executor.map(process_file, files)
for analysis in results:
stats['processed'] += 1
if analysis.is_thin:
stats['thin'] += 1
stats['entities'] += len(analysis.entities)
return stats
Pipeline przetworzył 1247 stron w 4 godziny na własnej infrastrukturze, nie w chmurze, nie przez API zewnętrzne. Znalazł 3400 unikalnych encji, wygenerował brakujące meta opisy dla 680 stron i zidentyfikował 89 stron z thin content wymagających rozbudowy.
Model AI działał na dedykowanym serwerze z GPU, bez korzystania z AWS, Azure ani żadnego cloud-managed rozwiązania. Pełna kontrola nad danymi klienta, zero przesyłania treści do zewnętrznych API.
Migracja danych z MySQL do PostgreSQL
Migracja bazy danych z MySQL 5.5 do PostgreSQL wymagała nie tylko przeniesienia danych, ale fundamentalnej przebudowy schematu. Stary schemat nie miał relacji, indeksów ani ograniczeń integralności.
-- Migracja schematu: od chaosu do porządku
-- Stary MySQL (brak relacji, brak indeksów)
-- CREATE TABLE services (id INT AUTO_INCREMENT, title VARCHAR(255), ...);
-- CREATE TABLE categories (id INT AUTO_INCREMENT, name VARCHAR(100), ...);
-- Brak FOREIGN KEY, brak INDEX poza PK
-- Nowy PostgreSQL z prawidłową strukturą
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
slug VARCHAR(200) UNIQUE NOT NULL,
parent_id INTEGER REFERENCES categories(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE services (
id SERIAL PRIMARY KEY,
slug VARCHAR(200) UNIQUE NOT NULL,
category_id INTEGER NOT NULL REFERENCES categories(id),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE service_translations (
id SERIAL PRIMARY KEY,
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
language VARCHAR(5) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL,
meta_title VARCHAR(70) NOT NULL,
meta_description VARCHAR(160) NOT NULL,
search_vector TSVECTOR,
UNIQUE(service_id, language)
);
-- Indeksy dla wydajnego wyszukiwania
CREATE INDEX idx_translations_search ON service_translations
USING GIN(search_vector);
CREATE INDEX idx_translations_lang ON service_translations(language);
CREATE INDEX idx_services_category ON services(category_id)
WHERE is_active = true;
-- Trigger automatycznej aktualizacji search_vector
CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('simple', COALESCE(NEW.description, '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_search_vector
BEFORE INSERT OR UPDATE ON service_translations
FOR EACH ROW EXECUTE FUNCTION update_search_vector();
Migracja SEO: przekierowania i dane strukturalne
Najkrytyczniejszy element migracji to zachowanie pozycji w Google. Pomimo tego, że stara platforma traciła ruch, wciąż miała setki zaindeksowanych URL-i i dziesiątki backlinków.
Zmapowałem każdy stary URL do nowego i wdrożyłem przekierowania 301 na poziomie Cloudflare Workers:
// redirects.ts -- mapa przekierowań że starej platformy
const redirectMap: Record<string, string> = {
'/uslugi.php?id=1': '/pl/uslugi/konsulting/',
'/services.php?id=1': '/en/services/consulting/',
'/index.php?page=about': '/pl/o-nas/',
'/kontakt.php': '/pl/kontakt/',
'/leistungen.php': '/de/dienstleistungen/',
// ... 963 przekierowania wygenerowane automatycznie
};
export function handleRedirects(url: URL): Response | null {
// Sprawdź dokładne dopasowanie
const exactMatch = redirectMap[url.pathname + url.search];
if (exactMatch) {
return new Response(null, {
status: 301,
headers: { Location: exactMatch },
});
}
// Sprawdź dopasowanie bez query string
const pathMatch = redirectMap[url.pathname];
if (pathMatch) {
return new Response(null, {
status: 301,
headers: { Location: pathMatch },
});
}
return null;
}
Wdrożyłem kompletne dane strukturalne Schema.org z hreflang dla każdej wersji językowej. Stara platforma nie miała żadnych danych strukturalnych. Google nie rozumiał, czym jest strona.
Wyniki po 4 miesiącach
| Metryka | Przed | Po | Zmiana |
|---|---|---|---|
| PageSpeed (mobile) | 18 | 99 | +450% |
| LCP | 12.4s | 0.3s | -98% |
| INP | 1100ms | 22ms | -98% |
| CLS | 0.52 | 0.01 | -98% |
| TTFB | 4.7s | 0.03s | -99% |
| Waga strony | 11.2 MB | 0.28 MB | -97% |
| Czas odpowiedzi API | 4700ms | 45ms | -99% |
| Wyszukiwanie (200k rekordów) | 47s | 2ms | 23500x |
| Ruch organiczny | Bazowy | +340% | Wzrost |
| Zapytania od klientów | ~2/tydzień | ~14/tydzień | +600% |
| Podatności bezpieczeństwa | 37 | 0 | Wyeliminowane |
Pełny stos technologiczny projektu:
- Backend API: Python 3.12, Django 5, Django REST Framework, Celery
- Baza danych: PostgreSQL 16 z indeksami GIN i wyszukiwaniem pełnotekstowym
- Cache i kolejki: Redis 7 (cache, sesje, broker Celery)
- Mikroserwis wyszukiwania: Rust, Actix-web, Tantivy
- Frontend interaktywny: React 19, TypeScript, TanStack Query, Tailwind CSS
- Strona marketingowa: Astro 5 z wyspami React
- AI pipeline: Python, niestandardówy model językowy na dedykowanym GPU
- Deployment: Cloudflare Pages + Workers, GitHub Actions CI/CD
- Monitoring: Sentry, Prometheus, Grafana
Czego nauczył mnie ten projekt
Nie osądzaj. Diagnozuj. Klient przyszedł zraniony złym doświadczeniem z agencją. Ostatnie, czego potrzebował, to kolejny “ekspert” mówiący mu, jak bardzo jest źle. Zamiast tego przedstawiłem fakty w formie raportu, wyjaśniłem ryzyko i zaproponowałem plan działania z jasnym harmonogramem.
Dobierz narzędzie do zadania, nie zadanie do narzędzia. Django do API, React do interaktywnego UI, Rust do przetwarzania danych, Astro do statycznych stron. Każda technologia rozwiązuje konkretny problem. Monolityczny framework do wszystkiego to recepta na kompromisy.
AI to narzędzie, nie magia. Pipeline AI przetworzył 1247 stron w 4 godziny, co ręcznie zajęłoby tygodnie. Ale każdy wynik wymagał ludzkiej weryfikacji. AI generowało sugestie, człowiek podejmował decyzje. Niestandardówy model na własnym serwerze daje pełną kontrolę nad danymi klienta.
Rust uzasadnia się w konkretnych przypadkach. Nie napisałem całej platformy w Rust. Napisałem w Rust jeden mikroserwis, który przetwarza 200 000 rekordów. I tam różnica wydajności jest o rząd wielkości. Reszta systemu działa doskonale w Pythonie i TypeScript.
Migracja SEO to nie opcja. To obowiązek. Bez mapowania URL i przekierowań 301 klient straciłby resztki ruchu organicznego. Dzięki prawidłowej migracji ruch wzrósł o 340% w 4 miesiące.
Potrzebujesz ratunku po złym doświadczeniu z agencją?
Jeśli Twoja platforma została zbudowana na przestarzałych technologiach, jest wolna, niebezpieczna lub po prostu nie działa jak powinna, skontaktuj się z WPPoland. Przeprowadzę bezpłatny audyt zastany i przedstawię plan naprawczy z jasnym harmonogramem i zakresem prac.
Każdy projekt ratunkowy zaczyna się od jednego telefonu. Ten klient zadzwonił w piątek wieczorem. W poniedziałek rano miał raport. Osiem tygodni później miał platformę, z której jest dumny.



