Saltar a contenido

Arquitectura del sistema

Visión general

Versus es un monorepo con tres servicios independientes que se orquestan con Docker Compose. El frontend y el backend se comunican por HTTP/REST y WebSocket. Los scrapers publican preguntas directamente en la base de datos.

graph TB
    subgraph Cliente
        Browser["Navegador\nAngular 21 :4200"]
    end

    subgraph Backend ["Backend :8080"]
        API["REST API\n/api/**"]
        WS["STOMP/WebSocket\n/ws"]
        JWT["JwtAuthFilter"]
        Modules["auth · users · questions\ngame · stats · match\nmoderation · media · scraping"]
    end

    subgraph Almacenamiento
        PG["PostgreSQL :5432"]
        Storage["Almacenamiento de ficheros\nlocal (dev) · Cloudflare R2 (prod)"]
    end

    subgraph Scrapers
        Scrapy["Scrapy (Python)\nSpiders"]
    end

    Browser -->|"HTTP + Bearer JWT"| API
    Browser -->|"STOMP + JWT header"| WS
    API --> JWT --> Modules
    WS --> JWT
    Modules --> PG
    Modules --> Storage
    Scrapy -->|"INSERT questions\nstatus=PENDING_REVIEW"| PG

Servicios

Servicio Tecnología Puerto dev Responsabilidad
Frontend Angular 21, TypeScript 4200 SPA, UI de todos los modos de juego
Backend Spring Boot 4, Java 21 8080 REST API + WebSocket, lógica de negocio
Base de datos PostgreSQL 18 5432 Persistencia de todas las entidades
pgAdmin pgAdmin 4 5050 Administración de la BD (solo dev)
Scrapers Scrapy, Python Extracción de preguntas de la web

Flujo de una petición autenticada

sequenceDiagram
    participant C as Angular
    participant I as AuthInterceptor
    participant F as JwtAuthFilter
    participant A as Controller
    participant S as Service
    participant D as PostgreSQL

    C->>I: HTTP request
    I->>I: Añade "Authorization: Bearer <token>"
    I->>F: Petición con header
    F->>F: Valida JWT (firma + expiración)
    F->>A: SecurityContext con usuario autenticado
    A->>S: Delega lógica de negocio
    S->>D: Query / update
    D-->>S: Resultado
    S-->>A: DTO de respuesta
    A-->>C: JSON

Si el access token (15 min) ha expirado, AuthInterceptor intercepta el 401 y llama a POST /api/auth/refresh con el refresh token (7 días). Si el refresh también ha expirado, redirige a /login.

Estructura de paquetes — Backend

com.versus.api/
├── config/          SecurityConfig, OpenApiConfig, DevSeedConfig
├── common/          GlobalExceptionHandler, ApiException, ErrorCode, ErrorResponse
├── auth/            JWT, refresh tokens, AuthController
├── users/           Perfil, UserController
├── questions/       Entidad Question + QuestionOption, aleatorización
├── game/            Lógica singleplayer Survival + Precision
├── match/           Entidades PvP (Sprint 3)
├── stats/           PlayerStats, Ranking
├── moderation/      QuestionReport, flujo de revisión
├── media/           Upload de avatares e imágenes
├── storage/         Abstracción local / Cloudflare R2
├── scraping/        Spider + SpiderRun, integración Scrapy
└── websocket/       STOMP config, canal JWT, eventos de partida

Separación de responsabilidades

Capa Regla
Controller Solo deserialización de entrada y serialización de salida. Sin lógica de negocio.
Service Toda la lógica de negocio. Lanza ApiException ante invariantes rotos.
Repository Acceso a datos con Spring Data JPA. Sin lógica.
Domain Entidades JPA con anotaciones Lombok (@Data, @Builder). Sin comportamiento.
DTO Objetos de transferencia inmutables (records o clases con @Builder). Nunca exponen entidades directamente.

Gestión de errores

Todos los errores pasan por GlobalExceptionHandler y producen el mismo envelope:

{
  "error": "NOT_FOUND",
  "message": "Question not found",
  "status": 404
}

Códigos definidos en ErrorCode: UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), VALIDATION_ERROR (400), INTERNAL_ERROR (500).

WebSocket — arquitectura de canales

Cliente conecta a: ws://localhost:8080/ws  (SockJS fallback)
JwtChannelInterceptor valida el JWT en el frame STOMP CONNECT

Suscripción personal:  /user/queue/match     → eventos propios del jugador
Suscripción de sala:   /topic/match/{matchId} → broadcast de la partida

Envío de acciones:     /app/match/answer
                       /app/match/ready

Cada evento usa el envelope MatchEventEnvelope:

{ "type": "ROUND_START | ANSWER_RESULT | GAME_OVER | ...", "matchId": "uuid", "payload": { ... } }

Ver documentación detallada en backend/modules/websocket.md.