refactor(dtos): convert all DTOs to Java records for native-mode Jackson
CI / Build Native (push) Successful in 6m23s

En Quarkus native-image, Jackson no introspecta public fields de clases
con normales (deja de refleccionar field metadata). Salia error:

  No serializer found for class com.l2.shots.auth.AuthMeResponse and
  no properties discovered to create BeanSerializer

Los records tienen auto-accessors (id(), username(), etc) que Jackson
serializa nativamente, sin necesidad de reflection o
@RegisterForReflection.

Convertidos:
- auth: AuthMeResponse, AdminUserSummary, Credentials,
         ChangePasswordRequest, AdminResetPasswordRequest, ErrorBody,
         MustChangeBody
- history: RunSummary, RunDetails, RunItem, RunSnapshot, RunIn,
           HistoryStats
- state: AppState (con Insumos y FormulaDto anidados)

Callers actualizados para usar accessors en vez de field access:
- AuthService.listUsersForAdmin -> AdminUserSummary.from
- AuthService.saveRun -> RunSummary.from / RunDetails.of
- AuthResource.register/login -> creds.username() / creds.password()
- HistoryResource.saveRun -> input.items() etc

Notas:
- RunDetails NO puede extender RunSummary en records (JLS no permite
  extends entre records). Va como record independiente con todos los
  campos. El JSON que produce matchea la interface RunDetails del
  frontend (que extendia RunSummary).
- HistoryStats ahora se construye de una sola vez al final de
  computeStats en lugar de ir mutando campos.
- MustChangeBody quedo con un constructor no-canonico no-arg para
  mantener el call site original (new MustChangeBody()).
This commit is contained in:
2026-08-15 00:46:19 -04:00
parent 89a224ee0c
commit 017c58a2b2
18 changed files with 173 additions and 244 deletions
@@ -1,6 +1,4 @@
package com.l2.shots.auth; package com.l2.shots.auth;
public class AdminResetPasswordRequest { public record AdminResetPasswordRequest(String username, String newPassword) {
public String username;
public String newPassword;
} }
@@ -3,22 +3,21 @@ package com.l2.shots.auth;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
public class AdminUserSummary { public record AdminUserSummary(
public UUID id; UUID id,
public String username; String username,
public Instant createdAt; Instant createdAt,
public Instant lastLoginAt; Instant lastLoginAt,
public boolean isAdmin; boolean isAdmin,
public boolean mustChangePassword; boolean mustChangePassword) {
public AdminUserSummary() {} public static AdminUserSummary from(User u) {
return new AdminUserSummary(
public AdminUserSummary(User u) { u.id,
this.id = u.id; u.username,
this.username = u.username; u.createdAt,
this.createdAt = u.createdAt; u.lastLoginAt,
this.lastLoginAt = u.lastLoginAt; u.isAdmin,
this.isAdmin = u.isAdmin; u.mustChangePassword);
this.mustChangePassword = u.mustChangePassword;
} }
} }
@@ -3,20 +3,10 @@ package com.l2.shots.auth;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
public class AuthMeResponse { public record AuthMeResponse(
public UUID id; UUID id,
public String username; String username,
public Instant createdAt; Instant createdAt,
public boolean mustChangePassword; boolean mustChangePassword,
public boolean isAdmin; boolean isAdmin) {
public AuthMeResponse() {}
public AuthMeResponse(UUID id, String username, Instant createdAt, boolean mustChangePassword, boolean isAdmin) {
this.id = id;
this.username = username;
this.createdAt = createdAt;
this.mustChangePassword = mustChangePassword;
this.isAdmin = isAdmin;
}
} }
@@ -42,7 +42,7 @@ public class AuthResource {
@POST @POST
@Path("/register") @Path("/register")
public Response register(Credentials creds) { public Response register(Credentials creds) {
Optional<User> result = authService.register(creds.username, creds.password); Optional<User> result = authService.register(creds.username(), creds.password());
if (result.isEmpty()) { if (result.isEmpty()) {
return Response.status(409) return Response.status(409)
.entity(new ErrorBody("username no disponible o datos inválidos")) .entity(new ErrorBody("username no disponible o datos inválidos"))
@@ -58,7 +58,7 @@ public class AuthResource {
@POST @POST
@Path("/login") @Path("/login")
public Response login(Credentials creds) { public Response login(Credentials creds) {
Optional<User> result = authService.authenticate(creds.username, creds.password); Optional<User> result = authService.authenticate(creds.username(), creds.password());
if (result.isEmpty()) { if (result.isEmpty()) {
return Response.status(401) return Response.status(401)
.entity(new ErrorBody("credenciales inválidas")) .entity(new ErrorBody("credenciales inválidas"))
@@ -83,7 +83,7 @@ public class AuthResource {
@Path("/change-password") @Path("/change-password")
@Authenticated @Authenticated
public Response changePassword(@Context HttpHeaders headers, ChangePasswordRequest body) { public Response changePassword(@Context HttpHeaders headers, ChangePasswordRequest body) {
if (body == null || body.currentPassword == null || body.newPassword == null) { if (body == null || body.currentPassword() == null || body.newPassword() == null) {
return Response.status(400).entity(new ErrorBody("Faltan campos requeridos")).build(); return Response.status(400).entity(new ErrorBody("Faltan campos requeridos")).build();
} }
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers); Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
@@ -91,7 +91,7 @@ public class AuthResource {
Optional<User> user = authService.getEntityFromToken(jwt.get()); Optional<User> user = authService.getEntityFromToken(jwt.get());
if (user.isEmpty()) return Response.status(401).build(); if (user.isEmpty()) return Response.status(401).build();
AuthService.ChangePasswordResult result = authService.changePassword( AuthService.ChangePasswordResult result = authService.changePassword(
user.get().id, body.currentPassword, body.newPassword); user.get().id, body.currentPassword(), body.newPassword());
switch (result) { switch (result) {
case OK: case OK:
User refreshed = User.findById(user.get().id); User refreshed = User.findById(user.get().id);
@@ -116,7 +116,7 @@ public class AuthResource {
@Path("/admin/reset-password") @Path("/admin/reset-password")
@RolesAllowed("admin") @RolesAllowed("admin")
public Response adminResetPassword(AdminResetPasswordRequest body) { public Response adminResetPassword(AdminResetPasswordRequest body) {
if (body == null || body.username == null || body.newPassword == null) { if (body == null || body.username() == null || body.newPassword() == null) {
return Response.status(400).entity(new ErrorBody("Faltan campos requeridos")).build(); return Response.status(400).entity(new ErrorBody("Faltan campos requeridos")).build();
} }
UUID requesterId; UUID requesterId;
@@ -125,7 +125,7 @@ public class AuthResource {
} catch (Exception e) { } catch (Exception e) {
return Response.status(401).build(); return Response.status(401).build();
} }
boolean ok = authService.resetPasswordAsAdmin(requesterId, body.username, body.newPassword); boolean ok = authService.resetPasswordAsAdmin(requesterId, body.username(), body.newPassword());
if (!ok) { if (!ok) {
return Response.status(400).entity(new ErrorBody("Usuario no encontrado o contraseña inválida")).build(); return Response.status(400).entity(new ErrorBody("Usuario no encontrado o contraseña inválida")).build();
} }
@@ -180,9 +180,6 @@ public class AuthResource {
.build(); .build();
} }
public static class ErrorBody { public record ErrorBody(String error) {
public String error;
public ErrorBody() {}
public ErrorBody(String error) { this.error = error; }
} }
} }
@@ -77,7 +77,7 @@ public class AuthService {
public java.util.List<AdminUserSummary> listUsersForAdmin() { public java.util.List<AdminUserSummary> listUsersForAdmin() {
return User.<User>listAll().stream() return User.<User>listAll().stream()
.map(AdminUserSummary::new) .map(AdminUserSummary::from)
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
} }
@@ -1,6 +1,4 @@
package com.l2.shots.auth; package com.l2.shots.auth;
public class ChangePasswordRequest { public record ChangePasswordRequest(String currentPassword, String newPassword) {
public String currentPassword;
public String newPassword;
} }
@@ -1,6 +1,4 @@
package com.l2.shots.auth; package com.l2.shots.auth;
public class Credentials { public record Credentials(String username, String password) {
public String username;
public String password;
} }
@@ -60,8 +60,9 @@ public class MustChangePasswordFilter implements ContainerRequestFilter {
.build()); .build());
} }
public static class MustChangeBody { public record MustChangeBody(String error, boolean mustChangePassword) {
public String error = "Debe cambiar la contraseña antes de continuar"; public MustChangeBody() {
public boolean mustChangePassword = true; this("Debe cambiar la contraseña antes de continuar", true);
}
} }
} }
@@ -36,16 +36,16 @@ public class HistoryResource {
Optional<UUID> userId = extractUserId(headers); Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build(); if (userId.isEmpty()) return Response.status(401).build();
if (input == null || input.items == null || input.snapshot == null) { if (input == null || input.items() == null || input.snapshot() == null) {
return Response.status(400).entity("{\"error\":\"payload inválido\"}").build(); return Response.status(400).entity("{\"error\":\"payload inválido\"}").build();
} }
if (input.items.size() > 100) { if (input.items().size() > 100) {
return Response.status(400).entity("{\"error\":\"demasiados items\"}").build(); return Response.status(400).entity("{\"error\":\"demasiados items\"}").build();
} }
if (input.label != null && input.label.length() > 100) { if (input.label() != null && input.label().length() > 100) {
return Response.status(400).entity("{\"error\":\"label demasiado largo\"}").build(); return Response.status(400).entity("{\"error\":\"label demasiado largo\"}").build();
} }
if (input.totalCristalesUsed <= 0) { if (input.totalCristalesUsed() <= 0) {
return Response.status(400).entity("{\"error\":\"no hay cristales usados\"}").build(); return Response.status(400).entity("{\"error\":\"no hay cristales usados\"}").build();
} }
@@ -18,7 +18,7 @@ public class HistoryService {
public List<RunSummary> listForUser(UUID userId) { public List<RunSummary> listForUser(UUID userId) {
return ProductionRun.<ProductionRun>list("userId = ?1 ORDER BY createdAt DESC", userId) return ProductionRun.<ProductionRun>list("userId = ?1 ORDER BY createdAt DESC", userId)
.stream() .stream()
.map(RunSummary::new) .map(RunSummary::from)
.toList(); .toList();
} }
@@ -29,7 +29,7 @@ public class HistoryService {
List<RunItem> items = mapper.readValue(entity.itemsJson, mapper.getTypeFactory() List<RunItem> items = mapper.readValue(entity.itemsJson, mapper.getTypeFactory()
.constructCollectionType(List.class, RunItem.class)); .constructCollectionType(List.class, RunItem.class));
RunSnapshot snapshot = mapper.readValue(entity.snapshotJson, RunSnapshot.class); RunSnapshot snapshot = mapper.readValue(entity.snapshotJson, RunSnapshot.class);
return Optional.of(new RunDetails(entity, items, snapshot)); return Optional.of(RunDetails.of(entity, items, snapshot));
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
return Optional.empty(); return Optional.empty();
} }
@@ -41,21 +41,21 @@ public class HistoryService {
entity.id = UUID.randomUUID(); entity.id = UUID.randomUUID();
entity.userId = userId; entity.userId = userId;
entity.createdAt = Instant.now(); entity.createdAt = Instant.now();
entity.label = input.label; entity.label = input.label();
entity.totalCost = input.totalCost; entity.totalCost = input.totalCost();
entity.totalSale = input.totalSale; entity.totalSale = input.totalSale();
entity.totalProfit = input.totalProfit; entity.totalProfit = input.totalProfit();
entity.totalShots = input.totalShots; entity.totalShots = input.totalShots();
entity.totalCristalesUsed = input.totalCristalesUsed; entity.totalCristalesUsed = input.totalCristalesUsed();
entity.totalOreUsed = input.totalOreUsed; entity.totalOreUsed = input.totalOreUsed();
try { try {
entity.itemsJson = mapper.writeValueAsString(input.items); entity.itemsJson = mapper.writeValueAsString(input.items());
entity.snapshotJson = mapper.writeValueAsString(input.snapshot); entity.snapshotJson = mapper.writeValueAsString(input.snapshot());
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize run payload", e); throw new RuntimeException("Failed to serialize run payload", e);
} }
entity.persist(); entity.persist();
return new RunSummary(entity); return RunSummary.from(entity);
} }
@Transactional @Transactional
@@ -67,22 +67,8 @@ public class HistoryService {
List<ProductionRun> runs = ProductionRun.list( List<ProductionRun> runs = ProductionRun.list(
"userId = ?1 ORDER BY createdAt DESC", userId); "userId = ?1 ORDER BY createdAt DESC", userId);
HistoryStats stats = new HistoryStats();
stats.totalRuns = runs.size();
if (runs.isEmpty()) { if (runs.isEmpty()) {
stats.totalCost = 0; return new HistoryStats(0, 0, 0, 0, 0, 0, 0, 0, null, null, 0, 0);
stats.totalSale = 0;
stats.totalProfit = 0;
stats.totalShots = 0;
stats.avgProfit = 0;
stats.avgCost = 0;
stats.avgSale = 0;
stats.bestRun = null;
stats.worstRun = null;
stats.last5Avg = 0;
stats.last10Avg = 0;
return stats;
} }
long totalCost = 0; long totalCost = 0;
@@ -101,27 +87,28 @@ public class HistoryService {
if (r.totalProfit < worst.totalProfit) worst = r; if (r.totalProfit < worst.totalProfit) worst = r;
} }
stats.totalCost = totalCost; int n = runs.size();
stats.totalSale = totalSale; int n5 = Math.min(5, n);
stats.totalProfit = totalProfit; int n10 = Math.min(10, n);
stats.totalShots = totalShots;
stats.avgProfit = totalProfit / runs.size();
stats.avgCost = totalCost / runs.size();
stats.avgSale = totalSale / runs.size();
stats.bestRun = new RunSummary(best);
stats.worstRun = new RunSummary(worst);
int n5 = Math.min(5, runs.size());
int n10 = Math.min(10, runs.size());
long sum5 = 0; long sum5 = 0;
long sum10 = 0; long sum10 = 0;
for (int i = 0; i < n10; i++) { for (int i = 0; i < n10; i++) {
sum10 += runs.get(i).totalProfit; sum10 += runs.get(i).totalProfit;
if (i < n5) sum5 += runs.get(i).totalProfit; if (i < n5) sum5 += runs.get(i).totalProfit;
} }
stats.last5Avg = n5 > 0 ? sum5 / n5 : 0;
stats.last10Avg = n10 > 0 ? sum10 / n10 : 0;
return stats; return new HistoryStats(
n,
totalCost,
totalSale,
totalProfit,
totalShots,
totalProfit / n,
totalCost / n,
totalSale / n,
RunSummary.from(best),
RunSummary.from(worst),
n5 > 0 ? sum5 / n5 : 0,
n10 > 0 ? sum10 / n10 : 0);
} }
} }
@@ -1,16 +1,16 @@
package com.l2.shots.history; package com.l2.shots.history;
public class HistoryStats { public record HistoryStats(
public int totalRuns; int totalRuns,
public long totalCost; long totalCost,
public long totalSale; long totalSale,
public long totalProfit; long totalProfit,
public long totalShots; long totalShots,
public long avgProfit; long avgProfit,
public long avgCost; long avgCost,
public long avgSale; long avgSale,
public RunSummary bestRun; RunSummary bestRun,
public RunSummary worstRun; RunSummary worstRun,
public long last5Avg; long last5Avg,
public long last10Avg; long last10Avg) {
} }
@@ -1,16 +1,34 @@
package com.l2.shots.history; package com.l2.shots.history;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.UUID;
public class RunDetails extends RunSummary { public record RunDetails(
public List<RunItem> items; UUID id,
public RunSnapshot snapshot; Instant createdAt,
String label,
long totalCost,
long totalSale,
long totalProfit,
long totalShots,
long totalCristalesUsed,
long totalOreUsed,
List<RunItem> items,
RunSnapshot snapshot) {
public RunDetails() {} public static RunDetails of(ProductionRun r, List<RunItem> items, RunSnapshot snapshot) {
return new RunDetails(
public RunDetails(ProductionRun r, List<RunItem> items, RunSnapshot snapshot) { r.id,
super(r); r.createdAt,
this.items = items; r.label,
this.snapshot = snapshot; r.totalCost,
r.totalSale,
r.totalProfit,
r.totalShots,
r.totalCristalesUsed,
r.totalOreUsed,
items,
snapshot);
} }
} }
+10 -10
View File
@@ -2,14 +2,14 @@ package com.l2.shots.history;
import java.util.List; import java.util.List;
public class RunIn { public record RunIn(
public String label; String label,
public long totalCost; long totalCost,
public long totalSale; long totalSale,
public long totalProfit; long totalProfit,
public long totalShots; long totalShots,
public long totalCristalesUsed; long totalCristalesUsed,
public long totalOreUsed; long totalOreUsed,
public List<RunItem> items; List<RunItem> items,
public RunSnapshot snapshot; RunSnapshot snapshot) {
} }
+11 -28
View File
@@ -1,31 +1,14 @@
package com.l2.shots.history; package com.l2.shots.history;
public class RunItem { public record RunItem(
public String tipo; String tipo,
public String grado; String grado,
public int cristalesDisponibles; int cristalesDisponibles,
public int cristalesUsados; int cristalesUsados,
public int oreNecesario; int oreNecesario,
public int crafteosPosibles; int crafteosPosibles,
public int shotsObtenidos; int shotsObtenidos,
public long costoTotal; long costoTotal,
public long valorVenta; long valorVenta,
public long ganancia; long ganancia) {
public RunItem() {}
public RunItem(String tipo, String grado, int cristalesDisponibles, int cristalesUsados,
int oreNecesario, int crafteosPosibles, int shotsObtenidos,
long costoTotal, long valorVenta, long ganancia) {
this.tipo = tipo;
this.grado = grado;
this.cristalesDisponibles = cristalesDisponibles;
this.cristalesUsados = cristalesUsados;
this.oreNecesario = oreNecesario;
this.crafteosPosibles = crafteosPosibles;
this.shotsObtenidos = shotsObtenidos;
this.costoTotal = costoTotal;
this.valorVenta = valorVenta;
this.ganancia = ganancia;
}
} }
@@ -3,14 +3,7 @@ package com.l2.shots.history;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
public class RunSnapshot { public record RunSnapshot(
public Map<String, Object> insumos; Map<String, Object> insumos,
public List<Map<String, Object>> formulas; List<Map<String, Object>> formulas) {
public RunSnapshot() {}
public RunSnapshot(Map<String, Object> insumos, List<Map<String, Object>> formulas) {
this.insumos = insumos;
this.formulas = formulas;
}
} }
@@ -3,28 +3,27 @@ package com.l2.shots.history;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
public class RunSummary { public record RunSummary(
public UUID id; UUID id,
public Instant createdAt; Instant createdAt,
public String label; String label,
public long totalCost; long totalCost,
public long totalSale; long totalSale,
public long totalProfit; long totalProfit,
public long totalShots; long totalShots,
public long totalCristalesUsed; long totalCristalesUsed,
public long totalOreUsed; long totalOreUsed) {
public RunSummary() {} public static RunSummary from(ProductionRun r) {
return new RunSummary(
public RunSummary(ProductionRun r) { r.id,
this.id = r.id; r.createdAt,
this.createdAt = r.createdAt; r.label,
this.label = r.label; r.totalCost,
this.totalCost = r.totalCost; r.totalSale,
this.totalSale = r.totalSale; r.totalProfit,
this.totalProfit = r.totalProfit; r.totalShots,
this.totalShots = r.totalShots; r.totalCristalesUsed,
this.totalCristalesUsed = r.totalCristalesUsed; r.totalOreUsed);
this.totalOreUsed = r.totalOreUsed;
} }
} }
+17 -49
View File
@@ -3,57 +3,25 @@ package com.l2.shots.state;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
public class AppState { public record AppState(
Insumos insumos,
List<FormulaDto> formulas,
Map<String, Map<String, Integer>> disponibles) {
public Insumos insumos; public record Insumos(
public List<FormulaDto> formulas; Map<String, Integer> cristales,
public Map<String, Map<String, Integer>> disponibles; int soulOre,
int spiritOre,
public AppState() {} Map<String, Map<String, Integer>> venta) {
public AppState(Insumos insumos, List<FormulaDto> formulas, Map<String, Map<String, Integer>> disponibles) {
this.insumos = insumos;
this.formulas = formulas;
this.disponibles = disponibles;
} }
public static class Insumos { public record FormulaDto(
public Map<String, Integer> cristales; String id,
public int soulOre; String tipo,
public int spiritOre; String grado,
public Map<String, Map<String, Integer>> venta; int cristalesReq,
Integer soulOreReq,
public Insumos() {} Integer spiritOreReq,
int shotsObtenidos) {
public Insumos(Map<String, Integer> cristales, int soulOre, int spiritOre,
Map<String, Map<String, Integer>> venta) {
this.cristales = cristales;
this.soulOre = soulOre;
this.spiritOre = spiritOre;
this.venta = venta;
}
}
public static class FormulaDto {
public String id;
public String tipo;
public String grado;
public int cristalesReq;
public Integer soulOreReq;
public Integer spiritOreReq;
public int shotsObtenidos;
public FormulaDto() {}
public FormulaDto(String id, String tipo, String grado, int cristalesReq,
Integer soulOreReq, Integer spiritOreReq, int shotsObtenidos) {
this.id = id;
this.tipo = tipo;
this.grado = grado;
this.cristalesReq = cristalesReq;
this.soulOreReq = soulOreReq;
this.spiritOreReq = spiritOreReq;
this.shotsObtenidos = shotsObtenidos;
}
} }
} }
@@ -43,10 +43,10 @@ public class StateResource {
Optional<UUID> userId = extractUserId(headers); Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build(); if (userId.isEmpty()) return Response.status(401).build();
if (state == null || state.insumos == null || state.formulas == null || state.disponibles == null) { if (state == null || state.insumos() == null || state.formulas() == null || state.disponibles() == null) {
return Response.status(400).entity("{\"error\":\"estado inválido\"}").build(); return Response.status(400).entity("{\"error\":\"estado inválido\"}").build();
} }
if (state.formulas.size() > 200) { if (state.formulas().size() > 200) {
return Response.status(400).entity("{\"error\":\"estado demasiado grande\"}").build(); return Response.status(400).entity("{\"error\":\"estado demasiado grande\"}").build();
} }
stateService.saveForUser(userId.get(), state); stateService.saveForUser(userId.get(), state);