Backend¶
El backend es un servicio Spring Boot que concentra todo el dominio del Sistema de Premios: valida vouchers, administra el padrón de clientes, corre el motor de sorteo, genera el acta reproducible y expone la API que consumen la landing y el gestor. Es, además, quien empaqueta y sirve esos dos frontends — no hay servidor web separado.
Esta página es la referencia del backend como componente: stack, estructura de paquetes, arranque, seguridad y build. Para el detalle de configuración operativa (variables de entorno, perfiles), ver Configuración.
Stack¶
| Tecnología | Versión | Para qué |
|---|---|---|
| Java | 17 (maven.compiler.release) |
Lenguaje base |
| Spring Boot | 4.1.1 | Framework de aplicación (última GA al momento del upgrade, ver ADR-018) |
| Tomcat | 11 (embebido, gestionado por Boot 4) | Servidor HTTP |
| Hibernate | 7 (gestionado por Boot 4) | ORM, ddl-auto: validate |
| Jackson | 3 (paquete tools.jackson.*) |
Serialización JSON, SNAKE_CASE |
| SQL Server | 2022 | Base de datos, driver mssql-jdbc |
| Flyway | módulo flyway-sqlserver |
Migraciones versionadas, corren solas al boot |
| Maven | — | Build, mvn package |
Jackson 3 no es com.fasterxml.jackson.*
Spring Boot 4 trae Jackson 3, que renombra jackson-core/jackson-databind al paquete
tools.jackson.*. Las anotaciones (com.fasterxml.jackson.annotation.*) no cambiaron.
JsonProcessingException (checked) desaparece a favor de JacksonException (unchecked). Si
copiás un snippet viejo con import com.fasterxml.jackson.databind.ObjectMapper, no
compila — el import correcto es tools.jackson.databind.ObjectMapper. Detalle completo en
ADR-018.
Estructura de paquetes¶
Todo vive bajo com.tipre.sistemapremios:
backend/src/main/java/com/tipre/sistemapremios/
├── SistemaPremiosApplication.java # entrypoint @SpringBootApplication
├── adjudicacion/ # asignación de premios a ganadores/suplentes
├── api/ # controllers públicos + filtros HTTP
│ ├── dto/ # DTOs de la API pública (snake_case)
│ └── gestor/ # controllers del gestor (autenticados) + sus DTOs
├── clavefirma/ # cifrado/descifrado del secreto HMAC (ClaveFirma)
├── config/ # configuración de Flyway por perfil (e2e, load)
├── dominio/ # entidades JPA (Campania, Cliente, Sorteo, Chance, ...)
├── modoprueba/ # generador de vouchers de prueba (solo gestor, nunca prod)
├── participacion/ # servicio central: resolver/confirmar/alta, reconocimiento
├── premio/ # PremioUnidad y su ciclo de estado
├── qrtoken/ # espejo Java del token firmado (compartido con el POS)
├── repositorio/ # Spring Data JPA repositories
├── soporte/ # arranque: config externa, seed, avisos de env vars legacy
└── sorteo/ # motor de sorteo + generación de acta
api/ concentra toda la superficie HTTP. Lo que distingue a api/gestor/ del resto no es la
ubicación en disco sino el prefijo de ruta /api/gestor/**, que es lo que
GestorSecurityConfig usa para exigir autenticación.
qrtoken es un módulo vendoreado, no reimplementado
El backend no reinventa la validación del token del QR: usa el mismo módulo que corre en el POS (espejo Java de un módulo C89). Ver El token del QR para el formato del payload y el flujo de firma/validación.
Cómo arranca¶
El entrypoint es mínimo — toda la lógica de arranque vive en soporte/ y en la configuración de
Spring Boot:
@SpringBootApplication
public class SistemaPremiosApplication {
public static void main(String[] args) {
SpringApplication.run(SistemaPremiosApplication.class, args);
}
}
Secuencia real de arranque:
- Chequeo de config externa (
ConfigExternaEnvironmentPostProcessor, corre antes que cualquier bean) — exige que./config/application.ymlexista y traiga el centinelasp.config.externa: true, más las claves de contrato (SNAKE_CASE,ddl-auto: validate). Sin eso, el proceso ni intenta levantar el contexto. Ver Configuración → config externa. - Flyway migra la base automáticamente. No hay paso manual: al conectar, Flyway aplica
cualquier migración pendiente contra
SistemaPremiosantes de que el contexto termine de levantar. - Hibernate valida el esquema contra las entidades (
ddl-auto: validate) — nunca lo altera. Si el esquema real no coincide con lo que las entidades esperan, el arranque falla ahí, no en producción silenciosamente. - Beans de seguridad y guardas de arranque fail-fast —
GestorSecurityConfigexigeSP_GESTOR_PASSWORD,ClaveFirmaConfigexigeSP_MASTER_KEY,ModoPruebaConfigcorta el arranque sisp.modo-prueba.enabled=truey el perfil activo contieneprod. SeedMinimo(opcional) — solo si se pasa--sp.seed.archivo=<ruta.json>, carga campaña + sorteos + clave de firma desde un JSON, idempotente. Es el fallback operativo si el gestor no llega a tiempo para cargar la campaña a mano.
Seguridad¶
Spring Security cubre solo lo que necesita quedar detrás de credenciales; el resto de la API
pública queda permitAll a propósito (participación, padrón, bases, campaña vigente, salud,
branding — la landing es anónima por diseño).
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/participacion/**", "/api/padron/**", "/bases/**",
"/api/campania/vigente", "/api/salud", "/api/branding", "/branding/**")
.permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/actuator/**").authenticated()
.requestMatchers("/api/gestor/**").authenticated()
.anyRequest().permitAll())
.httpBasic(Customizer.withDefaults())
/api/gestor/**exige HTTP Basic, sin sesión (SessionCreationPolicy.STATELESS) — el gestor manda el headerAuthorizationen cada request, nunca una cookie.- Usuario y password del gestor vienen de
SP_GESTOR_USER/SP_GESTOR_PASSWORD, nunca hardcodeados. Si faltaSP_GESTOR_PASSWORD, el arranque falla conIllegalStateException— a propósito. La versión anterior generaba una password aleatoria y la logueaba en texto claro para que el operador la leyera; eso viola la regla de que un secreto nunca aparece en logs, así que se sacó: sin credencial, no arranca. /actuator/**queda cerrado salvo/actuator/health, y ese health no muestra detalle para un caller anónimo (show-details: when-authorized).actuatorllega transitivo con la consola de desarrollobootui(ver ADR-018) — sin esta restricción explícita,/actuator/env,configprops,beans,loggers,mappingsyconditionsquedaban alcanzables sin autenticación, volcando variables de entorno completas.- CSP estricta, con dos excepciones angostas y documentadas:
'wasm-unsafe-eval'enscript-src(necesario para compilar el WebAssembly del escáner de DNI, no habilitaeval()de JS arbitrario) ycamera=(self)en Permissions-Policy (la landing usa la cámara para escanear).
Rate limiting¶
Filtro propio, sin dependencias nuevas — mitigación mínima contra abuso (fuerza bruta de tokens, scraping), no un sustituto de un WAF real.
| Config | Default | Qué hace |
|---|---|---|
sp.rate-limit.enabled |
true |
apagado en el perfil de test para no disparar 429 espurios contra bursts legítimos de la suite |
sp.rate-limit.limite |
30 |
requests permitidos por ventana |
sp.rate-limit.ventana-ms |
60000 |
tamaño de la ventana (60 s) |
sp.rate-limit.cap-entradas |
10000 |
tope de IPs trackeadas antes de purgar entradas vencidas |
Cubre /api/** y también /p/** (el estado inicial de la landing se resuelve server-side ahí, así
que un scan de tokens contra /p/{token} pega tan fuerte como uno directo a
POST /api/participacion/resolver).
El filtro se registra explícito, ANTES de Security
RateLimitFilterConfig lo registra con Ordered.HIGHEST_PRECEDENCE, a propósito. Si
quedara con el orden por defecto de Spring Boot, correría después de la cadena de
seguridad: un intento con credenciales inválidas contra /api/gestor/** nunca llegaría a
contarse para el rate limit, y un atacante podría probar passwords sin freno.
Detrás de un reverse proxy, X-Forwarded-For tiene que estar sanitizado
Sin proxy, request.getRemoteAddr() ya es la IP real. Con un proxy delante (Caddy en el
runbook de despliegue), el proxy debe pisar el
X-Forwarded-For entrante con la IP real del cliente — si no, cualquiera puede falsificar su
IP en el header y esquivar el rate limit. forward-headers-strategy: framework ya está
activado en application.yml para que Spring resuelva getRemoteAddr() correctamente detrás
del proxy.
Modo prueba¶
Generador de vouchers de prueba (con su propio QR) invocado desde el gestor, para poder probar el flujo completo sin imprimir tickets reales. Vive detrás de dos guardas independientes:
sp.modo-prueba.enabled(defaultfalse): si la feature no está prendida, no existe (404), no solo está "escondida".- Fail-fast contra prod: si
sp.modo-prueba.enabled=truey el perfil activo contieneprod, el contexto no levanta. No hay combinación posible de "modo prueba en producción".
Seed inicial (SeedMinimo)¶
Fallback para cargar la campaña sin depender de que el gestor esté listo. Se activa solo con
--sp.seed.archivo=<ruta.json> (nunca por default) y es idempotente: correrlo dos veces sobre el
mismo archivo no duplica campaña, sorteos ni clave de firma.
El archivo trae campania, sorteos[] y clave_firma (con el secreto en texto plano, nunca
committeado — ver docs/seed-minimo.ejemplo.json en el repo del proyecto para el formato). Antes
de escribir nada valida que campania.key_id_vigente coincida con clave_firma.key_id: un typo
ahí dejaría la campaña firmando con una clave que no es la declarada, y se corta antes de tocar la
base.
Cifrado de secretos at-rest¶
El secreto HMAC que firma los tokens del QR (clave_firma.secreto_cifrado) nunca se persiste en
claro. CifradorSecretos cifra con AES-256-GCM, usando una master key de 32 bytes que llega
por SP_MASTER_KEY (Base64) y nunca se guarda en el repo.
- Formato:
IV(12 bytes) || ciphertext+tag(GCM)— IV aleatorio en cada cifrado, nunca reusado. - Sin
SP_MASTER_KEYconfigurada, el bean deCifradorSecretosni se registra; en su lugar se registra un bean que falla con un mensaje explícito (SP_MASTER_KEY no configurada) en vez del genéricoNoSuchBeanDefinitionExceptionque daría Spring por defecto. - Un
secreto_cifradoque no autentica (master key incorrecta o dato alterado) lanzaIllegalStateExceptional descifrar — nunca decodifica basura en silencio.
Build del jar único¶
El perfil Maven full (activo por defecto — se desactiva con -DskipFrontends=true) bundlea la
landing y el gestor dentro del jar del backend, vía frontend-maven-plugin:

Las ejecuciones de npm y el copy de los dist/ están atadas a la fase prepare-package
(después de test), así que mvn test del día a día nunca paga el costo de construir los
dos frontends. Node se descarga localmente (v20.18.1, vía frontend-maven-plugin), no hace
falta tenerlo instalado en la máquina de build.
El resultado es un único artefacto: backend/target/sistemapremios-backend-0.0.1-SNAPSHOT.jar,
que sirve la API, la landing (/, assets en /assets/**) y el gestor (/gestor/, assets en
/gestor/assets/**) — ver Instalar en el servidor para el
despliegue real.
QA y análisis estático¶
Perfil Maven qa (atado a verify, nunca a test, para no encarecer el ciclo diario):
checkstyle + PMD + SpotBugs (con findsecbugs). Ninguno de los tres rompe el build por sí mismo
(failOnViolation/failOnError en false) — el gate real lo aplica el pipeline de calidad del
proyecto sobre los reportes XML que generan.
jacoco-maven-plugin (0.8.15) instrumenta la suite en cada mvn test y emite el reporte XML de
cobertura.
Dependencias notables¶
| Dependencia | Para qué |
|---|---|
spring-boot-starter-web |
API REST |
spring-boot-starter-data-jpa |
Persistencia (Hibernate 7) |
spring-boot-starter-validation |
Bean Validation en DTOs de entrada |
spring-boot-starter-security |
HTTP Basic para /api/gestor/** |
mssql-jdbc |
Driver SQL Server (runtime) |
bootui-spring-boot-starter (1.14.1) |
Consola de desarrollo (introspección salud/JPA/seguridad); solo activa en dev/local, rechaza requests no-loopback — ver ADR-018 |
spring-boot-starter-flyway + flyway-sqlserver |
Migraciones versionadas |
com.google.zxing:core / javase (3.5.3) |
Genera el PNG del QR de los vouchers de prueba (solo ModoPruebaService, nunca en el camino de producción real) |
spring-boot-starter-webmvc-test (test) |
Soporte de MockMvc (módulo separado desde Boot 4) |