Como resgatei uma empresa europeia enganada por uma agência. Migração de PHP 5.6 e jQuery para React, Django, PostgreSQL, Redis e Rust. Código, métricas e lições.
PT-PT

Como resgatei uma empresa enganada por uma agência: reconstrução para uma arquitetura moderna

5.00 /5 - (31 votes )
Última verificação: 1 de maio de 2026
19min de leitura
Caso de estudo
Desenvolvedor full-stack

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étricaValorAvaliação
PageSpeed (mobile)18Crítico
LCP12,4sCrítico
INP1100msCrítico
CLS0,52Crítico
TTFB4,7sCrítico
Peso da página11,2 MBExcessivo
Tempo de resposta da API4,7s (méd.)Crítico
Tráfego orgânicoQueda de 72% a/aCrítico
Vulnerabilidades detetadas23 SQL injection, 14 XSSCrí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çãoPHP antigoRust novoMelhoria
Construção do índice (200k registos)47s1,8s26x mais rápido
Pesquisa simples4,7s2ms2350x mais rápido
Pesquisa com filtros8,3s5ms1660x mais rápido
Fuzzy matching (erros tipográficos)Inexistente8msNova funcionalidade
Consumo de memória512 MB84 MB6x 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'&nbsp;', ' ', 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étricaAntesDepoisVariação
PageSpeed (mobile)1899+450%
LCP12,4s0,3s-98%
INP1100ms22ms-98%
CLS0,520,01-98%
TTFB4,7s0,03s-99%
Peso da página11,2 MB0,28 MB-97%
Tempo de resposta da API4700ms45ms-99%
Pesquisa (200k registos)47s2ms23500x
Tráfego orgânicoBase+340%Crescimento
Consultas de clientes~2/semana~14/semana+600%
Vulnerabilidades de segurança370Eliminadas

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.

Próximo passo

Transforme o artigo numa implementação real

Este bloco reforça a ligação interna e conduz o leitor para o passo seguinte mais útil dentro da arquitetura do site.

Quer implementar isto no seu site?

Se a visibilidade no Google e em sistemas de IA importa, posso estruturar conteúdo, FAQ, schema e linkagem interna para SEO, GEO e AEO.

Cluster relacionado

Explorar outros serviços WordPress e base de conhecimento

Reforce o seu negócio com suporte técnico profissional em áreas-chave do ecossistema WordPress.

Quanto tempo demora a migração dé uma plataforma dé uma pilha obsoleta para uma arquitetura moderna?
O tempo depende da escala do projeto e é definido individualmenté após a auditoria. Uma plataforma simples leva 4 a 6 semanas. Um sistema complexo com múltiplas integrações, multilinguismo e componentes interativos leva 8 a 14 semanas.
Porque Rust em vez de Node.js ou Go para microsserviços?
O Rust oferece desempenho comparável ao C/C++ com garantias de segurança de memória. Para tarefas que exigem processamento de grandes conjuntos de dados, indexação de pesquisa e transformação de ficheiros, o Rust proporciona uma vantagem de desempenho dé uma ordem de grandeza facé ao Node.js.
A migração para uma nova pilha afeta as posições no Google?
Uma migração realizada corretamente com mapeamento de URL e redirecionamentos 301 protegé as posições existentes. A melhoria dos Core Web Vitals após a migração geralmente resulta num aumento do tráfego orgânico de 30 a 50 por cento ao longo de 3 meses.
O que fazer quando uma agência nos enganou e deixou a plataforma sem documentação?
O primeiro passo é a auditoria inicial: verificar o código, as licenças, a segurança é o desempenho. Em seguida, recuperar os dados é o conteúdo da instalação existente. Só depois planear a migração para uma nova plataforma com documentação completa.

Precisa de FAQ adaptado ao setor e mercado? Criamos uma versão alinhada com os seus objetivos de negócio.

Fale connosco

Artigos Relacionados

O WordPress não é apenas um CMS; é uma Framework de Aplicações. Aprenda a dominar a REST API, autenticação via App Passwords/JWT e construa apps Headless.
development

WP REST API, headless e endpoints personalizados

O WordPress não é apenas um CMS; é uma Framework de Aplicações. Aprenda a dominar a REST API, autenticação via App Passwords/JWT e construa apps Headless.

Saiba quando uma reconstrução de website é necessária. 7 sinais técnicos e de negócio mensuráveis de qué o seu site precisa de modernização em 2026.
wordpress

Quando reconstruir o seu website? 7 sinais de que precisa de modernização

Saiba quando uma reconstrução de website é necessária. 7 sinais técnicos e de negócio mensuráveis de qué o seu site precisa de modernização em 2026.

WordPress 7.0 com AI Client vs Astro 6 após aquisição pela Cloudflare. Comparação de velocidade, custos, SEO e segurança. A minha perspetiva após 20 anos como programador WP - quando migrar e quando ficar.
wordpress

WordPress 7.0 vs Astro 6 após aquisição pela Cloudflare - quem vence em 2026?

WordPress 7.0 com AI Client vs Astro 6 após aquisição pela Cloudflare. Comparação de velocidade, custos, SEO e segurança. A minha perspetiva após 20 anos como programador WP - quando migrar e quando ficar.