getErrores() {
return errores;
}
+ /** Texto del mensaje de Gestión y del registro: "2 eventos nuevos, 1 actualizados, …". */
@Override
public String toString() {
return eventosNuevos + " eventos nuevos, " + eventosActualizados + " actualizados, " + resultados
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionPlantillas.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionPlantillas.java
index 012925b..784ee08 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionPlantillas.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionPlantillas.java
@@ -26,6 +26,9 @@
* Una petición por competición. Crea los jugadores nuevos, actualiza los datos de los que ya estaban
* (sin tocar su nota, que es del creador) y quita los que han dejado el equipo. Los jugadores dados de
* alta a mano no se tocan.
+ *
+ * Implementa HU-50. La lanzan {@link SincronizacionProgramada} (una vez al día) y el botón
+ * Descargar plantillas de Gestión.
*/
@Service
public class SincronizacionPlantillas {
@@ -55,13 +58,22 @@ public String toString() {
}
}
+ /**
+ * Descarga y aplica las plantillas de cada liga de {@link ApiProperties#ligasDePlantillas()}.
+ * Si una liga falla, se apunta en los errores del resumen y se sigue con las demás.
+ *
+ * @return cuántos equipos se han actualizado y cuántos jugadores han entrado, cambiado o salido
+ * @throws IllegalStateException si no está la clave de la API
+ */
@Transactional
public Resumen sincronizar() {
if (!propiedades.configurada()) {
throw new IllegalStateException(
"Falta la clave de la API: define la variable de entorno FOOTBALL_DATA_TOKEN");
}
+ // Equipos ya actualizados: si un equipo llega por dos competiciones, solo cuenta la primera
Set procesados = new HashSet<>();
+ // Contadores que rellena actualizar(): [nuevos, actualizados, retirados]
int[] cuenta = new int[3];
List errores = new ArrayList<>();
for (String codigo : propiedades.ligasDePlantillas()) {
@@ -76,6 +88,7 @@ public Resumen sincronizar() {
actualizar(equipo, datos.squad(), cuenta);
}
} catch (RestClientException e) {
+ // Sin conexión, límite de peticiones…: se apunta y se pasa a la liga siguiente
LOG.warn("No se pudieron descargar las plantillas de {}: {}", codigo, e.getMessage());
errores.add(codigo + ": " + e.getMessage());
}
@@ -94,6 +107,7 @@ private void actualizar(Equipo equipo, List plantilla,
continue; // entrenadores y jugadores sin posición conocida
}
enLaPlantilla.add(datos.id());
+ // Por el identificador de la API: así se reconoce aunque cambie de nombre o venga de otro equipo
Jugador jugador = jugadores.findByIdExterno(datos.id()).orElse(null);
if (jugador == null) {
jugador = new Jugador(equipo, datos.name(), posicion);
@@ -102,10 +116,13 @@ private void actualizar(Equipo equipo, List plantilla,
} else {
cuenta[1]++;
}
+ // Solo los datos que da la API; la nota la pone el creador y no se toca (CA-50.1)
jugador.actualizar(equipo, datos.name(), posicion, datos.shirtNumber(), datos.nationality(),
fecha(datos.dateOfBirth()));
jugadores.save(jugador);
}
+ // Los de la API que ya no salen en la plantilla han dejado el equipo; los dados de alta a mano
+ // (sin identificador de la API) se quedan (CA-50.2)
for (Jugador jugador : jugadores.findByEquipo(equipo)) {
if (jugador.isDeLaApi() && !enLaPlantilla.contains(jugador.getIdExterno())) {
jugadores.delete(jugador);
@@ -114,7 +131,10 @@ private void actualizar(Equipo equipo, List plantilla,
}
}
- /** Por su identificador en la API o por un nombre equivalente. No crea equipos nuevos. */
+ /**
+ * Por su identificador en la API o por un nombre equivalente. No crea equipos nuevos.
+ * El nombre se compara con {@link Nombres#clave(String)}, que quita tildes, mayúsculas y palabras como "FC".
+ */
private Equipo buscar(PlantillaApi datos) {
if (datos.id() == null) {
return null;
@@ -132,6 +152,7 @@ private Equipo buscar(PlantillaApi datos) {
.orElse(null));
}
+ /** Fecha de nacimiento a partir de los 10 primeros caracteres (AAAA-MM-DD); null si falta o no es válida. */
private static LocalDate fecha(String texto) {
if (texto == null || texto.length() < 10) {
return null;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionProgramada.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionProgramada.java
index bfa8abb..9ae56e6 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionProgramada.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionProgramada.java
@@ -5,7 +5,17 @@
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
-/** Sincroniza con la API cada cierto tiempo (apuestas.api.intervalo), solo si hay clave configurada. */
+/**
+ * Sincroniza con la API cada cierto tiempo (apuestas.api.intervalo y apuestas.api.intervalo-plantillas),
+ * solo si hay clave configurada.
+ * Son dos tareas: partidos y resultados ({@link SincronizacionService}, cada 30 minutos) y plantillas
+ * ({@link SincronizacionPlantillas}, una vez al día). Así los resultados llegan y las apuestas se pagan sin que
+ * el creador tenga que hacer nada (HU-21, HU-25, HU-50).
+ *
+ * Los tiempos van en formato ISO-8601 (PT30S = 30 segundos, PT2M = 2 minutos, PT30M = 30 minutos,
+ * P1D = 1 día) y se pueden cambiar en application.properties. Con {@code fixedDelay} el intervalo se cuenta
+ * desde que termina la ejecución anterior, así que una tarea nunca se solapa consigo misma.
+ */
@Component
public class SincronizacionProgramada {
@@ -26,16 +36,19 @@ public SincronizacionProgramada(SincronizacionService sincronizacion, Sincroniza
@Scheduled(initialDelayString = "${apuestas.api.retraso-inicial-plantillas:PT2M}",
fixedDelayString = "${apuestas.api.intervalo-plantillas:P1D}")
public void sincronizarPlantillas() {
+ // Sin clave no se hace nada (sincronizar() lanzaría una excepción cada vez)
if (!propiedades.configurada()) {
return;
}
try {
plantillas.sincronizar();
} catch (RuntimeException e) {
+ // Se apunta en el registro; la próxima ejecución se intenta igual
LOG.error("Error al descargar las plantillas", e);
}
}
+ /** Partidos y resultados: 30 segundos después de arrancar y luego cada 30 minutos. */
@Scheduled(initialDelayString = "${apuestas.api.retraso-inicial:PT30S}",
fixedDelayString = "${apuestas.api.intervalo:PT30M}")
public void sincronizar() {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionService.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionService.java
index 40d92bf..e223763 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionService.java
@@ -32,6 +32,10 @@
* Sincroniza eventos y resultados con la API de datos deportivos (fila 13 del backlog, HU-21):
* crea los partidos que faltan, actualiza fechas, introduce resultados (lo que resuelve las apuestas),
* suspende los aplazados y anula los cancelados.
+ *
+ *
La lanzan {@link SincronizacionProgramada} (cada 30 minutos) y el botón Sincronizar de Gestión.
+ * Los partidos llegan de {@link FuenteDatosDeportivos}; los cambios de estado de los eventos y el pago de las
+ * apuestas los hace {@link ResolucionService}, igual que cuando los hace el creador a mano (HU-04, HU-05, HU-25).
*/
@Service
public class SincronizacionService {
@@ -70,12 +74,22 @@ public SincronizacionService(FuenteDatosDeportivos fuente, ApiProperties propied
this.eventos = eventos;
this.resolucion = resolucion;
this.reloj = reloj;
+ // TransactionTemplate en vez de @Transactional: permite abrir una transacción por partido dentro del bucle
this.transacciones = new TransactionTemplate(transacciones);
}
/**
* Cada partido se guarda en su propia transacción: si uno falla, se apunta el error y los demás
* se sincronizan (y se pagan) igual.
+ *
+ *
Con una sola transacción para todo, el fallo de un partido desharía también los demás. Además, así
+ * no queda ninguna transacción abierta mientras se espera la respuesta de la API.
+ *
+ *
Busca los partidos de cada competición configurada desde hace {@code diasAtras} días (para recoger
+ * resultados) hasta dentro de {@code diasAdelante} días (para dar de alta los próximos).
+ *
+ * @return lo que ha cambiado y los errores, para mostrarlo al creador
+ * @throws IllegalStateException si no está la clave de la API
*/
public ResumenSincronizacion sincronizar() {
if (!propiedades.configurada()) {
@@ -83,7 +97,9 @@ public ResumenSincronizacion sincronizar() {
"Falta la clave de la API: define la variable de entorno FOOTBALL_DATA_TOKEN");
}
ResumenSincronizacion resumen = new ResumenSincronizacion();
+ // "Hoy" según el reloj de la aplicación (hora de Madrid), no el del servidor
LocalDate hoy = LocalDate.now(reloj);
+ // Una petición por competición (PD y CL por defecto)
for (String codigo : propiedades.competiciones()) {
try {
List partidos = fuente.partidos(codigo, hoy.minusDays(propiedades.diasAtras()),
@@ -92,11 +108,13 @@ public ResumenSincronizacion sincronizar() {
try {
transacciones.executeWithoutResult(t -> procesar(partido, competicion(codigo), resumen));
} catch (RuntimeException e) {
+ // Se deshacen solo los cambios de este partido y se sigue con el siguiente
LOG.warn("No se pudo sincronizar el partido {}: {}", partido.id(), e.getMessage());
resumen.error("Partido " + partido.id() + ": " + e.getMessage());
}
}
} catch (RestClientException e) {
+ // La API dio error o no respondió para esta competición: se pasa a la siguiente
LOG.warn("No se pudo sincronizar {}: {}", codigo, e.getMessage());
resumen.error(codigo + ": " + e.getMessage());
}
@@ -105,6 +123,7 @@ public ResumenSincronizacion sincronizar() {
return resumen;
}
+ /** Busca o crea el evento de un partido de la API, actualiza su fecha y le aplica el estado del partido. */
private void procesar(PartidoApi partido, Competicion competicion, ResumenSincronizacion resumen) {
if (partido.homeTeam() == null || partido.homeTeam().id() == null
|| partido.awayTeam() == null || partido.awayTeam().id() == null) {
@@ -112,9 +131,12 @@ private void procesar(PartidoApi partido, Competicion competicion, ResumenSincro
}
Equipo local = equipo(partido.homeTeam(), competicion, resumen);
Equipo visitante = equipo(partido.awayTeam(), competicion, resumen);
+ // La API da la hora en UTC; los eventos se guardan en la zona de la aplicación (Madrid)
LocalDateTime fecha = LocalDateTime.ofInstant(Instant.parse(partido.utcDate()),
ZoneId.of(propiedades.zonaHoraria()));
+ // Emparejamiento: primero por el identificador de la API; si no, un evento con los mismos equipos
+ // dentro de ±2 días (uno creado a mano o por DatosIniciales, quizá con otra hora)
Evento evento = eventos.findByIdExterno(partido.id())
.or(() -> eventos.findByLocalAndVisitanteAndFechaHoraBetween(local, visitante,
fecha.minusDays(2), fecha.plusDays(2)).stream().findFirst())
@@ -123,22 +145,30 @@ private void procesar(PartidoApi partido, Competicion competicion, ResumenSincro
evento = eventos.save(new Evento(competicion, local, visitante, fecha));
resumen.eventoNuevo();
} else if (!fecha.equals(evento.getFechaHora()) || evento.getIdExterno() == null) {
+ // Ha cambiado la hora o es la primera vez que se enlaza. Evento solo cambia la fecha si aún no se ha jugado
evento.cambiarFecha(fecha);
resumen.eventoActualizado();
}
+ // Enlazado: en las siguientes sincronizaciones se encuentra directamente por el identificador
evento.setIdExterno(partido.id());
+ // La jornada solo se pone si no tiene fase: no pisa la que haya puesto el creador
if (evento.getFase() == null && partido.matchday() != null) {
evento.setFase("Jornada " + partido.matchday());
}
aplicarEstado(partido, evento, resumen);
}
+ /**
+ * Traduce el estado del partido en la API a un cambio en el evento, a través de {@link ResolucionService}.
+ * Solo actúa si el evento no está ya así, para no repetir el mismo cambio en cada sincronización.
+ */
private void aplicarEstado(PartidoApi partido, Evento evento, ResumenSincronizacion resumen) {
if (evento.getEstado() == EstadoEvento.ANULADO) {
return; // lo anuló el creador: la API no lo resucita ni le pone resultado
}
String estado = partido.status() == null ? "" : partido.status();
switch (estado) {
+ // Terminado: las apuestas se deciden con el resultado de los 90 minutos
case "FINISHED" -> resultado(partido).ifPresent(r -> {
PartidoApi.MarcadorApi marcador = partido.score();
PartidoApi.GolesApi goles = marcador.marcador90();
@@ -148,6 +178,8 @@ private void aplicarEstado(PartidoApi partido, Evento evento, ResumenSincronizac
+ evento.getVisitante().getNombre() + "): hubo prórroga y la API no da el marcador de "
+ "los 90 minutos; introdúcelo a mano en Gestión");
} else if (goles != null && evento.getDeporte().isAdmiteEmpate()) {
+ // Con marcador se resuelven el 1X2, los goles y ambos marcan (HU-52).
+ // Solo si es nuevo o distinto del guardado (una corrección vuelve a resolver las apuestas)
int local = goles.home();
int visitante = goles.away();
if (evento.getEstado() != EstadoEvento.FINALIZADO
@@ -157,16 +189,20 @@ private void aplicarEstado(PartidoApi partido, Evento evento, ResumenSincronizac
resumen.resultado();
}
} else if (evento.getEstado() != EstadoEvento.FINALIZADO || evento.getResultado() != r) {
+ // Solo se sabe el ganador: se resuelve el 1X2 y las de goles y ambos marcan se anulan
resolucion.introducirResultado(evento.getId(), r);
resumen.resultado();
}
});
case "POSTPONED", "SUSPENDED" -> {
+ // Aplazado: deja de admitir apuestas. Queda marcado como suspendido por la API para que la
+ // sincronización lo reactive sola cuando vuelva a tener fecha
if (evento.getEstado() == EstadoEvento.PROGRAMADO) {
resolucion.suspenderPorApi(evento.getId());
}
}
case "CANCELLED" -> {
+ // Cancelado: se anula y se devuelve lo apostado (HU-05)
if (evento.getEstado() == EstadoEvento.PROGRAMADO || evento.getEstado() == EstadoEvento.SUSPENDIDO) {
resolucion.anular(evento.getId());
}
@@ -183,6 +219,10 @@ private void aplicarEstado(PartidoApi partido, Evento evento, ResumenSincronizac
}
}
+ /**
+ * Ganador según la API; vacío si aún no lo hay. Si hubo prórroga o penaltis, es el ganador final,
+ * no el de los 90 minutos.
+ */
private static Optional resultado(PartidoApi partido) {
if (partido.score() == null || partido.score().winner() == null) {
return Optional.empty();
@@ -195,6 +235,7 @@ private static Optional resultado(PartidoApi partido) {
};
}
+ /** Nuestra competición para un código de la API (PD → LaLiga); si no existe, se crea como de fútbol. */
private Competicion competicion(String codigo) {
String nombre = COMPETICIONES.getOrDefault(codigo, codigo);
return competiciones.findByNombre(nombre)
@@ -215,22 +256,30 @@ private Equipo equipo(PartidoApi.EquipoApi datos, Competicion competicion, Resum
equipo = equipos.save(new Equipo(nombre, Deporte.FUTBOL, CALIDAD_INICIAL));
resumen.equipoNuevo();
}
+ // Enlazado con la API: la próxima vez se encuentra por el identificador
if (equipo.getIdExterno() == null) {
equipo.setIdExterno(datos.id());
}
+ // El escudo solo se pone si no tiene uno (no pisa el de Gestión) y solo con direcciones https://
if (equipo.getEscudoUrl() == null && datos.crest() != null && datos.crest().startsWith("https://")) {
equipo.setEscudoUrl(datos.crest());
}
+ // Se apunta en la competición si aún no estaba (p. ej. un equipo de LaLiga que juega la Champions)
if (!equipo.participaEn(competicion.getId())) {
equipo.participaEn(competicion);
}
return equipo;
}
+ /**
+ * Equipo de fútbol aún sin enlazar cuyo nombre equivale al largo o al corto de la API.
+ * {@link Nombres#clave(String)} quita tildes, mayúsculas y palabras como "Club" o "FC".
+ */
private Equipo porNombre(PartidoApi.EquipoApi datos) {
String clave = Nombres.clave(datos.name());
String claveCorta = Nombres.clave(datos.shortName());
return equipos.findByDeporte(Deporte.FUTBOL).stream()
+ // Un equipo ya enlazado con otro de la API no es este, aunque el nombre se parezca
.filter(e -> e.getIdExterno() == null)
.filter(e -> {
String nuestra = Nombres.clave(e.getNombre());
@@ -240,6 +289,7 @@ private Equipo porNombre(PartidoApi.EquipoApi datos) {
.orElse(null);
}
+ /** Nombre de un equipo nuevo: el largo; si no hay, el corto; si tampoco, "Equipo" y su identificador. */
private static String nombreVisible(PartidoApi.EquipoApi datos) {
if (datos.name() != null && !datos.name().isBlank()) {
return datos.name();
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/TiemposDeEsperaConfig.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/TiemposDeEsperaConfig.java
index 25d93fe..4393653 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/api/TiemposDeEsperaConfig.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/TiemposDeEsperaConfig.java
@@ -14,9 +14,16 @@
@Configuration
public class TiemposDeEsperaConfig {
+ /** Máximo para conectar con el servidor de la API. */
static final Duration ESPERA_CONEXION = Duration.ofSeconds(5);
+ /** Máximo esperando la respuesta, una vez conectados. */
static final Duration ESPERA_RESPUESTA = Duration.ofSeconds(20);
+ /**
+ * Spring aplica este ajuste a los {@code RestClient.Builder} que crea, como el que recibe
+ * {@link FootballDataCliente}. Si se pasa el tiempo, la llamada lanza una {@code RestClientException}
+ * y la sincronización la apunta como un error más.
+ */
@Bean
public RestClientCustomizer tiemposDeEspera() {
return builder -> {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Apuesta.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Apuesta.java
index be33a2c..ff0aef0 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Apuesta.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Apuesta.java
@@ -29,6 +29,11 @@
* Apuesta de un usuario: una selección (simple) o varias de eventos distintos (combinada, HU-28).
* La cuota total es el producto de las cuotas de las selecciones (HU-29).
* Las apuestas a largo plazo (HU-44) tienen una sola selección y no se combinan.
+ *
+ * Entidad JPA: guarda el importe, la cuota total, el estado y lo ya pagado al usuario. Sus
+ * {@link Seleccion}es se guardan y se borran con ella (cascade). El estado se recalcula cada vez que se
+ * resuelve o se anula una selección ({@code reevaluar}), y aquí se hacen los pagos, devoluciones y
+ * cambios de importe sobre el saldo del {@link Usuario}. El cobro inicial lo hace {@link ApuestaService}.
*/
@Entity
public class Apuesta {
@@ -36,6 +41,7 @@ public class Apuesta {
/**
* Cuota total máxima de una combinada. Sin tope, 10 selecciones de cuota 50 darían una cuota de 50^10:
* no cabe en la base de datos y un acierto improbable descuadraría el ranking.
+ * Se comprueba en {@link ApuestaService} antes de apostar y al añadir al boleto.
*/
public static final BigDecimal CUOTA_TOTAL_MAXIMA = new BigDecimal("1000");
@@ -50,13 +56,18 @@ public class Apuesta {
@OrderBy("id")
private List selecciones = new ArrayList<>();
+ /** Moneditas apostadas: se descuentan del saldo al apostar. */
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal importe;
- /** Producto de las cuotas de todas las selecciones en el momento de apostar. */
+ /**
+ * Producto de las cuotas de todas las selecciones en el momento de apostar, redondeado hacia abajo
+ * a 2 decimales. Se recalcula si el usuario cambia el importe (HU-27).
+ */
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal cuota;
+ /** Empieza activa; cambia al resolverse o anularse sus selecciones, o al cancelarla el usuario. */
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private EstadoApuesta estado = EstadoApuesta.ACTIVA;
@@ -72,6 +83,7 @@ protected Apuesta() {
// requerido por JPA
}
+ /** Apuesta vacía; la cuota empieza en 1,00 y se multiplica con cada selección que se añade. */
public Apuesta(Usuario usuario, BigDecimal importe, LocalDateTime fecha) {
this.usuario = usuario;
this.importe = importe;
@@ -89,6 +101,7 @@ public void anadir(Evento evento, Especial especial, BigDecimal cuotaSeleccion)
anadir(evento, new Seleccion(this, evento, especial, cuotaSeleccion));
}
+ /** Lanza IllegalArgumentException si ya hay una a largo plazo o una del mismo evento. */
private void anadir(Evento evento, Seleccion nueva) {
if (selecciones.stream().anyMatch(Seleccion::isLargoPlazo)) {
throw new IllegalArgumentException("Las apuestas a largo plazo no se pueden combinar");
@@ -97,6 +110,7 @@ private void anadir(Evento evento, Seleccion nueva) {
throw new IllegalArgumentException("No se pueden combinar dos selecciones del mismo evento");
}
selecciones.add(nueva);
+ // Multiplicador total (HU-29): producto de las cuotas de todas las selecciones
cuota = producto(selecciones.stream().map(Seleccion::getCuota).toList());
}
@@ -129,6 +143,7 @@ public void anular(Mercado mercado) {
/** Resultado de uno de sus eventos: se resuelve la selección y se recalcula la apuesta (HU-25, HU-30). */
public void resolver(Evento evento, Resultado resultado) {
+ // Una apuesta cancelada ya devolvió el importe: no se resuelve (igual en los otros resolver y anular)
if (estado == EstadoApuesta.CANCELADA) {
return;
}
@@ -155,6 +170,7 @@ private void reevaluar() {
boolean algunaPendiente = selecciones.stream().anyMatch(s -> s.getEstado() == EstadoSeleccion.PENDIENTE);
boolean todasAnuladas = selecciones.stream().allMatch(s -> s.getEstado() == EstadoSeleccion.ANULADA);
+ // El orden importa: una fallada pierde la combinada aunque queden otras pendientes (CA-30.1)
if (algunaFallada) {
estado = EstadoApuesta.PERDIDA;
} else if (algunaPendiente) {
@@ -162,14 +178,18 @@ private void reevaluar() {
} else if (todasAnuladas) {
estado = EstadoApuesta.ANULADA;
} else {
+ // Acertadas, o acertadas y anuladas: las anuladas cuentan con cuota 1,00 (CA-30.3)
estado = EstadoApuesta.GANADA;
}
+ // Lo que le corresponde al usuario en el estado actual; la ganancia ya incluye el importe apostado
BigDecimal debePagar = switch (estado) {
case GANADA -> getGananciaPotencial();
case ANULADA -> importe;
default -> BigDecimal.ZERO;
};
+ // Solo se abona la diferencia con lo pagado antes. Si se corrige un resultado y una ganada pasa a
+ // perdida, la diferencia es negativa y se le retira lo cobrado (el saldo puede quedar negativo)
usuario.ajustar(debePagar.subtract(pagado));
pagado = debePagar;
}
@@ -182,7 +202,11 @@ public boolean cancelable(LocalDateTime ahora) {
return estado == EstadoApuesta.ACTIVA && selecciones.stream().allMatch(s -> s.admiteCancelacion(ahora));
}
- /** Cancela la apuesta y devuelve el importe al usuario. */
+ /**
+ * Cancela la apuesta y devuelve el importe al usuario (HU-26).
+ *
+ * @throws IllegalStateException si ya no es cancelable (ver {@link #cancelable})
+ */
public void cancelar(LocalDateTime ahora) {
if (!cancelable(ahora)) {
throw new IllegalStateException("La apuesta ya no se puede cancelar");
@@ -194,11 +218,15 @@ public void cancelar(LocalDateTime ahora) {
/**
* Cambia el importe mientras se pueda cancelar (HU-27): se cobra o se devuelve la diferencia
* y se aplican las cuotas actuales, una por selección y en el mismo orden.
+ *
+ * @throws IllegalStateException si ya no se puede cancelar
+ * @throws es.ucm.fdi.is1.apuestas.usuarios.SaldoInsuficienteException si sube el importe y no hay saldo
*/
public void cambiarImporte(BigDecimal nuevo, List cuotasActuales, LocalDateTime ahora) {
if (!cancelable(ahora)) {
throw new IllegalStateException("La apuesta ya no se puede modificar");
}
+ // Sube: se cobra solo la diferencia. Baja: se devuelve la diferencia (CA-27.1, CA-27.2)
BigDecimal diferencia = nuevo.subtract(importe);
if (diferencia.signum() > 0) {
usuario.cargar(diferencia);
@@ -206,6 +234,7 @@ public void cambiarImporte(BigDecimal nuevo, List cuotasActuales, Lo
usuario.abonar(diferencia.negate());
}
importe = nuevo;
+ // La cuota vista al apostar ya no vale: toda la apuesta pasa a la cuota vigente ahora
for (int i = 0; i < selecciones.size(); i++) {
selecciones.get(i).actualizarCuota(cuotasActuales.get(i));
}
@@ -217,11 +246,15 @@ public BigDecimal getCuotaEfectiva() {
return producto(selecciones.stream().map(Seleccion::getCuotaEfectiva).toList());
}
- /** Lo que cobra el usuario si acierta: importe × cuota (HU-29). */
+ /**
+ * Lo que cobra el usuario si acierta: importe × cuota (HU-29), con el importe incluido.
+ * Se redondea hacia abajo para no pagar nunca un céntimo de más.
+ */
public BigDecimal getGananciaPotencial() {
return importe.multiply(getCuotaEfectiva()).setScale(2, RoundingMode.DOWN);
}
+ /** Ganada o perdida. Las anuladas y canceladas no cuentan en las estadísticas (HU-35). */
public boolean isResuelta() {
return estado == EstadoApuesta.GANADA || estado == EstadoApuesta.PERDIDA;
}
@@ -230,7 +263,7 @@ public boolean isCombinada() {
return selecciones.size() > 1;
}
- /** Beneficio neto de una apuesta resuelta: ganancia − importe si se acierta, −importe si no. */
+ /** Beneficio neto de una apuesta resuelta: ganancia − importe si se acierta, −importe si no; 0 en otro estado. */
public BigDecimal getBeneficio() {
return switch (estado) {
case GANADA -> getGananciaPotencial().subtract(importe);
@@ -253,6 +286,10 @@ private Seleccion seleccionDe(Mercado mercado) {
.orElseThrow(() -> new IllegalArgumentException("La apuesta no incluye ese mercado"));
}
+ /**
+ * Producto de varias cuotas redondeado hacia abajo a 2 decimales (1,00 si no hay ninguna).
+ * Lo usan también los servicios para calcular la cuota total antes de apostar.
+ */
static BigDecimal producto(List cuotas) {
return cuotas.stream().reduce(BigDecimal.ONE, BigDecimal::multiply).setScale(2, RoundingMode.DOWN);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaController.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaController.java
index 0ce49c5..1ade255 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaController.java
@@ -25,6 +25,12 @@
import es.ucm.fdi.is1.apuestas.usuarios.SaldoInsuficienteException;
import jakarta.validation.Valid;
+/**
+ * Controlador web de las apuestas del jugador: apuesta simple desde la página del partido (HU-23, HU-52),
+ * Mis apuestas (HU-24, HU-34, HU-35), cancelar (HU-26) y cambiar el importe (HU-27), además del ranking
+ * (HU-36) y el perfil público de un jugador (HU-48). Delega en {@link ApuestaService} y {@link RankingService}
+ * y convierte sus excepciones en mensajes para el usuario.
+ */
@Controller
public class ApuestaController {
@@ -49,7 +55,12 @@ public ApuestaController(ApuestaService apuestas, CatalogoService catalogo, Calc
this.reloj = reloj;
}
- /** Requiere sesión iniciada: un visitante es redirigido al login (HU-08). */
+ /**
+ * GET /eventos/{id}/apostar: página del partido con sus cuotas y el formulario de apuesta simple.
+ * Puede llegar con el pronóstico ya marcado ({@code resultado} o {@code especial}).
+ * Requiere sesión iniciada: un visitante es redirigido al login (HU-08).
+ * Si el evento ya no admite apuestas, 404 ({@link es.ucm.fdi.is1.apuestas.eventos.EventoNoDisponibleException}).
+ */
@GetMapping("/eventos/{id}/apostar")
public String formulario(@PathVariable Long id, @RequestParam(required = false) Resultado resultado,
@RequestParam(required = false) Especial especial, Model model) {
@@ -60,11 +71,16 @@ public String formulario(@PathVariable Long id, @RequestParam(required = false)
return vista(catalogo.eventoDisponible(id), model);
}
+ /**
+ * POST /eventos/{id}/apostar: registra la apuesta simple. Si sale bien, lleva a Mis apuestas con la
+ * ganancia potencial; si no, vuelve a la página del partido con el error junto al campo afectado.
+ */
@PostMapping("/eventos/{id}/apostar")
public String apostar(@PathVariable Long id, @Valid @ModelAttribute("apuesta") ApuestaForm form,
BindingResult errores, Principal principal, Model model,
RedirectAttributes redireccion) {
Evento evento = catalogo.eventoDisponible(id);
+ // Primero las validaciones del formulario (@Valid); solo si pasan se intenta apostar
if (!errores.hasErrors()) {
try {
Apuesta apuesta = apuestas.apostar(principal.getName(), List.of(
@@ -85,30 +101,42 @@ public String apostar(@PathVariable Long id, @Valid @ModelAttribute("apuesta") A
errores.rejectValue("opcion", "cuotaCambiada",
"La cuota ha cambiado mientras decidías. Revisa la nueva y confirma otra vez.");
} catch (IllegalArgumentException e) {
+ // El resto de reglas (cuota total, combinar, creador…); va la última porque
+ // ImporteFueraDeLimitesException y JuegoResponsableException son subclases suyas
errores.rejectValue("opcion", "invalido", e.getMessage());
}
}
return vista(evento, model);
}
+ /**
+ * GET /apuestas: Mis apuestas. Las activas con el total comprometido (HU-24), el historial (HU-34)
+ * y las estadísticas (HU-35).
+ */
@GetMapping("/apuestas")
public String misApuestas(Principal principal, Model model) {
List activas = apuestas.activas(principal.getName());
+ // Total comprometido: suma de los importes de las apuestas activas
BigDecimal comprometido = activas.stream().map(Apuesta::getImporte).reduce(BigDecimal.ZERO, BigDecimal::add);
model.addAttribute("activas", activas);
model.addAttribute("comprometido", comprometido);
+ // La vista llama a cancelable(ahora) para mostrar o no los botones de cancelar y cambiar importe
model.addAttribute("ahora", LocalDateTime.now(reloj));
model.addAttribute("historial", apuestas.historial(principal.getName()));
model.addAttribute("estadisticas", apuestas.estadisticas(principal.getName()));
return "apuestas";
}
- /** Ranking público: top 20 y, si no estás entre ellos, también tu posición (HU-36). */
+ /**
+ * GET /ranking?orden=...: ranking público, por saldo si no se indica criterio. Muestra el top 20 y,
+ * si no estás entre ellos, también tu posición (HU-36, CA-36.4). Sin sesión no hay "tu posición".
+ */
@GetMapping("/ranking")
public String ranking(@RequestParam(defaultValue = "SALDO") CriterioRanking orden, Principal principal,
Model model) {
List todos = rankings.ranking(principal == null ? null : principal.getName(), orden);
model.addAttribute("primeros", todos.stream().limit(20).toList());
+ // Tu fila aparte solo si no sale ya en el top 20
model.addAttribute("mio", todos.stream().filter(PuestoRanking::soyYo)
.filter(p -> p.posicion() > 20).findFirst().orElse(null));
model.addAttribute("total", todos.size());
@@ -117,13 +145,20 @@ public String ranking(@RequestParam(defaultValue = "SALDO") CriterioRanking orde
return "ranking";
}
- /** Perfil público de un jugador desde el ranking (HU-48). */
+ /**
+ * GET /jugadores/{id}: perfil público de un jugador desde el ranking (HU-48), con sus estadísticas
+ * pero sin sus apuestas. 404 si no es un jugador del ranking ({@link JugadorNoEncontradoException}).
+ */
@GetMapping("/jugadores/{id}")
public String jugador(@PathVariable Long id, Principal principal, Model model) {
model.addAttribute("perfil", rankings.perfil(id, principal == null ? null : principal.getName()));
return "jugador";
}
+ /**
+ * POST /apuestas/{id}/cancelar: cancela la apuesta y devuelve el importe (HU-26). Vuelve a Mis apuestas
+ * con un mensaje. Si la apuesta no es del usuario, 404 ({@link ApuestaNoEncontradaException}).
+ */
@PostMapping("/apuestas/{id}/cancelar")
public String cancelar(@PathVariable Long id, Principal principal, RedirectAttributes redireccion) {
try {
@@ -136,10 +171,14 @@ public String cancelar(@PathVariable Long id, Principal principal, RedirectAttri
return "redirect:/apuestas";
}
- /** Cambiar el importe de una apuesta activa (HU-27). */
+ /**
+ * POST /apuestas/{id}/importe: cambia el importe de una apuesta activa (HU-27) y vuelve a Mis apuestas
+ * con la nueva cuota y la nueva ganancia potencial, o con el error.
+ */
@PostMapping("/apuestas/{id}/importe")
public String modificarImporte(@PathVariable Long id, @RequestParam(required = false) BigDecimal importe,
Principal principal, RedirectAttributes redireccion) {
+ // Las mismas reglas que @DecimalMin y @Digits de ApuestaForm, a mano porque aquí no hay formulario
if (importe == null || importe.signum() <= 0 || importe.stripTrailingZeros().scale() > 2) {
redireccion.addFlashAttribute("error", "Introduce un importe válido, con 2 decimales como mucho");
return "redirect:/apuestas";
@@ -160,6 +199,10 @@ public String modificarImporte(@PathVariable Long id, @RequestParam(required = f
return "redirect:/apuestas";
}
+ /**
+ * Rellena la página "apostar": cuotas 1X2, otros tipos de apuesta (solo en fútbol, HU-52),
+ * límites de importe y cara a cara de los dos equipos (HU-33).
+ */
private String vista(Evento evento, Model model) {
model.addAttribute("evento", evento);
model.addAttribute("cuotas", calculadora.calcular(evento));
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaForm.java
index 3fe9f4a..7cd3ec9 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaForm.java
@@ -12,6 +12,9 @@
/**
* Pronóstico e importe de una apuesta simple. El pronóstico es un resultado (1X2) o un tipo especial (HU-52);
* en la página se eligen con un único grupo de opciones ({@code opcion}).
+ *
+ * Formulario de la página del partido ({@link ApuestaController}). Las anotaciones de validación se
+ * comprueban con {@code @Valid}; los límites del creador y el saldo, después, en {@link ApuestaService}.
*/
public class ApuestaForm {
@@ -27,6 +30,7 @@ public class ApuestaForm {
@Digits(integer = 10, fraction = 2, message = "Como mucho 2 decimales")
private BigDecimal importe;
+ /** Validación: hay que elegir un resultado o un tipo especial. */
@AssertTrue(message = "Elige un pronóstico")
public boolean isOpcionElegida() {
return resultado != null || especial != null;
@@ -40,12 +44,17 @@ public String getOpcion() {
return resultado != null ? resultado.name() : null;
}
+ /**
+ * Traduce la opción elegida al resultado o al tipo especial que le corresponde. Un valor desconocido
+ * deja los dos a null y lo rechaza {@link #isOpcionElegida()}.
+ */
public void setOpcion(String opcion) {
resultado = null;
especial = null;
if (opcion == null || opcion.isBlank()) {
return;
}
+ // Se busca a mano en vez de valueOf para no lanzar excepción con un valor manipulado
for (Resultado r : Resultado.values()) {
if (r.name().equals(opcion)) {
resultado = r;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaNoEncontradaException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaNoEncontradaException.java
index a21f22b..2f6db98 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaNoEncontradaException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaNoEncontradaException.java
@@ -3,6 +3,11 @@
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
+/**
+ * La apuesta no existe o no es del usuario que la pide (cancelar o cambiar el importe, HU-26, HU-27).
+ * Así no se revela si existe una apuesta ajena. Si nadie la captura, Spring responde con un 404
+ * gracias a {@code @ResponseStatus}.
+ */
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ApuestaNoEncontradaException extends RuntimeException {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaRepository.java
index 7fc28dd..dd5af03 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaRepository.java
@@ -10,14 +10,19 @@
import es.ucm.fdi.is1.apuestas.usuarios.Usuario;
+/** Repositorio de {@link Apuesta}: consultas para Mis apuestas, el ranking, el juego responsable y el panel de la casa. */
public interface ApuestaRepository extends JpaRepository {
+ /** Apuestas del usuario en un estado, de la más reciente a la más antigua (activas, HU-24). */
List findByUsuarioAndEstadoOrderByFechaDesc(Usuario usuario, EstadoApuesta estado);
+ /** Igual, pero con varios estados a la vez (historial, HU-34). */
List findByUsuarioAndEstadoInOrderByFechaDesc(Usuario usuario, Collection estados);
+ /** Apuestas de todos los usuarios en esos estados (panel de la casa, HU-54). */
List findByEstadoIn(Collection estados);
+ /** Cuántas apuestas tiene el usuario en ese estado (apuestas en juego del perfil público, HU-48). */
long countByUsuarioAndEstado(Usuario usuario, EstadoApuesta estado);
/**
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaService.java
index c019351..d105843 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaService.java
@@ -24,6 +24,16 @@
import es.ucm.fdi.is1.apuestas.usuarios.Usuario;
import es.ucm.fdi.is1.apuestas.usuarios.UsuarioRepository;
+/**
+ * Servicio de apuestas del jugador: apostar (simple, combinada o a largo plazo), cancelar, cambiar el importe
+ * y consultar las activas, el historial y las estadísticas (HU-10, HU-23, HU-24, HU-26, HU-27, HU-28, HU-29,
+ * HU-34, HU-35, HU-44, HU-52).
+ *
+ * Aquí se comprueban las reglas antes de tocar el saldo: eventos disponibles, límites del creador (HU-07),
+ * juego responsable (HU-10) y cuotas vistas. Cada método público es una transacción: si algo falla no se cobra
+ * nada. Si dos operaciones cambian el saldo del mismo usuario a la vez, el {@code @Version} de
+ * {@link Usuario} hace fallar la segunda al confirmar la transacción.
+ */
@Service
public class ApuestaService {
@@ -61,6 +71,15 @@ public Apuesta apostar(String email, Long eventoId, Resultado resultado, BigDeci
* apuestas, que no se repiten, los límites de apuesta (HU-07) y el saldo, y descuenta el importe.
* Las cuotas se calculan en el servidor. Si el usuario vio una cuota distinta de la actual, no se apuesta
* y se le pide que acepte las nuevas (HU-29).
+ *
+ * @throws EventoNoDisponibleException si algún evento no existe o ya no admite apuestas
+ * @throws ResultadoNoValidoException si algún pronóstico no se puede apostar en su evento
+ * @throws CuotasCambiadasException si alguna cuota vista no coincide con la actual
+ * @throws ImporteFueraDeLimitesException si el importe no respeta los límites del creador
+ * @throws JuegoResponsableException si el usuario está en pausa o supera sus límites personales
+ * @throws es.ucm.fdi.is1.apuestas.usuarios.SaldoInsuficienteException si no tiene saldo
+ * @throws IllegalArgumentException si el boleto está vacío, tiene demasiadas selecciones, repite evento,
+ * la cuota total pasa del máximo o el usuario es el creador
*/
@Transactional
public Apuesta apostar(String email, List pedidas, BigDecimal importe) {
@@ -70,6 +89,7 @@ public Apuesta apostar(String email, List pedidas, BigDecimal i
Limites limitesActuales = limites.actuales();
limitesActuales.comprobarSelecciones(pedidas.size());
limitesActuales.comprobarImporte(importe);
+ // Una sola hora para toda la operación: todas las selecciones se comprueban con el mismo "ahora"
LocalDateTime ahora = LocalDateTime.now(reloj);
Usuario usuario = usuarios.findByEmail(email).orElseThrow();
comprobarQueJuega(usuario);
@@ -79,7 +99,9 @@ public Apuesta apostar(String email, List pedidas, BigDecimal i
Evento evento = eventos.findById(pedida.eventoId())
.filter(e -> e.admiteApuestas(ahora))
.orElseThrow(() -> new EventoNoDisponibleException(pedida.eventoId()));
+ // La cuota se calcula aquí, nunca se fía de la que envía la página
BigDecimal cuota = cuota(evento, pedida.resultado(), pedida.especial());
+ // Se apuntan todas las cambiadas antes de avisar, para enseñarlas todas de una vez
if (pedida.cuotaVista() != null && pedida.cuotaVista().compareTo(cuota) != 0) {
cambiadas.add(evento.getId());
}
@@ -94,18 +116,24 @@ public Apuesta apostar(String email, List pedidas, BigDecimal i
}
comprobarCuotaTotal(apuesta.getCuota());
comprobarJuegoResponsable(usuario, importe, ahora);
+ // Se cobra tras todas las comprobaciones; sin saldo lanza SaldoInsuficienteException y no se guarda nada (CA-23.2)
usuario.cargar(importe);
+ // Las selecciones se guardan con la apuesta (cascade); el usuario, al confirmar la transacción
return apuestas.save(apuesta);
}
/**
* Apuesta a largo plazo (HU-44): al candidato de un mercado abierto, con la cuota que ha fijado el creador.
* Si el usuario vio otra cuota (el creador la ha cambiado mientras tanto), no se apuesta.
+ *
+ * @throws MercadoNoDisponibleException si el candidato no es de ese mercado o el mercado no admite apuestas
+ * @throws CuotasCambiadasException si la cuota vista no es la actual (con la lista de eventos vacía)
*/
@Transactional
public Apuesta apostarMercado(String email, Long mercadoId, Long candidatoId, BigDecimal cuotaVista,
BigDecimal importe) {
LocalDateTime ahora = LocalDateTime.now(reloj);
+ // El candidato tiene que ser del mercado de la URL y ese mercado tiene que seguir abierto
Candidato candidato = candidatos.findById(candidatoId)
.filter(c -> c.getMercado().getId().equals(mercadoId))
.filter(c -> c.getMercado().admiteApuestas(ahora))
@@ -127,12 +155,16 @@ public Apuesta apostarMercado(String email, Long mercadoId, Long candidatoId, Bi
* Cancela una apuesta activa del usuario antes de que empiece el evento (o, a largo plazo, mientras el
* mercado siga abierto) y le devuelve el importe (HU-26).
* Si la apuesta no es suya se trata como inexistente.
+ *
+ * @throws ApuestaNoEncontradaException si no existe o es de otro usuario
+ * @throws IllegalStateException si ya no se puede cancelar
*/
@Transactional
public Apuesta cancelar(String email, Long apuestaId) {
Apuesta apuesta = apuestas.findById(apuestaId)
.filter(a -> a.getUsuario().getEmail().equals(email))
.orElseThrow(() -> new ApuestaNoEncontradaException(apuestaId));
+ // No hace falta save: la apuesta está gestionada por JPA y los cambios se guardan al terminar la transacción
apuesta.cancelar(LocalDateTime.now(reloj));
return apuesta;
}
@@ -140,6 +172,11 @@ public Apuesta cancelar(String email, Long apuestaId) {
/**
* Cambia el importe de una apuesta activa del usuario antes de que empiece el evento (HU-27).
* Se aplican los límites de apuesta y las cuotas actuales, y se cobra o devuelve la diferencia.
+ *
+ * @throws ApuestaNoEncontradaException si no existe o es de otro usuario
+ * @throws IllegalStateException si alguno de sus eventos ya ha empezado
+ * @throws IllegalArgumentException si el nuevo importe o la nueva cuota total no respetan los límites
+ * @throws es.ucm.fdi.is1.apuestas.usuarios.SaldoInsuficienteException si sube el importe y no hay saldo
*/
@Transactional
public Apuesta modificarImporte(String email, Long apuestaId, BigDecimal nuevo) {
@@ -148,10 +185,12 @@ public Apuesta modificarImporte(String email, Long apuestaId, BigDecimal nuevo)
.orElseThrow(() -> new ApuestaNoEncontradaException(apuestaId));
limites.actuales().comprobarImporte(nuevo);
LocalDateTime ahora = LocalDateTime.now(reloj);
+ // Para el juego responsable solo cuenta lo que se añade; bajar el importe siempre se permite
BigDecimal aumento = nuevo.subtract(apuesta.getImporte());
if (aumento.signum() > 0) {
comprobarJuegoResponsable(apuesta.getUsuario(), aumento, ahora);
}
+ // Cuotas vigentes, en el mismo orden que las selecciones (Apuesta.cambiarImporte cuenta con ello)
List cuotas = apuesta.getSelecciones().stream().map(this::cuotaActual).toList();
comprobarCuotaTotal(Apuesta.producto(cuotas));
apuesta.cambiarImporte(nuevo, cuotas, ahora);
@@ -165,6 +204,7 @@ private static void comprobarQueJuega(Usuario usuario) {
}
}
+ /** Lanza IllegalArgumentException si la cuota total pasa de {@link Apuesta#CUOTA_TOTAL_MAXIMA}. */
static void comprobarCuotaTotal(BigDecimal cuota) {
if (cuota.compareTo(Apuesta.CUOTA_TOTAL_MAXIMA) > 0) {
throw new IllegalArgumentException("La cuota total no puede pasar de 1.000: quita alguna selección");
@@ -184,15 +224,19 @@ private void comprobarJuegoResponsable(Usuario usuario, BigDecimal importe, Loca
throw new JuegoResponsableException("Has pausado tus apuestas hasta el "
+ FECHA.format(usuario.getPausaHasta()) + ".");
}
+ // Ventanas móviles (las últimas 24 h y los últimos 7 días), no el día o la semana del calendario
comprobarLimite(usuario, usuario.getLimiteDiario(), ahora.minusDays(1), importe, "diario", "24 horas");
comprobarLimite(usuario, usuario.getLimiteSemanal(), ahora.minusDays(7), importe, "semanal", "7 días");
}
+ /** Lanza JuegoResponsableException si lo apostado desde {@code desde} más {@code importe} supera el límite. */
private void comprobarLimite(Usuario usuario, BigDecimal limite, LocalDateTime desde, BigDecimal importe,
String nombre, String periodo) {
+ // Sin límite fijado (null): no hay nada que comprobar
if (limite == null) {
return;
}
+ // Cuentan también las apuestas ya resueltas; las canceladas no (ApuestaRepository.apostadoDesde)
BigDecimal apostado = apuestas.apostadoDesde(usuario, desde);
if (apostado.add(importe).compareTo(limite) > 0) {
throw new JuegoResponsableException("Superarías tu límite " + nombre + " de " + monedas(limite)
@@ -204,6 +248,7 @@ private static String monedas(BigDecimal cantidad) {
return cantidad.setScale(2, RoundingMode.DOWN).toPlainString().replace('.', ',');
}
+ /** Cuota vigente de una selección ya apostada: la del candidato o la que calcula ahora la calculadora. */
private BigDecimal cuotaActual(Seleccion seleccion) {
if (seleccion.isLargoPlazo()) {
return seleccion.getCandidato().getCuota();
@@ -211,8 +256,13 @@ private BigDecimal cuotaActual(Seleccion seleccion) {
return calculadora.cuota(seleccion.getEvento(), seleccion.getPronostico(), seleccion.getEspecial());
}
- /** Cuota actual de un resultado o de un tipo especial (HU-52); exactamente uno de los dos. */
+ /**
+ * Cuota actual de un resultado o de un tipo especial (HU-52); exactamente uno de los dos.
+ *
+ * @throws ResultadoNoValidoException si vienen los dos, ninguno, o no hay cuota para eso en el evento
+ */
BigDecimal cuota(Evento evento, Resultado resultado, Especial especial) {
+ // Los dos null o los dos rellenos: no es un pronóstico válido
BigDecimal cuota = (resultado == null) == (especial == null) ? null
: calculadora.cuota(evento, resultado, especial);
if (cuota == null) {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/AvisosApuestas.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/AvisosApuestas.java
index 587f5ec..a149f74 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/AvisosApuestas.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/AvisosApuestas.java
@@ -9,7 +9,11 @@
import es.ucm.fdi.is1.apuestas.notificaciones.NotificacionService;
-/** Avisa al usuario cuando una de sus apuestas se gana, se pierde o se anula (HU-37). */
+/**
+ * Avisa al usuario cuando una de sus apuestas se gana, se pierde o se anula (HU-37). La usan
+ * {@link ResolucionService} y {@link ResolucionMercadosService} después de resolver cada apuesta;
+ * el aviso se guarda con {@link NotificacionService}. Solo es visible dentro del paquete.
+ */
@Component
class AvisosApuestas {
@@ -25,6 +29,7 @@ void siCambia(Apuesta apuesta, EstadoApuesta antes) {
if (ahora == antes || ahora == EstadoApuesta.ACTIVA || ahora == EstadoApuesta.CANCELADA) {
return;
}
+ // Si ya estaba resuelta, es una corrección del resultado (HU-04) y se dice así
String correccion = antes == EstadoApuesta.ACTIVA ? "" : "Resultado corregido. ";
String texto = switch (ahora) {
case GANADA -> correccion + "¡Has acertado " + descripcion(apuesta) + "! Cobras "
@@ -33,6 +38,7 @@ void siCambia(Apuesta apuesta, EstadoApuesta antes) {
default -> correccion + "Se ha anulado " + descripcion(apuesta) + ". Te hemos devuelto "
+ monedas(apuesta.getImporte()) + " monedas.";
};
+ // El tipo del aviso es el estado en minúsculas: "ganada", "perdida" o "anulada"
notificaciones.avisar(apuesta.getUsuario(), ahora.name().toLowerCase(Locale.ROOT), texto);
}
@@ -45,6 +51,7 @@ private static String descripcion(Apuesta apuesta) {
return "tu apuesta a " + seleccion.getDescripcionPronostico() + " (" + seleccion.getTitulo() + ")";
}
+ /** Importe en formato español: "1.234,50". */
private static String monedas(BigDecimal cantidad) {
DecimalFormat formato = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(Locale.forLanguageTag("es-ES")));
formato.setGroupingSize(3);
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaController.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaController.java
index 4775635..a47cd75 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaController.java
@@ -25,6 +25,10 @@ public BajaController(BajaService bajas, Sesiones sesiones) {
this.sesiones = sesiones;
}
+ /**
+ * POST /cuenta/eliminar: pide la contraseña para confirmar. Si es correcta, elimina la cuenta, cierra
+ * todas las sesiones del usuario y lleva a la portada; si no, vuelve a Mi cuenta con el error.
+ */
@PostMapping("/cuenta/eliminar")
public String eliminar(@RequestParam(required = false) String password, Principal principal,
HttpServletRequest peticion, RedirectAttributes redireccion) throws ServletException {
@@ -37,6 +41,7 @@ public String eliminar(@RequestParam(required = false) String password, Principa
redireccion.addFlashAttribute("errorBaja", e.getMessage());
return "redirect:/cuenta#eliminar-cuenta";
}
+ // Se cierran las sesiones abiertas en otros navegadores y después la actual (CA-18.2)
sesiones.cerrarOtras(principal.getName(), peticion.getSession(false));
peticion.logout();
return "redirect:/?cuentaEliminada";
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaService.java
index a5c985b..8289ab1 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaService.java
@@ -19,6 +19,9 @@
* Eliminar la cuenta (HU-18). Se cancelan las apuestas que aún se pueden cancelar, se borran los avisos
* y se anonimiza el usuario: el email y el nombre desaparecen, el saldo queda a cero y ya no se puede entrar.
* La fila se conserva para que las apuestas ya hechas sigan cuadrando; desaparece del ranking.
+ *
+ * Está en este paquete y no en el de usuarios porque necesita cancelar apuestas; así el paquete de
+ * usuarios no depende del de apuestas.
*/
@Service
public class BajaService {
@@ -40,6 +43,13 @@ public BajaService(UsuarioService usuarioService, UsuarioRepository usuarios, Ap
this.eventos = eventos;
}
+ /**
+ * Elimina la cuenta tras confirmar la contraseña (CA-18.2). Todo en una transacción: si algo falla,
+ * la cuenta queda como estaba.
+ *
+ * @throws PasswordIncorrectaException si la contraseña no es la del usuario
+ * @throws IllegalStateException si es la cuenta del creador de apuestas
+ */
@Transactional
public void eliminarCuenta(String email, String password) {
if (!usuarioService.passwordCorrecta(email, password)) {
@@ -50,10 +60,13 @@ public void eliminarCuenta(String email, String password) {
throw new IllegalStateException("La cuenta del creador de apuestas no se puede eliminar");
}
LocalDateTime ahora = LocalDateTime.now(reloj);
+ // Las que aún se pueden cancelar devuelven el importe (que se pierde al poner el saldo a cero).
+ // Las demás se resuelven igual más adelante, pero ya no se le abona nada (Usuario.ajustar)
apuestas.findByUsuarioAndEstadoOrderByFechaDesc(usuario, EstadoApuesta.ACTIVA).stream()
.filter(a -> a.cancelable(ahora))
.forEach(a -> a.cancelar(ahora));
notificaciones.borrarDe(usuario);
+ // Antes de anonimizar, para que otros paquetes (por ejemplo, las ligas) limpien lo suyo
eventos.publishEvent(new CuentaEliminada(usuario.getId()));
usuarioService.anonimizar(usuario);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Boleto.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Boleto.java
index 2b04bfe..66d3dc5 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Boleto.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Boleto.java
@@ -16,6 +16,11 @@
/**
* Boleto en construcción del usuario (HU-28): las selecciones que va eligiendo en el catálogo
* antes de confirmar. Vive en la sesión; no se guarda en la base de datos hasta confirmar.
+ *
+ *
Con {@code @SessionScope} Spring crea un boleto por sesión HTTP. En {@link BoletoController} se inyecta
+ * un proxy que, en cada petición, apunta al boleto de la sesión actual. Es {@link Serializable} para poder guardarse
+ * con la sesión. Las reglas (límites, cuotas, eventos disponibles) se aplican en {@link BoletoService};
+ * aquí solo se impide repetir partido (CA-28.2).
*/
@Component
@SessionScope
@@ -31,6 +36,7 @@ public class Boleto implements Serializable {
public record Linea(Long eventoId, Resultado resultado, Especial especial, BigDecimal cuotaVista)
implements Serializable {
+ /** Línea 1X2. */
public Linea(Long eventoId, Resultado resultado, BigDecimal cuotaVista) {
this(eventoId, resultado, null, cuotaVista);
}
@@ -51,6 +57,7 @@ void anadir(Long eventoId, Especial especial, BigDecimal cuotaVista) {
anadir(new Linea(eventoId, null, especial, cuotaVista));
}
+ /** Lanza IllegalArgumentException si ya hay una selección de ese partido (CA-28.2). */
private void anadir(Linea linea) {
if (contiene(linea.eventoId())) {
throw new IllegalArgumentException("Ya tienes una selección de ese partido en el boleto");
@@ -62,6 +69,7 @@ void quitar(Long eventoId) {
lineas.removeIf(l -> l.eventoId().equals(eventoId));
}
+ /** Cambia la cuota vista de la línea de ese evento; como Linea es un record, se sustituye por otra. */
void actualizarCuota(Long eventoId, BigDecimal cuota) {
lineas.replaceAll(l -> l.eventoId().equals(eventoId) ? new Linea(eventoId, l.resultado(), l.especial(), cuota) : l);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoController.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoController.java
index c66f20f..7ad9ad3 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoController.java
@@ -21,12 +21,17 @@
import es.ucm.fdi.is1.apuestas.web.RutaSegura;
import jakarta.servlet.http.HttpServletRequest;
-/** Boleto de apuestas: añadir selecciones desde el catálogo, revisarlas y confirmar (HU-28, HU-29). */
+/**
+ * Boleto de apuestas: añadir selecciones desde el catálogo, revisarlas y confirmar (HU-28, HU-29).
+ * Todas las rutas cuelgan de {@code /boleto} y exigen sesión. La lógica está en {@link BoletoService};
+ * aquí se traducen las excepciones a mensajes flash y se redirige.
+ */
@Controller
@RequestMapping("/boleto")
public class BoletoController {
private final BoletoService boletos;
+ /** Proxy al boleto de la sesión actual (ver {@link Boleto}). */
private final Boleto boleto;
private final LimitesService limites;
@@ -36,6 +41,10 @@ public BoletoController(BoletoService boletos, Boleto boleto, LimitesService lim
this.limites = limites;
}
+ /**
+ * GET /boleto: las selecciones con su cuota actual, el multiplicador y los límites de importe.
+ * Quita las que ya no admiten apuestas y oculta la barra del boleto, que aquí sobra.
+ */
@GetMapping
public String ver(Model model) {
model.addAttribute("vista", boletos.vista(boleto));
@@ -48,6 +57,7 @@ public String ver(Model model) {
* Se llega pulsando una cuota del catálogo, o con el botón "Añadir al boleto" de la página del partido,
* que envía la opción elegida ({@code opcion}); vuelve a la página de la que se venía.
* Es un POST porque cambia el boleto.
+ * POST /boleto/anadir. El creador de apuestas no puede añadir nada.
*/
@PostMapping("/anadir")
public String anadir(@RequestParam("evento") Long eventoId, @RequestParam(required = false) Resultado resultado,
@@ -59,6 +69,7 @@ public String anadir(@RequestParam("evento") Long eventoId, @RequestParam(requir
redireccion.addFlashAttribute("errorBoleto", "El creador de apuestas no puede apostar");
return "redirect:" + RutaSegura.de(referer, "/eventos");
}
+ // "opcion" llega del grupo de opciones de la página del partido; se traduce igual que en ApuestaForm
if (opcion != null) {
ApuestaForm elegida = new ApuestaForm();
elegida.setOpcion(opcion);
@@ -73,15 +84,17 @@ public String anadir(@RequestParam("evento") Long eventoId, @RequestParam(requir
} catch (EventoNoDisponibleException e) {
redireccion.addFlashAttribute("errorBoleto", "Ese partido ya no admite apuestas");
}
+ // Solo se vuelve al Referer si es una ruta de esta web (evita redirecciones a otros sitios)
return "redirect:" + RutaSegura.de(referer, "/boleto");
}
- /** Un enlace antiguo (o volver tras iniciar sesión) no añade nada: lleva a la página del partido. */
+ /** GET /boleto/anadir: un enlace antiguo (o volver tras iniciar sesión) no añade nada: lleva a la página del partido. */
@GetMapping("/anadir")
public String anadirDesdeEnlace(@RequestParam("evento") Long eventoId) {
return "redirect:/eventos/" + eventoId + "/apostar";
}
+ /** POST /boleto/quitar/{eventoId}: quita la selección de ese partido (CA-28.3). */
@PostMapping("/quitar/{eventoId}")
public String quitar(@PathVariable Long eventoId) {
boletos.quitar(boleto, eventoId);
@@ -89,14 +102,17 @@ public String quitar(@PathVariable Long eventoId) {
}
/**
+ * POST /boleto/confirmar: convierte el boleto en una apuesta simple o combinada y lleva a Mis apuestas.
* {@code cuotaVista}: las cuotas que el usuario tenía delante al confirmar ("evento:cuota"). Si alguna ha
* cambiado desde entonces, no se apuesta y se le enseñan las nuevas (HU-29). Así el botón
* "Aceptar nuevas cuotas" apuesta a la primera con las cuotas que acaba de ver.
+ * Si algo falla, vuelve al boleto con el error y el importe que había escrito.
*/
@PostMapping("/confirmar")
public String confirmar(@RequestParam(required = false) BigDecimal importe,
@RequestParam(required = false) List cuotaVista, Principal principal,
RedirectAttributes redireccion) {
+ // Positivo y con 2 decimales como mucho; los límites del creador se comprueban en el servicio
if (importe == null || importe.signum() <= 0 || importe.stripTrailingZeros().scale() > 2) {
redireccion.addFlashAttribute("error", "Introduce un importe válido, con 2 decimales como mucho");
return "redirect:/boleto";
@@ -118,6 +134,7 @@ public String confirmar(@RequestParam(required = false) BigDecimal importe,
} catch (IllegalArgumentException | ResultadoNoValidoException e) {
redireccion.addFlashAttribute("error", e.getMessage());
} catch (EventoNoDisponibleException e) {
+ // Se quita del boleto al volver a GET /boleto, que llama a BoletoService.vista
redireccion.addFlashAttribute("error",
"Uno de los partidos ya no admite apuestas (ha empezado o se ha suspendido) y se ha quitado del boleto.");
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoService.java
index c4ea316..b67cd24 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoService.java
@@ -16,7 +16,11 @@
import es.ucm.fdi.is1.apuestas.eventos.EventoNoDisponibleException;
import es.ucm.fdi.is1.apuestas.eventos.EventoRepository;
-/** Operaciones sobre el boleto del usuario: añadir, quitar, ver y confirmar (HU-28, HU-29). */
+/**
+ * Operaciones sobre el boleto del usuario: añadir, quitar, ver y confirmar (HU-28, HU-29).
+ * El {@link Boleto} vive en la sesión; este servicio consulta eventos y cuotas y, al confirmar,
+ * delega en {@link ApuestaService}, que es quien registra la apuesta y cobra.
+ */
@Service
public class BoletoService {
@@ -35,12 +39,21 @@ public BoletoService(EventoRepository eventos, CalculadoraCuotas calculadora, Ap
this.reloj = reloj;
}
+ /** Añade un resultado 1X2 al boleto. */
@Transactional(readOnly = true)
public void anadir(Boleto boleto, Long eventoId, Resultado resultado) {
anadir(boleto, eventoId, resultado, null);
}
- /** Añade un resultado (1X2) o un tipo especial (HU-52): exactamente uno de los dos. */
+ /**
+ * Añade un resultado (1X2) o un tipo especial (HU-52): exactamente uno de los dos. Guarda la cuota
+ * actual como la que ha visto el usuario. Se comprueban ya aquí el máximo de selecciones (HU-07) y la
+ * cuota total máxima, para avisar antes de llegar a confirmar.
+ *
+ * @throws EventoNoDisponibleException si el evento no existe o ya no admite apuestas
+ * @throws ResultadoNoValidoException si ese pronóstico no se puede apostar en el evento
+ * @throws IllegalArgumentException si se supera un límite o el partido ya está en el boleto
+ */
@Transactional(readOnly = true)
public void anadir(Boleto boleto, Long eventoId, Resultado resultado, Especial especial) {
Evento evento = disponible(eventoId);
@@ -49,6 +62,7 @@ public void anadir(Boleto boleto, Long eventoId, Resultado resultado, Especial e
if (boleto.getTamano() >= maximo) {
throw new IllegalArgumentException("El boleto admite como máximo " + maximo + " selecciones");
}
+ // Multiplicador que tendría el boleto con la nueva selección
List conLaNueva = new ArrayList<>(boleto.getLineas().stream().map(Boleto.Linea::cuotaVista).toList());
conLaNueva.add(cuota);
ApuestaService.comprobarCuotaTotal(Apuesta.producto(conLaNueva));
@@ -59,6 +73,7 @@ public void anadir(Boleto boleto, Long eventoId, Resultado resultado, Especial e
}
}
+ /** Quita la selección de ese partido; si queda una sola, el boleto pasa a ser una simple (CA-28.3). */
public void quitar(Boleto boleto, Long eventoId) {
boleto.quitar(eventoId);
}
@@ -84,6 +99,7 @@ public BoletoVista resumen(Boleto boleto) {
private BoletoVista vista(Boleto boleto, boolean quitarNoDisponibles) {
LocalDateTime ahora = LocalDateTime.now(reloj);
List lineas = new ArrayList<>();
+ // Se recorre una copia porque el bucle puede quitar líneas del boleto
for (Boleto.Linea linea : List.copyOf(boleto.getLineas())) {
Evento evento = eventos.findById(linea.eventoId()).filter(e -> e.admiteApuestas(ahora)).orElse(null);
if (evento == null) {
@@ -92,10 +108,12 @@ private BoletoVista vista(Boleto boleto, boolean quitarNoDisponibles) {
}
continue;
}
+ // La cuota puede haber cambiado desde que se añadió (por ejemplo, por el ajuste por volumen)
BigDecimal cuota = calculadora.cuota(evento, linea.resultado(), linea.especial());
lineas.add(new BoletoVista.LineaVista(evento, linea.resultado(), linea.especial(), cuota,
cuota.compareTo(linea.cuotaVista()) != 0));
}
+ // Multiplicador total con las cuotas actuales (CA-29.1)
BigDecimal total = Apuesta.producto(lineas.stream().map(BoletoVista.LineaVista::cuota).toList());
return new BoletoVista(lineas, total);
}
@@ -103,6 +121,8 @@ private BoletoVista vista(Boleto boleto, boolean quitarNoDisponibles) {
/**
* Confirma el boleto como apuesta simple o combinada. Si alguna cuota ha cambiado, actualiza las
* cuotas vistas y relanza la excepción para que el usuario acepte las nuevas (HU-29).
+ * Si sale bien, vacía el boleto. Las demás excepciones de {@link ApuestaService#apostar} llegan
+ * tal cual al controlador.
*/
@Transactional
public Apuesta confirmar(String email, Boleto boleto, BigDecimal importe) {
@@ -114,6 +134,8 @@ public Apuesta confirmar(String email, Boleto boleto, BigDecimal importe) {
boleto.vaciar();
return apuesta;
} catch (CuotasCambiadasException e) {
+ // La transacción se deshace (no se cobra nada), pero el boleto está en la sesión, no en la base
+ // de datos: las cuotas nuevas se quedan guardadas para el siguiente intento
aceptarCuotasActuales(boleto);
throw e;
}
@@ -131,12 +153,14 @@ public void fijarCuotasVistas(Boleto boleto, List cuotas) {
}
}
+ /** Pone como cuota vista la actual en cada línea (y quita las que ya no admiten apuestas). */
private void aceptarCuotasActuales(Boleto boleto) {
for (BoletoVista.LineaVista linea : vista(boleto).lineas()) {
boleto.actualizarCuota(linea.evento().getId(), linea.cuota());
}
}
+ /** El evento si existe y admite apuestas; si no, EventoNoDisponibleException. */
private Evento disponible(Long eventoId) {
return eventos.findById(eventoId)
.filter(e -> e.admiteApuestas(LocalDateTime.now(reloj)))
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoVista.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoVista.java
index 465674e..09a56ec 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoVista.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoVista.java
@@ -8,12 +8,23 @@
import es.ucm.fdi.is1.apuestas.equipos.Equipo;
import es.ucm.fdi.is1.apuestas.eventos.Evento;
-/** El boleto listo para mostrar: cada selección con su cuota actual y el multiplicador total (HU-29). */
+/**
+ * El boleto listo para mostrar: cada selección con su cuota actual y el multiplicador total (HU-29).
+ * Lo construye {@link BoletoService} a partir del {@link Boleto} de la sesión; no se guarda.
+ *
+ * @param cuotaTotal producto de las cuotas actuales (CA-29.1)
+ */
public record BoletoVista(List lineas, BigDecimal cuotaTotal) {
+ /**
+ * Una selección con su cuota actual.
+ *
+ * @param cambiada true si la cuota actual no coincide con la que vio el usuario (CA-29.3)
+ */
public record LineaVista(Evento evento, Resultado resultado, Especial especial, BigDecimal cuota,
boolean cambiada) {
+ /** Línea 1X2. */
public LineaVista(Evento evento, Resultado resultado, BigDecimal cuota, boolean cambiada) {
this(evento, resultado, null, cuota, cambiada);
}
@@ -24,10 +35,12 @@ public Equipo getEquipo() {
? evento.getVisitante() : evento.getLocal();
}
+ /** "1", "X", "2" o el del tipo especial ("1X", "+2,5"…). */
public String getSimbolo() {
return especial != null ? especial.getSimbolo() : resultado.getSimbolo();
}
+ /** Nombre del equipo elegido, "Empate" o la descripción del tipo especial (como en {@link Seleccion}). */
public String getDescripcionPronostico() {
if (especial != null) {
return especial.getDescripcion();
@@ -40,10 +53,12 @@ public String getDescripcionPronostico() {
}
}
+ /** Con una sola línea el boleto es una apuesta simple (CA-28.3). */
public boolean isCombinada() {
return lineas.size() > 1;
}
+ /** Si hay que pedir al usuario que acepte las cuotas nuevas antes de confirmar (CA-29.3). */
public boolean isAlgunaCambiada() {
return lineas.stream().anyMatch(LineaVista::cambiada);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CriterioRanking.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CriterioRanking.java
index 3814332..643f7c1 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CriterioRanking.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CriterioRanking.java
@@ -1,10 +1,13 @@
package es.ucm.fdi.is1.apuestas.apuesta;
-/** Criterios de ordenación del ranking (HU-36). */
+/** Criterios de ordenación del ranking (HU-36); el nombre es el texto que se muestra en la página. */
public enum CriterioRanking {
+ /** Saldo actual de cada jugador. */
SALDO("Saldo"),
+ /** Beneficio neto de las apuestas resueltas: pagado − apostado. */
GANANCIAS("Ganancias"),
+ /** Porcentaje de apuestas ganadas; primero los que tienen un mínimo de apuestas resueltas. */
ACIERTOS("% de aciertos");
private final String nombre;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasa.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasa.java
index fe20d76..5b699ae 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasa.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasa.java
@@ -6,9 +6,12 @@
/**
* Lo que se ha apostado, pagado y ganado la casa (HU-54), en total y desglosado.
+ * Lo construye {@link CuentasCasaService} y lo pinta el panel de la casa.
*
- * @param porTipo simples, combinadas y a largo plazo
- * @param porDeporte solo las simples de partidos, por deporte
+ * @param porTipo simples 1X2, simples de doble oportunidad y goles, combinadas y a largo plazo
+ * @param porDeporte solo las simples de partidos, por deporte
+ * @param jugadores usuarios distintos que han apostado
+ * @param margenTeorico margen que debería quedarse la casa según la fórmula de cuotas, en %
*/
public record CuentasCasa(Fila total, List porTipo, List porDeporte, long jugadores,
BigDecimal margenTeorico) {
@@ -19,11 +22,13 @@ public record CuentasCasa(Fila total, List porTipo, List porDeporte,
*/
public record Fila(String nombre, int apuestas, BigDecimal enJuego, BigDecimal apostado, BigDecimal pagado) {
+ /** Suma las apuestas de una fila; las activas van aparte porque aún no se sabe qué se pagará. */
static Fila de(String nombre, List apuestas) {
BigDecimal enJuego = BigDecimal.ZERO;
BigDecimal apostado = BigDecimal.ZERO;
BigDecimal pagado = BigDecimal.ZERO;
for (Apuesta a : apuestas) {
+ // pagado incluye el importe devuelto en las anuladas: en ellas la casa ni gana ni pierde
if (a.getEstado() == EstadoApuesta.ACTIVA) {
enJuego = enJuego.add(a.getImporte());
} else {
@@ -39,7 +44,10 @@ public BigDecimal getBeneficio() {
return apostado.subtract(pagado);
}
- /** Beneficio / apostado, en tanto por ciento con un decimal. Null si aún no hay nada cerrado. */
+ /**
+ * Margen real: beneficio / apostado, en tanto por ciento con un decimal. Se compara con el margen
+ * teórico (CA-54.1). Null si aún no hay nada cerrado.
+ */
public BigDecimal getMargen() {
if (apostado.signum() == 0) {
return null;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasaService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasaService.java
index 66aaf14..125443e 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasaService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasaService.java
@@ -27,10 +27,13 @@ public CuentasCasaService(ApuestaRepository apuestas) {
this.apuestas = apuestas;
}
+ /** Calcula el panel completo: total, desglose por tipo (CA-54.2) y por deporte, y los márgenes. */
@Transactional(readOnly = true)
public CuentasCasa cuentas() {
+ // Todos los estados menos CANCELADA
List todas = apuestas.findByEstadoIn(EnumSet.complementOf(EnumSet.of(EstadoApuesta.CANCELADA)));
+ // Tres grupos sin solapes: largo plazo, combinadas de partidos y simples de partidos
List largoPlazo = todas.stream().filter(Apuesta::isLargoPlazo).toList();
List combinadas = todas.stream().filter(a -> !a.isLargoPlazo() && a.isCombinada()).toList();
List simples = todas.stream().filter(a -> !a.isLargoPlazo() && !a.isCombinada()).toList();
@@ -43,6 +46,7 @@ public CuentasCasa cuentas() {
CuentasCasa.Fila.de("Combinadas", combinadas),
CuentasCasa.Fila.de("Largo plazo", largoPlazo));
+ // Solo las simples: una combinada puede mezclar deportes. TreeMap ordena por el orden del enum Deporte
Map> deportes = new TreeMap<>();
for (Apuesta a : simples) {
deportes.computeIfAbsent(a.getSelecciones().get(0).getEvento().getDeporte(), d -> new ArrayList<>()).add(a);
@@ -52,6 +56,7 @@ public CuentasCasa cuentas() {
.toList();
long jugadores = todas.stream().map(a -> a.getUsuario().getId()).distinct().count();
+ // Margen teórico de una simple (1 − 1/margen de la casa), en % con un decimal
BigDecimal margenTeorico = BigDecimal.valueOf(CalculadoraCuotas.margenTeorico() * 100)
.setScale(1, RoundingMode.HALF_UP);
return new CuentasCasa(CuentasCasa.Fila.de("Total", todas), porTipo, porDeporte, jugadores, margenTeorico);
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuotasCambiadasException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuotasCambiadasException.java
index 0cadf3a..2954976 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuotasCambiadasException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuotasCambiadasException.java
@@ -2,9 +2,13 @@
import java.util.List;
-/** Alguna cuota del boleto ha cambiado desde que el usuario la vio (HU-29). */
+/**
+ * Alguna cuota del boleto ha cambiado desde que el usuario la vio (HU-29). La lanza {@link ApuestaService}
+ * antes de cobrar nada; el controlador la captura y enseña las cuotas nuevas para que el usuario decida.
+ */
public class CuotasCambiadasException extends RuntimeException {
+ /** Ids de los eventos cuya cuota ha cambiado; vacía en las apuestas a largo plazo. */
private final List eventos;
public CuotasCambiadasException(List eventos) {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Estadisticas.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Estadisticas.java
index 6d17bc8..5c1fa40 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Estadisticas.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Estadisticas.java
@@ -5,7 +5,8 @@
import java.util.List;
/**
- * Estadísticas de un conjunto de apuestas resueltas (HU-35).
+ * Estadísticas de un conjunto de apuestas resueltas (HU-35). Se usan en Mis apuestas, en el ranking
+ * (HU-36) y en el perfil público (HU-48). Las anuladas y canceladas no cuentan.
*
* @param resueltas número de apuestas ganadas o perdidas
* @param ganadas número de apuestas ganadas
@@ -14,6 +15,7 @@
*/
public record Estadisticas(int resueltas, int ganadas, BigDecimal apostado, BigDecimal beneficio) {
+ /** A partir de las apuestas de un usuario; se queda solo con las ganadas y perdidas. */
public static Estadisticas de(List apuestas) {
List resueltas = apuestas.stream().filter(Apuesta::isResuelta).toList();
int ganadas = (int) resueltas.stream().filter(a -> a.getEstado() == EstadoApuesta.GANADA).count();
@@ -22,11 +24,15 @@ public static Estadisticas de(List apuestas) {
return new Estadisticas(resueltas.size(), ganadas, apostado, beneficio);
}
- /** A partir de los totales de la base de datos: beneficio = pagado − apostado. */
+ /**
+ * A partir de los totales de la base de datos ({@link ApuestaRepository#resumenResueltasPorUsuario}):
+ * beneficio = pagado − apostado. Da lo mismo que sumar {@link Apuesta#getBeneficio} una a una.
+ */
static Estadisticas de(long resueltas, long ganadas, BigDecimal apostado, BigDecimal pagado) {
return new Estadisticas((int) resueltas, (int) ganadas, apostado, pagado.subtract(apostado));
}
+ /** Para los jugadores sin ninguna apuesta resuelta. */
static final Estadisticas VACIAS = new Estadisticas(0, 0, BigDecimal.ZERO, BigDecimal.ZERO);
/** Porcentaje de apuestas acertadas (0 si no hay ninguna resuelta). */
@@ -34,10 +40,14 @@ public BigDecimal getPorcentajeAciertos() {
if (resueltas == 0) {
return BigDecimal.ZERO;
}
+ // ganadas × 100 / resueltas, con un decimal
return BigDecimal.valueOf(ganadas * 100L).divide(BigDecimal.valueOf(resueltas), 1, RoundingMode.HALF_UP);
}
- /** Rentabilidad: beneficio / apostado, en porcentaje (0 si no hay nada apostado). */
+ /**
+ * Rentabilidad: beneficio / apostado, en porcentaje con un decimal (0 si no hay nada apostado).
+ * Negativa si el balance es de pérdidas; −100 % si se ha perdido todo lo apostado.
+ */
public BigDecimal getRentabilidad() {
if (apostado.signum() == 0) {
return BigDecimal.ZERO;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoApuesta.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoApuesta.java
index 93fd4d1..54c9502 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoApuesta.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoApuesta.java
@@ -1,9 +1,17 @@
package es.ucm.fdi.is1.apuestas.apuesta;
+/**
+ * Estado de una {@link Apuesta}. Lo calcula la propia apuesta a partir de sus selecciones,
+ * salvo {@link #CANCELADA}, que solo se alcanza a petición del usuario.
+ */
public enum EstadoApuesta {
+ /** Recién hecha o con selecciones pendientes; el importe está comprometido (HU-24). */
ACTIVA,
+ /** Todas las selecciones acertadas (las anuladas cuentan con cuota 1,00): se abona la ganancia (HU-25, HU-30). */
GANADA,
+ /** Ha fallado al menos una selección; en una combinada basta con una (HU-30). */
PERDIDA,
+ /** Se anularon todas sus selecciones (evento o mercado anulado): se devolvió el importe (HU-05). */
ANULADA,
/** Cancelada por el usuario antes de empezar el evento; se le devolvió el importe (HU-26). */
CANCELADA
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoSeleccion.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoSeleccion.java
index eb1227f..e1233c3 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoSeleccion.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoSeleccion.java
@@ -1,9 +1,14 @@
package es.ucm.fdi.is1.apuestas.apuesta;
+/** Estado de una {@link Seleccion} dentro de una apuesta. La apuesta deduce el suyo a partir de estos. */
public enum EstadoSeleccion {
+ /** Aún no se conoce el resultado del evento o el ganador del mercado. */
PENDIENTE,
ACERTADA,
FALLADA,
- /** El evento se anuló: la selección cuenta con cuota 1,00 (HU-30). */
+ /**
+ * El evento o el mercado se anuló, o es de goles y solo se conoce el ganador (HU-52):
+ * la selección cuenta con cuota 1,00 (HU-30).
+ */
ANULADA
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ImporteFueraDeLimitesException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ImporteFueraDeLimitesException.java
index a98844b..d439c6b 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ImporteFueraDeLimitesException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ImporteFueraDeLimitesException.java
@@ -2,13 +2,17 @@
import java.math.BigDecimal;
-/** El importe de la apuesta no respeta los límites que ha fijado el creador (HU-07). */
+/**
+ * El importe de la apuesta no respeta los límites que ha fijado el creador (HU-07). La lanza
+ * {@link Limites#comprobarImporte}; el mensaje indica el rango permitido (CA-07.1).
+ */
public class ImporteFueraDeLimitesException extends IllegalArgumentException {
public ImporteFueraDeLimitesException(BigDecimal minimo, BigDecimal maximo) {
super("El importe debe estar entre " + texto(minimo) + " y " + texto(maximo) + " monedas");
}
+ /** "1" en lugar de "1.00" y "2,5" en lugar de "2.50": formato español sin ceros de sobra. */
private static String texto(BigDecimal cantidad) {
return cantidad.stripTrailingZeros().toPlainString().replace('.', ',');
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JuegoResponsableException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JuegoResponsableException.java
index 4a8c5a5..7c2e463 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JuegoResponsableException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JuegoResponsableException.java
@@ -1,6 +1,9 @@
package es.ucm.fdi.is1.apuestas.apuesta;
-/** La apuesta supera los límites que se ha puesto el usuario o tiene una pausa activa (HU-10). */
+/**
+ * La apuesta supera los límites que se ha puesto el usuario o tiene una pausa activa (HU-10).
+ * La lanza {@link ApuestaService}; el mensaje ya explica al usuario qué límite ha alcanzado.
+ */
public class JuegoResponsableException extends IllegalArgumentException {
public JuegoResponsableException(String mensaje) {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JugadorNoEncontradoException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JugadorNoEncontradoException.java
index 650e045..f7ba411 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JugadorNoEncontradoException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JugadorNoEncontradoException.java
@@ -3,6 +3,10 @@
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
+/**
+ * El perfil pedido no es de un jugador del ranking (HU-48): no existe, es el creador o la cuenta se eliminó.
+ * Spring responde con un 404 gracias a {@code @ResponseStatus}.
+ */
@ResponseStatus(HttpStatus.NOT_FOUND)
public class JugadorNoEncontradoException extends RuntimeException {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Limites.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Limites.java
index 87ff96a..4d8cb17 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Limites.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Limites.java
@@ -7,11 +7,16 @@
import jakarta.persistence.Id;
/**
- * Límites de apuesta que fija el creador (HU-07). Hay una sola fila, creada por la migración V3.
+ * Límites de apuesta que fija el creador (HU-07). Hay una sola fila, creada por la migración
+ * (V3 en H2, V8 en PostgreSQL); no se crean ni se borran filas desde el código.
+ *
+ * Entidad JPA. La leen {@link ApuestaService} y {@link BoletoService} antes de apostar, y la cambia
+ * el creador desde Gestión a través de {@link LimitesService}.
*/
@Entity
public class Limites {
+ /** Id de la única fila. */
static final Long ID = 1L;
@Id
@@ -38,6 +43,12 @@ protected Limites() {
// requerido por JPA
}
+ /**
+ * Fija los nuevos límites.
+ *
+ * @throws IllegalArgumentException si el mínimo no es positivo, el máximo es menor que el mínimo
+ * o se permiten menos de 2 selecciones (no habría combinadas)
+ */
public void cambiar(BigDecimal minimo, BigDecimal maximo, int selecciones) {
if (minimo.signum() <= 0) {
throw new IllegalArgumentException("El importe mínimo debe ser mayor que 0");
@@ -53,13 +64,14 @@ public void cambiar(BigDecimal minimo, BigDecimal maximo, int selecciones) {
maxSelecciones = selecciones;
}
- /** Lanza una excepción si el importe no está entre el mínimo y el máximo. */
+ /** Lanza ImporteFueraDeLimitesException si el importe no está entre el mínimo y el máximo (ambos incluidos). */
public void comprobarImporte(BigDecimal importe) {
if (importe.compareTo(importeMinimo) < 0 || importe.compareTo(importeMaximo) > 0) {
throw new ImporteFueraDeLimitesException(importeMinimo, importeMaximo);
}
}
+ /** Lanza IllegalArgumentException si la combinada tiene más selecciones de las permitidas. */
public void comprobarSelecciones(int selecciones) {
if (selecciones > maxSelecciones) {
throw new IllegalArgumentException("Una combinada admite como máximo " + maxSelecciones + " selecciones");
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesRepository.java
index 789b65f..e3107d2 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesRepository.java
@@ -2,5 +2,6 @@
import org.springframework.data.jpa.repository.JpaRepository;
+/** Repositorio de {@link Limites}. Solo hay una fila (id {@code Limites.ID}); se usa desde {@link LimitesService}. */
public interface LimitesRepository extends JpaRepository {
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesService.java
index 6385850..9a5d091 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesService.java
@@ -5,7 +5,10 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-/** Consulta y cambio de los límites de apuesta (HU-07). */
+/**
+ * Consulta y cambio de los límites de apuesta (HU-07) y del interruptor de juego responsable (HU-10).
+ * Lo usan los servicios de apuestas, el panel de gestión y las páginas que muestran el importe mínimo y máximo.
+ */
@Service
public class LimitesService {
@@ -15,6 +18,11 @@ public LimitesService(LimitesRepository limites) {
this.limites = limites;
}
+ /**
+ * La única fila de límites.
+ *
+ * @throws IllegalStateException si falta (la base de datos no tiene aplicadas las migraciones)
+ */
@Transactional(readOnly = true)
public Limites actuales() {
return limites.findById(Limites.ID)
@@ -27,9 +35,15 @@ public void activarJuegoResponsable(boolean activo) {
actuales().activarJuegoResponsable(activo);
}
+ /**
+ * Cambia los límites (HU-07). Las apuestas ya hechas no se tocan: los límites se comprueban al apostar.
+ *
+ * @throws IllegalArgumentException si los valores no son coherentes (ver {@link Limites#cambiar})
+ */
@Transactional
public Limites cambiar(BigDecimal importeMinimo, BigDecimal importeMaximo, int maxSelecciones) {
Limites actuales = actuales();
+ // Sin save: la entidad está gestionada y JPA guarda el cambio al terminar la transacción
actuales.cambiar(importeMinimo, importeMaximo, maxSelecciones);
return actuales;
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/MercadosController.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/MercadosController.java
index 3ae9596..ff9f814 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/MercadosController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/MercadosController.java
@@ -15,7 +15,10 @@
import es.ucm.fdi.is1.apuestas.mercados.MercadoService;
import es.ucm.fdi.is1.apuestas.usuarios.SaldoInsuficienteException;
-/** Apuestas a largo plazo: Balón de Oro, campeón de liga… (HU-44). La página es pública; apostar exige sesión. */
+/**
+ * Apuestas a largo plazo: Balón de Oro, campeón de liga… (HU-44). La página es pública; apostar exige sesión.
+ * Los mercados los gestiona el creador desde otro controlador del paquete de gestión (HU-45).
+ */
@Controller
public class MercadosController {
@@ -29,6 +32,7 @@ public MercadosController(MercadoService mercados, ApuestaService apuestas, Limi
this.limites = limites;
}
+ /** GET /mercados: los mercados abiertos con sus candidatos y cuotas (CA-44.1) y los ya terminados. */
@GetMapping("/mercados")
public String mercados(Model model) {
model.addAttribute("abiertos", mercados.abiertos());
@@ -37,11 +41,17 @@ public String mercados(Model model) {
return "mercados";
}
+ /**
+ * POST /mercados/{id}/apostar: apuesta a un candidato (CA-44.2). {@code cuota} es la que veía el usuario;
+ * si el creador la ha cambiado, no se apuesta. Si sale bien lleva a Mis apuestas; si no, vuelve al
+ * mercado con el error.
+ */
@PostMapping("/mercados/{id}/apostar")
public String apostar(@PathVariable Long id, @RequestParam(required = false) Long candidato,
@RequestParam(required = false) BigDecimal cuota,
@RequestParam(required = false) BigDecimal importe, Principal principal,
RedirectAttributes redireccion) {
+ // El ancla lleva de vuelta al mercado en el que se estaba
String volver = "redirect:/mercados#mercado-" + id;
if (candidato == null) {
redireccion.addFlashAttribute("error", "Elige un candidato");
@@ -62,6 +72,7 @@ public String apostar(@PathVariable Long id, @RequestParam(required = false) Lon
} catch (SaldoInsuficienteException e) {
redireccion.addFlashAttribute("error", "No tienes saldo suficiente para esta apuesta");
} catch (MercadoNoDisponibleException | IllegalArgumentException e) {
+ // Mercado cerrado (CA-44.3), límites de importe, juego responsable o creador que intenta apostar
redireccion.addFlashAttribute("error", e.getMessage());
}
return volver;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PerfilJugador.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PerfilJugador.java
index 2d5d105..91a1d97 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PerfilJugador.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PerfilJugador.java
@@ -1,5 +1,12 @@
package es.ucm.fdi.is1.apuestas.apuesta;
-/** Lo que se ve de otro jugador (HU-48): su puesto, sus cifras y cuántas apuestas tiene en juego. */
+/**
+ * Lo que se ve de otro jugador (HU-48): su puesto, sus cifras y cuántas apuestas tiene en juego.
+ * Lo construye {@link RankingService#perfil}; no incluye sus apuestas.
+ *
+ * @param puesto su fila en el ranking por saldo
+ * @param jugadores total de jugadores del ranking, para mostrar "puesto X de N"
+ * @param apuestasEnJuego número de apuestas activas
+ */
public record PerfilJugador(PuestoRanking puesto, int jugadores, long apuestasEnJuego) {
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PuestoRanking.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PuestoRanking.java
index f57cf73..4871542 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PuestoRanking.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PuestoRanking.java
@@ -2,7 +2,12 @@
import java.math.BigDecimal;
-/** Una fila del ranking: posición, nombre visible, cifras y si es el usuario que lo consulta. */
+/**
+ * Una fila del ranking: posición, nombre visible, cifras y si es el usuario que lo consulta.
+ *
+ * @param posicion empieza en 1
+ * @param soyYo true en la fila del usuario con sesión, para destacarla (CA-36.4)
+ */
public record PuestoRanking(int posicion, Long usuarioId, String nombre, BigDecimal saldo,
Estadisticas estadisticas, boolean soyYo) {
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/RankingService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/RankingService.java
index 5b966e6..5e8fb49 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/RankingService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/RankingService.java
@@ -16,7 +16,11 @@
import es.ucm.fdi.is1.apuestas.usuarios.Usuario;
import es.ucm.fdi.is1.apuestas.usuarios.UsuarioRepository;
-/** Ranking de jugadores por saldo, ganancias o porcentaje de aciertos (HU-36). */
+/**
+ * Ranking de jugadores por saldo, ganancias o porcentaje de aciertos (HU-36). También da el perfil público
+ * (HU-48) y el ranking de una liga privada (HU-51, desde {@code LigaService}). Solo aparecen los usuarios con
+ * rol de jugador y sin cuenta eliminada. Se calcula en cada consulta, así siempre está al día (CA-36.3).
+ */
@Service
public class RankingService {
@@ -31,6 +35,7 @@ public RankingService(UsuarioRepository usuarios, ApuestaRepository apuestas) {
this.apuestas = apuestas;
}
+ /** Ranking de todos los jugadores; {@code emailActual} (null sin sesión) marca la fila propia. */
@Transactional(readOnly = true)
public List ranking(String emailActual, CriterioRanking criterio) {
return ranking(emailActual, criterio, null);
@@ -39,15 +44,19 @@ public List ranking(String emailActual, CriterioRanking criterio)
/** Ranking solo entre algunos jugadores, como los miembros de una liga privada (HU-51). Null = todos. */
@Transactional(readOnly = true)
public List ranking(String emailActual, CriterioRanking criterio, Set soloUsuarios) {
+ // Los emails se guardan en minúsculas: se normaliza para compararlo
String yo = emailActual == null ? null : emailActual.trim().toLowerCase(Locale.ROOT);
+ // Una sola consulta agregada para todos: fila = [id, resueltas, ganadas, apostado, pagado]
Map porUsuario = new HashMap<>();
for (Object[] fila : apuestas.resumenResueltasPorUsuario()) {
+ // El tipo exacto de count y sum (Long, Integer…) depende de la consulta: se leen como Number
porUsuario.put((Long) fila[0], Estadisticas.de(((Number) fila[1]).longValue(),
((Number) fila[2]).longValue(), (BigDecimal) fila[3], (BigDecimal) fila[4]));
}
record Fila(Usuario usuario, Estadisticas estadisticas) {
}
+ // Los jugadores sin apuestas resueltas también salen, con estadísticas vacías
List filas = new ArrayList<>(usuarios.findByRolOrderBySaldoDescNombreAsc(Rol.USUARIO).stream()
.filter(u -> !u.isEliminado())
.filter(u -> soloUsuarios == null || soloUsuarios.contains(u.getId()))
@@ -63,8 +72,10 @@ record Fila(Usuario usuario, Estadisticas estadisticas) {
.thenComparing(Comparator.comparing((Fila f) -> f.estadisticas().isTieneDatos()).reversed())
.thenComparing(Comparator.comparing((Fila f) -> f.estadisticas().getPorcentajeAciertos()).reversed());
};
+ // A igualdad, por nombre, para que el orden sea siempre el mismo
filas.sort(orden.thenComparing(f -> f.usuario().getNombre()));
+ // La posición es el índice + 1: no hay puestos compartidos aunque haya empates
List puestos = new ArrayList<>();
for (int i = 0; i < filas.size(); i++) {
Fila f = filas.get(i);
@@ -77,6 +88,8 @@ record Fila(Usuario usuario, Estadisticas estadisticas) {
/**
* Perfil público de un jugador (HU-48): su puesto en el ranking por saldo y sus estadísticas,
* sin sus apuestas. El creador de apuestas no juega y no tiene perfil.
+ *
+ * @throws JugadorNoEncontradoException si no es un jugador del ranking
*/
@Transactional(readOnly = true)
public PerfilJugador perfil(Long usuarioId, String emailActual) {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionMercadosService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionMercadosService.java
index ccbaa09..f43dd57 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionMercadosService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionMercadosService.java
@@ -10,7 +10,11 @@
import es.ucm.fdi.is1.apuestas.mercados.Mercado;
import es.ucm.fdi.is1.apuestas.mercados.MercadoService;
-/** Ganador y anulación de los mercados a largo plazo, y pago de sus apuestas (HU-45). */
+/**
+ * Ganador y anulación de los mercados a largo plazo, y pago de sus apuestas (HU-45). Lo usa el panel de
+ * gestión del creador. El {@link Mercado} cambia de estado y cada {@link Apuesta} afectada se recalcula y
+ * ajusta el saldo de su usuario; todo en la misma transacción.
+ */
@Service
public class ResolucionMercadosService {
@@ -25,7 +29,13 @@ public ResolucionMercadosService(MercadoService mercados, SeleccionRepository se
this.avisos = avisos;
}
- /** Marca (o corrige) el ganador y paga las apuestas acertadas. Devuelve cuántas se han resuelto. */
+ /**
+ * Marca (o corrige) el ganador y paga las apuestas acertadas; el resto pasan a perdidas (CA-45.3).
+ * Si se corrige, la apuesta solo abona o retira la diferencia con lo ya pagado. Devuelve cuántas se han resuelto.
+ *
+ * @throws IllegalArgumentException si el candidato no es de este mercado
+ * @throws IllegalStateException si el mercado está anulado
+ */
@Transactional
public int resolver(Long mercadoId, Long candidatoId) {
Mercado mercado = mercados.mercado(mercadoId);
@@ -37,6 +47,7 @@ public int resolver(Long mercadoId, Long candidatoId) {
List afectadas = selecciones.findByCandidatoMercado(mercado);
for (Seleccion seleccion : afectadas) {
Apuesta apuesta = seleccion.getApuesta();
+ // Se guarda el estado anterior para avisar solo si cambia (HU-37)
EstadoApuesta antes = apuesta.getEstado();
apuesta.resolver(mercado, ganador);
avisos.siCambia(apuesta, antes);
@@ -44,7 +55,11 @@ public int resolver(Long mercadoId, Long candidatoId) {
return afectadas.size();
}
- /** Anula el mercado y devuelve el importe de todas sus apuestas. */
+ /**
+ * Anula el mercado y devuelve el importe de todas sus apuestas (las canceladas ya lo recuperaron).
+ *
+ * @throws IllegalStateException si el mercado ya estaba resuelto o anulado
+ */
@Transactional
public int anular(Long mercadoId) {
Mercado mercado = mercados.mercado(mercadoId);
@@ -59,7 +74,7 @@ public int anular(Long mercadoId) {
return afectadas.size();
}
- /** Número de apuestas e importe apostado a cada candidato, sin contar las canceladas. */
+ /** Número de apuestas e importe apostado a cada candidato, sin contar las canceladas ni las anuladas. */
@Transactional(readOnly = true)
public List volumen(Long mercadoId) {
Mercado mercado = mercados.mercado(mercadoId);
@@ -78,6 +93,7 @@ public List volumen(Long mercadoId) {
}).toList();
}
+ /** Una fila del volumen de un mercado: el candidato, lo apostado a él y en cuántas apuestas. */
public record VolumenCandidato(Candidato candidato, BigDecimal importe, int apuestas) {
}
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionService.java
index e1eb0f4..65fdd35 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionService.java
@@ -16,7 +16,12 @@
import es.ucm.fdi.is1.apuestas.eventos.EventoNoDisponibleException;
import es.ucm.fdi.is1.apuestas.eventos.EventoRepository;
-/** Acciones del creador de apuestas sobre un evento y sus apuestas (HU-04, HU-05, HU-06, HU-25). */
+/**
+ * Acciones del creador de apuestas sobre un evento y sus apuestas (HU-04, HU-05, HU-06, HU-25).
+ * Lo usan el panel de gestión y la sincronización con la API de resultados. El {@link Evento} cambia de
+ * estado y cada {@link Apuesta} afectada se recalcula y paga; {@link AvisosApuestas} avisa al usuario (HU-37).
+ * Cada método es una transacción: o se resuelven todas las apuestas o ninguna.
+ */
@Service
public class ResolucionService {
@@ -33,6 +38,7 @@ public ResolucionService(EventoRepository eventos, SeleccionRepository seleccion
this.calculadora = calculadora;
}
+ /** El evento en cualquier estado; EventoNoDisponibleException si no existe. */
@Transactional(readOnly = true)
public Evento evento(Long id) {
return eventos.findById(id).orElseThrow(() -> new EventoNoDisponibleException(id));
@@ -41,6 +47,10 @@ public Evento evento(Long id) {
/**
* Introduce o corrige el resultado: el evento pasa a finalizado y todas sus apuestas
* se resuelven y se pagan. Si se corrige, los saldos se reajustan (HU-04, HU-25).
+ * Devuelve cuántas selecciones se han resuelto.
+ *
+ * @throws IllegalStateException si el evento está anulado
+ * @throws IllegalArgumentException si es un empate en un deporte sin empate
*/
@Transactional
public int introducirResultado(Long eventoId, Resultado resultado) {
@@ -51,7 +61,9 @@ public int introducirResultado(Long eventoId, Resultado resultado) {
/**
* Igual, pero con el marcador final de un partido de fútbol: además del 1X2 se resuelven las apuestas
- * de goles y de ambos marcan (HU-52). Sin marcador, esas se anulan.
+ * de goles y de ambos marcan (HU-52). Con {@link #introducirResultado} no hay marcador y esas se anulan.
+ *
+ * @throws IllegalArgumentException si los goles son negativos o el deporte no es fútbol
*/
@Transactional
public int introducirMarcador(Long eventoId, int golesLocal, int golesVisitante) {
@@ -60,10 +72,16 @@ public int introducirMarcador(Long eventoId, int golesLocal, int golesVisitante)
return resolverApuestas(evento);
}
+ /**
+ * Resuelve todas las selecciones del evento. Una combinada solo cambia de estado cuando falla una
+ * selección o se conocen todas (HU-30); mientras tanto sigue activa.
+ */
private int resolverApuestas(Evento evento) {
+ // Por selección y no por apuesta: así salen también las combinadas que incluyen este evento
List afectadas = selecciones.findByEvento(evento);
for (Seleccion seleccion : afectadas) {
Apuesta apuesta = seleccion.getApuesta();
+ // Se guarda el estado anterior para avisar solo si cambia (HU-37)
EstadoApuesta antes = apuesta.getEstado();
apuesta.resolver(evento, evento.getResultado());
avisos.siCambia(apuesta, antes);
@@ -71,6 +89,7 @@ private int resolverApuestas(Evento evento) {
return afectadas.size();
}
+ /** Deja de admitir apuestas nuevas hasta que se reactive; las ya hechas siguen igual (HU-05). */
@Transactional
public void suspender(Long eventoId) {
evento(eventoId).suspender();
@@ -82,17 +101,24 @@ public void suspenderPorApi(Long eventoId) {
evento(eventoId).suspenderPorApi();
}
+ /** Vuelve a admitir apuestas un evento suspendido (HU-05). */
@Transactional
public void reactivar(Long eventoId) {
evento(eventoId).reactivar();
}
- /** Anula el evento y devuelve el importe de todas sus apuestas activas (HU-05). */
+ /**
+ * Anula el evento y devuelve el importe de sus apuestas simples activas (HU-05). En una combinada, la
+ * selección cuenta con cuota 1,00 y el resto sigue en juego (CA-30.3).
+ *
+ * @throws IllegalStateException si el evento ya estaba finalizado o anulado
+ */
@Transactional
public int anular(Long eventoId) {
Evento evento = evento(eventoId);
evento.anular();
List afectadas = selecciones.findByEvento(evento);
+ // Cada apuesta decide: devuelve el importe si es simple, o sigue con cuota 1,00 si es combinada
for (Seleccion seleccion : afectadas) {
Apuesta apuesta = seleccion.getApuesta();
EstadoApuesta antes = apuesta.getEstado();
@@ -110,6 +136,7 @@ public int anular(Long eventoId) {
@Transactional(readOnly = true)
public List volumen(Long eventoId) {
Evento evento = evento(eventoId);
+ // Las canceladas y las anuladas no cuentan: se devolvió el importe
List delEvento = selecciones.findByEvento(evento).stream()
.filter(s -> s.getApuesta().getEstado() != EstadoApuesta.CANCELADA
&& s.getApuesta().getEstado() != EstadoApuesta.ANULADA)
@@ -123,9 +150,11 @@ public List volumen(Long eventoId) {
volumen.add(fila(resultado.getSimbolo(), resultado.getDescripcion(), cuotas.de(resultado),
delEvento.stream().filter(s -> s.getPronostico() == resultado).toList()));
}
+ // Vacío si no es fútbol
Map especiales = calculadora.especiales(evento);
for (Especial especial : Especial.values()) {
List aEste = delEvento.stream().filter(s -> s.getEspecial() == especial).toList();
+ // Se muestra si se ofrece o si alguien le ha apostado (entonces puede ir sin cuota)
if (especiales.containsKey(especial) || !aEste.isEmpty()) {
volumen.add(fila(especial.getSimbolo(), especial.getDescripcion(), especiales.get(especial), aEste));
}
@@ -133,6 +162,7 @@ public List volumen(Long eventoId) {
return volumen;
}
+ /** Suma el importe completo de las apuestas con esas selecciones y las cuenta. */
private static VolumenResultado fila(String simbolo, String descripcion, BigDecimal cuota,
List aEsta) {
BigDecimal total = aEsta.stream().map(s -> s.getApuesta().getImporte())
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResultadoNoValidoException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResultadoNoValidoException.java
index 8528c84..bcd653a 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResultadoNoValidoException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResultadoNoValidoException.java
@@ -3,12 +3,18 @@
import es.ucm.fdi.is1.apuestas.cuotas.Especial;
import es.ucm.fdi.is1.apuestas.cuotas.Resultado;
+/**
+ * El pronóstico pedido no se puede apostar en ese evento: un empate en un deporte sin empate, un tipo
+ * especial fuera del fútbol (HU-52) o no se ha elegido nada. La lanza {@link ApuestaService} al calcular la cuota.
+ */
public class ResultadoNoValidoException extends RuntimeException {
+ /** Empate en un deporte que no lo admite. */
public ResultadoNoValidoException(Resultado resultado) {
super("El resultado " + resultado.getDescripcion() + " no es uno de los resultados posibles en este evento");
}
+ /** Doble oportunidad, goles o ambos marcan en un deporte que no es fútbol. */
public ResultadoNoValidoException(Especial especial) {
super("La apuesta \"" + especial.getDescripcion() + "\" solo está disponible en los partidos de fútbol");
}
@@ -18,6 +24,7 @@ public ResultadoNoValidoException() {
super("Elige un pronóstico:");
}
+ /** La excepción adecuada cuando no hay cuota para esa combinación de resultado y tipo especial. */
static ResultadoNoValidoException de(Resultado resultado, Especial especial) {
if (resultado != null && especial != null || resultado == null && especial == null) {
return new ResultadoNoValidoException();
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Seleccion.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Seleccion.java
index 6bb4061..a877163 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Seleccion.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Seleccion.java
@@ -21,6 +21,9 @@
* Un pronóstico dentro de una apuesta, con la cuota fijada al apostar: el resultado de un evento (1/X/2),
* otro tipo de apuesta del partido (doble oportunidad, goles o ambos marcan, HU-52) o el ganador de un mercado
* a largo plazo (HU-44). Tiene evento y pronóstico, evento y especial, o candidato.
+ *
+ * Entidad JPA. Solo la crea su {@link Apuesta} (los constructores no son públicos), que decide su
+ * propio estado a partir del de sus selecciones.
*/
@Entity
public class Seleccion {
@@ -32,21 +35,27 @@ public class Seleccion {
@ManyToOne(optional = false)
private Apuesta apuesta;
+ /** Partido al que se apuesta; null en las apuestas a largo plazo. */
@ManyToOne
private Evento evento;
+ /** Resultado 1X2 elegido; null si es de un tipo especial o a largo plazo. */
@Enumerated(EnumType.STRING)
private Resultado pronostico;
+ /** Doble oportunidad, goles o ambos marcan (HU-52); null en las demás. */
@Enumerated(EnumType.STRING)
private Especial especial;
+ /** Candidato elegido de un mercado; solo en las apuestas a largo plazo. */
@ManyToOne
private Candidato candidato;
+ /** Cuota fijada al apostar; solo cambia si se modifica el importe (HU-27). */
@Column(nullable = false, precision = 8, scale = 2)
private BigDecimal cuota;
+ /** Pendiente hasta que se conoce el resultado del evento o el ganador del mercado. */
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private EstadoSeleccion estado = EstadoSeleccion.PENDIENTE;
@@ -80,10 +89,12 @@ public boolean isLargoPlazo() {
return candidato != null;
}
+ /** Se compara por id porque {@link Evento} no redefine {@code equals}. */
boolean esDe(Evento otro) {
return evento != null && evento.getId().equals(otro.getId());
}
+ /** Si es una apuesta a largo plazo a un candidato de ese mercado. */
boolean esDe(Mercado mercado) {
return candidato != null && candidato.getMercado().getId().equals(mercado.getId());
}
@@ -96,21 +107,26 @@ boolean admiteCancelacion(LocalDateTime ahora) {
return evento.getEstado() == EstadoEvento.PROGRAMADO && evento.getFechaHora().isAfter(ahora);
}
+ /** Acertada si el candidato elegido es el ganador del mercado; fallada si no. */
void resolver(Candidato ganador) {
estado = candidato.getId().equals(ganador.getId()) ? EstadoSeleccion.ACERTADA : EstadoSeleccion.FALLADA;
}
/** Con el resultado del evento y, si se conoce, su marcador (las de goles se anulan sin él). */
void resolver(Resultado resultado, Integer golesLocal, Integer golesVisitante) {
+ // Una 1X2 se acierta si coincide el resultado; una especial lo decide Especial.acierta,
+ // que devuelve null si hace falta el marcador y solo se conoce el ganador
Boolean acierto = especial != null ? especial.acierta(resultado, golesLocal, golesVisitante)
: Boolean.valueOf(pronostico == resultado);
if (acierto == null) {
+ // No se puede decidir: se anula y cuenta con cuota 1,00 (CA-52.3)
estado = EstadoSeleccion.ANULADA;
} else {
estado = acierto ? EstadoSeleccion.ACERTADA : EstadoSeleccion.FALLADA;
}
}
+ /** El evento o el mercado se ha anulado: la selección cuenta con cuota 1,00 (HU-05, HU-30). */
void anular() {
estado = EstadoSeleccion.ANULADA;
}
@@ -120,7 +136,7 @@ void actualizarCuota(BigDecimal nueva) {
cuota = nueva;
}
- /** Cuota que cuenta para el pago: 1,00 si el evento se anuló. */
+ /** Cuota que cuenta para el pago: 1,00 si la selección está anulada (evento o mercado anulado, o goles sin marcador). */
BigDecimal getCuotaEfectiva() {
return estado == EstadoSeleccion.ANULADA ? BigDecimal.ONE : cuota;
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionPedida.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionPedida.java
index 05d347a..13b2be4 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionPedida.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionPedida.java
@@ -7,11 +7,16 @@
/**
* Selección que pide el usuario al confirmar el boleto: un resultado (1X2) o un tipo especial (HU-52).
+ * Es lo que recibe {@link ApuestaService#apostar(String, java.util.List, BigDecimal)}; la cuota real
+ * se calcula siempre en el servidor.
*
+ * @param resultado pronóstico 1X2; null si se pide un tipo especial
+ * @param especial tipo especial; null si se pide un resultado
* @param cuotaVista cuota que vio el usuario; null si no hay que comprobarla
*/
public record SeleccionPedida(Long eventoId, Resultado resultado, Especial especial, BigDecimal cuotaVista) {
+ /** Selección 1X2. */
public SeleccionPedida(Long eventoId, Resultado resultado, BigDecimal cuotaVista) {
this(eventoId, resultado, null, cuotaVista);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionRepository.java
index b3ddb89..45554d0 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionRepository.java
@@ -8,12 +8,19 @@
import es.ucm.fdi.is1.apuestas.eventos.Evento;
import es.ucm.fdi.is1.apuestas.mercados.Mercado;
+/**
+ * Repositorio de {@link Seleccion}. Se busca por selección y no por apuesta porque al resolver un evento
+ * o un mercado hay que encontrar todas las apuestas que lo incluyen, sean simples o combinadas.
+ */
public interface SeleccionRepository extends JpaRepository {
+ /** Todas las selecciones de un evento, de apuestas en cualquier estado. */
List findByEvento(Evento evento);
+ /** Si alguien ha apostado alguna vez al evento: entonces no se puede borrar ni cambiar de equipos (HU-46). */
boolean existsByEvento(Evento evento);
+ /** Selecciones a cualquier candidato del mercado (navega {@code candidato.mercado}). */
List findByCandidatoMercado(Mercado mercado);
/**
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenApuestas.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenApuestas.java
index 5ab0467..25f9167 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenApuestas.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenApuestas.java
@@ -20,10 +20,15 @@
* y cancelar es gratis, sin este límite un solo usuario podía mover las cuotas a su favor (apostar mucho a
* un resultado, apostar al contrario con la cuota inflada y cancelar lo primero).
*
+ *
+ * Implementa la interfaz {@link VolumenApostado} del paquete de cuotas: así
+ * {@link es.ucm.fdi.is1.apuestas.cuotas.CalculadoraCuotas} obtiene el volumen sin depender de las apuestas.
+ * Solo cuentan las selecciones 1X2: los otros tipos de apuesta no tienen ajuste por volumen.
*/
@Component
class VolumenApuestas implements VolumenApostado {
+ /** Máximo que cuenta de un mismo usuario en un mismo resultado, en moneditas. */
static final BigDecimal APORTACION_MAXIMA = new BigDecimal("100");
private final SeleccionRepository selecciones;
@@ -32,20 +37,25 @@ class VolumenApuestas implements VolumenApostado {
this.selecciones = selecciones;
}
+ /** Importe que cuenta en cada resultado del evento; los resultados sin apuestas no aparecen en el mapa. */
@Override
public Map importes(Evento evento) {
Map importes = new EnumMap<>(Resultado.class);
+ // Un evento sin guardar (sin id) aún no puede tener apuestas
if (evento.getId() == null) {
return importes;
}
record Clave(Resultado resultado, Long usuario) {
}
Map porUsuario = new HashMap<>();
+ // fila = [pronóstico, id del usuario, importe de la apuesta, número de selecciones]
for (Object[] fila : selecciones.seleccionesActivasPorPronostico(evento)) {
+ // Parte de la apuesta que corresponde a esta selección: importe / número de selecciones
BigDecimal parte = ((BigDecimal) fila[2])
.divide(BigDecimal.valueOf(((Number) fila[3]).longValue()), MathContext.DECIMAL64);
porUsuario.merge(new Clave((Resultado) fila[0], (Long) fila[1]), parte, BigDecimal::add);
}
+ // Primero se suma por usuario y resultado; después se aplica el tope y se suma por resultado
porUsuario.forEach((clave, importe) ->
importes.merge(clave.resultado(), importe.min(APORTACION_MAXIMA), BigDecimal::add));
return importes;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenResultado.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenResultado.java
index bfcfcc0..7ad6348 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenResultado.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenResultado.java
@@ -4,7 +4,11 @@
/**
* Cuánto se ha apostado a una opción de un evento y en cuántas apuestas (HU-06): un resultado (1X2)
- * o uno de los otros tipos de apuesta (HU-52), con su cuota actual.
+ * o uno de los otros tipos de apuesta (HU-52), con su cuota actual. Lo calcula {@link ResolucionService#volumen}.
+ *
+ * @param cuota cuota actual; null si la opción ya no se ofrece pero tiene apuestas
+ * @param importe suma de los importes completos de las apuestas (también de las combinadas)
+ * @param apuestas número de apuestas que incluyen esta opción
*/
public record VolumenResultado(String simbolo, String descripcion, BigDecimal cuota, BigDecimal importe,
long apuestas) {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/config/DatosIniciales.java b/src/main/java/es/ucm/fdi/is1/apuestas/config/DatosIniciales.java
index a1bc7b9..df860c4 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/config/DatosIniciales.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/config/DatosIniciales.java
@@ -27,8 +27,11 @@
/**
* Datos iniciales: usuarios de prueba, competiciones, equipos, partidos reales de LaLiga y Champions 2026/27
* y mercados a largo plazo.
- * Solo se añade lo que falta (por nombre), así que no duplica ni borra lo que el creador
- * de apuestas haya introducido desde el panel de gestión.
+ * De usuarios, competiciones y equipos solo se añade lo que falta (por email o por nombre), así que no duplica
+ * ni borra lo que el creador de apuestas haya introducido desde el panel de gestión. Los partidos y los mercados
+ * solo se cargan si la base de datos no tiene ninguno (ver {@code cargarPartidos}).
+ *
+ * Como {@link ApplicationRunner}, Spring lo ejecuta una vez en cada arranque, cuando la aplicación ya está lista.
*/
@Component
public class DatosIniciales implements ApplicationRunner {
@@ -52,8 +55,9 @@ public class DatosIniciales implements ApplicationRunner {
/**
* Las contraseñas de los usuarios de prueba se pueden cambiar con las variables de entorno
- * CREADOR_PASSWORD y USUARIO_PASSWORD. En el perfil {@code nube} la del creador es obligatoria
- * y el usuario de prueba solo se crea si se le da contraseña.
+ * CREADOR_PASSWORD y USUARIO_PASSWORD. En los perfiles {@code compartir} y {@code nube} la del creador es
+ * obligatoria y se le aplica aunque ya exista ({@code passwordCreadorFija}); en {@code nube} el usuario de
+ * prueba solo se crea si se le da contraseña.
*/
public DatosIniciales(CompeticionRepository competiciones, EquipoRepository equipos, EventoRepository eventos,
MercadoRepository mercados, UsuarioRepository usuarioRepository, UsuarioService usuarios,
@@ -71,7 +75,10 @@ public DatosIniciales(CompeticionRepository competiciones, EquipoRepository equi
this.passwordCreadorFija = passwordCreadorFija;
}
- /** Identificadores de los escudos en el servicio público de football-data.org. */
+ /**
+ * Identificadores de los escudos en el servicio público de football-data.org. Son los mismos que usa su API,
+ * así que también sirven para enlazar cada equipo con ella ({@code idExterno}).
+ */
private static final Map ESCUDOS = Map.ofEntries(
Map.entry("Real Madrid", 86), Map.entry("FC Barcelona", 81),
Map.entry("Atlético de Madrid", 78), Map.entry("Athletic Club", 77),
@@ -102,25 +109,35 @@ public DatosIniciales(CompeticionRepository competiciones, EquipoRepository equi
Map.entry("Slovan Bratislava", 7509), Map.entry("LASK", 2016),
Map.entry("Viking FK", 5720), Map.entry("Sabah FK", 10233));
+ /**
+ * Carga los datos que falten. Va en una sola transacción: si algo falla, no queda nada a medias.
+ *
+ * @throws IllegalStateException si no hay contraseña para el creador; entonces la aplicación no arranca
+ */
@Override
@Transactional
public void run(ApplicationArguments args) {
+ // En local tiene valor por defecto; en los perfiles compartir y nube solo existe si se define CREADOR_PASSWORD
if (passwordCreador.isBlank()) {
throw new IllegalStateException("Falta la contraseña del creador de apuestas: define la variable de entorno "
+ "CREADOR_PASSWORD");
}
+ // Si el usuario ya existe no se crea ni se cambia su contraseña
usuario("creador@apuestas.es", "Creador de apuestas", passwordCreador, Rol.CREADOR);
if (passwordCreadorFija) {
// Perfiles públicos (compartir, nube): manda la contraseña de la variable, no la que ya hubiera
usuarios.fijarPassword("creador@apuestas.es", passwordCreador);
}
+ // Sin contraseña (perfil nube sin USUARIO_PASSWORD) no se crea el usuario de prueba
if (!passwordUsuario.isBlank()) {
usuario("usuario@apuestas.es", "Usuario de prueba", passwordUsuario, Rol.USUARIO);
}
+ // Se decide antes de crear nada: con la base de datos vacía se cargan partidos y mercados de ejemplo
cargarPartidos = eventos.count() == 0;
cargarMercados = mercados.count() == 0;
+ // Competiciones (se crean si no existen)
Competicion laLiga = competicion("LaLiga", Deporte.FUTBOL);
Competicion champions = competicion("Champions League", Deporte.FUTBOL);
Competicion acb = competicion("Liga ACB", Deporte.BALONCESTO);
@@ -336,7 +353,10 @@ private void mercadosALargoPlazo() {
"Charles Leclerc;9.00", "Lewis Hamilton;15.00", "Fernando Alonso;40.00", "Carlos Sainz;50.00");
}
- /** Candidatos con el formato "Nombre;cuota". Si el nombre es un equipo o deportista, se enlaza con él. */
+ /**
+ * Candidatos con el formato "Nombre;cuota". Si el nombre es un equipo o deportista, se enlaza con él.
+ * Si ya hay un mercado con ese nombre, no se toca.
+ */
private void mercado(String nombre, Deporte deporte, LocalDateTime cierre, String... candidatos) {
if (mercados.findByNombre(nombre).isPresent()) {
return;
@@ -350,6 +370,7 @@ private void mercado(String nombre, Deporte deporte, LocalDateTime cierre, Strin
mercados.save(mercado);
}
+ /** Crea el usuario si no hay ninguno con ese email. */
private void usuario(String email, String nombre, String password, Rol rol) {
if (!usuarioRepository.existsByEmail(email)) {
usuarios.crear(email, nombre, password, rol);
@@ -361,6 +382,7 @@ private Competicion competicion(String nombre, Deporte deporte) {
.orElseGet(() -> competiciones.save(new Competicion(nombre, deporte)));
}
+ /** Crea el equipo con su calificación y sus competiciones si no hay ninguno con ese nombre (no pisa cambios de Gestión). */
private void equipo(String nombre, Deporte deporte, double calidad, Competicion... enCompeticiones) {
if (equipos.findByNombre(nombre).isPresent()) {
return;
@@ -372,6 +394,10 @@ private void equipo(String nombre, Deporte deporte, double calidad, Competicion.
equipos.save(equipo);
}
+ /**
+ * Crea un partido, en hora de Madrid, solo si la base de datos no tenía eventos. Los dos equipos tienen que
+ * existir ya: si no, {@code orElseThrow} lanza una excepción y el arranque falla.
+ */
private void partido(Competicion competicion, String fase, String local, String visitante,
int anio, int mes, int dia, int hora, int minuto) {
if (!cargarPartidos) {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/config/RelojConfig.java b/src/main/java/es/ucm/fdi/is1/apuestas/config/RelojConfig.java
index cc76e24..5472667 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/config/RelojConfig.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/config/RelojConfig.java
@@ -15,6 +15,10 @@
@Configuration
public class RelojConfig {
+ /**
+ * Reloj del sistema en la zona de apuestas.zona-horaria (Europe/Madrid si no se indica).
+ * Los servicios piden la fecha con {@code LocalDateTime.now(reloj)} en vez de {@code LocalDateTime.now()}.
+ */
@Bean
public Clock reloj(@Value("${apuestas.zona-horaria:Europe/Madrid}") String zonaHoraria) {
return Clock.system(ZoneId.of(zonaHoraria));
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/config/SeguridadConfig.java b/src/main/java/es/ucm/fdi/is1/apuestas/config/SeguridadConfig.java
index 3716533..72202b7 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/config/SeguridadConfig.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/config/SeguridadConfig.java
@@ -12,15 +12,23 @@
import es.ucm.fdi.is1.apuestas.usuarios.RecargaAlEntrarHandler;
+/**
+ * Configuración de Spring Security: quién puede entrar en cada página, inicio y cierre de sesión,
+ * sesiones abiertas y cifrado de contraseñas (HU-08, HU-11, HU-12, HU-18, HU-47).
+ * Hay tres niveles: visitante sin sesión (catálogo público), usuario con sesión (apostar, cuenta…)
+ * y creador de apuestas (rol CREADOR: panel de Gestión).
+ */
@Configuration
public class SeguridadConfig {
+ /** Reglas de acceso. Se comprueban en orden y se aplica la primera que encaja con la dirección. */
@Bean
public SecurityFilterChain filtroSeguridad(HttpSecurity http, RecargaAlEntrarHandler alEntrar,
SessionRegistry registroSesiones) throws Exception {
http
.authorizeHttpRequests(peticiones -> peticiones
// Páginas públicas: cualquier visitante puede ver el catálogo (HU-08)
+ // ("/equipos/*" y "/jugadores/*" abarcan un solo nivel: la ficha, no sus subpáginas)
.requestMatchers("/", "/eventos", "/equipos", "/equipos/*", "/mercados", "/ranking", "/jugadores/*", "/registro", "/login", "/recuperar", "/recuperar/nueva",
"/css/**", "/error").permitAll()
// Consola de la base de datos (solo existe en local): únicamente para el creador
@@ -29,23 +37,31 @@ public SecurityFilterChain filtroSeguridad(HttpSecurity http, RecargaAlEntrarHan
.requestMatchers("/gestion/**").hasRole("CREADOR")
// El resto (apostar, cuenta…) exige haber iniciado sesión
.anyRequest().authenticated())
+ // Formulario propio en /login; el campo del usuario se llama email. Al entrar, RecargaAlEntrarHandler
+ // aplica la recarga gratuita pendiente (HU-14) y vuelve a la página que se pedía
.formLogin(login -> login
.loginPage("/login")
.usernameParameter("email")
.successHandler(alEntrar)
.permitAll())
+ // Al cerrar sesión se vuelve a la portada
.logout(logout -> logout.logoutSuccessUrl("/"))
// Registro de sesiones: al eliminar la cuenta o cambiar la contraseña se cierran las de otros navegadores
+ // (-1 = sin límite de sesiones por usuario; una sesión cerrada así lleva a /login?expirada)
.sessionManagement(sesiones -> sesiones
.maximumSessions(-1)
.sessionRegistry(registroSesiones)
.expiredUrl("/login?expirada"))
+ // CSRF activado: cada formulario POST lleva un token oculto (Thymeleaf lo añade solo), así otra web
+ // no puede enviar formularios en nombre del usuario.
// La consola de H2 (solo desarrollo) usa frames y no envía token CSRF
.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**"))
+ // Frames solo desde la propia web (los necesita la consola de H2); otras webs no pueden incrustarla
.headers(cabeceras -> cabeceras.frameOptions(opciones -> opciones.sameOrigin()));
return http.build();
}
+ /** Sesiones abiertas de cada usuario; la usa {@code usuarios.Sesiones} para cerrar las de otros navegadores. */
@Bean
public SessionRegistry registroSesiones() {
return new SessionRegistryImpl();
@@ -57,6 +73,7 @@ public HttpSessionEventPublisher publicadorSesiones() {
return new HttpSessionEventPublisher();
}
+ /** Las contraseñas se guardan con BCrypt: un hash con sal, nunca en claro (HU-11, HU-12). */
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/CalculadoraCuotas.java b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/CalculadoraCuotas.java
index df05ace..908efaf 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/CalculadoraCuotas.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/CalculadoraCuotas.java
@@ -31,23 +31,45 @@
* {@value #GOLES_MEDIOS} × e^(±{@value #GOLES_POR_PUNTO} × diferencia). Más de 2,5 = 1 − P(2 goles o menos); ambos marcan = P(local marca) × P(visitante marca).
* Se aplica el mismo margen y los mismos límites; aquí no hay ajuste por volumen.
*
+ *
+ * Las cuotas no se guardan en el evento: se calculan cada vez que se piden. Lo usan el catálogo, el boleto y
+ * {@code ApuestaService}, que copia la cuota en la selección al apostar. El dinero apostado lo da
+ * {@link VolumenApostado}. Hay un ejemplo resuelto en {@code docs/wiki/Algoritmo-de-cuotas.md}.
*/
@Service
public class CalculadoraCuotas {
+ /** Puntos de nivel que gana el local por jugar en casa: medio punto en la escala de calidad 0–10. */
static final double VENTAJA_LOCAL = 0.5;
+ /** Lo que vale cada punto de forma (de −2 a +2): como mucho mueve el nivel ±0,8, menos que un punto de calidad. */
static final double VALOR_FORMA = 0.4;
+ /**
+ * Pendiente de la curva logística: cuanto mayor, más rápido se va la probabilidad hacia el favorito.
+ * Con 0,45, un punto de diferencia reparte lo que no es empate ≈ 61 % / 39 %.
+ */
static final double PENDIENTE = 0.45;
+ /** Probabilidad de empate entre equipos igualados (diferencia 0). */
static final double EMPATE_MAXIMO = 0.30;
+ /** Suelo del empate: por grande que sea la diferencia, nunca baja del 10 %. */
static final double EMPATE_MINIMO = 0.10;
+ /** Lo que baja la probabilidad de empate por cada punto de diferencia; llega al mínimo con 10 puntos. */
static final double EMPATE_POR_PUNTO = 0.02;
+ /** Peso máximo del dinero apostado frente al nivel de los equipos. No se alcanza nunca: el nivel pesa siempre más del 70 %. */
static final double PESO_MAXIMO_VOLUMEN = 0.30;
/** Con este importe total apostado, el volumen pesa la mitad de su máximo. */
static final double VOLUMEN_REFERENCIA = 1000;
+ /**
+ * Margen de la casa: las probabilidades se inflan un 7 %, así la suma de 1/cuota ronda 1,07 y la casa
+ * se queda a la larga ≈ 6,5 % de lo apostado (HU-03, HU-54).
+ */
static final double MARGEN_CASA = 1.07;
+ /** Cuota mínima (CA-03.3): por mucho que se apueste a un resultado, acertarlo siempre da algo de ganancia. */
static final BigDecimal CUOTA_MINIMA = new BigDecimal("1.01");
+ /** Cuota máxima: limita lo que paga la casa por un resultado muy improbable. */
static final BigDecimal CUOTA_MAXIMA = new BigDecimal("50.00");
+ /** Goles de media de cada equipo en un partido igualado (2,6 entre los dos). */
static final double GOLES_MEDIOS = 1.30;
+ /** Cuánto cambia la media de goles por punto de diferencia: el mejor marca más (× e^(0,1 × dif)) y el rival menos. */
static final double GOLES_POR_PUNTO = 0.10;
private final VolumenApostado volumen;
@@ -56,6 +78,10 @@ public CalculadoraCuotas(VolumenApostado volumen) {
this.volumen = volumen;
}
+ /**
+ * Cuotas 1X2 actuales de un evento: según el nivel de sus equipos, si el deporte admite empate y el dinero
+ * apostado a cada resultado. La de empate es null si el deporte no lo admite.
+ */
public Cuotas calcular(Evento evento) {
return calcular(nivelLocal(evento), nivelVisitante(evento),
evento.getDeporte().isAdmiteEmpate(), volumen.importes(evento));
@@ -77,19 +103,28 @@ public BigDecimal cuota(Evento evento, Resultado resultado, Especial especial) {
return resultado == null ? null : calcular(evento).de(resultado);
}
+ /** Cuotas especiales de fútbol a partir del nivel de los dos equipos (HU-52). */
public Map especiales(double nivelLocal, double nivelVisitante) {
+ // Probabilidades 1X2 por nivel, siempre con empate (es fútbol) y sin ajuste por volumen
Map p = probabilidades(nivelLocal, nivelVisitante, true);
+ // La misma diferencia que en el 1X2, con la ventaja del local
double diferencia = nivelLocal + VENTAJA_LOCAL - nivelVisitante;
+ // Media de goles de cada equipo: con diferencia 0 los dos marcan 1,3; el de más nivel marca más
double golesLocal = GOLES_MEDIOS * Math.exp(GOLES_POR_PUNTO * diferencia);
double golesVisitante = GOLES_MEDIOS * Math.exp(-GOLES_POR_PUNTO * diferencia);
+ // La suma de dos Poisson independientes es otra Poisson cuya media es la suma de las medias
double total = golesLocal + golesVisitante;
+ // Poisson: P(0, 1 o 2 goles en total) = e^(−total) × (1 + total + total² / 2)
double dosOMenos = Math.exp(-total) * (1 + total + total * total / 2);
+ // P(un equipo marca) = 1 − P(0 goles) = 1 − e^(−media). Se multiplican: los goles de uno no influyen en los del otro
double ambos = (1 - Math.exp(-golesLocal)) * (1 - Math.exp(-golesVisitante));
Map cuotas = new EnumMap<>(Especial.class);
+ // Doble oportunidad: se suman las probabilidades de los dos resultados que cubre
cuotas.put(Especial.DOBLE_1X, cuota(p.get(Resultado.LOCAL) + p.get(Resultado.EMPATE)));
cuotas.put(Especial.DOBLE_X2, cuota(p.get(Resultado.EMPATE) + p.get(Resultado.VISITANTE)));
cuotas.put(Especial.DOBLE_12, cuota(p.get(Resultado.LOCAL) + p.get(Resultado.VISITANTE)));
+ // Más y menos de 2,5 goles son contrarios: sus probabilidades suman 1
cuotas.put(Especial.MAS_2_5, cuota(1 - dosOMenos));
cuotas.put(Especial.MENOS_2_5, cuota(dosOMenos));
cuotas.put(Especial.AMBOS_SI, cuota(ambos));
@@ -115,6 +150,7 @@ public static double margenTeorico() {
/** Calificación ajustada con la forma reciente (de −2 a +2). */
static double nivel(double calidad, int forma) {
+ // Paso 1: nivel = calidad + 0,4 × forma
return calidad + VALOR_FORMA * forma;
}
@@ -123,28 +159,42 @@ public Cuotas calcular(double nivelLocal, double nivelVisitante, boolean admiteE
return calcular(nivelLocal, nivelVisitante, admiteEmpate, Map.of());
}
+ /**
+ * Cuotas 1X2 a partir del nivel de los equipos y del dinero apostado a cada resultado (pueden faltar
+ * resultados sin apuestas). Si no se admite empate, la cuota de empate es null.
+ */
public Cuotas calcular(double nivelLocal, double nivelVisitante, boolean admiteEmpate,
Map apostado) {
+ // Pasos 2 a 4: probabilidad de cada resultado según el nivel
Map probabilidades = probabilidades(nivelLocal, nivelVisitante, admiteEmpate);
+ // Paso 5: se mezcla con el reparto del dinero apostado
ajustarPorVolumen(probabilidades, apostado);
+ // Paso 6: margen de la casa, redondeo hacia abajo y límites
return new Cuotas(
cuota(probabilidades.get(Resultado.LOCAL)),
admiteEmpate ? cuota(probabilidades.get(Resultado.EMPATE)) : null,
cuota(probabilidades.get(Resultado.VISITANTE)));
}
+ /** Probabilidades 1X2 por nivel, sin margen; suman 1. Sin empate, el mapa no tiene clave EMPATE. */
private static Map probabilidades(double nivelLocal, double nivelVisitante,
boolean admiteEmpate) {
+ // Paso 2: diferencia de nivel; el local suma la ventaja de jugar en casa
double diferencia = nivelLocal + VENTAJA_LOCAL - nivelVisitante;
Map probabilidades = new EnumMap<>(Resultado.class);
double probabilidadEmpate = 0;
if (admiteEmpate) {
+ // Paso 3: 0,30 con equipos igualados; baja 0,02 por punto de diferencia (a favor de cualquiera
+ // de los dos, por eso el valor absoluto) y nunca baja de 0,10
probabilidadEmpate = Math.max(EMPATE_MINIMO, EMPATE_MAXIMO - EMPATE_POR_PUNTO * Math.abs(diferencia));
probabilidades.put(Resultado.EMPATE, probabilidadEmpate);
}
+ // Paso 4: curva logística. Con diferencia 0 da 0,5; tiende a 1 si el local es mucho mejor y a 0 si lo es
+ // el visitante, pero nunca llega a 0 ni a 1: ningún resultado es imposible
double repartoLocal = 1 / (1 + Math.exp(-PENDIENTE * diferencia));
+ // Lo que no es empate se reparte entre local y visitante, así las tres probabilidades suman 1
probabilidades.put(Resultado.LOCAL, (1 - probabilidadEmpate) * repartoLocal);
probabilidades.put(Resultado.VISITANTE, (1 - probabilidadEmpate) * (1 - repartoLocal));
return probabilidades;
@@ -155,20 +205,28 @@ private static Map probabilidades(double nivelLocal, double n
* Las dos partes suman 1, así que el margen de la casa se mantiene.
*/
private static void ajustarPorVolumen(Map probabilidades, Map apostado) {
+ // Solo cuenta el dinero de los resultados posibles (en un deporte sin empate no hay clave EMPATE)
double total = probabilidades.keySet().stream()
.mapToDouble(r -> apostado.getOrDefault(r, BigDecimal.ZERO).doubleValue())
.sum();
+ // Sin apuestas, las cuotas dependen solo del nivel
if (total <= 0) {
return;
}
+ // Crece con el dinero en juego: casi 0 con poco dinero, 0,15 con 1.000 moneditas y tiende a 0,30 sin
+ // llegar nunca. Así unas pocas apuestas apenas mueven las cuotas
double peso = PESO_MAXIMO_VOLUMEN * total / (total + VOLUMEN_REFERENCIA);
+ // Media ponderada entre la probabilidad por nivel y la parte del dinero que hay en cada resultado
probabilidades.replaceAll((resultado, p) ->
(1 - peso) * p + peso * apostado.getOrDefault(resultado, BigDecimal.ZERO).doubleValue() / total);
}
/** Cuota = 1 / (probabilidad × margen), redondeada hacia abajo a 2 decimales y acotada. */
private static BigDecimal cuota(double probabilidad) {
+ // Multiplicar por el margen (1,07) baja todas las cuotas; redondear hacia abajo (1,789 → 1,78)
+ // las baja un poco más, nunca a favor del apostante
BigDecimal cuota = BigDecimal.valueOf(1 / (probabilidad * MARGEN_CASA)).setScale(2, RoundingMode.DOWN);
+ // Entre 1,01 (CA-03.3) y 50
return cuota.max(CUOTA_MINIMA).min(CUOTA_MAXIMA);
}
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Cuotas.java b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Cuotas.java
index 5a734ce..25a1c0e 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Cuotas.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Cuotas.java
@@ -5,6 +5,7 @@
/**
* Cuotas decimales de un evento. {@code empate} es null en deportes sin empate.
+ * Las crea {@link CalculadoraCuotas} cada vez que se piden (HU-03, HU-20); no se guardan en la base de datos.
*/
public record Cuotas(BigDecimal local, BigDecimal empate, BigDecimal visitante) {
@@ -17,8 +18,12 @@ public BigDecimal de(Resultado resultado) {
};
}
- /** Suma de probabilidades implícitas (1/cuota). Mayor que 1 significa margen para la casa. */
+ /**
+ * Suma de probabilidades implícitas (1/cuota). Mayor que 1 significa margen para la casa.
+ * Las pruebas la usan para comprobar CA-03.2.
+ */
public BigDecimal sumaProbabilidadesImplicitas() {
+ // Con MathContext: sin él, una división con infinitos decimales (1/3) lanzaría ArithmeticException
BigDecimal suma = BigDecimal.ONE.divide(local, MathContext.DECIMAL64)
.add(BigDecimal.ONE.divide(visitante, MathContext.DECIMAL64));
if (empate != null) {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Especial.java b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Especial.java
index 95a1a69..e4c23ae 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Especial.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Especial.java
@@ -3,6 +3,8 @@
/**
* Otros tipos de apuesta de un partido de fútbol, además del 1X2 (HU-52):
* doble oportunidad, más/menos de 2,5 goles y ambos marcan.
+ * Cada uno tiene un símbolo corto, una descripción y el grupo en el que se muestra en la página del partido.
+ * Sus cuotas las calcula {@link CalculadoraCuotas#especiales(double, double)}.
*/
public enum Especial {
@@ -35,9 +37,11 @@ public boolean isNecesitaMarcador() {
*/
public Boolean acierta(Resultado resultado, Integer golesLocal, Integer golesVisitante) {
if (!isNecesitaMarcador()) {
+ // Doble oportunidad: se acierta si no sale el único resultado que no cubre
return switch (this) {
case DOBLE_1X -> resultado != Resultado.VISITANTE;
case DOBLE_X2 -> resultado != Resultado.LOCAL;
+ // DOBLE_12
default -> resultado != Resultado.EMPATE;
};
}
@@ -46,10 +50,12 @@ public Boolean acierta(Resultado resultado, Integer golesLocal, Integer golesVis
}
int total = golesLocal + golesVisitante;
boolean ambos = golesLocal > 0 && golesVisitante > 0;
+ // Los goles son enteros: más de 2,5 es 3 o más y menos de 2,5 es 2 o menos
return switch (this) {
case MAS_2_5 -> total > 2;
case MENOS_2_5 -> total < 3;
case AMBOS_SI -> ambos;
+ // AMBOS_NO
default -> !ambos;
};
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Resultado.java b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Resultado.java
index fae19c4..770791c 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Resultado.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Resultado.java
@@ -1,6 +1,10 @@
package es.ucm.fdi.is1.apuestas.cuotas;
-/** Resultado de un evento sobre el que se apuesta (G/E/P desde el punto de vista del local). */
+/**
+ * Resultado de un evento sobre el que se apuesta (G/E/P desde el punto de vista del local).
+ * Es el pronóstico de una selección 1X2 y el resultado que se guarda al finalizar un evento (HU-04, HU-20, HU-23).
+ * {@code EMPATE} solo es posible en fútbol, el único deporte que admite empate.
+ */
public enum Resultado {
LOCAL("1", "Gana el local"),
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/VolumenApostado.java b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/VolumenApostado.java
index 482d2eb..78597eb 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/VolumenApostado.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/VolumenApostado.java
@@ -5,7 +5,11 @@
import es.ucm.fdi.is1.apuestas.eventos.Evento;
-/** Dinero en juego en cada resultado de un evento, para ajustar las cuotas (fila 3 del backlog). */
+/**
+ * Dinero en juego en cada resultado de un evento, para ajustar las cuotas (fila 3 del backlog).
+ * Es una interfaz para que el paquete de cuotas no dependa del de apuestas: la implementa
+ * {@code apuesta.VolumenApuestas}, que limita lo que cuenta cada usuario y reparte el importe de las combinadas.
+ */
public interface VolumenApostado {
/** Importe de las apuestas activas a cada resultado; los resultados sin apuestas pueden faltar. */
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Alineacion.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Alineacion.java
index 668c188..8c3ecbd 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Alineacion.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Alineacion.java
@@ -9,7 +9,12 @@
* Alineación probable en 4-3-3: por cada posición salen los de mejor nota; a igual nota, el dorsal
* más bajo (los titulares suelen llevar del 1 al 11) y después el nombre.
*
- * @param lineas de arriba abajo: delanteros, centrocampistas, defensas y portero
+ * Objeto de valor de la ficha del equipo (HU-49). Lo crea {@link PlantillaService#alineacion(Equipo)}
+ * a partir de las notas que pone el creador a cada {@link Jugador}.
+ *
+ * @param sistema siempre "4-3-3"
+ * @param lineas de arriba abajo: delanteros, centrocampistas, defensas y portero
+ * @param notaMedia media de las notas de los once titulares, con un decimal
*/
public record Alineacion(String sistema, List lineas, double notaMedia) {
@@ -17,19 +22,27 @@ public record Alineacion(String sistema, List lineas, double notaMedia) {
static final Map HUECOS = Map.of(
Posicion.DELANTERO, 3, Posicion.CENTROCAMPISTA, 3, Posicion.DEFENSA, 4, Posicion.PORTERO, 1);
+ /** Una fila del campo: los titulares de una posición, ordenados por dorsal. */
public record Linea(Posicion posicion, List jugadores) {
}
+ /** Criterio para elegir titulares: nota de mayor a menor, después dorsal (sin dorsal al final) y nombre. */
static final Comparator MEJOR_PRIMERO = Comparator.comparingDouble(Jugador::getNota).reversed()
.thenComparing(Jugador::getDorsal, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Jugador::getNombre);
- /** Null si no hay jugadores suficientes en alguna posición. */
+ /**
+ * Elige los titulares de la plantilla.
+ *
+ * @return null si no hay jugadores suficientes en alguna posición
+ */
public static Alineacion de(List plantilla) {
List lineas = new ArrayList<>();
+ // Se recorre de la delantera a la portería, que es como se pinta el campo de arriba abajo
for (Posicion posicion : List.of(Posicion.DELANTERO, Posicion.CENTROCAMPISTA, Posicion.DEFENSA,
Posicion.PORTERO)) {
int cuantos = HUECOS.get(posicion);
+ // Los "cuantos" mejores de esa posición
List elegidos = plantilla.stream()
.filter(j -> j.getPosicion() == posicion)
.sorted(MEJOR_PRIMERO)
@@ -43,6 +56,7 @@ public static Alineacion de(List plantilla) {
.sorted(Comparator.comparing(Jugador::getDorsal, Comparator.nullsLast(Comparator.naturalOrder())))
.toList()));
}
+ // Media de los once, redondeada a un decimal
double media = lineas.stream().flatMap(l -> l.jugadores().stream()).mapToDouble(Jugador::getNota)
.average().orElse(0);
return new Alineacion("4-3-3", lineas, Math.round(media * 10) / 10.0);
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Competicion.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Competicion.java
index ac2d8f8..77d912e 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Competicion.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Competicion.java
@@ -8,7 +8,11 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
-/** Competición (LaLiga, Champions, ACB…) en la que participan equipos (HU-01). */
+/**
+ * Competición (LaLiga, Champions, ACB…) en la que participan equipos (HU-01).
+ * Entidad JPA: cada {@code Evento} pertenece a una y toma de ella su deporte. La relación con los equipos
+ * se guarda en {@link Equipo#getCompeticiones()}.
+ */
@Entity
public class Competicion {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/CompeticionRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/CompeticionRepository.java
index 7cb1023..feeaa0a 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/CompeticionRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/CompeticionRepository.java
@@ -5,8 +5,10 @@
import org.springframework.data.jpa.repository.JpaRepository;
+/** Repositorio Spring Data de {@link Competicion}; las consultas salen del nombre de cada método. */
public interface CompeticionRepository extends JpaRepository {
+ /** Todas, agrupadas por deporte (en el orden del enum) y por nombre dentro de cada deporte. */
List findAllByOrderByDeporteAscNombreAsc();
/**
@@ -17,7 +19,9 @@ default Optional findByNombre(String nombre) {
return findFirstByNombreOrderByIdAsc(nombre);
}
+ /** La de menor id (la más antigua) con ese nombre exacto. */
Optional findFirstByNombreOrderByIdAsc(String nombre);
+ /** Para no dar de alta dos competiciones con el mismo nombre escrito con otras mayúsculas. */
boolean existsByNombreIgnoreCase(String nombre);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Deporte.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Deporte.java
index 88d321c..3390d14 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Deporte.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Deporte.java
@@ -1,11 +1,19 @@
package es.ucm.fdi.is1.apuestas.equipos;
+/**
+ * Deportes de la web, con el nombre y el icono que se muestran y dos propiedades que cambian las reglas:
+ * si hay empate (mercado 1X2 o solo 1/2) y si compiten deportistas individuales en lugar de equipos.
+ * El orden de los valores es el de las pestañas del catálogo y del listado de equipos.
+ */
public enum Deporte {
+ /** El único con empate; también el único con alineación probable (HU-49) y apuestas de goles (HU-52). */
FUTBOL("Fútbol", "⚽", true, false),
BALONCESTO("Baloncesto", "🏀", false, false),
TENIS("Tenis", "🎾", false, true),
+ /** Fórmula 1: cada evento enfrenta a dos pilotos. */
AUTOMOVILISMO("Fórmula 1", "🏎️", false, true),
+ /** MotoGP: cada evento enfrenta a dos pilotos. */
MOTOCICLISMO("MotoGP", "🏍️", false, true);
private final String nombre;
@@ -28,7 +36,7 @@ public String getIcono() {
return icono;
}
- /** En baloncesto o tenis no hay empate: solo se apuesta a 1 o 2. */
+ /** Solo el fútbol admite empate; en baloncesto, tenis o motor solo se apuesta a 1 o 2. */
public boolean isAdmiteEmpate() {
return admiteEmpate;
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Equipo.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Equipo.java
index d54b18c..231eec6 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Equipo.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Equipo.java
@@ -19,6 +19,10 @@
/**
* Equipo o deportista sobre el que se puede apostar (HU-01).
* La calificación de calidad (0-10) es la base del cálculo de cuotas.
+ *
+ * Entidad JPA. Participa en varias {@link Competicion}es, juega como local o visitante en los
+ * {@code Evento}s y puede tener una plantilla de {@link Jugador}es (HU-49). Su ficha pública la
+ * monta {@code FichaEquipoService} (HU-31).
*/
@Entity
public class Equipo {
@@ -34,11 +38,13 @@ public class Equipo {
@Enumerated(EnumType.STRING)
private Deporte deporte;
+ /** Calificación de calidad de 0 a 10 (CA-01.2: fuera de rango se rechaza al validar). */
@NotNull
@DecimalMin("0.0")
@DecimalMax("10.0")
private Double calidad;
+ /** Se cargan siempre junto con el equipo (EAGER). */
@ManyToMany(fetch = FetchType.EAGER)
private Set competiciones = new HashSet<>();
@@ -65,10 +71,12 @@ public Equipo(String nombre, Deporte deporte, Double calidad) {
this.calidad = calidad;
}
+ /** Lo apunta a una competición; si ya estaba, no cambia nada (es un conjunto). */
public void participaEn(Competicion competicion) {
competiciones.add(competicion);
}
+ /** True si juega en la competición con ese id; lo usa el filtro del listado de equipos. */
public boolean participaEn(Long competicionId) {
return competiciones.stream().anyMatch(c -> c.getId().equals(competicionId));
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquipoRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquipoRepository.java
index 6acc1ea..6be02a9 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquipoRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquipoRepository.java
@@ -5,8 +5,10 @@
import org.springframework.data.jpa.repository.JpaRepository;
+/** Repositorio Spring Data de {@link Equipo}; las consultas salen del nombre de cada método. */
public interface EquipoRepository extends JpaRepository {
+ /** Todos, agrupados por deporte (en el orden del enum) y por nombre dentro de cada deporte. */
List findAllByOrderByDeporteAscNombreAsc();
/**
@@ -17,10 +19,13 @@ default Optional findByNombre(String nombre) {
return findFirstByNombreOrderByIdAsc(nombre);
}
+ /** El de menor id (el más antiguo) con ese nombre exacto. */
Optional findFirstByNombreOrderByIdAsc(String nombre);
+ /** Para no dar de alta dos equipos con el mismo nombre escrito con otras mayúsculas. */
boolean existsByNombreIgnoreCase(String nombre);
+ /** Por su identificador en football-data.org, al sincronizar con la API. */
Optional findByIdExterno(Integer idExterno);
List findByDeporte(Deporte deporte);
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquiposService.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquiposService.java
index b55c7d4..e6b2e47 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquiposService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquiposService.java
@@ -9,7 +9,11 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-/** Listado público de equipos y deportistas, por deporte y competición, con buscador. */
+/**
+ * Listado público de equipos y deportistas, por deporte y competición, con buscador.
+ * Servicio de solo lectura que usa {@code InicioController} en {@code GET /equipos}; desde cada equipo
+ * se entra en su ficha (HU-31).
+ */
@Service
public class EquiposService {
@@ -21,7 +25,13 @@ public EquiposService(EquipoRepository equipos, CompeticionRepository competicio
this.competiciones = competiciones;
}
- /** Equipos agrupados por deporte y ordenados por nombre; los filtros son opcionales. */
+ /**
+ * Equipos agrupados por deporte y ordenados por nombre; los filtros son opcionales.
+ *
+ * @param deporte null para todos los deportes
+ * @param competicionId null para todas las competiciones
+ * @param texto parte del nombre, sin distinguir mayúsculas ni tildes; null o vacío para no filtrar
+ */
@Transactional(readOnly = true)
public Map> buscar(Deporte deporte, Long competicionId, String texto) {
String buscado = Nombres.normalizar(texto);
@@ -29,7 +39,9 @@ public Map> buscar(Deporte deporte, Long competicionId, St
.filter(e -> deporte == null || e.getDeporte() == deporte)
.filter(e -> competicionId == null || e.participaEn(competicionId))
.filter(e -> buscado.isEmpty() || Nombres.normalizar(e.getNombre()).contains(buscado))
+ // Por el nombre normalizado, para que una inicial con tilde ("Á") no quede detrás de la "Z"
.sorted(Comparator.comparing(e -> Nombres.normalizar(e.getNombre())))
+ // EnumMap: los deportes salen en el orden del enum
.collect(Collectors.groupingBy(Equipo::getDeporte, () -> new EnumMap<>(Deporte.class),
Collectors.toList()));
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Forma.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Forma.java
index 74a305c..0221c33 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Forma.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Forma.java
@@ -2,12 +2,14 @@
/**
* Factor manual de forma reciente de un equipo (HU-02): rachas, moral, lesiones…
- * Lo fija el creador de apuestas y se suma a la calificación al calcular las cuotas.
+ * Lo fija el creador de apuestas y, al calcular las cuotas, su valor multiplicado por un peso se suma a la
+ * calificación (ver {@code CalculadoraCuotas}). Cada valor lleva el texto y el símbolo que se muestran.
*/
public enum Forma {
MUY_MALA(-2, "Muy mala racha", "▼▼"),
MALA(-1, "Mala racha", "▼"),
+ /** Valor por defecto: no cambia la calificación. */
NORMAL(0, "Normal", "–"),
BUENA(1, "Buena racha", "▲"),
MUY_BUENA(2, "Muy buena racha", "▲▲");
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Jugador.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Jugador.java
index def993c..82a2b75 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Jugador.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Jugador.java
@@ -14,10 +14,14 @@
/**
* Jugador de la plantilla de un equipo. Llega de la API (con su identificador) o lo da de alta el creador.
* La nota (0-10) la pone el creador y decide quién sale en la alineación probable.
+ *
+ * Entidad JPA de las plantillas (HU-49, HU-50). La gestiona {@link PlantillaService}; la
+ * sincronización con la API crea y actualiza los que tienen {@code idExterno} sin tocar su nota.
*/
@Entity
public class Jugador {
+ /** Nota con la que empieza todo jugador nuevo, hasta que el creador la cambie. */
public static final double NOTA_INICIAL = 6.0;
@Id
@@ -40,6 +44,7 @@ public class Jugador {
private LocalDate fechaNacimiento;
+ /** De 0 a 10, con un decimal. */
@Column(nullable = false)
private double nota = NOTA_INICIAL;
@@ -57,7 +62,10 @@ public Jugador(Equipo equipo, String nombre, Posicion posicion) {
this.posicion = posicion;
}
- /** Datos que vienen de la API; la nota no se toca, es del creador. */
+ /**
+ * Datos que vienen de la API; la nota no se toca, es del creador.
+ * Cambia también el equipo, por si el jugador ha fichado por otro.
+ */
public void actualizar(Equipo nuevoEquipo, String nuevoNombre, Posicion nuevaPosicion, Integer nuevoDorsal,
String nuevaNacionalidad, LocalDate nuevaFecha) {
equipo = nuevoEquipo;
@@ -68,7 +76,13 @@ public void actualizar(Equipo nuevoEquipo, String nuevoNombre, Posicion nuevaPos
fechaNacimiento = nuevaFecha;
}
+ /**
+ * El creador puntúa al jugador (HU-50). La nota se redondea a un decimal.
+ *
+ * @throws IllegalArgumentException si no está entre 0 y 10
+ */
public void cambiarNota(double nueva) {
+ // NaN no cumple ninguna comparación, así que se comprueba aparte
if (Double.isNaN(nueva) || nueva < 0 || nueva > 10) {
throw new IllegalArgumentException("La nota debe estar entre 0 y 10");
}
@@ -80,6 +94,7 @@ public Integer getEdad() {
return fechaNacimiento == null ? null : Period.between(fechaNacimiento, LocalDate.now()).getYears();
}
+ /** True si viene de la API; los añadidos a mano no los borra la sincronización (CA-50.2). */
public boolean isDeLaApi() {
return idExterno != null;
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/JugadorRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/JugadorRepository.java
index ce06470..fd933b2 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/JugadorRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/JugadorRepository.java
@@ -5,10 +5,12 @@
import org.springframework.data.jpa.repository.JpaRepository;
+/** Repositorio Spring Data de {@link Jugador}; las consultas salen del nombre de cada método. */
public interface JugadorRepository extends JpaRepository {
List findByEquipo(Equipo equipo);
+ /** Por su identificador en football-data.org, al descargar las plantillas (HU-50). */
Optional findByIdExterno(Integer idExterno);
long countByEquipo(Equipo equipo);
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Nombres.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Nombres.java
index c4630cc..ae6f788 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Nombres.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Nombres.java
@@ -6,7 +6,11 @@
import java.util.Set;
import java.util.stream.Collectors;
-/** Utilidades para comparar nombres de equipos y textos sin tildes ni mayúsculas. */
+/**
+ * Utilidades para comparar nombres de equipos y textos sin tildes ni mayúsculas.
+ * Las usan los buscadores (HU-22) y la sincronización con la API, para reconocer un equipo que allí
+ * se llama de otra forma. Clase de métodos estáticos: no se instancia.
+ */
public final class Nombres {
/** Palabras que no distinguen a un club: "FC Barcelona" y "Barcelona" son el mismo. */
@@ -16,11 +20,12 @@ public final class Nombres {
private Nombres() {
}
- /** "Atlético" → "atletico". */
+ /** "Atlético" → "atletico". Quita los espacios de los extremos; null se convierte en "". */
public static String normalizar(String texto) {
if (texto == null) {
return "";
}
+ // NFD separa cada letra de su tilde ("é" → "e" + "´") y \p{M} borra esas marcas
return Normalizer.normalize(texto.trim(), Normalizer.Form.NFD)
.replaceAll("\\p{M}", "")
.toLowerCase(Locale.ROOT);
@@ -28,6 +33,7 @@ public static String normalizar(String texto) {
/** Clave para reconocer un club con nombres distintos: "Club Atlético de Madrid" → "atletico madrid". */
public static String clave(String nombre) {
+ // Se parte por todo lo que no sea letra o número y se quitan las palabras genéricas
return Arrays.stream(normalizar(nombre).split("[^a-z0-9]+"))
.filter(p -> !p.isBlank() && !GENERICAS.contains(p))
.collect(Collectors.joining(" "));
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/PlantillaService.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/PlantillaService.java
index e3c0725..3739da0 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/PlantillaService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/PlantillaService.java
@@ -9,7 +9,11 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-/** Plantillas de los equipos: consulta pública y alta, nota y baja de jugadores por el creador. */
+/**
+ * Plantillas de los equipos: consulta pública y alta, nota y baja de jugadores por el creador.
+ * Implementa la parte de plantilla y alineación de HU-49 y la gestión a mano de HU-50 (la descarga desde
+ * la API está en el paquete {@code api}). Lo usan {@code FichaEquipoService} y {@code GestionController}.
+ */
@Service
public class PlantillaService {
@@ -24,6 +28,8 @@ public PlantillaService(JugadorRepository jugadores, EquipoRepository equipos) {
/** Jugadores agrupados por posición (de portero a delantero) y ordenados por dorsal. */
@Transactional(readOnly = true)
public Map> plantilla(Equipo equipo) {
+ // Primero por dorsal (sin dorsal al final) y, si empatan, por nombre.
+ // El EnumMap ordena los grupos como el enum Posicion: portero, defensa, centrocampista, delantero
return jugadores.findByEquipo(equipo).stream()
.sorted(Comparator.comparing(Jugador::getDorsal, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Jugador::getNombre))
@@ -31,7 +37,12 @@ public Map> plantilla(Equipo equipo) {
Collectors.toList()));
}
- /** Alineación probable; solo en fútbol y si hay jugadores suficientes en cada posición. */
+ /**
+ * Alineación probable; solo en fútbol y si hay jugadores suficientes en cada posición.
+ * Cómo se eligen los titulares está en {@link Alineacion#de(List)}.
+ *
+ * @return null en otro deporte o si faltan jugadores
+ */
@Transactional(readOnly = true)
public Alineacion alineacion(Equipo equipo) {
if (equipo.getDeporte() != Deporte.FUTBOL) {
@@ -40,6 +51,14 @@ public Alineacion alineacion(Equipo equipo) {
return Alineacion.de(jugadores.findByEquipo(equipo));
}
+ /**
+ * El creador añade un jugador a mano (HU-50). Sin {@code idExterno}, así que la sincronización con la
+ * API no lo borra. Dorsal, nota y nacionalidad son opcionales; sin nota se queda con
+ * {@link Jugador#NOTA_INICIAL}.
+ *
+ * @throws IllegalArgumentException si el equipo no existe o algún dato no es válido; el mensaje
+ * se muestra tal cual al creador
+ */
@Transactional
public Jugador anadir(Long equipoId, String nombre, Posicion posicion, Integer dorsal, Double nota,
String nacionalidad) {
@@ -58,6 +77,7 @@ public Jugador anadir(Long equipoId, String nombre, Posicion posicion, Integer d
}
Jugador jugador = new Jugador(equipo, nombre.trim(), posicion);
jugador.setDorsal(dorsal);
+ // Una nacionalidad en blanco se guarda como null
jugador.setNacionalidad(nacionalidad == null || nacionalidad.isBlank() ? null : nacionalidad.trim());
if (nota != null) {
jugador.cambiarNota(nota);
@@ -65,6 +85,13 @@ public Jugador anadir(Long equipoId, String nombre, Posicion posicion, Integer d
return jugadores.save(jugador);
}
+ /**
+ * El creador cambia la nota de un jugador (HU-50); con ello puede cambiar la alineación probable.
+ * No hace falta {@code save}: el jugador está gestionado por JPA y el cambio se guarda al terminar
+ * la transacción.
+ *
+ * @throws IllegalArgumentException si el jugador no existe o la nota no está entre 0 y 10
+ */
@Transactional
public Jugador cambiarNota(Long jugadorId, double nota) {
Jugador jugador = jugador(jugadorId);
@@ -72,7 +99,12 @@ public Jugador cambiarNota(Long jugadorId, double nota) {
return jugador;
}
- /** Devuelve el jugador borrado, para volver a la página de su equipo. */
+ /**
+ * Quita un jugador de la plantilla (HU-50), venga de la API o se añadiera a mano.
+ * Devuelve el jugador borrado, para volver a la página de su equipo.
+ *
+ * @throws IllegalArgumentException si el jugador no existe
+ */
@Transactional
public Jugador borrar(Long jugadorId) {
Jugador jugador = jugador(jugadorId);
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Posicion.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Posicion.java
index ebbe129..89d1ff8 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Posicion.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Posicion.java
@@ -2,7 +2,12 @@
import java.util.Locale;
-/** Demarcación de un jugador de fútbol, de la portería a la delantera. */
+/**
+ * Demarcación de un jugador de fútbol, de la portería a la delantera.
+ * El orden de los valores es el de los grupos de la plantilla (HU-49). Cada valor lleva su nombre,
+ * el plural para el título del grupo y la abreviatura que lleva en la camiseta de la alineación si no
+ * tiene dorsal.
+ */
public enum Posicion {
PORTERO("Portero", "Porteros", "POR"),
@@ -29,6 +34,7 @@ public static Posicion desdeApi(String posicion) {
return null;
}
String texto = posicion.toLowerCase(Locale.ROOT);
+ // Se busca por trozos de palabra, así valen tanto la posición general como la detallada
if (texto.contains("goalkeeper")) {
return PORTERO;
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Balance.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Balance.java
index 25d9d1e..fefa4f7 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Balance.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Balance.java
@@ -2,9 +2,14 @@
import java.util.Collection;
-/** Victorias, empates y derrotas de un equipo en una serie de partidos (HU-31, HU-33). */
+/**
+ * Victorias, empates y derrotas de un equipo en una serie de partidos (HU-31, HU-33).
+ * Objeto de valor inmutable: lo calculan {@link FichaEquipoService} para la ficha, la clasificación
+ * y el cara a cara.
+ */
public record Balance(int victorias, int empates, int derrotas) {
+ /** Cuenta cuántos resultados de cada tipo hay en la colección. */
public static Balance de(Collection resultados) {
int v = 0;
int e = 0;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CaraACara.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CaraACara.java
index ab40867..de31f67 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CaraACara.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CaraACara.java
@@ -4,11 +4,19 @@
/**
* Comparación de los dos rivales de un evento (HU-33): enfrentamientos anteriores entre ellos
- * y la racha reciente de cada uno.
+ * y la racha reciente de cada uno. La construye {@link FichaEquipoService#caraACara(Evento)} y se
+ * muestra en la página de apostar de un evento.
+ *
+ * @param evento el partido que se va a jugar
+ * @param anteriores últimos partidos finalizados entre los dos, del más reciente al más antiguo
+ * @param balanceLocal balance de todos esos enfrentamientos visto desde el equipo local del evento
+ * @param rachaLocal últimos resultados del local, del más antiguo al más reciente
+ * @param rachaVisitante últimos resultados del visitante, del más antiguo al más reciente
*/
public record CaraACara(Evento evento, List anteriores, Balance balanceLocal,
List rachaLocal, List rachaVisitante) {
+ /** True si hay algo que comparar: enfrentamientos previos o resultados recientes de alguno de los dos. */
public boolean hayDatos() {
return !anteriores.isEmpty() || !rachaLocal.isEmpty() || !rachaVisitante.isEmpty();
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CatalogoService.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CatalogoService.java
index a86f1cc..8acbb33 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CatalogoService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CatalogoService.java
@@ -14,6 +14,14 @@
import es.ucm.fdi.is1.apuestas.equipos.Deporte;
import es.ucm.fdi.is1.apuestas.equipos.Nombres;
+/**
+ * Servicio del catálogo público de eventos (HU-19, HU-22): qué partidos se pueden apostar,
+ * agrupados y filtrados, y los de la portada. Lo usan {@code EventosController}, {@code InicioController}
+ * y {@code ApuestaController}.
+ *
+ * Un evento está disponible si está {@link EstadoEvento#PROGRAMADO} y aún no ha empezado.
+ * La hora actual sale del {@link Clock} inyectado, para poder fijarla en los tests.
+ */
@Service
public class CatalogoService {
@@ -37,6 +45,10 @@ public Map>> eventosDisponiblesPorDeporte() {
/**
* Búsqueda y filtros del catálogo (HU-22): por deporte y por texto en el nombre de los equipos
* o de la competición. Sin distinguir mayúsculas ni tildes.
+ *
+ * @param deporte null para no filtrar por deporte
+ * @param texto null o vacío para no filtrar por texto
+ * @return deporte → nombre de la competición → eventos, ordenados por fecha
*/
@Transactional(readOnly = true)
public Map>> buscar(Deporte deporte, String texto) {
@@ -44,6 +56,9 @@ public Map>> buscar(Deporte deporte, String te
return disponibles().stream()
.filter(e -> deporte == null || e.getDeporte() == deporte)
.filter(e -> buscado.isEmpty() || coincide(e, buscado))
+ // EnumMap: los deportes salen en el orden del enum, no en uno aleatorio.
+ // LinkedHashMap: las competiciones salen en el orden en que aparece su primer evento,
+ // y como la lista viene ordenada por fecha, cada grupo queda ordenado por fecha.
.collect(Collectors.groupingBy(Evento::getDeporte,
() -> new EnumMap<>(Deporte.class),
Collectors.groupingBy(e -> e.getCompeticion().getNombre(),
@@ -60,17 +75,22 @@ public Map recuentoPorDeporte() {
Collectors.counting()));
}
+ /** Eventos programados que aún no han empezado, del más próximo al más lejano (CA-19.2). */
private List disponibles() {
return eventos.findByEstadoAndFechaHoraAfterOrderByFechaHoraAsc(EstadoEvento.PROGRAMADO, LocalDateTime.now(reloj));
}
+ /** El texto (ya normalizado) aparece en el nombre del local, del visitante o de la competición. */
private static boolean coincide(Evento evento, String buscado) {
return Nombres.normalizar(evento.getLocal().getNombre()).contains(buscado)
|| Nombres.normalizar(evento.getVisitante().getNombre()).contains(buscado)
|| Nombres.normalizar(evento.getCompeticion().getNombre()).contains(buscado);
}
- /** Partido destacado de la portada: el próximo de fútbol, o el próximo de cualquier deporte. */
+ /**
+ * Partido destacado de la portada: el próximo de fútbol, o el próximo de cualquier deporte.
+ * Null si no hay ningún evento disponible.
+ */
@Transactional(readOnly = true)
public Evento destacado() {
List disponibles = disponibles();
@@ -78,7 +98,7 @@ public Evento destacado() {
.orElse(disponibles.isEmpty() ? null : disponibles.get(0));
}
- /** Los próximos partidos disponibles, para la portada. */
+ /** Los próximos partidos disponibles, para la portada. Como mucho {@code cuantos}. */
@Transactional(readOnly = true)
public List proximos(int cuantos) {
return eventos.findByEstadoAndFechaHoraAfterOrderByFechaHoraAsc(EstadoEvento.PROGRAMADO, LocalDateTime.now(reloj))
@@ -87,6 +107,12 @@ public List proximos(int cuantos) {
.toList();
}
+ /**
+ * Evento en el que se puede apostar ahora mismo (página de apostar).
+ *
+ * @throws EventoNoDisponibleException si no existe o ya no admite apuestas
+ * (suspendido, finalizado, anulado o ya empezado); se traduce en un 404
+ */
@Transactional(readOnly = true)
public Evento eventoDisponible(Long id) {
return eventos.findById(id)
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EquipoNoEncontradoException.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EquipoNoEncontradoException.java
index 639ab26..0afb7a0 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EquipoNoEncontradoException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EquipoNoEncontradoException.java
@@ -3,6 +3,10 @@
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
+/**
+ * No existe ningún equipo con ese identificador. La lanzan {@link FichaEquipoService} y la gestión de
+ * equipos; gracias a {@code @ResponseStatus}, Spring responde con un 404.
+ */
@ResponseStatus(HttpStatus.NOT_FOUND)
public class EquipoNoEncontradoException extends RuntimeException {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EstadoEvento.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EstadoEvento.java
index f4f5ca2..de6634f 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EstadoEvento.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EstadoEvento.java
@@ -1,8 +1,22 @@
package es.ucm.fdi.is1.apuestas.eventos;
+/**
+ * Estado de un {@link Evento} en su ciclo de vida (HU-04, HU-05). Las transiciones las controla el propio
+ * evento:
+ *
+ *
+ * PROGRAMADO ──suspender──▶ SUSPENDIDO ──reactivar──▶ PROGRAMADO
+ * PROGRAMADO / SUSPENDIDO ──finalizar──▶ FINALIZADO (se puede volver a finalizar para corregir)
+ * PROGRAMADO / SUSPENDIDO ──anular──▶ ANULADO (definitivo)
+ *
+ */
public enum EstadoEvento {
+ /** Estado inicial. Admite apuestas mientras no haya empezado. */
PROGRAMADO,
+ /** Aplazado o en revisión: no admite apuestas nuevas hasta que se reactive (HU-05). */
SUSPENDIDO,
+ /** Ya tiene resultado y sus apuestas se han resuelto (HU-04). */
FINALIZADO,
+ /** Cancelado: se devuelve lo apostado y ya no puede tener resultado (HU-05). */
ANULADO
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Evento.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Evento.java
index cd70857..2a3f5e3 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Evento.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Evento.java
@@ -15,7 +15,17 @@
import jakarta.persistence.ManyToOne;
import jakarta.validation.constraints.NotNull;
-/** Partido entre dos equipos de una competición, sobre el que se apuesta (G/E/P). */
+/**
+ * Partido entre dos equipos de una competición, sobre el que se apuesta (G/E/P).
+ *
+ * Entidad JPA y pieza central del dominio: las apuestas, las cuotas y las estadísticas de equipos
+ * se calculan a partir de ella. Lo crea el creador de apuestas a mano o la sincronización con la API
+ * (HU-21).
+ *
+ *
Guarda su {@link EstadoEvento} y solo permite las transiciones válidas; los métodos que cambian
+ * el estado lanzan {@link IllegalStateException} si no se puede. El efecto sobre las apuestas
+ * (resolverlas, devolver el dinero) no se hace aquí, sino en {@code ResolucionService}.
+ */
@Entity
public class Evento {
@@ -65,6 +75,7 @@ protected Evento() {
// requerido por JPA
}
+ /** Evento nuevo, en estado programado y sin resultado. */
public Evento(Competicion competicion, Equipo local, Equipo visitante, LocalDateTime fechaHora) {
this.competicion = competicion;
this.local = local;
@@ -77,7 +88,14 @@ public boolean admiteApuestas(LocalDateTime ahora) {
return estado == EstadoEvento.PROGRAMADO && fechaHora.isAfter(ahora);
}
- /** Fija (o corrige) el resultado final y marca el evento como finalizado (HU-04). */
+ /**
+ * Fija (o corrige) el resultado final y marca el evento como finalizado (HU-04).
+ * Vale desde programado, suspendido o finalizado (para corregir). Borra el marcador: con un resultado
+ * sin goles, las apuestas de goles se anulan.
+ *
+ * @throws IllegalStateException si el evento está anulado
+ * @throws IllegalArgumentException si es empate en un deporte que no lo admite
+ */
public void finalizar(Resultado resultadoFinal) {
if (estado == EstadoEvento.ANULADO) {
throw new IllegalStateException("Un evento anulado no puede tener resultado");
@@ -91,20 +109,32 @@ public void finalizar(Resultado resultadoFinal) {
estado = EstadoEvento.FINALIZADO;
}
- /** Fija (o corrige) el marcador final; el resultado sale de él (HU-52). Solo en fútbol. */
+ /**
+ * Fija (o corrige) el marcador final; el resultado sale de él (HU-52). Solo en fútbol.
+ *
+ * @throws IllegalArgumentException si algún marcador es negativo o el deporte no admite empate
+ * @throws IllegalStateException si el evento está anulado
+ */
public void finalizarConMarcador(int local, int visitante) {
if (local < 0 || visitante < 0) {
throw new IllegalArgumentException("Los goles no pueden ser negativos");
}
+ // "Admite empate" equivale aquí a "es fútbol": en el resto no hay goles que apuntar
if (!getDeporte().isAdmiteEmpate()) {
throw new IllegalArgumentException("En " + getDeporte().getNombre() + " solo se indica el ganador");
}
+ // finalizar() borra los goles, así que se guardan después
finalizar(local > visitante ? Resultado.LOCAL : local < visitante ? Resultado.VISITANTE : Resultado.EMPATE);
golesLocal = local;
golesVisitante = visitante;
}
- /** Deja de admitir apuestas nuevas hasta que se reactive (HU-05). */
+ /**
+ * Deja de admitir apuestas nuevas hasta que se reactive (HU-05). Las que ya hay siguen activas.
+ * Suspendido por el creador: la API no lo reactivará sola.
+ *
+ * @throws IllegalStateException si no está programado
+ */
public void suspender() {
if (estado != EstadoEvento.PROGRAMADO) {
throw new IllegalStateException("Solo se puede suspender un evento programado");
@@ -123,6 +153,11 @@ public boolean isSuspendidoPorApi() {
return suspendidoPorApi;
}
+ /**
+ * Vuelve a programado y admite apuestas otra vez, si no ha empezado (HU-05).
+ *
+ * @throws IllegalStateException si no está suspendido
+ */
public void reactivar() {
if (estado != EstadoEvento.SUSPENDIDO) {
throw new IllegalStateException("Solo se puede reactivar un evento suspendido");
@@ -131,7 +166,11 @@ public void reactivar() {
suspendidoPorApi = false;
}
- /** Anula el evento; las apuestas se devuelven desde el servicio (HU-05). */
+ /**
+ * Anula el evento; las apuestas se devuelven desde el servicio (HU-05). Es definitivo.
+ *
+ * @throws IllegalStateException si ya está finalizado o anulado
+ */
public void anular() {
if (estado == EstadoEvento.FINALIZADO || estado == EstadoEvento.ANULADO) {
throw new IllegalStateException("No se puede anular un evento finalizado o ya anulado");
@@ -139,6 +178,7 @@ public void anular() {
estado = EstadoEvento.ANULADO;
}
+ /** True a partir de la hora de inicio, sea cual sea el estado. */
public boolean haEmpezado(LocalDateTime ahora) {
return !fechaHora.isAfter(ahora);
}
@@ -155,10 +195,12 @@ public Integer getGolesVisitante() {
return golesVisitante;
}
+ /** True si se finalizó con marcador (HU-52) y no solo con el ganador. */
public boolean isConMarcador() {
return golesLocal != null && golesVisitante != null;
}
+ /** El evento no guarda el deporte: es el de su competición. */
public Deporte getDeporte() {
return competicion.getDeporte();
}
@@ -190,6 +232,8 @@ public EstadoEvento getEstado() {
/**
* El creador corrige un evento mal dado de alta (HU-46). Solo antes de que empiece y si no está
* finalizado ni anulado; las comprobaciones de equipos y apuestas las hace el servicio.
+ *
+ * @throws IllegalStateException si el evento no es {@link #editable(LocalDateTime) editable}
*/
public void modificar(Equipo nuevoLocal, Equipo nuevoVisitante, LocalDateTime nuevaFecha, String nuevaFase,
LocalDateTime ahora) {
@@ -207,7 +251,10 @@ public boolean editable(LocalDateTime ahora) {
return (estado == EstadoEvento.PROGRAMADO || estado == EstadoEvento.SUSPENDIDO) && fechaHora.isAfter(ahora);
}
- /** La fuente de datos ha cambiado la fecha del partido (solo si aún no se ha jugado). */
+ /**
+ * La fuente de datos ha cambiado la fecha del partido (solo si aún no se ha jugado).
+ * En un evento finalizado o anulado no hace nada, sin lanzar excepción.
+ */
public void cambiarFecha(LocalDateTime nuevaFecha) {
if (estado == EstadoEvento.PROGRAMADO || estado == EstadoEvento.SUSPENDIDO) {
fechaHora = nuevaFecha;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoNoDisponibleException.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoNoDisponibleException.java
index 8cebe4c..6e5b68d 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoNoDisponibleException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoNoDisponibleException.java
@@ -3,6 +3,11 @@
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
+/**
+ * El evento pedido no existe o ya no admite apuestas (suspendido, empezado, finalizado o anulado).
+ * La lanzan {@link CatalogoService} y los servicios de apuestas y de gestión; si nadie la captura,
+ * Spring responde con un 404 gracias a {@code @ResponseStatus}.
+ */
@ResponseStatus(HttpStatus.NOT_FOUND)
public class EventoNoDisponibleException extends RuntimeException {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoRepository.java
index 082fb80..5578a86 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoRepository.java
@@ -10,8 +10,13 @@
import es.ucm.fdi.is1.apuestas.equipos.Competicion;
import es.ucm.fdi.is1.apuestas.equipos.Equipo;
+/**
+ * Repositorio Spring Data de {@link Evento}. Spring genera la implementación: las consultas salen del
+ * nombre de cada método o de su {@code @Query} (JPQL).
+ */
public interface EventoRepository extends JpaRepository {
+ /** Eventos en un estado que empiezan después de una fecha, del más próximo al más lejano (catálogo, HU-19). */
List findByEstadoAndFechaHoraAfterOrderByFechaHoraAsc(EstadoEvento estado, LocalDateTime fecha);
List findAllByOrderByFechaHoraAsc();
@@ -39,5 +44,6 @@ List findByLocalAndVisitanteAndFechaHoraBetween(Equipo local, Equipo vis
order by e.fechaHora desc""")
List enfrentamientos(Equipo uno, Equipo otro);
+ /** Eventos de una competición en un estado; con FINALIZADO sirve para calcular la clasificación (HU-31). */
List findByCompeticionAndEstado(Competicion competicion, EstadoEvento estado);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventosController.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventosController.java
index 6889e1d..7088f68 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventosController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventosController.java
@@ -7,6 +7,10 @@
import es.ucm.fdi.is1.apuestas.equipos.Deporte;
+/**
+ * Controlador MVC del catálogo público de eventos (HU-19, HU-22). Delega en {@link CatalogoService}
+ * y pinta la plantilla Thymeleaf {@code eventos.html}.
+ */
@Controller
public class EventosController {
@@ -16,10 +20,18 @@ public EventosController(CatalogoService catalogo) {
this.catalogo = catalogo;
}
+ /**
+ * {@code GET /eventos}: eventos disponibles agrupados por deporte y competición, con pestañas por
+ * deporte y buscador.
+ *
+ * @param deporte pestaña elegida ({@code ?deporte=FUTBOL}); sin él se muestran todos
+ * @param q texto buscado en equipos y competición ({@code ?q=madrid}); opcional
+ */
@GetMapping("/eventos")
public String catalogo(@RequestParam(required = false) Deporte deporte,
@RequestParam(required = false) String q, Model model) {
model.addAttribute("eventosPorDeporte", catalogo.buscar(deporte, q));
+ // El recuento de las pestañas no depende de los filtros: siempre se ven todos los deportes
model.addAttribute("recuento", catalogo.recuentoPorDeporte());
model.addAttribute("deporteElegido", deporte);
model.addAttribute("q", q == null ? "" : q.trim());
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipo.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipo.java
index 9f819bf..dd7a7c8 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipo.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipo.java
@@ -12,7 +12,18 @@
/**
* Ficha de un equipo o deportista (HU-31): últimos resultados, balance, racha, posición en cada competición
* según los resultados registrados en la web, próximos partidos, gráfico de evolución (HU-32),
- * plantilla y alineación probable (null si no hay jugadores suficientes).
+ * plantilla y alineación probable (HU-49; null si no hay jugadores suficientes).
+ *
+ * Objeto de solo lectura que monta {@link FichaEquipoService#ficha(Long)} y se pasa entero a la
+ * plantilla {@code equipo.html}.
+ *
+ * @param ultimos últimos partidos finalizados, del más reciente al más antiguo
+ * @param balance balance de todos los partidos finalizados del equipo
+ * @param racha últimos resultados, del más antiguo al más reciente
+ * @param clasificaciones puesto en cada competición en la que ya ha jugado, por nombre de competición
+ * @param proximos próximos partidos en los que aún se puede apostar
+ * @param evolucion null si ha jugado menos de dos partidos
+ * @param plantilla jugadores agrupados por posición; vacía si no hay jugadores
*/
public record FichaEquipo(Equipo equipo, List ultimos, Balance balance, List racha,
List clasificaciones, List proximos, GraficoEvolucion evolucion,
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipoService.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipoService.java
index b33d3ac..73ed625 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipoService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipoService.java
@@ -17,12 +17,21 @@
import es.ucm.fdi.is1.apuestas.equipos.EquipoRepository;
import es.ucm.fdi.is1.apuestas.equipos.PlantillaService;
-/** Banco de estadísticas de equipos: ficha (HU-31) y cara a cara (HU-33). */
+/**
+ * Banco de estadísticas de equipos: ficha (HU-31, HU-32, HU-49) y cara a cara (HU-33).
+ *
+ * Servicio de solo lectura. Todas las estadísticas se calculan con los eventos finalizados guardados en
+ * la base de datos; la plantilla y la alineación las pide a {@link PlantillaService}. Lo usan
+ * {@code InicioController} (ficha) y {@code ApuestaController} (cara a cara).
+ */
@Service
public class FichaEquipoService {
+ /** Partidos jugados que se listan en la ficha y en el cara a cara. */
static final int ULTIMOS = 10;
+ /** Resultados que forman la racha reciente (CA-31.1). */
static final int RACHA = 5;
+ /** Próximos partidos que se muestran en la ficha. */
static final int PROXIMOS = 5;
private final EquipoRepository equipos;
@@ -38,34 +47,50 @@ public FichaEquipoService(EquipoRepository equipos, EventoRepository eventos, Pl
this.reloj = reloj;
}
+ /**
+ * Ficha completa de un equipo o deportista (HU-31).
+ *
+ * @throws EquipoNoEncontradoException si no existe (404)
+ */
@Transactional(readOnly = true)
public FichaEquipo ficha(Long equipoId) {
Equipo equipo = equipos.findById(equipoId).orElseThrow(() -> new EquipoNoEncontradoException(equipoId));
+ // Del más reciente al más antiguo
List jugados = eventos.partidosDe(equipo, EstadoEvento.FINALIZADO);
List ultimos = jugados.stream().limit(ULTIMOS)
.map(e -> {
+ // Se compara por id porque Equipo no redefine equals()
boolean enCasa = e.getLocal().getId().equals(equipo.getId());
return new FichaEquipo.PartidoJugado(e, enCasa ? e.getVisitante() : e.getLocal(), enCasa,
ResultadoEquipo.de(e, equipo));
})
.toList();
+ // El balance cuenta todos los partidos jugados, no solo los últimos
Balance balance = Balance.de(jugados.stream().map(e -> ResultadoEquipo.de(e, equipo)).toList());
LocalDateTime ahora = LocalDateTime.now(reloj);
+ // Próximos: programados y sin empezar, del más cercano al más lejano
List proximos = eventos.partidosDe(equipo, EstadoEvento.PROGRAMADO).stream()
.filter(e -> e.admiteApuestas(ahora))
.sorted(Comparator.comparing(Evento::getFechaHora))
.limit(PROXIMOS)
.toList();
+ // Un puesto por competición; se omiten aquellas en las que aún no ha jugado
List clasificaciones = equipo.getCompeticiones().stream()
.sorted(Comparator.comparing(Competicion::getNombre))
.map(c -> puesto(c, equipo))
.filter(p -> p != null)
.toList();
+ // El gráfico necesita los partidos en orden cronológico: se invierte la lista
return new FichaEquipo(equipo, ultimos, balance, racha(jugados, equipo), clasificaciones, proximos,
GraficoEvolucion.de(jugados.reversed(), equipo), plantillas.plantilla(equipo),
plantillas.alineacion(equipo));
}
+ /**
+ * Cara a cara de los dos rivales de un evento (HU-33): sus últimos enfrentamientos, el balance de
+ * todos ellos visto desde el local y la racha de cada uno. Las listas pueden venir vacías si no hay
+ * partidos previos (CA-33.2).
+ */
@Transactional(readOnly = true)
public CaraACara caraACara(Evento evento) {
Equipo local = evento.getLocal();
@@ -79,6 +104,7 @@ public CaraACara caraACara(Evento evento) {
/** Últimos resultados, del más antiguo al más reciente (el último de la lista es el más reciente). */
private static List racha(List jugados, Equipo equipo) {
+ // jugados viene del más reciente al más antiguo: se cogen los RACHA primeros y se invierten
List recientes = jugados.stream().limit(RACHA).map(e -> ResultadoEquipo.de(e, equipo)).toList();
return recientes.reversed();
}
@@ -88,6 +114,7 @@ private static List racha(List jugados, Equipo equipo)
* a igualdad de puntos, más victorias. Null si el equipo aún no ha jugado en ella.
*/
private FichaEquipo.Puesto puesto(Competicion competicion, Equipo equipo) {
+ // Resultados de cada participante, por id de equipo
Map> porEquipo = new HashMap<>();
Map nombres = new HashMap<>();
for (Evento evento : eventos.findByCompeticionAndEstado(competicion, EstadoEvento.FINALIZADO)) {
@@ -100,6 +127,7 @@ private FichaEquipo.Puesto puesto(Competicion competicion, Equipo equipo) {
if (!porEquipo.containsKey(equipo.getId())) {
return null;
}
+ // Orden de la tabla: más puntos, después más victorias y, por último, nombre (orden estable)
List> tabla = porEquipo.entrySet().stream()
.map(e -> Map.entry(e.getKey(), Balance.de(e.getValue())))
.sorted(Comparator.>comparingInt(e -> e.getValue().puntos()).reversed()
@@ -107,6 +135,7 @@ private FichaEquipo.Puesto puesto(Competicion competicion, Equipo equipo) {
.reversed())
.thenComparing(e -> nombres.get(e.getKey())))
.toList();
+ // La posición es el índice en la tabla más uno
for (int i = 0; i < tabla.size(); i++) {
if (tabla.get(i).getKey().equals(equipo.getId())) {
return new FichaEquipo.Puesto(competicion, i + 1, tabla.size(), tabla.get(i).getValue());
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/GraficoEvolucion.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/GraficoEvolucion.java
index 9c77aed..7325e28 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/GraficoEvolucion.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/GraficoEvolucion.java
@@ -10,12 +10,25 @@
/**
* Evolución de un equipo a lo largo de la temporada (HU-32): puntos acumulados partido a partido
* (3 por victoria y 1 por empate). Las coordenadas se calculan aquí para dibujar un SVG sin JavaScript.
+ *
+ * Forma parte de {@link FichaEquipo}; la plantilla {@code equipo.html} solo pinta los datos.
+ *
+ * @param ancho ancho del SVG en píxeles
+ * @param alto alto del SVG en píxeles
+ * @param linea atributo {@code d} del {@code } que une los puntos ("M x,y L x,y …")
+ * @param puntos un círculo por partido, con su descripción como texto emergente
+ * @param ejeY etiquetas de puntos del eje vertical
+ * @param ejeX etiquetas de fecha del eje horizontal
+ * @param base coordenada y de la línea de 0 puntos (parte de abajo del área del gráfico)
+ * @param izquierda coordenada x donde empiezan las líneas horizontales de la rejilla
+ * @param derecha coordenada x donde terminan las líneas horizontales de la rejilla
*/
public record GraficoEvolucion(int ancho, int alto, String linea, List puntos, List ejeY,
List ejeX, double base, double izquierda, double derecha) {
static final int ANCHO = 640;
static final int ALTO = 220;
+ // Márgenes que dejan sitio a las etiquetas de los ejes
static final double MARGEN_IZQ = 36;
static final double MARGEN_DER = 16;
static final double MARGEN_ARRIBA = 16;
@@ -34,6 +47,7 @@ static GraficoEvolucion de(List jugados, Equipo equipo) {
if (jugados.size() < 2) {
return null;
}
+ // Puntos acumulados después de cada partido
List acumulados = new ArrayList<>();
int total = 0;
for (Evento evento : jugados) {
@@ -44,29 +58,37 @@ static GraficoEvolucion de(List jugados, Equipo equipo) {
};
acumulados.add(total);
}
+ // Tope del eje Y: el total redondeado hacia arriba a un múltiplo de 3 (como mínimo 3),
+ // para que las marcas del eje (cada tercio) sean números enteros
int maximo = Math.max(3, (int) Math.ceil(total / 3.0) * 3);
double altoUtil = ALTO - MARGEN_ARRIBA - MARGEN_ABAJO;
double anchoUtil = ANCHO - MARGEN_IZQ - MARGEN_DER;
+ // Separación horizontal entre partidos: el primero queda en el borde izquierdo y el último en el derecho
double paso = anchoUtil / (jugados.size() - 1);
List puntos = new ArrayList<>();
List ejeX = new ArrayList<>();
StringBuilder linea = new StringBuilder();
+ // Como mucho unas 8 fechas en el eje X, para que no se solapen
int cadaCuantos = Math.max(1, (int) Math.ceil(jugados.size() / 8.0));
for (int i = 0; i < jugados.size(); i++) {
Evento evento = jugados.get(i);
double x = MARGEN_IZQ + i * paso;
+ // En SVG la y crece hacia abajo: 0 puntos va abajo y el máximo arriba
double y = MARGEN_ARRIBA + altoUtil * (1 - acumulados.get(i) / (double) maximo);
linea.append(i == 0 ? "M" : " L").append(redondear(x)).append(',').append(redondear(y));
ResultadoEquipo resultado = ResultadoEquipo.de(evento, equipo);
Equipo rival = evento.getLocal().getId().equals(equipo.getId()) ? evento.getVisitante() : evento.getLocal();
+ // Texto emergente del punto, y la clase CSS (victoria, empate o derrota) para el color
puntos.add(new Marca(x, y, FECHA.format(evento.getFechaHora()) + " · " + resultado.getDescripcion()
+ " contra " + rival.getNombre() + " · " + acumulados.get(i) + " puntos",
resultado.name().toLowerCase(Locale.ROOT)));
+ // La fecha del último partido se muestra siempre
if (i % cadaCuantos == 0 || i == jugados.size() - 1) {
ejeX.add(new Marca(x, ALTO - 8, FECHA.format(evento.getFechaHora()), ""));
}
}
+ // Marcas del eje Y: 0, un tercio, dos tercios y el máximo
List ejeY = new ArrayList<>();
for (int valor = 0; valor <= maximo; valor += Math.max(1, maximo / 3)) {
ejeY.add(new Marca(MARGEN_IZQ - 8, MARGEN_ARRIBA + altoUtil * (1 - valor / (double) maximo),
@@ -76,6 +98,7 @@ static GraficoEvolucion de(List jugados, Equipo equipo) {
ALTO - MARGEN_ABAJO, MARGEN_IZQ, ANCHO - MARGEN_DER);
}
+ /** Un decimal y punto como separador, sea cual sea el idioma del servidor (el SVG lo exige). */
private static String redondear(double valor) {
return String.format(Locale.ROOT, "%.1f", valor);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/ResultadoEquipo.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/ResultadoEquipo.java
index 358548d..e7c8e43 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/ResultadoEquipo.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/ResultadoEquipo.java
@@ -3,7 +3,12 @@
import es.ucm.fdi.is1.apuestas.cuotas.Resultado;
import es.ucm.fdi.is1.apuestas.equipos.Equipo;
-/** Resultado de un partido visto desde uno de los dos equipos (HU-31). */
+/**
+ * Resultado de un partido visto desde uno de los dos equipos (HU-31).
+ * A diferencia de {@link Resultado} (local, empate, visitante), aquí importa quién mira:
+ * un {@code LOCAL} es victoria para el local y derrota para el visitante.
+ * Cada valor lleva la letra de la racha (V/E/D) y su descripción.
+ */
public enum ResultadoEquipo {
VICTORIA("V", "Victoria"),
@@ -23,6 +28,7 @@ public static ResultadoEquipo de(Evento evento, Equipo equipo) {
if (evento.getResultado() == Resultado.EMPATE) {
return EMPATE;
}
+ // Gana si es local y ganó el local, o si es visitante y ganó el visitante
boolean ganaLocal = evento.getResultado() == Resultado.LOCAL;
boolean esLocal = evento.getLocal().getId().equals(equipo.getId());
return ganaLocal == esLocal ? VICTORIA : DERROTA;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CasaController.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CasaController.java
index 075c7e4..5159786 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CasaController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CasaController.java
@@ -6,7 +6,10 @@
import es.ucm.fdi.is1.apuestas.apuesta.CuentasCasaService;
-/** Panel de la casa para el creador de apuestas (HU-54). */
+/**
+ * Controlador del panel de la casa para el creador de apuestas (HU-54). Los cálculos (total apostado,
+ * pagado, beneficio y margen real frente al teórico) los hace {@link CuentasCasaService}.
+ */
@Controller
public class CasaController {
@@ -16,6 +19,10 @@ public CasaController(CuentasCasaService cuentas) {
this.cuentas = cuentas;
}
+ /**
+ * GET /gestion/casa (solo CREADOR): cuentas de la casa, en total y desglosadas por tipo de apuesta
+ * y por deporte (CA-54.1, CA-54.2).
+ */
@GetMapping("/gestion/casa")
public String casa(Model model) {
model.addAttribute("cuentas", cuentas.cuentas());
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CompeticionForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CompeticionForm.java
index 6994d16..dcd5040 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CompeticionForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CompeticionForm.java
@@ -5,6 +5,10 @@
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.NotNull;
+/**
+ * Formulario de alta de una competición (HU-01). Que no se repita el nombre lo comprueba
+ * {@link GestionService#crearCompeticion}, porque necesita consultar la base de datos.
+ */
public class CompeticionForm {
@NotBlank(message = "Introduce el nombre")
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/DatosInvalidosException.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/DatosInvalidosException.java
index 907b19b..62e8b27 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/DatosInvalidosException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/DatosInvalidosException.java
@@ -1,6 +1,10 @@
package es.ucm.fdi.is1.apuestas.gestion;
-/** Regla de negocio incumplida en un formulario; indica el campo afectado. */
+/**
+ * Regla de negocio incumplida en un formulario; indica el campo afectado.
+ * La lanza {@link GestionService} y el controlador la convierte en un error de ese campo, para mostrarlo
+ * junto a él en lugar de en una página de error.
+ */
public class DatosInvalidosException extends RuntimeException {
private final String campo;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEquipoForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEquipoForm.java
index 999c7f8..2a6373c 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEquipoForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEquipoForm.java
@@ -7,7 +7,10 @@
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
-/** Datos editables de un equipo ya existente: calificación (HU-01), forma reciente (HU-02) y escudo. */
+/**
+ * Formulario con los datos editables de un equipo ya existente: calificación (HU-01), forma reciente (HU-02)
+ * y escudo. El nombre y el deporte no se editan.
+ */
public class EdicionEquipoForm {
@NotNull(message = "Introduce la calificación")
@@ -18,7 +21,10 @@ public class EdicionEquipoForm {
@NotNull(message = "Elige la forma reciente")
private Forma forma = Forma.NORMAL;
- /** Vacío para quitar el escudo y volver a mostrar las iniciales. */
+ /**
+ * Vacío para quitar el escudo y volver a mostrar las iniciales. Si no, tiene que ser una dirección
+ * http o https sin espacios.
+ */
@Size(max = 500, message = "Máximo 500 caracteres")
@Pattern(regexp = "^$|^https?://\\S+$", message = "Debe ser una dirección que empiece por http:// o https://")
private String escudoUrl;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEventoForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEventoForm.java
index 9ab6167..421686d 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEventoForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEventoForm.java
@@ -8,7 +8,11 @@
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
-/** Datos editables de un evento que aún no ha empezado (HU-46). La competición no cambia. */
+/**
+ * Formulario con los datos editables de un evento que aún no ha empezado (HU-46). La competición no cambia.
+ * Las reglas que dependen del evento (que no haya empezado, que no tenga apuestas si cambian los equipos)
+ * las comprueba {@link GestionService#editarEvento}.
+ */
public class EdicionEventoForm {
@NotNull(message = "Elige el equipo local")
@@ -17,11 +21,13 @@ public class EdicionEventoForm {
@NotNull(message = "Elige el equipo visitante")
private Long visitanteId;
+ // La nueva fecha también tiene que ser futura; mismo formato que el campo datetime-local de HTML
@NotNull(message = "Introduce la fecha y hora")
@Future(message = "La fecha debe ser futura")
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
private LocalDateTime fechaHora;
+ /** Opcional; vacía deja el evento sin fase. */
@Size(max = 80, message = "Máximo 80 caracteres")
private String fase;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EquipoForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EquipoForm.java
index aba68b6..c94357a 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EquipoForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EquipoForm.java
@@ -10,6 +10,10 @@
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.NotNull;
+/**
+ * Formulario de alta de un equipo o deportista con su calificación (HU-01). {@link GestionService#crearEquipo}
+ * comprueba además que el nombre no exista y que las competiciones sean de su deporte.
+ */
public class EquipoForm {
@NotBlank(message = "Introduce el nombre")
@@ -19,11 +23,13 @@ public class EquipoForm {
@NotNull(message = "Elige un deporte")
private Deporte deporte;
+ /** Calificación de calidad de 0 a 10, con la que se calculan las cuotas (CA-01.2). */
@NotNull(message = "Introduce la calificación")
@DecimalMin(value = "0.0", message = "La calificación debe estar entre 0 y 10")
@DecimalMax(value = "10.0", message = "La calificación debe estar entre 0 y 10")
private Double calidad;
+ /** Ids de las competiciones marcadas; puede quedar vacío. */
private Set competicionIds = new HashSet<>();
public String getNombre() {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EventoForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EventoForm.java
index 5e750be..7bc8ebb 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EventoForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EventoForm.java
@@ -8,6 +8,10 @@
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
+/**
+ * Formulario de alta de un evento a mano (HU-21). {@link GestionService#crearEvento} comprueba además
+ * que local y visitante sean distintos y que los dos jueguen la competición elegida.
+ */
public class EventoForm {
@NotNull(message = "Elige una competición")
@@ -19,11 +23,13 @@ public class EventoForm {
@NotNull(message = "Elige el equipo visitante")
private Long visitanteId;
+ // No se puede crear un evento en el pasado. El patrón es el formato del campo datetime-local de HTML
@NotNull(message = "Introduce la fecha y hora")
@Future(message = "La fecha debe ser futura")
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
private LocalDateTime fechaHora;
+ /** Opcional (jornada, semifinal...). */
@Size(max = 80, message = "Máximo 80 caracteres")
private String fase;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionController.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionController.java
index cf6d079..9f60b7c 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionController.java
@@ -32,7 +32,16 @@
import es.ucm.fdi.is1.apuestas.mercados.MercadoService;
import jakarta.validation.Valid;
-/** Panel del creador de apuestas (solo rol CREADOR, ver SeguridadConfig). */
+/**
+ * Panel del creador de apuestas (solo rol CREADOR, ver SeguridadConfig). Todas sus rutas cuelgan de
+ * {@code /gestion}: competiciones, equipos y plantillas (HU-01, HU-50), eventos y su resultado
+ * (HU-04, HU-05, HU-21, HU-46), límites de apuesta (HU-07), juego responsable (HU-10) y sincronización
+ * con la API. Los mercados a largo plazo tienen su propio controlador, {@link GestionMercadosController}.
+ *
+ * Patrón de los formularios: si la validación falla o el servicio lanza {@link DatosInvalidosException},
+ * se vuelve a mostrar el formulario con el error en su campo; si todo va bien, se redirige con un mensaje
+ * (atributo flash) para que recargar la página no repita la acción.
+ */
@Controller
@RequestMapping("/gestion")
public class GestionController {
@@ -62,7 +71,10 @@ public GestionController(GestionService gestion, ResolucionService resolucion,
this.reloj = reloj;
}
- /** Descarga ahora mismo partidos y resultados de la API (HU-21). */
+ /**
+ * POST /gestion/sincronizar: descarga ahora mismo partidos y resultados de la API (HU-21).
+ * Si la API no está configurada, o algún partido falla, se avisa con un mensaje de error.
+ */
@PostMapping("/sincronizar")
public String sincronizar(RedirectAttributes redireccion) {
try {
@@ -77,7 +89,7 @@ public String sincronizar(RedirectAttributes redireccion) {
return "redirect:/gestion";
}
- /** Descarga ahora mismo las plantillas reales de los equipos. */
+ /** POST /gestion/sincronizar-plantillas: descarga ahora mismo las plantillas reales de los equipos (HU-50). */
@PostMapping("/sincronizar-plantillas")
public String sincronizarPlantillas(RedirectAttributes redireccion) {
try {
@@ -94,6 +106,10 @@ public String sincronizarPlantillas(RedirectAttributes redireccion) {
// --- Jugadores ---
+ /**
+ * POST /gestion/equipos/{id}/jugadores: añade a mano un jugador a la plantilla del equipo (HU-50).
+ * Los parámetros son opcionales para que sea {@link PlantillaService} quien explique qué falta.
+ */
@PostMapping("/equipos/{id}/jugadores")
public String anadirJugador(@PathVariable Long id, @RequestParam(required = false) String nombre,
@RequestParam(required = false) Posicion posicion,
@@ -109,6 +125,7 @@ public String anadirJugador(@PathVariable Long id, @RequestParam(required = fals
return "redirect:/gestion/equipos/" + id + "/editar#jugadores";
}
+ /** POST /gestion/jugadores/{id}/nota: cambia la nota (0–10) de un jugador y vuelve a la página de su equipo. */
@PostMapping("/jugadores/{id}/nota")
public String notaJugador(@PathVariable Long id, @RequestParam double nota, RedirectAttributes redireccion) {
Long equipoId;
@@ -124,6 +141,7 @@ public String notaJugador(@PathVariable Long id, @RequestParam double nota, Redi
return "redirect:/gestion/equipos/" + equipoId + "/editar#jugadores";
}
+ /** POST /gestion/jugadores/{id}/borrar: quita un jugador de la plantilla. */
@PostMapping("/jugadores/{id}/borrar")
public String borrarJugador(@PathVariable Long id, RedirectAttributes redireccion) {
try {
@@ -136,16 +154,22 @@ public String borrarJugador(@PathVariable Long id, RedirectAttributes redireccio
}
}
+ /** Deportes para los desplegables; Spring lo añade al modelo de todas las páginas de este controlador. */
@ModelAttribute("deportes")
public Deporte[] deportes() {
return Deporte.values();
}
+ /** Formas recientes posibles (HU-02), también para los desplegables. */
@ModelAttribute("formas")
public Forma[] formas() {
return Forma.values();
}
+ /**
+ * GET /gestion: página principal del panel, con competiciones, equipos, eventos, mercados, límites
+ * y si la API está configurada (si no, la vista lo avisa y desactiva la descarga de plantillas).
+ */
@GetMapping
public String panel(Model model) {
model.addAttribute("competiciones", gestion.competiciones());
@@ -160,6 +184,7 @@ public String panel(Model model) {
// --- Límites de apuesta (HU-07) ---
+ /** GET /gestion/limites: formulario de límites de apuesta, relleno con los valores actuales (HU-07). */
@GetMapping("/limites")
public String limites(Model model) {
Limites actuales = limites.actuales();
@@ -171,6 +196,10 @@ public String limites(Model model) {
return "gestion/limites";
}
+ /**
+ * POST /gestion/limites: guarda los límites de apuesta (HU-07). Las anotaciones del formulario comprueban
+ * cada campo; {@link Limites#cambiar} comprueba además que el máximo no sea menor que el mínimo.
+ */
@PostMapping("/limites")
public String guardarLimites(@Valid @ModelAttribute("limites") LimitesForm form, BindingResult errores,
RedirectAttributes redireccion) {
@@ -180,13 +209,14 @@ public String guardarLimites(@Valid @ModelAttribute("limites") LimitesForm form,
redireccion.addFlashAttribute("mensaje", "Límites de apuesta actualizados");
return "redirect:/gestion";
} catch (IllegalArgumentException e) {
+ // Las reglas entre campos (máximo >= mínimo) se muestran junto al importe máximo
errores.rejectValue("importeMaximo", "invalido", e.getMessage());
}
}
return "gestion/limites";
}
- /** Activa o desactiva el juego responsable en toda la web (HU-10). */
+ /** POST /gestion/juego-responsable: activa o desactiva el juego responsable en toda la web (HU-10). */
@PostMapping("/juego-responsable")
public String juegoResponsable(@RequestParam boolean activo, RedirectAttributes redireccion) {
limites.activarJuegoResponsable(activo);
@@ -198,12 +228,14 @@ public String juegoResponsable(@RequestParam boolean activo, RedirectAttributes
// --- Competiciones ---
+ /** GET /gestion/competiciones/nueva: formulario de alta de una competición (HU-01). */
@GetMapping("/competiciones/nueva")
public String nuevaCompeticion(Model model) {
model.addAttribute("competicion", new CompeticionForm());
return "gestion/competicion";
}
+ /** POST /gestion/competiciones/nueva: crea la competición o vuelve al formulario con el error (HU-01). */
@PostMapping("/competiciones/nueva")
public String crearCompeticion(@Valid @ModelAttribute("competicion") CompeticionForm form,
BindingResult errores, RedirectAttributes redireccion) {
@@ -222,6 +254,7 @@ public String crearCompeticion(@Valid @ModelAttribute("competicion") Competicion
// --- Equipos ---
+ /** GET /gestion/equipos/nuevo: formulario de alta de un equipo o deportista (HU-01). */
@GetMapping("/equipos/nuevo")
public String nuevoEquipo(Model model) {
model.addAttribute("equipo", new EquipoForm());
@@ -229,6 +262,7 @@ public String nuevoEquipo(Model model) {
return "gestion/equipo";
}
+ /** POST /gestion/equipos/nuevo: crea el equipo o vuelve al formulario con el error (HU-01). */
@PostMapping("/equipos/nuevo")
public String crearEquipo(@Valid @ModelAttribute("equipo") EquipoForm form, BindingResult errores,
Model model, RedirectAttributes redireccion) {
@@ -245,6 +279,10 @@ public String crearEquipo(@Valid @ModelAttribute("equipo") EquipoForm form, Bind
return "gestion/equipo";
}
+ /**
+ * GET /gestion/equipos/{id}/editar: formulario para cambiar calificación, forma y escudo, junto con
+ * la plantilla del equipo (HU-50).
+ */
@GetMapping("/equipos/{id}/editar")
public String editarEquipo(@PathVariable Long id, Model model) {
Equipo equipo = gestion.equipo(id);
@@ -256,9 +294,11 @@ public String editarEquipo(@PathVariable Long id, Model model) {
return formularioEquipo(equipo, model);
}
+ /** POST /gestion/equipos/{id}/editar: guarda calificación, forma y escudo del equipo. */
@PostMapping("/equipos/{id}/editar")
public String guardarEquipo(@PathVariable Long id, @Valid @ModelAttribute("edicion") EdicionEquipoForm form,
BindingResult errores, Model model, RedirectAttributes redireccion) {
+ // Se carga antes de validar porque la página de edición lo necesita si hay errores
Equipo equipo = gestion.equipo(id);
if (errores.hasErrors()) {
return formularioEquipo(equipo, model);
@@ -268,6 +308,7 @@ public String guardarEquipo(@PathVariable Long id, @Valid @ModelAttribute("edici
return "redirect:/gestion";
}
+ /** Prepara el modelo de la página de edición de un equipo (datos, plantilla y posiciones). */
private String formularioEquipo(Equipo equipo, Model model) {
model.addAttribute("equipo", equipo);
model.addAttribute("plantilla", plantillas.plantilla(equipo));
@@ -277,12 +318,14 @@ private String formularioEquipo(Equipo equipo, Model model) {
// --- Eventos ---
+ /** GET /gestion/eventos/nuevo: formulario para crear un evento a mano (HU-21). */
@GetMapping("/eventos/nuevo")
public String nuevoEvento(Model model) {
model.addAttribute("evento", new EventoForm());
return formularioEvento(model);
}
+ /** POST /gestion/eventos/nuevo: crea el evento o vuelve al formulario con el error (HU-21). */
@PostMapping("/eventos/nuevo")
public String crearEvento(@Valid @ModelAttribute("evento") EventoForm form, BindingResult errores,
Model model, RedirectAttributes redireccion) {
@@ -298,6 +341,7 @@ public String crearEvento(@Valid @ModelAttribute("evento") EventoForm form, Bind
return formularioEvento(model);
}
+ /** GET /gestion/eventos/{id}/editar: formulario de edición de un evento, relleno con sus datos (HU-46). */
@GetMapping("/eventos/{id}/editar")
public String editarEvento(@PathVariable Long id, Model model) {
Evento evento = gestion.evento(id);
@@ -310,6 +354,10 @@ public String editarEvento(@PathVariable Long id, Model model) {
return formularioEdicionEvento(evento, model);
}
+ /**
+ * POST /gestion/eventos/{id}/editar: guarda los cambios de fecha, equipos o fase (HU-46).
+ * Las reglas (evento sin empezar, equipos fijos si ya hay apuestas) están en {@link GestionService#editarEvento}.
+ */
@PostMapping("/eventos/{id}/editar")
public String guardarEvento(@PathVariable Long id, @Valid @ModelAttribute("edicion") EdicionEventoForm form,
BindingResult errores, Model model, RedirectAttributes redireccion) {
@@ -322,9 +370,14 @@ public String guardarEvento(@PathVariable Long id, @Valid @ModelAttribute("edici
errores.rejectValue(e.getCampo(), "invalido", e.getMessage());
}
}
+ // La página de edición necesita el evento para mostrar sus datos junto a los errores
return formularioEdicionEvento(gestion.evento(id), model);
}
+ /**
+ * POST /gestion/eventos/{id}/borrar: borra un evento sin apuestas (HU-46). Si tiene apuestas no se borra
+ * y se muestra el motivo: el creador tiene que anularlo.
+ */
@PostMapping("/eventos/{id}/borrar")
public String borrarEvento(@PathVariable Long id, RedirectAttributes redireccion) {
try {
@@ -337,6 +390,10 @@ public String borrarEvento(@PathVariable Long id, RedirectAttributes redireccion
}
}
+ /**
+ * Prepara el modelo de la página de edición. Solo se ofrecen los equipos de la competición del evento,
+ * y {@code editable} indica a la vista si el formulario se puede enviar (evento sin empezar).
+ */
private String formularioEdicionEvento(Evento evento, Model model) {
model.addAttribute("evento", evento);
model.addAttribute("equipos",
@@ -345,6 +402,10 @@ private String formularioEdicionEvento(Evento evento, Model model) {
return "gestion/editar-evento";
}
+ /**
+ * GET /gestion/eventos/{id}: ficha del evento para el creador, con el dinero apostado a cada opción (HU-06)
+ * y los botones de resultado, suspender, reactivar, anular, editar y borrar.
+ */
@GetMapping("/eventos/{id}")
public String evento(@PathVariable Long id, Model model) {
Evento evento = resolucion.evento(id);
@@ -355,6 +416,11 @@ public String evento(@PathVariable Long id, Model model) {
return "gestion/evento-detalle";
}
+ /**
+ * POST /gestion/eventos/{id}/resultado: introduce o corrige el resultado y resuelve las apuestas (HU-04).
+ * Si llega el marcador completo se usa el marcador, que además resuelve goles y ambos marcan (HU-52);
+ * si solo llega el ganador, se usa el ganador. Los errores se muestran en la ficha del evento.
+ */
@PostMapping("/eventos/{id}/resultado")
public String resultado(@PathVariable Long id, @RequestParam(required = false) Resultado resultado,
@RequestParam(required = false) Integer golesLocal,
@@ -363,6 +429,7 @@ public String resultado(@PathVariable Long id, @RequestParam(required = false) R
try {
String guardado;
int resueltas;
+ // Con los dos goles manda el marcador; el ganador se deduce de él
if (golesLocal != null && golesVisitante != null) {
Resultado delMarcador = golesLocal > golesVisitante ? Resultado.LOCAL
: golesLocal < golesVisitante ? Resultado.VISITANTE : Resultado.EMPATE;
@@ -388,22 +455,29 @@ public String resultado(@PathVariable Long id, @RequestParam(required = false) R
return "redirect:/gestion/eventos/" + id;
}
+ /** POST /gestion/eventos/{id}/suspender: el evento deja de admitir apuestas nuevas (HU-05). */
@PostMapping("/eventos/{id}/suspender")
public String suspender(@PathVariable Long id, RedirectAttributes redireccion) {
return accion(id, () -> resolucion.suspender(id), "Evento suspendido: no admite apuestas nuevas.", redireccion);
}
+ /** POST /gestion/eventos/{id}/reactivar: un evento suspendido vuelve a admitir apuestas (HU-05). */
@PostMapping("/eventos/{id}/reactivar")
public String reactivar(@PathVariable Long id, RedirectAttributes redireccion) {
return accion(id, () -> resolucion.reactivar(id), "Evento reactivado.", redireccion);
}
+ /** POST /gestion/eventos/{id}/anular: anula el evento y devuelve el importe de sus apuestas (HU-05). */
@PostMapping("/eventos/{id}/anular")
public String anular(@PathVariable Long id, RedirectAttributes redireccion) {
return accion(id, () -> resolucion.anular(id),
"Evento anulado. Se ha devuelto el importe de todas sus apuestas.", redireccion);
}
+ /**
+ * Ejecuta una acción sobre el evento y vuelve a su ficha. Si el estado del evento no la permite
+ * (por ejemplo, suspender uno ya finalizado), muestra el error en lugar de fallar.
+ */
private String accion(Long id, Runnable accion, String mensaje, RedirectAttributes redireccion) {
try {
accion.run();
@@ -414,6 +488,7 @@ private String accion(Long id, Runnable accion, String mensaje, RedirectAttribut
return "redirect:/gestion/eventos/" + id;
}
+ /** Prepara el modelo del formulario de alta de evento (competiciones y equipos para los desplegables). */
private String formularioEvento(Model model) {
model.addAttribute("competiciones", gestion.competiciones());
model.addAttribute("equipos", gestion.equipos());
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionMercadosController.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionMercadosController.java
index 45b30a4..fd3d84d 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionMercadosController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionMercadosController.java
@@ -21,7 +21,14 @@
import es.ucm.fdi.is1.apuestas.mercados.MercadoService;
import jakarta.validation.Valid;
-/** Mercados a largo plazo en el panel del creador: alta, cuotas, cierre y ganador (HU-45). */
+/**
+ * Controlador de los mercados a largo plazo en el panel del creador: alta, cuotas, cierre, ganador y
+ * anulación (HU-45). Sus rutas cuelgan de {@code /gestion/mercados}, así que solo las usa el rol CREADOR
+ * (ver SeguridadConfig). La parte pública, donde los usuarios apuestan (HU-44), está en
+ * {@code MercadosController}.
+ *
+ * Las acciones vuelven siempre a la ficha del mercado con un mensaje de éxito o con el error.
+ */
@Controller
@RequestMapping("/gestion/mercados")
public class GestionMercadosController {
@@ -36,17 +43,23 @@ public GestionMercadosController(MercadoService mercados, ResolucionMercadosServ
this.reloj = reloj;
}
+ /** Deportes para el desplegable del formulario de alta. */
@ModelAttribute("deportes")
public Deporte[] deportes() {
return Deporte.values();
}
+ /** GET /gestion/mercados/nuevo: formulario de alta de un mercado. */
@GetMapping("/nuevo")
public String nuevo(Model model) {
model.addAttribute("mercado", new MercadoForm());
return "gestion/mercado";
}
+ /**
+ * POST /gestion/mercados/nuevo: crea el mercado y lleva a su ficha (CA-45.1). Los errores al leer
+ * los candidatos (formato, cuotas, menos de 2) se muestran junto al campo de candidatos.
+ */
@PostMapping("/nuevo")
public String crear(@Valid @ModelAttribute("mercado") MercadoForm form, BindingResult errores,
RedirectAttributes redireccion) {
@@ -63,6 +76,10 @@ public String crear(@Valid @ModelAttribute("mercado") MercadoForm form, BindingR
return "gestion/mercado";
}
+ /**
+ * GET /gestion/mercados/{id}: ficha del mercado para el creador, con lo apostado a cada candidato
+ * y si todavía admite apuestas.
+ */
@GetMapping("/{id}")
public String detalle(@PathVariable Long id, Model model) {
Mercado mercado = mercados.mercado(id);
@@ -72,6 +89,7 @@ public String detalle(@PathVariable Long id, Model model) {
return "gestion/mercado-detalle";
}
+ /** POST /gestion/mercados/{id}/candidatos: añade un candidato (solo con el mercado abierto). */
@PostMapping("/{id}/candidatos")
public String anadirCandidato(@PathVariable Long id, @RequestParam String nombre,
@RequestParam(required = false) BigDecimal cuota, RedirectAttributes redireccion) {
@@ -79,6 +97,10 @@ public String anadirCandidato(@PathVariable Long id, @RequestParam String nombre
"Candidato añadido: " + nombre.trim(), redireccion);
}
+ /**
+ * POST /gestion/mercados/{id}/candidatos/{candidatoId}/cuota: cambia la cuota de un candidato.
+ * La cuota es opcional en la petición para que, si falta, el error lo explique el propio candidato.
+ */
@PostMapping("/{id}/candidatos/{candidatoId}/cuota")
public String cambiarCuota(@PathVariable Long id, @PathVariable Long candidatoId,
@RequestParam(required = false) BigDecimal cuota, RedirectAttributes redireccion) {
@@ -86,11 +108,16 @@ public String cambiarCuota(@PathVariable Long id, @PathVariable Long candidatoId
"Cuota actualizada. Las apuestas ya hechas mantienen la suya.", redireccion);
}
+ /** POST /gestion/mercados/{id}/cerrar: el mercado deja de admitir apuestas antes de su fecha (CA-45.2). */
@PostMapping("/{id}/cerrar")
public String cerrar(@PathVariable Long id, RedirectAttributes redireccion) {
return accion(id, () -> mercados.cerrar(id), "Mercado cerrado: ya no admite apuestas.", redireccion);
}
+ /**
+ * POST /gestion/mercados/{id}/ganador: marca (o corrige) el candidato ganador. Se cobran las apuestas
+ * a ese candidato y las demás pasan a perdidas (CA-45.3).
+ */
@PostMapping("/{id}/ganador")
public String ganador(@PathVariable Long id, @RequestParam Long candidato, RedirectAttributes redireccion) {
try {
@@ -102,12 +129,14 @@ public String ganador(@PathVariable Long id, @RequestParam Long candidato, Redir
return "redirect:/gestion/mercados/" + id;
}
+ /** POST /gestion/mercados/{id}/anular: anula el mercado y devuelve el importe de sus apuestas. */
@PostMapping("/{id}/anular")
public String anular(@PathVariable Long id, RedirectAttributes redireccion) {
return accion(id, () -> resolucion.anular(id),
"Mercado anulado. Se ha devuelto el importe de todas sus apuestas.", redireccion);
}
+ /** Ejecuta la acción y vuelve a la ficha del mercado; si una regla la impide, muestra el motivo. */
private String accion(Long id, Runnable accion, String mensaje, RedirectAttributes redireccion) {
try {
accion.run();
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionService.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionService.java
index baa43dc..0f523d3 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionService.java
@@ -17,7 +17,12 @@
import es.ucm.fdi.is1.apuestas.eventos.EventoNoDisponibleException;
import es.ucm.fdi.is1.apuestas.eventos.EventoRepository;
-/** Alta de competiciones, equipos y eventos por parte del creador de apuestas (HU-01, HU-21). */
+/**
+ * Servicio del panel de gestión: alta de competiciones, equipos y eventos por parte del creador de apuestas
+ * (HU-01, HU-21), y edición o borrado de eventos mal creados (HU-46).
+ * Las reglas que incumple un formulario se avisan con {@link DatosInvalidosException}, que indica el campo,
+ * para que {@link GestionController} muestre el error junto a él.
+ */
@Service
public class GestionService {
@@ -36,15 +41,26 @@ public GestionService(CompeticionRepository competiciones, EquipoRepository equi
this.reloj = reloj;
}
+ /**
+ * Da de alta una competición (HU-01).
+ *
+ * @throws DatosInvalidosException si ya hay otra con el mismo nombre (sin distinguir mayúsculas)
+ */
@Transactional
public Competicion crearCompeticion(CompeticionForm form) {
String nombre = form.getNombre().trim();
+ // Se compara sin mayúsculas para no tener "LaLiga" y "laliga" como dos competiciones distintas
if (competiciones.existsByNombreIgnoreCase(nombre)) {
throw new DatosInvalidosException("nombre", "Ya hay una competición que se llama " + nombre);
}
return competiciones.save(new Competicion(nombre, form.getDeporte()));
}
+ /**
+ * Da de alta un equipo o deportista con su calificación y las competiciones en las que participa (HU-01).
+ *
+ * @throws DatosInvalidosException si el nombre ya existe o alguna competición es de otro deporte
+ */
@Transactional
public Equipo crearEquipo(EquipoForm form) {
String nombre = form.getNombre().trim();
@@ -55,6 +71,7 @@ public Equipo crearEquipo(EquipoForm form) {
}
Equipo equipo = new Equipo(nombre, form.getDeporte(), form.getCalidad());
for (Competicion competicion : competiciones.findAllById(form.getCompeticionIds())) {
+ // Un equipo de fútbol no puede jugar una competición de baloncesto
if (competicion.getDeporte() != form.getDeporte()) {
throw new DatosInvalidosException("competicionIds",
"La competición " + competicion.getNombre() + " no es de " + form.getDeporte().getNombre());
@@ -64,20 +81,34 @@ public Equipo crearEquipo(EquipoForm form) {
return equipos.save(equipo);
}
+ /** @throws EquipoNoEncontradoException si no existe un equipo con ese id */
@Transactional(readOnly = true)
public Equipo equipo(Long id) {
return equipos.findById(id).orElseThrow(() -> new EquipoNoEncontradoException(id));
}
+ /**
+ * Cambia la calificación, la forma reciente (HU-02) y el escudo de un equipo. El nombre y el deporte
+ * no se editan.
+ *
+ * @throws EquipoNoEncontradoException si no existe el equipo
+ */
@Transactional
public void editarEquipo(Long equipoId, EdicionEquipoForm form) {
Equipo equipo = equipo(equipoId);
equipo.setCalidad(form.getCalidad());
equipo.setForma(form.getForma());
String escudo = form.getEscudoUrl();
+ // Un escudo en blanco se guarda como null: el equipo se queda sin escudo
equipo.setEscudoUrl(escudo == null || escudo.isBlank() ? null : escudo.trim());
}
+ /**
+ * Crea un evento a mano (HU-21). Se guarda programado y aparece enseguida en el catálogo.
+ *
+ * @throws DatosInvalidosException si local y visitante coinciden, la competición no existe o algún equipo
+ * no participa en ella
+ */
@Transactional
public Evento crearEvento(EventoForm form) {
if (form.getLocalId().equals(form.getVisitanteId())) {
@@ -88,6 +119,7 @@ public Evento crearEvento(EventoForm form) {
Equipo local = equipoDeLaCompeticion(form.getLocalId(), competicion, "localId");
Equipo visitante = equipoDeLaCompeticion(form.getVisitanteId(), competicion, "visitanteId");
Evento evento = new Evento(competicion, local, visitante, form.getFechaHora());
+ // La fase es opcional (jornada, semifinal...): si viene vacía el evento se queda sin ella
if (form.getFase() != null && !form.getFase().isBlank()) {
evento.setFase(form.getFase().trim());
}
@@ -97,6 +129,10 @@ public Evento crearEvento(EventoForm form) {
/**
* Corrige un evento que aún no ha empezado (HU-46). Si ya tiene apuestas no se pueden cambiar
* los equipos, porque cambiaría el sentido de esas apuestas; la fecha y la fase sí.
+ * La competición no se cambia: los equipos nuevos tienen que participar en la del evento.
+ *
+ * @throws DatosInvalidosException si los datos no son válidos, el evento ya ha empezado (o no está
+ * programado ni suspendido) o se cambian los equipos de un evento con apuestas
*/
@Transactional
public Evento editarEvento(Long eventoId, EdicionEventoForm form) {
@@ -106,6 +142,7 @@ public Evento editarEvento(Long eventoId, EdicionEventoForm form) {
}
Equipo local = equipoDeLaCompeticion(form.getLocalId(), evento.getCompeticion(), "localId");
Equipo visitante = equipoDeLaCompeticion(form.getVisitanteId(), evento.getCompeticion(), "visitanteId");
+ // Se comparan los ids porque Equipo no redefine equals
boolean cambianEquipos = !local.getId().equals(evento.getLocal().getId())
|| !visitante.getId().equals(evento.getVisitante().getId());
if (cambianEquipos && selecciones.existsByEvento(evento)) {
@@ -113,6 +150,7 @@ public Evento editarEvento(Long eventoId, EdicionEventoForm form) {
"El evento ya tiene apuestas: no se pueden cambiar los equipos. Si está mal, anúlalo.");
}
String fase = form.getFase() == null || form.getFase().isBlank() ? null : form.getFase().trim();
+ // El propio evento comprueba que aún no ha empezado; su error se convierte en un error del formulario
try {
evento.modificar(local, visitante, form.getFechaHora(), fase, LocalDateTime.now(reloj));
} catch (IllegalStateException e) {
@@ -121,10 +159,15 @@ public Evento editarEvento(Long eventoId, EdicionEventoForm form) {
return evento;
}
- /** Borra un evento creado por error (HU-46). Si tiene apuestas solo se puede anular. */
+ /**
+ * Borra un evento creado por error (HU-46). Si tiene apuestas solo se puede anular.
+ *
+ * @throws IllegalStateException si alguna apuesta (simple o combinada) tiene una selección en el evento
+ */
@Transactional
public void borrarEvento(Long eventoId) {
Evento evento = evento(eventoId);
+ // Borrarlo dejaría apuestas apuntando a un evento que no existe; anularlo devuelve el importe
if (selecciones.existsByEvento(evento)) {
throw new IllegalStateException("El evento tiene apuestas: no se puede borrar, solo anular "
+ "(se devuelve el importe).");
@@ -132,11 +175,16 @@ public void borrarEvento(Long eventoId) {
eventos.delete(evento);
}
+ /** @throws EventoNoDisponibleException si no existe un evento con ese id */
@Transactional(readOnly = true)
public Evento evento(Long eventoId) {
return eventos.findById(eventoId).orElseThrow(() -> new EventoNoDisponibleException(eventoId));
}
+ /**
+ * Busca un equipo y comprueba que juega la competición. {@code campo} es el campo del formulario
+ * al que se asocia el error.
+ */
private Equipo equipoDeLaCompeticion(Long equipoId, Competicion competicion, String campo) {
Equipo equipo = equipos.findById(equipoId)
.orElseThrow(() -> new DatosInvalidosException(campo, "El equipo no existe"));
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/LimitesForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/LimitesForm.java
index ef4d949..f11eb31 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/LimitesForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/LimitesForm.java
@@ -8,9 +8,14 @@
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
-/** Límites de apuesta que fija el creador (HU-07). */
+/**
+ * Formulario de los límites de apuesta que fija el creador (HU-07). Lo usa {@link GestionController};
+ * los valores se guardan en la entidad {@code Limites}, que comprueba además que el máximo no sea menor
+ * que el mínimo.
+ */
public class LimitesForm {
+ // Importes en moneditas con céntimos: positivos y con 2 decimales como mucho
@NotNull(message = "Introduce el importe mínimo")
@DecimalMin(value = "0.01", message = "Tiene que ser mayor que 0")
@Digits(integer = 10, fraction = 2, message = "Como mucho 2 decimales")
@@ -21,6 +26,7 @@ public class LimitesForm {
@Digits(integer = 10, fraction = 2, message = "Como mucho 2 decimales")
private BigDecimal importeMaximo;
+ /** Máximo de selecciones de una combinada: una combinada necesita al menos 2. */
@NotNull(message = "Introduce el número máximo de selecciones")
@Min(value = 2, message = "Al menos 2")
@Max(value = 30, message = "Como mucho 30")
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/MercadoForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/MercadoForm.java
index fa79857..531f7ec 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/MercadoForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/MercadoForm.java
@@ -10,7 +10,11 @@
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
-/** Alta de un mercado a largo plazo con sus candidatos (HU-45). */
+/**
+ * Formulario de alta de un mercado a largo plazo con sus candidatos (HU-45). Las anotaciones solo
+ * comprueban que los campos estén rellenos; {@code MercadoService#crear} lee los candidatos y comprueba
+ * que haya al menos 2 y que sus cuotas sean válidas.
+ */
public class MercadoForm {
@NotBlank(message = "Escribe el nombre del mercado")
@@ -20,12 +24,13 @@ public class MercadoForm {
@NotNull(message = "Elige un deporte")
private Deporte deporte;
+ /** Último momento para apostar; formato del campo datetime-local de HTML. */
@NotNull(message = "Introduce la fecha de cierre")
@Future(message = "La fecha de cierre tiene que ser futura")
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
private LocalDateTime cierre;
- /** Un candidato por línea: "Nombre; cuota". */
+ /** Un candidato por línea: "Nombre; cuota". Se escribe en un área de texto y se lee en el servicio. */
@NotBlank(message = "Añade los candidatos")
private String candidatos;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/Liga.java b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/Liga.java
index ee289e7..df7abb3 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/Liga.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/Liga.java
@@ -19,8 +19,9 @@
import jakarta.persistence.ManyToOne;
/**
- * Liga privada entre amigos (HU-51): tiene un código de invitación para unirse y su propio ranking,
- * calculado solo con sus miembros.
+ * Entidad JPA: liga privada entre amigos (HU-51). Tiene un código de invitación para unirse y su propio
+ * ranking, calculado solo con sus miembros. Las reglas (cuántas ligas, quién sale o borra) están en
+ * {@link LigaService}; por eso unir y salir no son públicos.
*/
@Entity
public class Liga {
@@ -32,16 +33,21 @@ public class Liga {
@Column(nullable = false, length = 40)
private String nombre;
+ /** Código de invitación de 6 caracteres; único, lo genera {@link LigaService}. */
@Column(nullable = false, unique = true, length = 6)
private String codigo;
+ /** Quien la creó: es el único que puede borrarla y no puede salir de ella. */
@ManyToOne(optional = false)
private Usuario creador;
@Column(nullable = false)
private LocalDateTime creada;
- /** Se cargan con la liga: las páginas los muestran fuera de la transacción (open-in-view desactivado). */
+ /**
+ * Relación muchos a muchos con los usuarios, en la tabla intermedia liga_miembro.
+ * Se cargan con la liga: las páginas los muestran fuera de la transacción (open-in-view desactivado).
+ */
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "liga_miembro", joinColumns = @JoinColumn(name = "liga_id"),
inverseJoinColumns = @JoinColumn(name = "usuario_id"))
@@ -51,6 +57,7 @@ protected Liga() {
// requerido por JPA
}
+ /** Crea la liga con su creador como primer miembro (CA-51.1). */
public Liga(String nombre, String codigo, Usuario creador, LocalDateTime creada) {
this.nombre = nombre;
this.codigo = codigo;
@@ -59,6 +66,10 @@ public Liga(String nombre, String codigo, Usuario creador, LocalDateTime creada)
miembros.add(creador);
}
+ /**
+ * Compara por id porque Usuario no redefine equals: el mismo usuario cargado dos veces serían dos
+ * objetos distintos. Lo mismo en {@link #esCreador} y {@link #salir}.
+ */
public boolean esMiembro(Usuario usuario) {
return miembros.stream().anyMatch(m -> m.getId().equals(usuario.getId()));
}
@@ -67,6 +78,7 @@ public boolean esCreador(Usuario usuario) {
return creador.getId().equals(usuario.getId());
}
+ /** Lo llama {@link LigaService#unirse}, que antes comprueba que no sea ya miembro. */
void unir(Usuario usuario) {
miembros.add(usuario);
}
@@ -75,6 +87,7 @@ void salir(Usuario usuario) {
miembros.removeIf(m -> m.getId().equals(usuario.getId()));
}
+ /** Ids de los miembros, para filtrar el ranking general y quedarse solo con la liga. */
public Set getIdsMiembros() {
return miembros.stream().map(Usuario::getId).collect(Collectors.toSet());
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaNoEncontradaException.java b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaNoEncontradaException.java
index 624b64f..3512cf2 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaNoEncontradaException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaNoEncontradaException.java
@@ -3,7 +3,10 @@
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
-/** La liga no existe o el usuario no es miembro: en los dos casos se trata como inexistente. */
+/**
+ * La liga no existe o el usuario no es miembro: en los dos casos se trata como inexistente, para no revelar
+ * a quien no es miembro que esa liga existe. Con {@code @ResponseStatus}, Spring responde con un 404.
+ */
@ResponseStatus(HttpStatus.NOT_FOUND)
public class LigaNoEncontradaException extends RuntimeException {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaRepository.java
index bd4d9b3..7b49594 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaRepository.java
@@ -8,12 +8,15 @@
import es.ucm.fdi.is1.apuestas.usuarios.Usuario;
+/** Repositorio Spring Data de las ligas privadas (HU-51). */
public interface LigaRepository extends JpaRepository {
Optional findByCodigo(String codigo);
+ /** Para no repetir código al generar uno nuevo. */
boolean existsByCodigo(String codigo);
+ /** Ligas en las que está el usuario (creadas por él o no), por nombre. El join recorre la tabla liga_miembro. */
@Query("select l from Liga l join l.miembros m where m = :usuario order by l.nombre")
List deMiembro(Usuario usuario);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaService.java b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaService.java
index f90522a..f687c06 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaService.java
@@ -18,19 +18,25 @@
import es.ucm.fdi.is1.apuestas.usuarios.Usuario;
import es.ucm.fdi.is1.apuestas.usuarios.UsuarioRepository;
-/** Ligas privadas entre amigos con código de invitación y ranking propio (HU-51). */
+/**
+ * Servicio de las ligas privadas entre amigos, con código de invitación y ranking propio (HU-51).
+ * El ranking de una liga es el general de {@link RankingService} filtrado a sus miembros, así que usa
+ * los mismos criterios (CA-51.2). Los usuarios llegan por su email, el de la sesión iniciada.
+ */
@Service
public class LigaService {
/** Sin letras ni números que se confundan al dictarlos (0/O, 1/I/L). */
static final String ALFABETO = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
static final int LONGITUD_CODIGO = 6;
+ /** Ligas en las que puede estar un jugador a la vez, contando las que ha creado. */
static final int MAX_LIGAS = 10;
private final LigaRepository ligas;
private final UsuarioRepository usuarios;
private final RankingService rankings;
private final Clock reloj;
+ /** SecureRandom y no Random: los códigos no se pueden adivinar a partir de otros. */
private final SecureRandom azar = new SecureRandom();
public LigaService(LigaRepository ligas, UsuarioRepository usuarios, RankingService rankings, Clock reloj) {
@@ -40,12 +46,18 @@ public LigaService(LigaRepository ligas, UsuarioRepository usuarios, RankingServ
this.reloj = reloj;
}
+ /** Ligas en las que está el usuario, por nombre. */
@Transactional(readOnly = true)
public List mias(String email) {
return ligas.deMiembro(usuario(email));
}
- /** Crea la liga con un código nuevo; quien la crea es su primer miembro. */
+ /**
+ * Crea la liga con un código nuevo; quien la crea es su primer miembro (CA-51.1).
+ *
+ * @throws IllegalArgumentException si es el creador de apuestas, el nombre no tiene entre 1 y 40 caracteres
+ * o el usuario ya está en {@link #MAX_LIGAS} ligas
+ */
@Transactional
public Liga crear(String email, String nombre) {
Usuario usuario = jugador(email);
@@ -59,10 +71,16 @@ public Liga crear(String email, String nombre) {
return ligas.save(new Liga(limpio, codigoNuevo(), usuario, LocalDateTime.now(reloj)));
}
- /** Entra en la liga del código (sin distinguir mayúsculas ni espacios). */
+ /**
+ * Entra en la liga del código (sin distinguir mayúsculas ni espacios) (CA-51.2).
+ *
+ * @throws IllegalArgumentException si es el creador de apuestas, el código no existe, ya es miembro
+ * o ya está en {@link #MAX_LIGAS} ligas
+ */
@Transactional
public Liga unirse(String email, String codigo) {
Usuario usuario = jugador(email);
+ // "abc 123" y "ABC123" son el mismo código: se quitan los espacios y se pasa a mayúsculas
String limpio = codigo == null ? "" : codigo.replaceAll("\\s", "").toUpperCase(Locale.ROOT);
Liga liga = ligas.findByCodigo(limpio)
.orElseThrow(() -> new IllegalArgumentException("No hay ninguna liga con el código " + limpio));
@@ -72,11 +90,16 @@ public Liga unirse(String email, String codigo) {
if (ligas.deMiembro(usuario).size() >= MAX_LIGAS) {
throw new IllegalArgumentException("Puedes estar como mucho en " + MAX_LIGAS + " ligas");
}
+ // No hace falta save: la liga está gestionada por JPA y el cambio se guarda al acabar la transacción
liga.unir(usuario);
return liga;
}
- /** Solo la ven sus miembros. */
+ /**
+ * Devuelve la liga si el usuario es miembro. Solo la ven sus miembros.
+ *
+ * @throws LigaNoEncontradaException si no existe o el usuario no es miembro
+ */
@Transactional(readOnly = true)
public Liga ver(Long id, String email) {
Usuario usuario = usuario(email);
@@ -84,12 +107,21 @@ public Liga ver(Long id, String email) {
.orElseThrow(() -> new LigaNoEncontradaException(id));
}
+ /**
+ * Ranking de la liga con el criterio elegido (saldo, ganancias o aciertos), solo con sus miembros.
+ * El email sirve para destacar el puesto de quien lo consulta.
+ */
@Transactional(readOnly = true)
public List ranking(Liga liga, String email, CriterioRanking criterio) {
return rankings.ranking(email, criterio, liga.getIdsMiembros());
}
- /** Sale de la liga. Quien la creó no puede salir: la borra. */
+ /**
+ * Sale de la liga y deja de aparecer en su ranking (CA-51.3). Quien la creó no puede salir: la borra.
+ *
+ * @throws LigaNoEncontradaException si no existe o no es miembro
+ * @throws IllegalArgumentException si es quien la creó
+ */
@Transactional
public Liga salir(Long id, String email) {
Liga liga = ver(id, email);
@@ -101,7 +133,12 @@ public Liga salir(Long id, String email) {
return liga;
}
- /** Solo la puede borrar quien la creó. */
+ /**
+ * Borra la liga con todos sus miembros (CA-51.3). Solo la puede borrar quien la creó.
+ *
+ * @throws LigaNoEncontradaException si no existe o no es miembro
+ * @throws IllegalArgumentException si no es quien la creó
+ */
@Transactional
public Liga borrar(Long id, String email) {
Liga liga = ver(id, email);
@@ -114,6 +151,8 @@ public Liga borrar(Long id, String email) {
/**
* Al eliminar una cuenta: se borran las ligas que creó (nadie más podría borrarlas) y sale de las demás.
+ * Escucha el evento {@link CuentaEliminada} que publica la baja (HU-18); se ejecuta en el momento,
+ * dentro de la misma transacción.
*/
@EventListener
public void alEliminarCuenta(CuentaEliminada cuenta) {
@@ -127,6 +166,7 @@ public void alEliminarCuenta(CuentaEliminada cuenta) {
}
}
+ /** Genera códigos al azar con {@link #ALFABETO} hasta dar con uno que no use otra liga. */
private String codigoNuevo() {
String codigo;
do {
@@ -139,6 +179,7 @@ private String codigoNuevo() {
return codigo;
}
+ /** Usuario de la sesión; siempre existe porque ha iniciado sesión. */
private Usuario usuario(String email) {
return usuarios.findByEmail(email).orElseThrow();
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigasController.java b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigasController.java
index eee1311..2ecff3d 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigasController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigasController.java
@@ -15,7 +15,12 @@
import es.ucm.fdi.is1.apuestas.apuesta.CriterioRanking;
import es.ucm.fdi.is1.apuestas.apuesta.PuestoRanking;
-/** Ligas privadas (HU-51): mis ligas, crear, unirse con un código, ver su ranking, salir y borrar. */
+/**
+ * Controlador de las ligas privadas (HU-51): mis ligas, crear, unirse con un código, ver su ranking, salir
+ * y borrar. Las rutas cuelgan de {@code /ligas} y exigen haber iniciado sesión (ver SeguridadConfig);
+ * el usuario se obtiene del {@link Principal}, cuyo nombre es su email.
+ * Los errores de las reglas se muestran como mensaje en la página a la que se redirige.
+ */
@Controller
@RequestMapping("/ligas")
public class LigasController {
@@ -26,12 +31,14 @@ public LigasController(LigaService ligas) {
this.ligas = ligas;
}
+ /** GET /ligas: ligas del usuario, con los formularios para crear una o unirse con un código. */
@GetMapping
public String mias(Principal principal, Model model) {
model.addAttribute("ligas", ligas.mias(principal.getName()));
return "ligas";
}
+ /** POST /ligas: crea una liga y lleva a su página, con el código que hay que compartir (CA-51.1). */
@PostMapping
public String crear(@RequestParam(required = false) String nombre, Principal principal,
RedirectAttributes redireccion) {
@@ -46,6 +53,7 @@ public String crear(@RequestParam(required = false) String nombre, Principal pri
}
}
+ /** POST /ligas/unirse: entra en la liga del código y lleva a su página (CA-51.2). */
@PostMapping("/unirse")
public String unirse(@RequestParam(required = false) String codigo, Principal principal,
RedirectAttributes redireccion) {
@@ -59,6 +67,10 @@ public String unirse(@RequestParam(required = false) String codigo, Principal pr
}
}
+ /**
+ * GET /ligas/{id}: página de la liga, solo para sus miembros (si no, 404). Muestra el ranking
+ * de sus miembros con el criterio de {@code orden} (por defecto, saldo) y el código para invitar.
+ */
@GetMapping("/{id}")
public String ver(@PathVariable Long id, @RequestParam(defaultValue = "SALDO") CriterioRanking orden,
Principal principal, Model model) {
@@ -68,10 +80,12 @@ public String ver(@PathVariable Long id, @RequestParam(defaultValue = "SALDO") C
model.addAttribute("puestos", puestos);
model.addAttribute("orden", orden);
model.addAttribute("criterios", CriterioRanking.values());
+ // La vista ofrece "borrar" al creador y "salir" al resto
model.addAttribute("soyCreador", liga.getCreador().getEmail().equalsIgnoreCase(principal.getName()));
return "liga";
}
+ /** POST /ligas/{id}/salir: sale de la liga (el creador no puede; tiene que borrarla) (CA-51.3). */
@PostMapping("/{id}/salir")
public String salir(@PathVariable Long id, Principal principal, RedirectAttributes redireccion) {
try {
@@ -84,6 +98,7 @@ public String salir(@PathVariable Long id, Principal principal, RedirectAttribut
}
}
+ /** POST /ligas/{id}/borrar: borra la liga; solo quien la creó (CA-51.3). */
@PostMapping("/{id}/borrar")
public String borrar(@PathVariable Long id, Principal principal, RedirectAttributes redireccion) {
try {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Candidato.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Candidato.java
index 34a5381..932ce52 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Candidato.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Candidato.java
@@ -9,10 +9,15 @@
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
-/** Uno de los posibles ganadores de un mercado, con la cuota que fija el creador (HU-45). */
+/**
+ * Entidad JPA: uno de los posibles ganadores de un mercado, con la cuota que fija el creador (HU-45).
+ * Las apuestas a largo plazo apuntan a un candidato (HU-44). Se crea solo desde
+ * {@link Mercado#anadirCandidato}, por eso el constructor no es público.
+ */
@Entity
public class Candidato {
+ /** Cuotas admitidas para un candidato: con 1,00 el que acierta no ganaría nada. */
static final BigDecimal CUOTA_MINIMA = new BigDecimal("1.01");
static final BigDecimal CUOTA_MAXIMA = new BigDecimal("1000.00");
@@ -43,14 +48,20 @@ protected Candidato() {
cambiarCuota(cuota);
}
- /** Las apuestas ya hechas conservan la cuota con la que se hicieron. */
+ /**
+ * Cambia la cuota para las apuestas nuevas. Las apuestas ya hechas conservan la cuota con la que se hicieron.
+ *
+ * @throws IllegalArgumentException si falta, no está entre 1,01 y 1000 o tiene más de 2 decimales
+ */
public void cambiarCuota(BigDecimal nueva) {
if (nueva == null || nueva.compareTo(CUOTA_MINIMA) < 0 || nueva.compareTo(CUOTA_MAXIMA) > 0) {
throw new IllegalArgumentException("La cuota debe estar entre 1,01 y 1000");
}
+ // stripTrailingZeros hace que "2.500" cuente como 2,5 y se acepte
if (nueva.stripTrailingZeros().scale() > 2) {
throw new IllegalArgumentException("La cuota admite como mucho 2 decimales");
}
+ // Siempre con 2 decimales, como la columna (scale = 2)
cuota = nueva.setScale(2);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/CandidatoRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/CandidatoRepository.java
index 095fab8..141906b 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/CandidatoRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/CandidatoRepository.java
@@ -2,5 +2,6 @@
import org.springframework.data.jpa.repository.JpaRepository;
+/** Repositorio Spring Data de los candidatos; se buscan por id al cambiar su cuota o al apostar. */
public interface CandidatoRepository extends JpaRepository {
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/EstadoMercado.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/EstadoMercado.java
index 9e37202..7f0ee9c 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/EstadoMercado.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/EstadoMercado.java
@@ -2,7 +2,7 @@
/** Ciclo de vida de un mercado a largo plazo (HU-45). */
public enum EstadoMercado {
- /** Admite apuestas hasta la fecha de cierre. */
+ /** Admite apuestas hasta la fecha de cierre; pasada esa fecha sigue en este estado, pero ya no las admite. */
ABIERTO,
/** El creador lo ha cerrado antes de tiempo; espera al ganador. */
CERRADO,
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Mercado.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Mercado.java
index f187fa1..5bb1432 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Mercado.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Mercado.java
@@ -20,8 +20,10 @@
import jakarta.persistence.OrderBy;
/**
- * Apuesta a largo plazo con varios candidatos: Balón de Oro, campeón de liga… (HU-44, HU-45).
- * El creador fija la cuota de cada candidato, lo cierra y marca el ganador.
+ * Entidad JPA: mercado de apuesta a largo plazo con varios candidatos: Balón de Oro, campeón de liga…
+ * (HU-44, HU-45). El creador fija la cuota de cada candidato, lo cierra y marca el ganador.
+ * Sus estados están en {@link EstadoMercado}. El mercado guarda sus reglas (qué se puede hacer en cada
+ * estado); el pago de las apuestas lo hace {@code ResolucionMercadosService}.
*/
@Entity
public class Mercado {
@@ -45,10 +47,15 @@ public class Mercado {
@Enumerated(EnumType.STRING)
private EstadoMercado estado = EstadoMercado.ABIERTO;
+ /**
+ * Los candidatos se guardan y se borran junto con el mercado (cascada). Se cargan siempre con él (EAGER),
+ * ordenados de la cuota más baja (el favorito) a la más alta.
+ */
@OneToMany(mappedBy = "mercado", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
@OrderBy("cuota, nombre")
private List candidatos = new ArrayList<>();
+ /** Candidato ganador; null hasta que el creador lo marca. */
@ManyToOne
private Candidato ganador;
@@ -62,6 +69,14 @@ public Mercado(String nombre, Deporte deporte, LocalDateTime cierre) {
this.cierre = cierre;
}
+ /**
+ * Añade un candidato con su cuota. Solo con el mercado abierto: si estuviera cerrado, nadie podría
+ * apostarle.
+ *
+ * @throws IllegalStateException si el mercado no está abierto
+ * @throws IllegalArgumentException si el nombre está vacío, pasa de 100 caracteres, ya es candidato
+ * (sin distinguir mayúsculas) o la cuota no es válida
+ */
public Candidato anadirCandidato(String nombreCandidato, BigDecimal cuota) {
if (estado != EstadoMercado.ABIERTO) {
throw new IllegalStateException("Solo se pueden añadir candidatos a un mercado abierto");
@@ -78,11 +93,19 @@ public Candidato anadirCandidato(String nombreCandidato, BigDecimal cuota) {
return candidato;
}
+ /**
+ * Se puede apostar si está abierto y no ha llegado la fecha de cierre. Al llegar esa fecha deja de admitir
+ * apuestas aunque su estado siga siendo ABIERTO: no hace falta que nadie lo cierre a mano (CA-44.3).
+ */
public boolean admiteApuestas(LocalDateTime ahora) {
return estado == EstadoMercado.ABIERTO && cierre.isAfter(ahora);
}
- /** Deja de admitir apuestas antes de la fecha de cierre. */
+ /**
+ * Deja de admitir apuestas antes de la fecha de cierre (CA-45.2).
+ *
+ * @throws IllegalStateException si no está abierto
+ */
public void cerrar() {
if (estado != EstadoMercado.ABIERTO) {
throw new IllegalStateException("Solo se puede cerrar un mercado abierto");
@@ -90,11 +113,19 @@ public void cerrar() {
estado = EstadoMercado.CERRADO;
}
- /** Marca (o corrige) el ganador; las apuestas se pagan desde el servicio. */
+ /**
+ * Marca (o corrige) el ganador; las apuestas se pagan desde el servicio (CA-45.3).
+ * Se admite en cualquier estado salvo anulado: también con el mercado abierto y, para corregir un error,
+ * con el mercado ya resuelto.
+ *
+ * @throws IllegalStateException si el mercado está anulado
+ * @throws IllegalArgumentException si el candidato no es de este mercado
+ */
public void resolver(Candidato candidato) {
if (estado == EstadoMercado.ANULADO) {
throw new IllegalStateException("Un mercado anulado no puede tener ganador");
}
+ // Candidato no redefine equals: se busca el mismo objeto, cargado en la misma transacción
if (!candidatos.contains(candidato)) {
throw new IllegalArgumentException(candidato.getNombre() + " no es candidato de " + nombre);
}
@@ -102,6 +133,12 @@ public void resolver(Candidato candidato) {
estado = EstadoMercado.RESUELTO;
}
+ /**
+ * Anula el mercado; el importe de sus apuestas se devuelve desde el servicio. Uno resuelto ya no se anula
+ * porque sus apuestas están pagadas: si el ganador estaba mal, se corrige con {@link #resolver}.
+ *
+ * @throws IllegalStateException si ya está resuelto o anulado
+ */
public void anular() {
if (estado == EstadoMercado.RESUELTO || estado == EstadoMercado.ANULADO) {
throw new IllegalStateException("No se puede anular un mercado resuelto o ya anulado");
@@ -129,6 +166,7 @@ public EstadoMercado getEstado() {
return estado;
}
+ /** Lista de solo lectura: los candidatos se añaden con {@link #anadirCandidato}, que aplica las reglas. */
public List getCandidatos() {
return Collections.unmodifiableList(candidatos);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoNoDisponibleException.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoNoDisponibleException.java
index 241397a..19c43c2 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoNoDisponibleException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoNoDisponibleException.java
@@ -1,6 +1,9 @@
package es.ucm.fdi.is1.apuestas.mercados;
-/** El mercado no existe, no tiene ese candidato o ya no admite apuestas. */
+/**
+ * El mercado no existe, no tiene ese candidato o ya no admite apuestas. La lanzan {@link MercadoService}
+ * y {@code ApuestaService} al apostar (CA-44.3). El mensaje es el mismo en los tres casos.
+ */
public class MercadoNoDisponibleException extends RuntimeException {
public MercadoNoDisponibleException(Long id) {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoRepository.java
index 8c1fa49..18ac03c 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoRepository.java
@@ -5,9 +5,12 @@
import org.springframework.data.jpa.repository.JpaRepository;
+/** Repositorio Spring Data de los mercados a largo plazo (HU-44, HU-45). */
public interface MercadoRepository extends JpaRepository {
+ /** Todos los mercados, del que cierra antes al que cierra después. */
List findAllByOrderByCierreAsc();
+ /** Lo usan los datos iniciales para no crear dos veces el mismo mercado de ejemplo. */
Optional findByNombre(String nombre);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoService.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoService.java
index ddab9f9..e101503 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoService.java
@@ -12,7 +12,11 @@
import es.ucm.fdi.is1.apuestas.equipos.Deporte;
import es.ucm.fdi.is1.apuestas.equipos.EquipoRepository;
-/** Mercados a largo plazo: consulta pública (HU-44) y alta y edición por el creador (HU-45). */
+/**
+ * Servicio de los mercados a largo plazo: consulta pública (HU-44) y alta y edición por el creador (HU-45).
+ * Lo usan {@code MercadosController} (catálogo) y {@code GestionMercadosController} (panel del creador).
+ * Marcar el ganador y anular, que mueven dinero, están en {@code ResolucionMercadosService}.
+ */
@Service
public class MercadoService {
@@ -36,7 +40,10 @@ public List abiertos() {
return mercados.findAllByOrderByCierreAsc().stream().filter(m -> m.admiteApuestas(ahora)).toList();
}
- /** Mercados cerrados o ya resueltos, pendientes de ganador o con él (no los anulados). */
+ /**
+ * Mercados cerrados o ya resueltos, pendientes de ganador o con él (no los anulados).
+ * Incluye los que siguen ABIERTO pero ya han pasado su fecha de cierre.
+ */
@Transactional(readOnly = true)
public List terminados() {
LocalDateTime ahora = LocalDateTime.now(reloj);
@@ -45,11 +52,13 @@ public List terminados() {
.toList();
}
+ /** Todos los mercados, en cualquier estado, para el panel del creador. */
@Transactional(readOnly = true)
public List todos() {
return mercados.findAllByOrderByCierreAsc();
}
+ /** @throws MercadoNoDisponibleException si no existe un mercado con ese id */
@Transactional(readOnly = true)
public Mercado mercado(Long id) {
return mercados.findById(id).orElseThrow(() -> new MercadoNoDisponibleException(id));
@@ -58,6 +67,10 @@ public Mercado mercado(Long id) {
/**
* Crea un mercado con sus candidatos, uno por línea con el formato "Nombre; cuota" (HU-45).
* Si un candidato se llama igual que un equipo o deportista del mismo deporte, se enlaza para mostrar su escudo.
+ * Se valida todo antes de guardar: si una línea está mal, no se crea nada.
+ *
+ * @throws IllegalArgumentException si el cierre no es futuro, hay menos de 2 candidatos, una línea no
+ * tiene el formato, una cuota o un nombre no son válidos o un nombre se repite
*/
@Transactional
public Mercado crear(String nombre, Deporte deporte, LocalDateTime cierre, String lineasCandidatos) {
@@ -68,6 +81,7 @@ public Mercado crear(String nombre, Deporte deporte, LocalDateTime cierre, Strin
if (leidos.size() < 2) {
throw new IllegalArgumentException("Hacen falta al menos 2 candidatos");
}
+ // Si algún candidato falla, la excepción llega antes del save y el mercado no se guarda
Mercado mercado = new Mercado(nombre.trim(), deporte, cierre);
for (String[] candidato : leidos) {
anadir(mercado, candidato[0], cuota(candidato[1], candidato[0]));
@@ -75,6 +89,12 @@ public Mercado crear(String nombre, Deporte deporte, LocalDateTime cierre, Strin
return mercados.save(mercado);
}
+ /**
+ * Añade un candidato a un mercado ya creado. Solo si el mercado sigue abierto.
+ *
+ * @throws IllegalArgumentException si el nombre está vacío o repetido, o la cuota no es válida
+ * @throws IllegalStateException si el mercado no está abierto
+ */
@Transactional
public Candidato anadirCandidato(Long mercadoId, String nombre, BigDecimal cuota) {
if (nombre == null || nombre.isBlank()) {
@@ -83,10 +103,18 @@ public Candidato anadirCandidato(Long mercadoId, String nombre, BigDecimal cuota
return anadir(mercado(mercadoId), nombre.trim(), cuota);
}
- /** Las apuestas ya hechas conservan su cuota; solo cambia para las nuevas. */
+ /**
+ * Cambia la cuota de un candidato. Las apuestas ya hechas conservan su cuota; solo cambia para las nuevas.
+ * Se puede con el mercado abierto o cerrado, pero no si ya está resuelto o anulado.
+ *
+ * @throws MercadoNoDisponibleException si el candidato no existe o no es de ese mercado
+ * @throws IllegalStateException si el mercado está resuelto o anulado
+ * @throws IllegalArgumentException si la cuota no es válida
+ */
@Transactional
public Candidato cambiarCuota(Long mercadoId, Long candidatoId, BigDecimal cuota) {
Candidato candidato = candidatos.findById(candidatoId)
+ // Se comprueba que el candidato es de ese mercado: el id del mercado viene en la URL
.filter(c -> c.getMercado().getId().equals(mercadoId))
.orElseThrow(() -> new MercadoNoDisponibleException(mercadoId));
EstadoMercado estado = candidato.getMercado().getEstado();
@@ -97,11 +125,17 @@ public Candidato cambiarCuota(Long mercadoId, Long candidatoId, BigDecimal cuota
return candidato;
}
+ /**
+ * Cierra el mercado antes de su fecha: deja de admitir apuestas (CA-45.2).
+ *
+ * @throws IllegalStateException si no está abierto
+ */
@Transactional
public void cerrar(Long mercadoId) {
mercado(mercadoId).cerrar();
}
+ /** Añade el candidato y, si hay un equipo del mismo deporte con ese nombre exacto, lo enlaza. */
private Candidato anadir(Mercado mercado, String nombre, BigDecimal cuota) {
Candidato candidato = mercado.anadirCandidato(nombre, cuota);
equipos.findByNombre(nombre)
@@ -110,15 +144,21 @@ private Candidato anadir(Mercado mercado, String nombre, BigDecimal cuota) {
return candidato;
}
+ /**
+ * Lee el texto del formulario: una línea por candidato, "Nombre; cuota". Las líneas vacías se saltan.
+ * Devuelve pares [nombre, cuota como texto].
+ */
private static List leerCandidatos(String texto) {
List leidos = new ArrayList<>();
if (texto == null) {
return leidos;
}
+ // \R es cualquier salto de línea (\n en Linux, \r\n en Windows)
for (String linea : texto.split("\\R")) {
if (linea.isBlank()) {
continue;
}
+ // Exactamente un ";" y un nombre no vacío; la cuota se comprueba después
String[] partes = linea.split(";");
if (partes.length != 2 || partes[0].isBlank()) {
throw new IllegalArgumentException("Línea no válida: \"" + linea.trim()
@@ -129,6 +169,7 @@ private static List leerCandidatos(String texto) {
return leidos;
}
+ /** Convierte la cuota; admite coma decimal ("2,50") además de punto. El rango lo comprueba Candidato. */
private static BigDecimal cuota(String texto, String candidato) {
try {
return new BigDecimal(texto.replace(',', '.'));
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/Notificacion.java b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/Notificacion.java
index 0d29d00..8bc8db5 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/Notificacion.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/Notificacion.java
@@ -9,7 +9,10 @@
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
-/** Aviso a un usuario, por ejemplo cuando se resuelve una de sus apuestas (HU-37). */
+/**
+ * Entidad JPA: aviso a un usuario, por ejemplo cuando se resuelve una de sus apuestas (HU-37).
+ * Las crea {@link NotificacionService#avisar}; los textos de las apuestas los compone {@code AvisosApuestas}.
+ */
@Entity
public class Notificacion {
@@ -30,6 +33,7 @@ public class Notificacion {
@Column(nullable = false)
private LocalDateTime fecha;
+ /** Si ya se ha visto en la página de avisos; los no leídos cuentan en la campana de la cabecera. */
@Column(nullable = false)
private boolean leida;
@@ -37,9 +41,11 @@ protected Notificacion() {
// requerido por JPA
}
+ /** Aviso nuevo, sin leer. */
public Notificacion(Usuario usuario, String tipo, String texto, LocalDateTime fecha) {
this.usuario = usuario;
this.tipo = tipo;
+ // La columna admite 500 caracteres: un texto más largo se corta y termina en "…"
this.texto = texto.length() > 500 ? texto.substring(0, 497) + "…" : texto;
this.fecha = fecha;
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionRepository.java
index 77056ad..3ce99d8 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionRepository.java
@@ -6,13 +6,18 @@
import es.ucm.fdi.is1.apuestas.usuarios.Usuario;
+/** Repositorio Spring Data de los avisos (HU-37). Las consultas salen del nombre de cada método. */
public interface NotificacionRepository extends JpaRepository {
+ /** Los 50 avisos más recientes del usuario; con la misma fecha, el último creado primero. */
List findTop50ByUsuarioOrderByFechaDescIdDesc(Usuario usuario);
+ /** Cuántos avisos sin leer tiene el usuario con ese email ({@code UsuarioEmail} = usuario.email). */
long countByUsuarioEmailAndLeidaFalse(String email);
+ /** Avisos del usuario que aún no ha leído. */
List findByUsuarioAndLeidaFalse(Usuario usuario);
+ /** Borra todos los avisos del usuario (baja de la cuenta, HU-18). */
void deleteByUsuario(Usuario usuario);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionService.java b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionService.java
index ba5e692..327bee1 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionService.java
@@ -10,7 +10,10 @@
import es.ucm.fdi.is1.apuestas.usuarios.Usuario;
import es.ucm.fdi.is1.apuestas.usuarios.UsuarioRepository;
-/** Avisos dentro de la web (HU-37). */
+/**
+ * Servicio de los avisos dentro de la web (HU-37): crearlos, contarlos para la campana, leerlos y
+ * borrarlos al eliminar la cuenta. No envía correos: los avisos solo se ven en la página de avisos.
+ */
@Service
public class NotificacionService {
@@ -24,21 +27,32 @@ public NotificacionService(NotificacionRepository notificaciones, UsuarioReposit
this.reloj = reloj;
}
+ /**
+ * Guarda un aviso nuevo, sin leer, con la fecha de ahora.
+ *
+ * @param tipo para el icono de la vista: "ganada", "perdida", "anulada"…
+ */
@Transactional
public Notificacion avisar(Usuario usuario, String tipo, String texto) {
return notificaciones.save(new Notificacion(usuario, tipo, texto, LocalDateTime.now(reloj)));
}
+ /** Número de avisos sin leer, para la campana de la cabecera. */
@Transactional(readOnly = true)
public long sinLeer(String email) {
return notificaciones.countByUsuarioEmailAndLeidaFalse(email);
}
- /** Las últimas notificaciones del usuario; las que aún no había leído salen marcadas como nuevas. */
+ /**
+ * Las últimas 50 notificaciones del usuario, de la más reciente a la más antigua; las que aún no
+ * había leído salen marcadas como nuevas y, a partir de ahora, quedan leídas. Se puede volver
+ * a consultar un aviso ya leído (CA-37.3).
+ */
@Transactional
public List leer(String email) {
Usuario usuario = usuarios.findByEmail(email).orElseThrow();
List mostradas = notificaciones.findTop50ByUsuarioOrderByFechaDescIdDesc(usuario);
+ // Se copian antes de marcarlos: así la vista sabe cuáles eran nuevos
List avisos = mostradas.stream()
.map(n -> new Aviso(n.getTipo(), n.getTexto(), n.getFecha(), !n.isLeida()))
.toList();
@@ -53,6 +67,7 @@ public void borrarDe(Usuario usuario) {
notificaciones.deleteByUsuario(usuario);
}
+ /** Aviso tal como lo muestra la vista; {@code nuevo} si no se había leído antes de abrir la página. */
public record Aviso(String tipo, String texto, LocalDateTime fecha, boolean nuevo) {
}
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionesController.java b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionesController.java
index ea84b6a..16ac0e6 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionesController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionesController.java
@@ -6,7 +6,10 @@
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
-/** Página de avisos del usuario (HU-37). Al abrirla, los avisos quedan leídos. */
+/**
+ * Controlador de la página de avisos del usuario (HU-37). Al abrirla, los avisos que se ven quedan leídos.
+ * Exige haber iniciado sesión ({@code SeguridadConfig}).
+ */
@Controller
public class NotificacionesController {
@@ -16,10 +19,11 @@ public NotificacionesController(NotificacionService notificaciones) {
this.notificaciones = notificaciones;
}
+ /** GET /notificaciones: los últimos avisos del usuario, con los nuevos destacados. */
@GetMapping("/notificaciones")
public String notificaciones(Principal principal, Model model) {
model.addAttribute("avisos", notificaciones.leer(principal.getName()));
- // La cabecera ya no tiene avisos pendientes
+ // La cabecera ya no tiene avisos pendientes: el recuento de UsuarioActualAdvice se hizo antes de leerlos
model.addAttribute("avisosSinLeer", 0L);
return "notificaciones";
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Contrasenas.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Contrasenas.java
index 795ea75..cc814fb 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Contrasenas.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Contrasenas.java
@@ -2,7 +2,11 @@
import java.nio.charset.StandardCharsets;
-/** BCrypt solo admite 72 bytes: con tildes o ñ, menos de 72 caracteres pueden pasar de ese límite. */
+/**
+ * BCrypt solo admite 72 bytes: con tildes o ñ, menos de 72 caracteres pueden pasar de ese límite.
+ * Los formularios ya limitan a 72 caracteres con {@code @Size}; los controladores de registro,
+ * cambio y recuperación de contraseña usan además esta comprobación en bytes.
+ */
public final class Contrasenas {
static final int MAXIMO_BYTES = 72;
@@ -10,6 +14,7 @@ public final class Contrasenas {
private Contrasenas() {
}
+ /** Si la contraseña cabe en BCrypt. Una contraseña null se da por buena: de eso se encarga @NotBlank. */
public static boolean cabe(String password) {
return password == null || password.getBytes(StandardCharsets.UTF_8).length <= MAXIMO_BYTES;
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CorreoRecuperacion.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CorreoRecuperacion.java
index 74e7537..df077cd 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CorreoRecuperacion.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CorreoRecuperacion.java
@@ -12,12 +12,14 @@
/**
* Envía el enlace por correo si hay un servidor configurado (spring.mail.host, ver README). Si no lo hay,
* por ejemplo en el ordenador de cada uno, el enlace se escribe en la consola de la aplicación.
+ * Implementación de {@link EnvioCorreo} que usa {@link RecuperacionService} (HU-17).
*/
@Component
public class CorreoRecuperacion implements EnvioCorreo {
private static final Logger LOG = LoggerFactory.getLogger(CorreoRecuperacion.class);
+ // ObjectProvider: Spring Boot solo crea el JavaMailSender si hay servidor de correo configurado
private final ObjectProvider correo;
private final String servidorCorreo;
private final String remitente;
@@ -30,6 +32,7 @@ public CorreoRecuperacion(ObjectProvider correo,
this.remitente = remitente;
}
+ /** Manda el correo con el enlace o, si no hay servidor, lo escribe en el registro de la aplicación. */
@Override
public void enviarRecuperacion(String email, String nombre, String enlace) {
JavaMailSender servidor = servidorCorreo.isBlank() ? null : correo.getIfAvailable();
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CuentaEliminada.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CuentaEliminada.java
index c20c707..5c97f17 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CuentaEliminada.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CuentaEliminada.java
@@ -1,5 +1,9 @@
package es.ucm.fdi.is1.apuestas.usuarios;
-/** Se publica al eliminar una cuenta (HU-18), antes de anonimizarla, para que otras partes limpien lo suyo. */
+/**
+ * Se publica al eliminar una cuenta (HU-18), antes de anonimizarla, para que otras partes limpien lo suyo.
+ * Evento de Spring: lo publica {@code BajaService} y lo escucha {@code LigaService} con
+ * {@code @EventListener} (borra las ligas que creó el usuario y lo saca de las demás).
+ */
public record CuentaEliminada(Long usuarioId) {
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EmailYaRegistradoException.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EmailYaRegistradoException.java
index 3da99a2..ef9ea75 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EmailYaRegistradoException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EmailYaRegistradoException.java
@@ -1,5 +1,9 @@
package es.ucm.fdi.is1.apuestas.usuarios;
+/**
+ * Ya hay una cuenta con ese email, o usa el dominio reservado de las cuentas eliminadas (HU-11, CA-11.2).
+ * La lanza {@link UsuarioService#crear}.
+ */
public class EmailYaRegistradoException extends RuntimeException {
public EmailYaRegistradoException(String email) {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EnvioCorreo.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EnvioCorreo.java
index 4047903..c605812 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EnvioCorreo.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EnvioCorreo.java
@@ -1,7 +1,11 @@
package es.ucm.fdi.is1.apuestas.usuarios;
-/** Envía el enlace para recuperar la contraseña (HU-17). Interfaz para poder cambiarla en las pruebas. */
+/**
+ * Envía el enlace para recuperar la contraseña (HU-17). Interfaz para poder cambiarla en las pruebas.
+ * La implementación de la aplicación es {@link CorreoRecuperacion}.
+ */
public interface EnvioCorreo {
+ /** @param enlace dirección completa, con el código, para crear la contraseña nueva */
void enviarRecuperacion(String email, String nombre, String enlace);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/IntentosLogin.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/IntentosLogin.java
index 00243af..9203369 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/IntentosLogin.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/IntentosLogin.java
@@ -15,6 +15,8 @@
/**
* Freno a quien prueba contraseñas: tras {@value #MAXIMO_FALLOS} intentos fallidos seguidos con un email,
* ese email no puede entrar durante 15 minutos (aunque acierte). Se guarda en memoria: al reiniciar se olvida.
+ * Escucha los eventos de inicio de sesión que publica Spring Security, y {@link UsuarioDetailsService}
+ * le pregunta si la cuenta está bloqueada (HU-12).
*/
@Component
public class IntentosLogin {
@@ -22,9 +24,11 @@ public class IntentosLogin {
static final int MAXIMO_FALLOS = 5;
static final Duration BLOQUEO = Duration.ofMinutes(15);
+ /** Fallos seguidos de un email y momento del último. */
private record Fallos(int seguidos, Instant ultimo) {
}
+ // Concurrente: pueden llegar varios inicios de sesión a la vez
private final Map fallos = new ConcurrentHashMap<>();
private final Clock reloj;
@@ -32,13 +36,20 @@ public IntentosLogin(Clock reloj) {
this.reloj = reloj;
}
+ /**
+ * Email o contraseña incorrectos: suma un fallo. Si el anterior fue hace más de 15 minutos, la cuenta
+ * empieza de nuevo. Con la cuenta ya bloqueada Spring publica otro evento, así que esos intentos
+ * no alargan el bloqueo.
+ */
@EventListener
public void alFallar(AuthenticationFailureBadCredentialsEvent evento) {
Instant ahora = reloj.instant();
+ // merge: si no había fallos guarda el nuevo; si los había, los combina con la lambda
fallos.merge(clave(evento.getAuthentication().getName()), new Fallos(1, ahora), (antes, nuevo) ->
caducado(antes, ahora) ? nuevo : new Fallos(antes.seguidos() + 1, ahora));
}
+ /** Un inicio de sesión correcto borra los fallos acumulados. */
@EventListener
public void alEntrar(AuthenticationSuccessEvent evento) {
fallos.remove(clave(evento.getAuthentication().getName()));
@@ -49,15 +60,18 @@ public void olvidar(String email) {
fallos.remove(clave(email));
}
+ /** Lleva {@value #MAXIMO_FALLOS} fallos o más y el último fue hace menos de 15 minutos. */
public boolean bloqueado(String email) {
Fallos f = fallos.get(clave(email));
return f != null && f.seguidos() >= MAXIMO_FALLOS && !caducado(f, reloj.instant());
}
+ /** Han pasado más de {@link #BLOQUEO} desde el último fallo. */
private static boolean caducado(Fallos f, Instant ahora) {
return f.ultimo().plus(BLOQUEO).isBefore(ahora);
}
+ /** El email normalizado, para que "Ana@x.es" y "ana@x.es" cuenten juntos. */
private static String clave(String email) {
return email == null ? "" : email.trim().toLowerCase(Locale.ROOT);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/LimitesPersonalesForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/LimitesPersonalesForm.java
index d28b0ef..998b3bc 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/LimitesPersonalesForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/LimitesPersonalesForm.java
@@ -5,7 +5,10 @@
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
-/** Límites que se pone el propio usuario (HU-10). Vacío = sin límite. */
+/**
+ * Formulario de los límites que se pone el propio usuario en Mi cuenta (HU-10). Vacío = sin límite.
+ * Que el diario no supere al semanal lo comprueba {@link UsuarioService#fijarLimites}.
+ */
public class LimitesPersonalesForm {
@DecimalMin(value = "1", message = "Como mínimo 1 moneda")
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NombreForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NombreForm.java
index 537cfb6..77738ca 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NombreForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NombreForm.java
@@ -3,7 +3,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
-/** Cambio del nombre visible (HU-47). Mismas reglas que en el registro. */
+/** Formulario de cambio del nombre visible en Mi cuenta (HU-47). Mismas reglas que en el registro. */
public class NombreForm {
@NotBlank(message = "Introduce tu nombre")
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NuevaPasswordForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NuevaPasswordForm.java
index c2cf146..667ce97 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NuevaPasswordForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NuevaPasswordForm.java
@@ -6,12 +6,14 @@
/** Contraseña nueva desde el enlace de recuperación (HU-17). Mismas reglas que en el registro. */
public class NuevaPasswordForm {
+ /** Código del enlace del correo; viaja en un campo oculto del formulario. */
private String token;
@NotBlank(message = "Introduce la contraseña nueva")
@Size(min = 8, max = 72, message = "La contraseña debe tener entre 8 y 72 caracteres")
private String nueva;
+ /** La contraseña nueva repetida; el controlador comprueba que coincide. */
private String confirmacion;
public String getToken() {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordForm.java
index 57f1f7b..0721ab9 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordForm.java
@@ -3,7 +3,10 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
-/** Cambio de contraseña (HU-47). Mismas reglas que en el registro. */
+/**
+ * Formulario de cambio de contraseña en Mi cuenta (HU-47). Mismas reglas que en el registro.
+ * La actual se comprueba en {@link UsuarioService#cambiarPassword}.
+ */
public class PasswordForm {
@NotBlank(message = "Introduce tu contraseña actual")
@@ -13,6 +16,7 @@ public class PasswordForm {
@Size(min = 8, max = 72, message = "La contraseña debe tener entre 8 y 72 caracteres")
private String nueva;
+ /** La contraseña nueva repetida; el controlador comprueba que coincide. */
private String confirmacion;
public String getActual() {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordIncorrectaException.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordIncorrectaException.java
index 2d9c33b..0ed529c 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordIncorrectaException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordIncorrectaException.java
@@ -1,6 +1,9 @@
package es.ucm.fdi.is1.apuestas.usuarios;
-/** La contraseña actual no es la correcta (HU-47). */
+/**
+ * La contraseña actual no es la correcta: al cambiarla (HU-47) o al confirmar la baja de la cuenta (HU-18).
+ * Los controladores la recogen y muestran un mensaje de error.
+ */
public class PasswordIncorrectaException extends RuntimeException {
public PasswordIncorrectaException() {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecargaAlEntrarHandler.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecargaAlEntrarHandler.java
index ba2ee30..a5eaabb 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecargaAlEntrarHandler.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecargaAlEntrarHandler.java
@@ -10,7 +10,10 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
-/** Al iniciar sesión se aplica la recarga gratuita pendiente y se vuelve a la página que se pedía. */
+/**
+ * Al iniciar sesión se aplica la recarga gratuita pendiente y se vuelve a la página que se pedía
+ * (HU-14, CA-14.2). {@code SeguridadConfig} lo registra como lo que se hace tras un login correcto.
+ */
@Component
public class RecargaAlEntrarHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@@ -18,12 +21,14 @@ public class RecargaAlEntrarHandler extends SavedRequestAwareAuthenticationSucce
public RecargaAlEntrarHandler(UsuarioService usuarios) {
this.usuarios = usuarios;
+ // Si no se venía de ninguna página protegida, se va al catálogo
setDefaultTargetUrl("/eventos");
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws ServletException, IOException {
+ // authentication.getName() es el email con el que ha entrado
usuarios.consultarConRecarga(authentication.getName());
super.onAuthenticationSuccess(request, response, authentication);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionController.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionController.java
index c9f07c0..cf6a241 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionController.java
@@ -12,7 +12,10 @@
import jakarta.validation.Valid;
-/** Recuperar la contraseña (HU-17). */
+/**
+ * Controlador de "He olvidado mi contraseña" (HU-17). Sus rutas son públicas ({@code SeguridadConfig}):
+ * quien la ha olvidado no puede iniciar sesión. La lógica está en {@link RecuperacionService}.
+ */
@Controller
@RequestMapping("/recuperar")
public class RecuperacionController {
@@ -25,18 +28,27 @@ public RecuperacionController(RecuperacionService recuperacion, Sesiones sesione
this.sesiones = sesiones;
}
+ /** GET /recuperar: formulario para pedir el enlace con el email. */
@GetMapping
public String formulario() {
return "recuperar";
}
- /** Siempre la misma respuesta, exista o no la cuenta. */
+ /**
+ * POST /recuperar: pide el enlace y vuelve a /recuperar?enviado. Siempre la misma respuesta,
+ * exista o no la cuenta.
+ */
@PostMapping
public String solicitar(@RequestParam(defaultValue = "") String email) {
+ // La base del enlace es la dirección por la que se ha entrado: vale en local y en la nube
recuperacion.solicitar(email, ServletUriComponentsBuilder.fromCurrentContextPath().toUriString());
return "redirect:/recuperar?enviado";
}
+ /**
+ * GET /recuperar/nueva?token=...: la página del enlace del correo. Si el enlace ha caducado o ya
+ * se ha usado, la vista lo avisa en lugar de mostrar el formulario (CA-17.2).
+ */
@GetMapping("/nueva")
public String nueva(@RequestParam(defaultValue = "") String token, Model model) {
NuevaPasswordForm form = new NuevaPasswordForm();
@@ -46,6 +58,10 @@ public String nueva(@RequestParam(defaultValue = "") String token, Model model)
return "recuperar-nueva";
}
+ /**
+ * POST /recuperar/nueva: comprueba que las dos contraseñas coinciden y caben en BCrypt, pone la nueva,
+ * cierra todas las sesiones abiertas de esa cuenta y lleva a /login?recuperada.
+ */
@PostMapping("/nueva")
public String restablecer(@Valid @ModelAttribute("nuevaPassword") NuevaPasswordForm form,
BindingResult errores, Model model) {
@@ -58,13 +74,16 @@ public String restablecer(@Valid @ModelAttribute("nuevaPassword") NuevaPasswordF
if (!errores.hasErrors()) {
try {
String email = recuperacion.restablecer(form.getToken(), form.getNueva());
+ // null: quien recupera no tiene sesión iniciada, así que se cierran todas
sesiones.cerrarOtras(email, null);
return "redirect:/login?recuperada";
} catch (EnlaceNoValidoException e) {
+ // Caducó o se usó mientras se rellenaba el formulario
model.addAttribute("enlaceValido", false);
return "recuperar-nueva";
}
}
+ // Errores en el formulario: se vuelve a mostrar; el enlace se comprueba otra vez al reenviarlo
model.addAttribute("enlaceValido", true);
return "recuperar-nueva";
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionService.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionService.java
index 901894f..3e80ebc 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionService.java
@@ -19,12 +19,15 @@
/**
* Recuperar la contraseña (HU-17): se pide un enlace con el email y el enlace permite poner una nueva.
* La respuesta es la misma exista o no la cuenta, para no desvelar qué emails están registrados.
+ * El código del enlace es aleatorio y en la base de datos solo se guarda su resumen ({@link TokenRecuperacion}).
*/
@Service
public class RecuperacionService {
+ /** Tiempo que sirve un enlace desde que se pide (CA-17.2). */
static final Duration VALIDEZ = Duration.ofMinutes(30);
+ // Generador criptográfico: con Random los códigos se podrían adivinar
private static final SecureRandom ALEATORIO = new SecureRandom();
private final UsuarioRepository usuarios;
@@ -55,13 +58,16 @@ public void solicitar(String email, String base) {
String normalizado = email == null ? "" : email.trim().toLowerCase(Locale.ROOT);
Optional cuenta = usuarios.findByEmail(normalizado).filter(u -> !u.isEliminado());
if (cuenta.isEmpty()) {
+ // Sin cuenta no se envía nada, pero el controlador responde igual que si la hubiera
return;
}
Usuario usuario = cuenta.get();
tokens.borrarDe(usuario);
+ // 32 bytes aleatorios (256 bits), en Base64 apto para URL: se puede poner tal cual en el enlace
byte[] bytes = new byte[32];
ALEATORIO.nextBytes(bytes);
String codigo = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ // Se guarda el resumen; el código en claro solo viaja en el correo
tokens.save(new TokenRecuperacion(usuario, resumen(codigo), LocalDateTime.now(reloj).plus(VALIDEZ)));
correo.enviarRecuperacion(usuario.getEmail(), usuario.getNombre(), base + "/recuperar/nueva?token=" + codigo);
}
@@ -84,10 +90,12 @@ public String restablecer(String codigo, String nueva) {
Usuario usuario = token.getUsuario();
usuario.cambiarPasswordHash(passwordEncoder.encode(nueva));
token.usar();
+ // Si estaba bloqueado por fallos de inicio de sesión, ya puede entrar con la nueva
intentos.olvidar(usuario.getEmail());
return usuario.getEmail();
}
+ /** Token que corresponde al código, solo si aún sirve. */
private Optional buscar(String codigo) {
if (codigo == null || codigo.isBlank()) {
return Optional.empty();
@@ -96,11 +104,17 @@ private Optional buscar(String codigo) {
return tokens.findByHash(resumen(codigo)).filter(t -> t.valido(ahora));
}
+ /**
+ * Resumen SHA-256 del código, en hexadecimal. Siempre da el mismo resultado para el mismo código,
+ * y por eso se puede buscar en la base de datos (con BCrypt, que usa sal aleatoria, no se podría).
+ * Como el código ya es aleatorio y largo, no hace falta un algoritmo lento como BCrypt.
+ */
static String resumen(String codigo) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(codigo.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
+ // No pasa: todas las versiones de Java incluyen SHA-256
throw new IllegalStateException(e);
}
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RegistroForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RegistroForm.java
index 37c260b..a615be3 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RegistroForm.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RegistroForm.java
@@ -4,6 +4,11 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
+/**
+ * Formulario de registro (HU-11). Las anotaciones las comprueba {@code @Valid} en
+ * {@link UsuariosController}; que la confirmación coincida y que la contraseña quepa en BCrypt
+ * se comprueba allí a mano.
+ */
public class RegistroForm {
@NotBlank(message = "Introduce tu nombre")
@@ -16,9 +21,11 @@ public class RegistroForm {
private String email;
@NotBlank(message = "Introduce una contraseña")
+ // 72: el máximo de BCrypt (en bytes, ver Contrasenas)
@Size(min = 8, max = 72, message = "La contraseña debe tener entre 8 y 72 caracteres")
private String password;
+ /** La contraseña repetida, para evitar erratas. */
private String confirmacion;
public String getNombre() {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Rol.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Rol.java
index ac066d8..9b4f16b 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Rol.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Rol.java
@@ -1,6 +1,12 @@
package es.ucm.fdi.is1.apuestas.usuarios;
+/**
+ * Rol de una cuenta. Spring Security lo recibe como {@code ROLE_USUARIO} o {@code ROLE_CREADOR}
+ * y {@code SeguridadConfig} decide con él qué páginas puede abrir cada uno.
+ */
public enum Rol {
+ /** Jugador: apuesta con sus moneditas. */
USUARIO,
+ /** Creador de apuestas: además entra en el panel de gestión ({@code /gestion}). */
CREADOR
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoInsuficienteException.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoInsuficienteException.java
index 3f1a49d..aedcac5 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoInsuficienteException.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoInsuficienteException.java
@@ -2,6 +2,10 @@
import java.math.BigDecimal;
+/**
+ * El saldo no llega para el importe de una apuesta. La lanza {@link Usuario#cargar} y el mensaje
+ * se muestra al usuario.
+ */
public class SaldoInsuficienteException extends RuntimeException {
public SaldoInsuficienteException(BigDecimal saldo, BigDecimal importe) {
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoProperties.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoProperties.java
index eaba388..e71f910 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoProperties.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoProperties.java
@@ -6,7 +6,8 @@
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
- * Configuración de las moneditas (application.properties, prefijo apuestas.saldo).
+ * Configuración de las moneditas (application.properties, prefijo apuestas.saldo) para el saldo
+ * de bienvenida y las recargas gratuitas (HU-14). Spring crea este record con esos valores.
*
* @param bienvenida saldo inicial al registrarse
* @param recargaImporte importe de cada recarga gratuita
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Sesiones.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Sesiones.java
index 82f5dc8..c298f20 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Sesiones.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Sesiones.java
@@ -9,6 +9,8 @@
/**
* Cierra las sesiones de un usuario en otros navegadores al eliminar la cuenta o cambiar la contraseña:
* si no, seguían abiertas (y después de una baja solo daban errores).
+ * Se usa en la baja (HU-18), el cambio de contraseña (HU-47) y la recuperación (HU-17).
+ * El registro de sesiones lo configura {@code SeguridadConfig}.
*/
@Component
public class Sesiones {
@@ -22,10 +24,12 @@ public Sesiones(SessionRegistry registro) {
/** Todas las del usuario menos {@code actual} (null = todas). */
public void cerrarOtras(String email, HttpSession actual) {
String id = actual == null ? null : actual.getId();
+ // Cada principal es un usuario con sesión; false = sin contar las que ya han caducado
registro.getAllPrincipals().stream()
.filter(p -> p instanceof UserDetails u && u.getUsername().equalsIgnoreCase(email))
.flatMap(p -> registro.getAllSessions(p, false).stream())
.filter(s -> !s.getSessionId().equals(id))
+ // expireNow la marca como caducada: en su siguiente petición Spring la cierra y lleva a /login?expirada
.forEach(s -> s.expireNow());
}
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacion.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacion.java
index c87e020..6751626 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacion.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacion.java
@@ -11,6 +11,7 @@
/**
* Enlace para crear una contraseña nueva (HU-17). Solo se guarda el resumen SHA-256 del código del enlace:
* quien lea la base de datos no puede usarlo. Caduca y sirve una sola vez.
+ * Entidad JPA; la crea y la consulta {@link RecuperacionService}.
*/
@Entity
public class TokenRecuperacion {
@@ -22,9 +23,11 @@ public class TokenRecuperacion {
@ManyToOne(optional = false)
private Usuario usuario;
+ /** Resumen SHA-256 del código, en hexadecimal (64 caracteres). */
@Column(nullable = false, unique = true, length = 64)
private String hash;
+ /** Momento hasta el que sirve ({@link RecuperacionService#VALIDEZ} desde que se pidió). */
@Column(nullable = false)
private LocalDateTime caduca;
@@ -41,10 +44,12 @@ public TokenRecuperacion(Usuario usuario, String hash, LocalDateTime caduca) {
this.caduca = caduca;
}
+ /** Sirve si no se ha usado, no ha caducado y la cuenta no se ha eliminado (CA-17.2). */
public boolean valido(LocalDateTime ahora) {
return !usado && ahora.isBefore(caduca) && !usuario.isEliminado();
}
+ /** Gasta el enlace: ya no vale para otra vez. */
public void usar() {
usado = true;
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacionRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacionRepository.java
index 3bd15d8..4d9fe25 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacionRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacionRepository.java
@@ -6,11 +6,16 @@
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
+/** Repositorio Spring Data de los enlaces para recuperar la contraseña (HU-17). */
public interface TokenRecuperacionRepository extends JpaRepository {
+ /** Se busca por el resumen SHA-256 del código: el código en claro no está en la base de datos. */
Optional findByHash(String hash);
- /** Al pedir un enlace nuevo, los anteriores dejan de servir. */
+ /**
+ * Al pedir un enlace nuevo, los anteriores dejan de servir. Consulta JPQL escrita a mano;
+ * {@code @Modifying} indica que cambia datos (un delete) en lugar de devolver filas.
+ */
@Modifying
@Query("delete from TokenRecuperacion t where t.usuario = ?1")
void borrarDe(Usuario usuario);
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Usuario.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Usuario.java
index 20b9b81..d3447ee 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Usuario.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Usuario.java
@@ -12,7 +12,13 @@
import jakarta.persistence.Id;
import jakarta.persistence.Version;
-/** Cuenta de usuario con su saldo de moneditas virtuales (HU-11, HU-13, HU-14). */
+/**
+ * Entidad JPA: cuenta de usuario con su saldo de moneditas virtuales (HU-11, HU-13, HU-14).
+ * Sirve tanto para los jugadores como para el creador de apuestas (ver {@link Rol}).
+ * Aquí están las reglas del saldo (cargar, abonar, recargas gratuitas), los límites del juego
+ * responsable (HU-10), el cambio de nombre y contraseña (HU-47) y la baja (HU-18).
+ * {@link UsuarioService} llama a estos métodos dentro de una transacción.
+ */
@Entity
public class Usuario {
@@ -30,12 +36,14 @@ public class Usuario {
@GeneratedValue
private Long id;
+ /** Se guarda normalizado (sin espacios y en minúsculas) y es único. */
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String nombre;
+ /** Resumen BCrypt de la contraseña. La contraseña en claro no se guarda nunca. */
@Column(nullable = false)
private String passwordHash;
@@ -43,6 +51,7 @@ public class Usuario {
@Enumerated(EnumType.STRING)
private Rol rol;
+ /** Moneditas disponibles. BigDecimal con 2 decimales: sin los errores de redondeo de double. */
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal saldo;
@@ -69,6 +78,11 @@ protected Usuario() {
// requerido por JPA
}
+ /**
+ * Cuenta nueva con el saldo de bienvenida (HU-14). La fecha de alta cuenta como la primera recarga.
+ *
+ * @param passwordHash la contraseña ya cifrada con BCrypt
+ */
public Usuario(String email, String nombre, String passwordHash, Rol rol,
BigDecimal saldoBienvenida, LocalDateTime fechaAlta) {
this.email = email;
@@ -80,7 +94,7 @@ public Usuario(String email, String nombre, String passwordHash, Rol rol,
}
/**
- * Si ha pasado el periodo desde la última recarga gratuita, suma el importe al saldo.
+ * Si ha pasado el periodo desde la última recarga gratuita, suma el importe al saldo (HU-14, CA-14.2).
*
* @return true si se ha aplicado la recarga
*/
@@ -89,11 +103,16 @@ public boolean aplicarRecargaPeriodica(LocalDateTime ahora, Duration periodo, Bi
return false;
}
saldo = saldo.add(importe);
+ // El periodo vuelve a contar desde hoy: si pasa varias semanas sin entrar, recibe una sola recarga
ultimaRecarga = ahora;
return true;
}
- /** Descuenta el importe de una apuesta (HU-23). */
+ /**
+ * Descuenta el importe de una apuesta (HU-23). El saldo nunca queda negativo por apostar.
+ *
+ * @throws SaldoInsuficienteException si el saldo no llega al importe
+ */
public void cargar(BigDecimal importe) {
if (saldo.compareTo(importe) < 0) {
throw new SaldoInsuficienteException(saldo, importe);
@@ -104,8 +123,8 @@ public void cargar(BigDecimal importe) {
/**
* Ajuste por corrección de un resultado: puede restar una ganancia ya pagada.
* A diferencia de {@link #cargar}, permite que el saldo quede negativo.
+ * Una cuenta eliminada no recibe dinero: sus apuestas se resuelven igual, pero sin abonarle nada.
*/
- /** Una cuenta eliminada no recibe dinero: sus apuestas se resuelven igual, pero sin abonarle nada. */
public void ajustar(BigDecimal diferencia) {
if (eliminado) {
return;
@@ -113,7 +132,7 @@ public void ajustar(BigDecimal diferencia) {
saldo = saldo.add(diferencia);
}
- /** Devuelve o abona un importe al saldo (cancelaciones y ganancias). */
+ /** Devuelve o abona un importe al saldo (cancelaciones y ganancias). Si la cuenta está eliminada, nada. */
public void abonar(BigDecimal importe) {
if (eliminado) {
return;
@@ -121,7 +140,7 @@ public void abonar(BigDecimal importe) {
saldo = saldo.add(importe);
}
- /** HU-47. */
+ /** Cambia el nombre visible (HU-47). */
public void cambiarNombre(String nuevo) {
nombre = nuevo;
}
@@ -131,6 +150,7 @@ public void cambiarPasswordHash(String nuevoHash) {
passwordHash = nuevoHash;
}
+ /** Fecha a partir de la cual toca la siguiente recarga gratuita (HU-14). */
public LocalDateTime proximaRecarga(Duration periodo) {
return ultimaRecarga.plus(periodo);
}
@@ -151,6 +171,7 @@ public void pausarHasta(LocalDateTime hasta) {
}
}
+ /** HU-10: si tiene una pausa que aún no ha terminado. */
public boolean enPausa(LocalDateTime ahora) {
return pausaHasta != null && pausaHasta.isAfter(ahora);
}
@@ -158,8 +179,11 @@ public boolean enPausa(LocalDateTime ahora) {
/**
* HU-18: borra los datos personales y deja la cuenta inutilizable. Se conserva la fila para que
* las apuestas ya hechas y su historial sigan cuadrando.
+ *
+ * @param hashInutilizable resumen BCrypt de una contraseña aleatoria que nadie conoce
*/
public void eliminar(String hashInutilizable) {
+ // El id hace que el email siga siendo único (la columna lo exige)
email = "eliminado-" + id + DOMINIO_ELIMINADOS;
nombre = "Usuario eliminado";
passwordHash = hashInutilizable;
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioDetailsService.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioDetailsService.java
index ee067ec..402ed1d 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioDetailsService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioDetailsService.java
@@ -6,7 +6,11 @@
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
-/** Spring Security carga los usuarios por email para el inicio de sesión (HU-12). */
+/**
+ * Spring Security carga los usuarios por email para el inicio de sesión (HU-12).
+ * Con lo que devuelve, Spring comprueba la contraseña escrita contra el resumen BCrypt guardado;
+ * nuestro código nunca compara contraseñas en el login.
+ */
@Service
public class UsuarioDetailsService implements UserDetailsService {
@@ -18,12 +22,19 @@ public UsuarioDetailsService(UsuarioService usuarios, IntentosLogin intentos) {
this.intentos = intentos;
}
+ /**
+ * Datos de la cuenta para Spring Security: email, resumen de la contraseña, rol y si puede entrar.
+ *
+ * @throws UsernameNotFoundException si no hay cuenta; Spring da el mismo error genérico que con
+ * una contraseña incorrecta (CA-12.2)
+ */
@Override
public UserDetails loadUserByUsername(String email) {
return usuarios.buscar(email)
.map(u -> User.withUsername(u.getEmail())
.password(u.getPasswordHash())
.roles(u.getRol().name())
+ // Cuenta eliminada (HU-18) o con demasiados fallos seguidos: no entra aunque acierte
.disabled(u.isEliminado())
.accountLocked(intentos.bloqueado(email))
.build())
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioRepository.java
index b2d0b50..264a8f3 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioRepository.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioRepository.java
@@ -5,11 +5,16 @@
import org.springframework.data.jpa.repository.JpaRepository;
+/**
+ * Repositorio Spring Data de {@link Usuario}. Las consultas las genera Spring a partir del nombre
+ * de cada método. Los emails deben llegar ya normalizados (ver {@link UsuarioService}).
+ */
public interface UsuarioRepository extends JpaRepository {
Optional findByEmail(String email);
boolean existsByEmail(String email);
+ /** Usuarios de un rol, de más a menos saldo y, con el mismo saldo, por nombre (ranking, HU-36). */
List findByRolOrderBySaldoDescNombreAsc(Rol rol);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioService.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioService.java
index 6f601c4..62658f4 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioService.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioService.java
@@ -12,9 +12,18 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+/**
+ * Servicio de las cuentas: registro, saldo con recargas gratuitas, nombre y contraseña, juego responsable
+ * y anonimización al eliminar la cuenta (HU-10, HU-11, HU-13, HU-14, HU-18, HU-47).
+ * Las contraseñas se cifran con el {@link PasswordEncoder} de {@code SeguridadConfig}, que es BCrypt.
+ * Los emails se buscan siempre normalizados, así que da igual cómo los escriba el usuario.
+ * Los métodos que cambian un usuario no llaman a {@code save}: el usuario lo ha cargado JPA dentro
+ * de la transacción y sus cambios se guardan solos al terminarla.
+ */
@Service
public class UsuarioService {
+ /** Duraciones de pausa (en días) que se pueden elegir en Mi cuenta (HU-10). */
static final Set DIAS_DE_PAUSA = Set.of(1, 7, 30);
private final UsuarioRepository usuarios;
@@ -36,6 +45,12 @@ public Usuario registrar(RegistroForm form) {
return crear(form.getEmail(), form.getNombre(), form.getPassword(), Rol.USUARIO);
}
+ /**
+ * Crea una cuenta con el rol indicado y el saldo de bienvenida. También la usan los datos iniciales
+ * para el creador de apuestas y el usuario de prueba.
+ *
+ * @throws EmailYaRegistradoException si ya hay una cuenta con ese email o usa el dominio reservado
+ */
@Transactional
public Usuario crear(String email, String nombre, String password, Rol rol) {
String emailNormalizado = normalizar(email);
@@ -43,12 +58,16 @@ public Usuario crear(String email, String nombre, String password, Rol rol) {
if (usuarios.existsByEmail(emailNormalizado) || emailNormalizado.endsWith(Usuario.DOMINIO_ELIMINADOS)) {
throw new EmailYaRegistradoException(emailNormalizado);
}
+ // encode aplica BCrypt con una sal aleatoria: se guarda el resumen, nunca la contraseña
Usuario usuario = new Usuario(emailNormalizado, nombre.trim(), passwordEncoder.encode(password), rol,
saldo.bienvenida(), LocalDateTime.now(reloj));
return usuarios.save(usuario);
}
- /** Devuelve el usuario aplicando antes la recarga gratuita si le corresponde (HU-13, HU-14). */
+ /**
+ * Devuelve el usuario aplicando antes la recarga gratuita si le corresponde (HU-13, HU-14).
+ * Se llama al iniciar sesión y al abrir Mi cuenta.
+ */
@Transactional
public Usuario consultarConRecarga(String email) {
Usuario usuario = usuarios.findByEmail(normalizar(email)).orElseThrow();
@@ -62,10 +81,15 @@ public void cambiarNombre(String email, String nombre) {
usuarios.findByEmail(normalizar(email)).orElseThrow().cambiarNombre(nombre.trim());
}
- /** Cambia la contraseña si la actual es correcta (HU-47). */
+ /**
+ * Cambia la contraseña si la actual es correcta (HU-47).
+ *
+ * @throws PasswordIncorrectaException si la contraseña actual no coincide (CA-47.2)
+ */
@Transactional
public void cambiarPassword(String email, String actual, String nueva) {
Usuario usuario = usuarios.findByEmail(normalizar(email)).orElseThrow();
+ // BCrypt no se puede descifrar: matches cifra la contraseña escrita con la sal del resumen y los compara
if (!passwordEncoder.matches(actual, usuario.getPasswordHash())) {
throw new PasswordIncorrectaException();
}
@@ -75,12 +99,17 @@ public void cambiarPassword(String email, String actual, String nueva) {
/** Pone esta contraseña si no es ya la actual (contraseña del creador en los perfiles públicos). */
@Transactional
public void fijarPassword(String email, String password) {
+ // Se comprueba antes: con su sal aleatoria, BCrypt daría un resumen nuevo en cada arranque
usuarios.findByEmail(normalizar(email))
.filter(u -> !passwordEncoder.matches(password, u.getPasswordHash()))
.ifPresent(u -> u.cambiarPasswordHash(passwordEncoder.encode(password)));
}
- /** Límites diario y semanal que se pone el usuario; null quita el límite (HU-10). */
+ /**
+ * Límites diario y semanal que se pone el usuario; null quita el límite (HU-10).
+ *
+ * @throws IllegalArgumentException si el límite diario es mayor que el semanal
+ */
@Transactional
public void fijarLimites(String email, BigDecimal diario, BigDecimal semanal) {
if (diario != null && semanal != null && diario.compareTo(semanal) > 0) {
@@ -89,7 +118,12 @@ public void fijarLimites(String email, BigDecimal diario, BigDecimal semanal) {
usuarios.findByEmail(normalizar(email)).orElseThrow().fijarLimites(diario, semanal);
}
- /** Pausa las apuestas durante unos días; no se puede deshacer antes de tiempo (HU-10). */
+ /**
+ * Pausa las apuestas durante unos días; no se puede deshacer antes de tiempo (HU-10).
+ *
+ * @return cuándo termina la pausa (si ya había una más larga, la de esa)
+ * @throws IllegalArgumentException si los días no son 1, 7 o 30
+ */
@Transactional
public LocalDateTime pausar(String email, int dias) {
if (!DIAS_DE_PAUSA.contains(dias)) {
@@ -100,7 +134,10 @@ public LocalDateTime pausar(String email, int dias) {
return usuario.getPausaHasta();
}
- /** Comprueba la contraseña del usuario, por ejemplo antes de eliminar la cuenta (HU-18). */
+ /**
+ * Comprueba la contraseña del usuario, por ejemplo antes de eliminar la cuenta (HU-18).
+ * Devuelve false si la contraseña es null o no existe la cuenta.
+ */
@Transactional(readOnly = true)
public boolean passwordCorrecta(String email, String password) {
return password != null && usuarios.findByEmail(normalizar(email))
@@ -111,14 +148,17 @@ public boolean passwordCorrecta(String email, String password) {
/** Anonimiza la cuenta: los datos personales desaparecen y ya no se puede entrar con ella (HU-18). */
@Transactional
public void anonimizar(Usuario usuario) {
+ // La contraseña pasa a ser un UUID aleatorio que no se guarda en ningún sitio: nadie la conoce
usuario.eliminar(passwordEncoder.encode(UUID.randomUUID().toString()));
}
+ /** Busca la cuenta por email, sin aplicar la recarga. */
@Transactional(readOnly = true)
public Optional buscar(String email) {
return usuarios.findByEmail(normalizar(email));
}
+ /** Sin espacios alrededor y en minúsculas: " Ana@Mail.es" y "ana@mail.es" son la misma cuenta. */
static String normalizar(String email) {
return email.trim().toLowerCase(Locale.ROOT);
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuariosController.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuariosController.java
index dc6dd49..63ffd71 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuariosController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuariosController.java
@@ -17,6 +17,12 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
+/**
+ * Controlador de las cuentas: inicio de sesión, registro y Mi cuenta (saldo y recargas, juego responsable,
+ * nombre y contraseña). HU-10, HU-11, HU-12, HU-13, HU-14 y HU-47. La baja (HU-18) está en
+ * {@code BajaController}. La lógica está en {@link UsuarioService}.
+ * Cuando un formulario tiene errores no se redirige: se vuelve a pintar la página para que se vean los mensajes.
+ */
@Controller
public class UsuariosController {
@@ -32,11 +38,16 @@ public UsuariosController(UsuarioService usuarios, SaldoProperties saldo, Clock
this.sesiones = sesiones;
}
+ /**
+ * GET /login: formulario de inicio de sesión (HU-12). El envío (POST /login) no llega aquí:
+ * lo procesa Spring Security, configurado en {@code SeguridadConfig}.
+ */
@GetMapping("/login")
public String login() {
return "login";
}
+ /** GET /registro: formulario de registro, con el saldo de bienvenida que se recibirá (HU-11, HU-14). */
@GetMapping("/registro")
public String registro(Model model) {
model.addAttribute("registro", new RegistroForm());
@@ -44,6 +55,11 @@ public String registro(Model model) {
return "registro";
}
+ /**
+ * POST /registro: crea la cuenta y lleva a /login?registrado (CA-11.1). Además de las reglas de
+ * {@link RegistroForm}, comprueba que las contraseñas coinciden y caben en BCrypt.
+ * Si el email ya está registrado, lo marca como error del campo (CA-11.2).
+ */
@PostMapping("/registro")
public String registrar(@Valid @ModelAttribute("registro") RegistroForm form, BindingResult errores, Model model) {
if (form.getPassword() != null && !form.getPassword().equals(form.getConfirmacion())) {
@@ -64,12 +80,16 @@ public String registrar(@Valid @ModelAttribute("registro") RegistroForm form, Bi
return "registro";
}
+ /** GET /cuenta: Mi cuenta. Antes aplica la recarga gratuita si toca, así el saldo está al día (HU-13, HU-14). */
@GetMapping("/cuenta")
public String cuenta(Principal principal, Model model) {
return cuenta(usuarios.consultarConRecarga(principal.getName()), model);
}
- /** Límites diario y semanal que se pone el usuario (HU-10). */
+ /**
+ * POST /cuenta/limites: límites diario y semanal que se pone el usuario (HU-10). Vuelve a la sección
+ * de juego responsable de Mi cuenta. Si el diario supera al semanal, lo muestra como error del campo.
+ */
@PostMapping("/cuenta/limites")
public String limites(@Valid @ModelAttribute("limitesPersonales") LimitesPersonalesForm form,
BindingResult errores, Principal principal, Model model, RedirectAttributes redireccion) {
@@ -85,10 +105,11 @@ public String limites(@Valid @ModelAttribute("limitesPersonales") LimitesPersona
return cuenta(usuarios.consultarConRecarga(principal.getName()), model);
}
- /** Pausa temporal de las apuestas (HU-10). */
+ /** POST /cuenta/pausa: pausa temporal de las apuestas (HU-10); los días deben ser 1, 7 o 30. */
@PostMapping("/cuenta/pausa")
public String pausa(@RequestParam(defaultValue = "0") int dias, Principal principal,
RedirectAttributes redireccion) {
+ // Sin elegir duración llega 0, y el servicio lo rechaza con un mensaje
try {
LocalDateTime hasta = usuarios.pausar(principal.getName(), dias);
redireccion.addFlashAttribute("mensaje", "Apuestas en pausa hasta el "
@@ -99,7 +120,7 @@ public String pausa(@RequestParam(defaultValue = "0") int dias, Principal princi
return "redirect:/cuenta#juego-responsable";
}
- /** HU-47. */
+ /** POST /cuenta/nombre: cambia el nombre visible (HU-47). */
@PostMapping("/cuenta/nombre")
public String cambiarNombre(@Valid @ModelAttribute("datosNombre") NombreForm form, BindingResult errores,
Principal principal, Model model, RedirectAttributes redireccion) {
@@ -111,7 +132,10 @@ public String cambiarNombre(@Valid @ModelAttribute("datosNombre") NombreForm for
return "redirect:/cuenta";
}
- /** HU-47. */
+ /**
+ * POST /cuenta/password: cambia la contraseña si la actual es correcta (HU-47) y cierra las sesiones
+ * de la cuenta en otros navegadores; la de este sigue abierta.
+ */
@PostMapping("/cuenta/password")
public String cambiarPassword(@Valid @ModelAttribute("datosPassword") PasswordForm form, BindingResult errores,
Principal principal, Model model, RedirectAttributes redireccion,
@@ -125,6 +149,7 @@ public String cambiarPassword(@Valid @ModelAttribute("datosPassword") PasswordFo
if (!errores.hasErrors()) {
try {
usuarios.cambiarPassword(principal.getName(), form.getActual(), form.getNueva());
+ // getSession(false): la sesión actual, sin crear una nueva
sesiones.cerrarOtras(principal.getName(), peticion.getSession(false));
redireccion.addFlashAttribute("mensaje", "Contraseña cambiada. Úsala la próxima vez que entres.");
return "redirect:/cuenta";
@@ -153,6 +178,7 @@ private String cuenta(Usuario usuario, Model model) {
}
model.addAttribute("enPausa", usuario.enPausa(LocalDateTime.now(reloj)));
model.addAttribute("usuario", usuario);
+ // Cuándo llega la próxima recarga gratuita y de cuánto es (HU-14)
model.addAttribute("proximaRecarga", usuario.proximaRecarga(saldo.recargaPeriodo()));
model.addAttribute("importeRecarga", saldo.recargaImporte());
return "cuenta";
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/Colores.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/Colores.java
index 4501e35..94004db 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/web/Colores.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/Colores.java
@@ -5,6 +5,7 @@
/**
* Colores de cada club para dibujar su escudo cuando no hay imagen: {fondo, borde}.
* Los equipos que no aparecen reciben un color calculado a partir del nombre.
+ * Solo la usa {@link Formato#colores}; la clave es el nombre exacto del equipo.
*/
final class Colores {
@@ -108,6 +109,7 @@ final class Colores {
Map.entry("Pedro Acosta", new String[] {"#ff6600", "#000000"}),
Map.entry("Marco Bezzecchi", new String[] {"#008c45", "#000000"}));
+ // Clase de constantes: no se crean objetos de ella
private Colores() {
}
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/ConcurrenciaAdvice.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/ConcurrenciaAdvice.java
index 96761d1..97bd8f4 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/web/ConcurrenciaAdvice.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/ConcurrenciaAdvice.java
@@ -9,15 +9,19 @@
/**
* Dos operaciones a la vez sobre el mismo saldo (doble clic, dos pestañas…): la segunda se rechaza
* y se pide repetirla, en lugar de mostrar un error 500.
+ * {@code @ControllerAdvice}: se aplica a todos los controladores. La excepción la lanza JPA cuando falla
+ * el control de versión ({@code @Version}) de {@code Usuario}: otra operación cambió el saldo antes.
*/
@ControllerAdvice
public class ConcurrenciaAdvice {
+ /** Vuelve a la página de la que venía (Referer) con el aviso; si no se sabe, a Mis apuestas (/apuestas). */
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public String aLaVez(@RequestHeader(value = "Referer", required = false) String referer,
RedirectAttributes redireccion) {
redireccion.addFlashAttribute("error",
"Se ha hecho otra operación con tu saldo al mismo tiempo. Comprueba tus apuestas e inténtalo de nuevo.");
+ // RutaSegura: el Referer lo manda el navegador y no hay que fiarse de él
return "redirect:" + RutaSegura.de(referer, "/apuestas");
}
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/Formato.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/Formato.java
index 06f694e..709a3bf 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/web/Formato.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/Formato.java
@@ -10,7 +10,9 @@
import org.springframework.stereotype.Component;
/**
- * Utilidades de presentación para las plantillas: {@code ${@formato.fecha(evento.fechaHora)}}.
+ * Utilidades de presentación para las plantillas Thymeleaf. Es un bean de Spring llamado "formato",
+ * y las vistas lo llaman así: {@code ${@formato.dia(evento.fechaHora)}}.
+ * Fechas en español, cuotas con coma decimal y el escudo dibujado de los equipos sin imagen.
*/
@Component("formato")
public class Formato {
@@ -18,23 +20,26 @@ public class Formato {
private static final Locale ES = Locale.forLanguageTag("es-ES");
private static final DateTimeFormatter DIA = DateTimeFormatter.ofPattern("EEE d MMM", ES);
private static final DateTimeFormatter HORA = DateTimeFormatter.ofPattern("HH:mm", ES);
+ /** Palabras que no cuentan para las iniciales del escudo (siglas del tipo de club, artículos…). */
private static final Set PALABRAS_IGNORADAS = Set.of("fc", "cf", "cd", "ca", "rc", "rcd", "ud", "sd",
"de", "del", "la", "el", "as", "fk", "vfb", "rb", "club", "real");
/** "Sáb 10 oct". */
public String dia(LocalDateTime fecha) {
+ // Se quitan los puntos de las abreviaturas y se pone en mayúscula la primera letra
String texto = DIA.format(fecha).replace(".", "");
return texto.substring(0, 1).toUpperCase(ES) + texto.substring(1);
}
/** Identificador para enlaces dentro de la página: "Champions League" → "champions-league". */
public String ancla(String texto) {
+ // NFD separa cada letra de su tilde, y \p{M} quita las tildes sueltas: "Fórmula" → "Formula"
String sinTildes = java.text.Normalizer.normalize(texto, java.text.Normalizer.Form.NFD)
.replaceAll("\\p{M}", "");
return sinTildes.toLowerCase(ES).replaceAll("[^a-z0-9]+", "-").replaceAll("(^-|-$)", "");
}
- /** Cuota con coma decimal: 1.85 → "1,85". */
+ /** Cuota con coma decimal: 1.85 → "1,85". Siempre dos decimales, cortando sin redondear; "–" si no hay. */
public String cuota(java.math.BigDecimal cuota) {
return cuota == null ? "–" : cuota.setScale(2, java.math.RoundingMode.DOWN).toPlainString().replace('.', ',');
}
@@ -46,6 +51,7 @@ public String cuota(java.math.BigDecimal cuota) {
public String apellido(String nombre) {
String[] partes = nombre.trim().split("\\s+");
int inicio = partes.length - 1;
+ // Se retrocede mientras la palabra anterior empiece por minúscula ("de", "van"…), sin llegar a la primera
while (inicio > 1 && Character.isLowerCase(partes[inicio - 1].charAt(0))) {
inicio--;
}
@@ -57,20 +63,27 @@ public String hora(LocalDateTime fecha) {
return HORA.format(fecha);
}
- /** Iniciales para el escudo genérico: "Real Madrid" → "RM", "FC Barcelona" → "B". */
+ /**
+ * Iniciales para el escudo genérico, como mucho dos: "Racing de Santander" → "RS", "FC Barcelona" → "B",
+ * "Real Madrid" → "M" (se saltan las palabras de PALABRAS_IGNORADAS).
+ */
public String iniciales(String nombre) {
String[] palabras = nombre.split("[\\s/-]+");
String significativas = Arrays.stream(palabras)
.filter(p -> !PALABRAS_IGNORADAS.contains(p.toLowerCase(ES)))
.map(p -> p.substring(0, 1).toUpperCase(ES))
.collect(Collectors.joining());
+ // Si todas las palabras se ignoran, la inicial de la primera
if (significativas.isEmpty()) {
significativas = palabras[0].substring(0, 1).toUpperCase(ES);
}
return significativas.length() > 2 ? significativas.substring(0, 2) : significativas;
}
- /** Color estable para el escudo de cada equipo, calculado a partir del nombre. */
+ /**
+ * Color estable para el escudo de cada equipo, calculado a partir del nombre: el mismo nombre
+ * da siempre el mismo tono (de 0 a 359 en la rueda de color).
+ */
public String color(String nombre) {
int tono = Math.floorMod(nombre.hashCode(), 360);
return "hsl(" + tono + " 55% 38%)";
@@ -89,6 +102,7 @@ public String[] colores(String nombre) {
return new String[] {club[0], club[1], texto};
}
+ /** Si un color "#rrggbb" es claro, según su luminosidad percibida (el verde pesa más que el azul). */
private static boolean esClaro(String hex) {
int r = Integer.parseInt(hex.substring(1, 3), 16);
int g = Integer.parseInt(hex.substring(3, 5), 16);
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/InicioController.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/InicioController.java
index ea7f747..d2399dd 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/web/InicioController.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/InicioController.java
@@ -14,6 +14,10 @@
import es.ucm.fdi.is1.apuestas.eventos.FichaEquipoService;
import es.ucm.fdi.is1.apuestas.usuarios.SaldoProperties;
+/**
+ * Controlador de las páginas públicas generales: la portada y el listado y la ficha de los equipos.
+ * Se ven sin haber iniciado sesión (HU-08; las rutas públicas están en {@code SeguridadConfig}).
+ */
@Controller
public class InicioController {
@@ -30,6 +34,10 @@ public InicioController(EquiposService equipos, CatalogoService catalogo, FichaE
this.saldo = saldo;
}
+ /**
+ * GET /: portada con los 6 próximos partidos, el partido destacado, los eventos disponibles de cada deporte
+ * y el saldo de bienvenida que se recibe al registrarse (HU-08, HU-14).
+ */
@GetMapping("/")
public String inicio(Model model) {
model.addAttribute("proximos", catalogo.proximos(6));
@@ -39,6 +47,10 @@ public String inicio(Model model) {
return "index";
}
+ /**
+ * GET /equipos: equipos y deportistas agrupados por deporte. Filtros opcionales: deporte, competición
+ * y texto {@code q} en el nombre.
+ */
@GetMapping("/equipos")
public String equipos(@RequestParam(required = false) Deporte deporte,
@RequestParam(required = false) Long competicion,
@@ -47,12 +59,13 @@ public String equipos(@RequestParam(required = false) Deporte deporte,
model.addAttribute("recuento", equipos.recuentoPorDeporte());
model.addAttribute("deporteElegido", deporte);
model.addAttribute("competicionElegida", competicion);
+ // El desplegable de competiciones solo aparece con un deporte elegido
model.addAttribute("competiciones", deporte == null ? List.of() : equipos.competiciones(deporte));
model.addAttribute("q", q == null ? "" : q.trim());
return "equipos";
}
- /** Ficha pública de un equipo o deportista (HU-31). */
+ /** GET /equipos/{id}: ficha pública de un equipo o deportista (HU-31). */
@GetMapping("/equipos/{id}")
public String ficha(@PathVariable Long id, Model model) {
model.addAttribute("ficha", fichas.ficha(id));
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/RutaSegura.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/RutaSegura.java
index 9b05282..cc31a9d 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/web/RutaSegura.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/RutaSegura.java
@@ -2,24 +2,37 @@
import java.net.URI;
-/** Ruta de esta misma web a la que volver (por ejemplo, la del Referer); si no es segura, la indicada. */
+/**
+ * Ruta de esta misma web a la que volver (por ejemplo, la del Referer); si no es segura, la indicada.
+ * Evita las redirecciones abiertas: un enlace preparado no puede mandar al usuario a otra web.
+ */
public final class RutaSegura {
+ // Clase de utilidades: no se crean objetos de ella
private RutaSegura() {
}
+ /**
+ * Ruta y parámetros de {@code referer}, sin el servidor; {@code porDefecto} si falta o no vale.
+ *
+ * @param referer dirección a la que volver (puede ser null)
+ * @param porDefecto ruta interna que se usa si la anterior no sirve
+ */
public static String de(String referer, String porDefecto) {
if (referer == null) {
return porDefecto;
}
try {
URI uri = URI.create(referer);
+ // Solo se queda la ruta: aunque el Referer sea de otro servidor, se vuelve a esta web
String ruta = uri.getRawPath();
+ // "//otra-web.com" el navegador lo entendería como otro servidor; a /login no tiene sentido volver
if (ruta == null || !ruta.startsWith("/") || ruta.startsWith("//") || ruta.startsWith("/login")) {
return porDefecto;
}
return uri.getRawQuery() == null ? ruta : ruta + "?" + uri.getRawQuery();
} catch (IllegalArgumentException e) {
+ // No es una dirección bien formada
return porDefecto;
}
}
diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/UsuarioActualAdvice.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/UsuarioActualAdvice.java
index 42fa5bb..f4ff81c 100644
--- a/src/main/java/es/ucm/fdi/is1/apuestas/web/UsuarioActualAdvice.java
+++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/UsuarioActualAdvice.java
@@ -16,11 +16,17 @@
import es.ucm.fdi.is1.apuestas.usuarios.UsuarioService;
import jakarta.servlet.http.HttpServletRequest;
-/** Datos comunes a todas las vistas: usuario con sesión iniciada (cabecera, saldo) y su boleto. */
+/**
+ * Datos comunes a todas las vistas: usuario con sesión iniciada (cabecera, saldo) y su boleto.
+ * {@code @ControllerAdvice}: cada método {@code @ModelAttribute} se ejecuta antes de cualquier controlador
+ * y deja su resultado en el modelo con ese nombre, así las plantillas lo tienen siempre a mano.
+ * Para los visitantes ({@code principal} null) se devuelven valores vacíos.
+ */
@ControllerAdvice
public class UsuarioActualAdvice {
private final UsuarioService usuarios;
+ // Boleto es de sesión: Spring inyecta un intermediario que lleva al boleto de la sesión de cada petición
private final Boleto boleto;
private final BoletoService boletos;
private final NotificacionService notificaciones;
@@ -61,7 +67,7 @@ public LocalDateTime pausaHasta(Principal principal) {
.orElse(null);
}
- /** Número de avisos sin leer, para la campana de la cabecera (HU-37). */
+ /** Número de avisos sin leer, para la campana de la cabecera (HU-37). 0 para los visitantes. */
@ModelAttribute("avisosSinLeer")
public Long avisosSinLeer(Principal principal) {
return principal == null ? 0L : notificaciones.sinLeer(principal.getName());
@@ -85,6 +91,10 @@ public BoletoVista boletoResumen(Principal principal) {
return boletos.resumen(boleto);
}
+ /**
+ * Usuario con sesión iniciada, para la cabecera (nombre, saldo); null para los visitantes.
+ * No aplica la recarga gratuita: eso se hace al entrar y en Mi cuenta.
+ */
@ModelAttribute("usuarioActual")
public Usuario usuarioActual(Principal principal) {
if (principal == null) {