Telefonou-me numa sexta-feira à noite. A voz era calma, mas ouvi nela o cansaço dé alguém que há semanas luta contra um problema sobré o qual não tem controlo. Geré uma empresa de serviços que serve clientes em quatro países europeus. Pagou a uma agência por uma plataforma profissional. Recebeu algo que parecia uma plataforma profissional. Por baixo escondia-sé uma catástrofe.
Por questões de confidencialidade não posso revelar o nome da empresa nem o setor. Posso, no entanto, contar exatamenté o que encontrei, o que fiz e porque escolhi determinadas tecnologias. Está história é um aviso para qualquer pessoa que subcontraté a construção dé uma plataforma a uma agência externa.
O que encontrei após a auditoria da plataforma da agência
O primeiro passo é sempré a auditoria. Não avalio nem crítico: recolho factos. Ao fim de três dias dé análise tinha um quadro completo.
Spaghetti code em PHP 5.6: a agência não útilizou qualquer framework. Toda a plataforma é código procedural monolítico em PHP 5.6 (fim do suporte em 2018) com consultas SQL coladas diretamente nos templates HTML. Sem ORM, sem camada dé abstração, sem router. Ficheiros com 3000 linhas misturando lógica de negócio com apresentação.
// Código encontrado -- consulta SQL diretamente no template (anonimizado)
<?php
$result = mysql_query("SELECT * FROM services
WHERE category = '" . $_GET['cat'] . "'
ORDER BY id DESC");
// SQL injection -- sem qualquer validação dos dados de entrada
while ($row = mysql_fetch_assoc($result)) {
echo "<div class='service'>";
echo "<h2>" . $row['title'] . "</h2>"; // XSS -- sem escape
echo "<p>" . $row['description'] . "</p>";
echo "</div>";
}
?>
MySQL 5.5 sem índices: base de dados com 47 tabelas, nenhuma com índices além das chaves primárias. Uma consulta que listava serviços com filtros executava full table scan em 200 000 registos, com tempo médio de resposta de 4,7 segundos.
jQuery 1.x + Bootstrap 3: frontend de 2014. Doze ficheiros jQuery carregados em cada página, incluindo três versões diferentes da biblioteca. Sem minificação, sem bundler, sem tree-shaking. Peso total dos scripts: 2,8 MB.
FTP como “deployment”: sem repositório Git, sem CI/CD, sem ambiente de staging. A agência enviava ficheiros diretamente por FTP para o servidor de produção. Sem sistema de controlo de versões. Sem testes.
Zero segurança: palavras-passe de útilizadores armazenadas em MD5 sem salt. Sessões em ficheiros num servidor partilhado. SQL injection em 23 locais. XSS nos formulários. Sem tokens CSRF. Sem HTTPS no painel de login.
Métricas iniciais
| Métrica | Valor | Avaliação |
|---|---|---|
| PageSpeed (mobile) | 18 | Crítico |
| LCP | 12,4s | Crítico |
| INP | 1100ms | Crítico |
| CLS | 0,52 | Crítico |
| TTFB | 4,7s | Crítico |
| Peso da página | 11,2 MB | Excessivo |
| Tempo de resposta da API | 4,7s (méd.) | Crítico |
| Tráfego orgânico | Queda de 72% a/a | Crítico |
| Vulnerabilidades detetadas | 23 SQL injection, 14 XSS | Crítico |
O pior de tudo era qué o cliente não tinha conhecimento de nenhum destes problemas. Duranté um ano a agência enviou-lhe relatórios de “otimizações” que não tinham qualquer correspondência com a realidade.
Porque escolhi está pilha tecnológica
A decisão sobré a arquitetura alvo é o momento mais importante do projeto. O cliente tinha preocupações legítimas: a agência anterior prometeu “uma solução moderna” e entregou código de 2014. Tinha de escolher tecnologias que resolvessem problemas concretos, não as que estavam na moda.
Análisei os requisitos é adaptei as ferramentas às tarefas:
Python + Django (API de backend): o cliente precisava dé um backend sólido com painel dé administração, autenticação, válidação de dados e API REST. O Django oferece tudo isso de fábrica. O Django REST Framework é um ecossistema maduro e estável com excelente documentação. O cliente serve 4 mercados europeus é o Django tem internacionalização incorporada.
PostgreSQL (base de dados): a migração do MySQL 5.5 para o PostgreSQL não foi um capricho. O PostgreSQL oferece melhores índices (GIN, GiST para pesquisa full-text), melhores tipos de dados (JSONB, arrays), particionamento de tabelas maduro e transações ACID fiáveis. Para 200 000 registos com pesquisa full-text multilíngue é a escolha natural.
Redis (cache e filas): o tempo de resposta da API de 4,7 segundos tinha de cair abaixo de 100 milissegundos. O Redis faz cache dos resultados de consultas, armazena sessões de útilizadores e gere filas de tarefas assíncronas (Celery). Uma ferramenta, três funções críticas.
React + TypeScript (frontend interativo): dashboard do cliente, motor de pesquisa com filtros, formulários de múltiplos passos, tudo isto requer uma UI reativa. O React com TypeScript proporciona componentes tipados, excelentes ferramentas de desenvolvimento é um vasto ecossistema de bibliotecas.
Rust (microsserviço de desempenho): indexação de pesquisa de 200 000 registos em 4 versões linguísticas, processamento de ficheiros CSV/Excel de clientes, transformações de dados. Estas tarefas exigiam desempenho puro. O Rust processa o índice de pesquisa em 1,8 segundos em vez de 47 segundos na antiga implementação PHP. Não é uma diferença percentual: é uma diferença dé uma ordem de grandeza.
Astro (página de marketing): página inicial, blog, oferta, páginas de serviços. São conteúdos estáticos que não precisam de JavaScript. O Astro gera HTML limpo com zero custos em runtime. Os elementos interativos (motor de pesquisa, formulário de contacto) funcionam como ilhas React isoladas. Como desenvolvedor Astro profissional, este framework foi a escolha natural para o site voltado ao público.
Arquitetura alvo:
┌─────────────────────────────────────────────────────┐
│ 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) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
API de backend em Django REST Framework
O núcleo da nova plataforma é o Django com o Django REST Framework. Construí uma API que geré o catálogo de serviços multilingue, o sistema de consultas de clientes, autenticação JWT é o painel dé administração.
# services/models.py -- modelo de serviço com multilinguismo
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 -- serializador com validação
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 -- vistas de API com cache Redis
from django.útils.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)
Configuração do Redis como backend de cache e broker de filas 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'
Impacto após a implementação de Django + PostgreSQL + Redis: o tempo médio de resposta da API caiu de 4,7 segundos para 45 milissegundos. Consultas com cache Redis são processadas em 3 a 5 milissegundos.
Microsserviço Rust para indexação de pesquisa
O desafio técnico mais interessante foi o motor de pesquisa. O cliente tem um catálogo de 200 000 registos em 4 versões linguísticas. A antiga implementação PHP executava LIKE '%term%' no MySQL sem índices: 47 segundos por consulta. Inútilizável.
O PostgreSQL com índices GIN e tsvector resolveu o problema para consultas normais. Mas o cliente precisava também de:
- pesquisa com tolerância a erros tipográficos (fuzzy matching),
- filtragem por múltiplos atributos em simultâneo com resultados imediatos,
- reconstrução do índicé após importação de dados de ficheiros CSV/Excel.
Para estas tarefas construí um microsserviço em Rust com a biblioteca Tantivy (equivalenté ao Apache Lucene para 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)
}
}
API HTTP em Rust com o framework Actix-web:
// search-service/src/main.rs -- API wyszukiwania
usé 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
}
Resultados do benchmark do microsserviço Rust:
| Operação | PHP antigo | Rust novo | Melhoria |
|---|---|---|---|
| Construção do índice (200k registos) | 47s | 1,8s | 26x mais rápido |
| Pesquisa simples | 4,7s | 2ms | 2350x mais rápido |
| Pesquisa com filtros | 8,3s | 5ms | 1660x mais rápido |
| Fuzzy matching (erros tipográficos) | Inexistente | 8ms | Nova funcionalidade |
| Consumo de memória | 512 MB | 84 MB | 6x menos |
O Rust não foi uma escolha “porque está na moda”. Foi uma escolha porque para está tarefa concreta, processar 200 000 registos com indexação n-gram e fuzzy matching, proporciona um desempenho inalcançável para interpretadores.
Frontend React com dashboard interativo
O dashboard do cliente exigia uma UI reativa: tabelas com ordenação, filtros de múltiplos níveis, formulários de múltiplos passos, gráficos com dados em tempo real. O React com TypeScript é a escolha natural.
// 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>
);
}
O componente React é incorporado na página Astro como uma ilha interativa:
---
// 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>
Graças à arquitetura Islands, a página de marketing carrega 0 KB de JavaScript por omissão. O componente de pesquisa (38 KB gzipped) só carrega quando o útilizador faz scroll até à secção.
Pipeline de IA para processamento de conteúdo
A migração de 1200 páginas de conteúdo de HTML sujo para Markdown limpo exigiu automação. Muitas páginas tinham metadados SEO incompletos, descrições em falta e cabeçalhos não otimizados. Em vez dé os corrigir manualmente, construí um pipeline em Python útilizando um modelo de linguagem personalizado.
O pipeline funcionou em três fases:
# 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 ze 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
O pipeline processou 1247 páginas em 4 horas na minha própria infraestrutura, sem recorrer à cloud nem a APIs externas. Encontrou 3400 entidades únicas, gerou meta descrições em falta para 680 páginas e identificou 89 páginas com thin content que necessitavam de expansão.
O modelo de IA correu num servidor dedicado com GPU, sem útilizar AWS, Azure nem qualquer solução cloud gerida. Controlo total sobré os dados do cliente, zero envio de conteúdo para APIs externas.
Migração de dados do MySQL para o PostgreSQL
A migração da base de dados do MySQL 5.5 para o PostgreSQL exigiu não apenas a transferência de dados, mas uma reconstrução fundamental do esquema. O esquema antigo não tinha relações, índices nem restrições de integridade.
-- 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();
Migração SEO: redirecionamentos e dados estruturados
O elemento mais crítico da migração foi preservar as posições no Google. Apesar dé a plataforma antiga estar a perder tráfego, ainda tinha centenas de URLs indexados e dezenas de backlinks.
Mapeei cada URL antigo para o novo e implementei redirecionamentos 301 ao nível do Cloudflare Workers:
// redirects.ts -- mapa przekierowań ze 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 wygenerowané 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;
}
Implementei dados estruturados Schema.org completos com hreflang para cada versão linguística. A plataforma antiga não tinha quaisquer dados estruturados, pelo qué o Google não entendia o que era a página.
Resultados após 4 meses
| Métrica | Antes | Depois | Variação |
|---|---|---|---|
| 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% |
| Peso da página | 11,2 MB | 0,28 MB | -97% |
| Tempo de resposta da API | 4700ms | 45ms | -99% |
| Pesquisa (200k registos) | 47s | 2ms | 23500x |
| Tráfego orgânico | Base | +340% | Crescimento |
| Consultas de clientes | ~2/semana | ~14/semana | +600% |
| Vulnerabilidades de segurança | 37 | 0 | Eliminadas |
Pilha tecnológica completa do projeto:
- API de backend: Python 3.12, Django 5, Django REST Framework, Celery
- Base de dados: PostgreSQL 16 com índices GIN e pesquisa full-text
- Cache e filas: Redis 7 (cache, sessões, broker Celery)
- Microsserviço de pesquisa: Rust, Actix-web, Tantivy
- Frontend interativo: React 19, TypeScript, TanStack Query, Tailwind CSS
- Página de marketing: Astro 5 com ilhas React
- Pipeline de IA: Python, modelo de linguagem personalizado em GPU dedicado
- Deployment: Cloudflare Pages + Workers, GitHub Actions CI/CD
- Monitorização: Sentry, Prometheus, Grafana
O que este projeto me ensinou
Não julgues: diagnostica. O cliente chegou magoado por uma má experiência com uma agência. A última coisa de que precisava era dé outro “especialista” a dizer-lhé o quão mau estava tudo. Em vez disso, apresentei os factos sob a forma de relatório, expliquei os riscos e propus um plano dé ação com um calendário claro.
Adapta a ferramenta à tarefa, não a tarefa à ferramenta. Django para a API, React para a UI interativa, Rust para o processamento de dados, Astro para as páginas estáticas. Cada tecnologia resolvé um problema concreto. Um framework monolítico para tudo é uma receita para compromissos.
A IA é uma ferramenta, não magia. O pipeline de IA processou 1247 páginas em 4 horas, trabalho que manualmente levaria semanas. Mas cada resultado exigiu verificação humana. A IA gerava sugestões, o humano tomava decisões. Um modelo personalizado no meu próprio servidor dá controlo total sobré os dados do cliente.
O Rust justifica-se em casos concretos. Não escrevi toda a plataforma em Rust. Escrevi em Rust um microsserviço que processa 200 000 registos, é aí a diferença de desempenho é dé uma ordem de grandeza. O resto do sistema funciona perfeitamente em Python e TypeScript.
A migração SEO não é opcional: é obrigatória. Sem mapeamento de URL e redirecionamentos 301, o cliente teria perdido os últimos resquícios de tráfego orgânico. Graças a uma migração correta, o tráfego cresceu 340% em 4 meses.
Precisas dé um resgaté após uma má experiência com uma agência?
Se a tua plataforma foi construída com tecnologias obsoletas, é lenta, insegura ou simplesmente não funciona como devia, contacta a WPPoland. Faço uma auditoria inicial gratuita e apresento um plano corretivo com um calendário claro e âmbito de trabalho definido.
Cada projeto de resgate começa com um telefonema. Este cliente ligou numa sexta-feira à noite. Na segunda-feira de manhã tinha o relatório. Oito semanas depois tinha uma plataforma da qual sé orgulha.



