feat: shot-crafter-calculator with H2 persistence and production history
Quarkus 3.20.1 monolith serving React 18 + TypeScript + Tailwind SPA. Features: - Three-tab calculator (Insumos, Fórmulas, Calculadora) for Soulshot, Spiritshot and Blessed Spiritshot crafting in Lineage 2 Interlude/Clásico with all 15 grades and pre-loaded recipes - Real-time profitability computation (cristales → ore → crafteos → shots → cost → sale → ganancia) - Multi-user auth with JWT in httpOnly cookie (bcrypt + RSA 2048) - H2 file-based persistence in ./data/shots.mv.db (file-based, H2) - Auto-save on state changes (debounced 500ms) - Production history with stats (total/avg/best/worst/last5avg) and per-run detail modal with snapshot of insumos+formulas Stack: - Backend: Quarkus REST + Hibernate ORM Panache + smallrye-jwt - Frontend: React 18 + TypeScript + Vite + Tailwind 3 - Build: Maven runs frontend-maven-plugin (Node 22 + npm ci) then copies dist to META-INF/resources for Quarkus to serve Verified: - 5 backend endpoints + 5 history endpoints with curl - 35/35 browser tests via Playwright + Chromium - All TS strict, all builds green
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public class AuthMeResponse {
|
||||
public UUID id;
|
||||
public String username;
|
||||
public Instant createdAt;
|
||||
|
||||
public AuthMeResponse() {}
|
||||
|
||||
public AuthMeResponse(UUID id, String username, Instant createdAt) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import io.quarkus.security.Authenticated;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.NewCookie;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Path("/api/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class AuthResource {
|
||||
|
||||
@Inject
|
||||
AuthService authService;
|
||||
|
||||
@Inject
|
||||
JwtCookieAuth jwtCookieAuth;
|
||||
|
||||
@ConfigProperty(name = "app.auth.cookie-name")
|
||||
String cookieName;
|
||||
|
||||
@ConfigProperty(name = "app.auth.cookie-max-age-seconds")
|
||||
int cookieMaxAge;
|
||||
|
||||
@POST
|
||||
@Path("/register")
|
||||
public Response register(Credentials creds) {
|
||||
Optional<User> result = authService.register(creds.username, creds.password);
|
||||
if (result.isEmpty()) {
|
||||
return Response.status(409)
|
||||
.entity(new ErrorBody("username no disponible o datos inválidos"))
|
||||
.build();
|
||||
}
|
||||
User user = result.get();
|
||||
String token = authService.buildToken(user.id);
|
||||
return Response.ok(AuthService.toAuthMe(user))
|
||||
.cookie(buildAuthCookie(token))
|
||||
.build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/login")
|
||||
public Response login(Credentials creds) {
|
||||
Optional<User> result = authService.authenticate(creds.username, creds.password);
|
||||
if (result.isEmpty()) {
|
||||
return Response.status(401)
|
||||
.entity(new ErrorBody("credenciales inválidas"))
|
||||
.build();
|
||||
}
|
||||
User user = result.get();
|
||||
String token = authService.buildToken(user.id);
|
||||
return Response.ok(AuthService.toAuthMe(user))
|
||||
.cookie(buildAuthCookie(token))
|
||||
.build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/logout")
|
||||
public Response logout() {
|
||||
return Response.noContent()
|
||||
.cookie(clearAuthCookie())
|
||||
.build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/me")
|
||||
public Response me(@Context HttpHeaders headers) {
|
||||
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
|
||||
if (jwt.isEmpty()) {
|
||||
return Response.status(401).build();
|
||||
}
|
||||
return authService.getUserFromToken(jwt.get())
|
||||
.map(u -> Response.ok(u).build())
|
||||
.orElse(Response.status(401).build());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/check")
|
||||
@Authenticated
|
||||
public Response check() {
|
||||
return Response.ok().build();
|
||||
}
|
||||
|
||||
private NewCookie buildAuthCookie(String token) {
|
||||
return new NewCookie.Builder(cookieName)
|
||||
.value(token)
|
||||
.path("/")
|
||||
.httpOnly(true)
|
||||
.secure(false)
|
||||
.sameSite(NewCookie.SameSite.LAX)
|
||||
.maxAge(cookieMaxAge)
|
||||
.build();
|
||||
}
|
||||
|
||||
private NewCookie clearAuthCookie() {
|
||||
return new NewCookie.Builder(cookieName)
|
||||
.value("")
|
||||
.path("/")
|
||||
.httpOnly(true)
|
||||
.secure(false)
|
||||
.sameSite(NewCookie.SameSite.LAX)
|
||||
.maxAge(0)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static class ErrorBody {
|
||||
public String error;
|
||||
public ErrorBody() {}
|
||||
public ErrorBody(String error) { this.error = error; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||
import io.smallrye.jwt.build.Jwt;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@ApplicationScoped
|
||||
public class AuthService {
|
||||
|
||||
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]{3,30}$");
|
||||
public static final int MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
@ConfigProperty(name = "mp.jwt.verify.issuer")
|
||||
String issuer;
|
||||
|
||||
@Transactional
|
||||
public Optional<User> register(String username, String password) {
|
||||
if (username == null || password == null) return Optional.empty();
|
||||
username = username.trim();
|
||||
if (!USERNAME_PATTERN.matcher(username).matches()) return Optional.empty();
|
||||
if (password.length() < MIN_PASSWORD_LENGTH) return Optional.empty();
|
||||
if (User.findByUsernameCaseInsensitive(username) != null) return Optional.empty();
|
||||
|
||||
User user = new User();
|
||||
user.id = UUID.randomUUID();
|
||||
user.username = username.toLowerCase();
|
||||
user.passwordHash = BcryptUtil.bcryptHash(password);
|
||||
user.createdAt = Instant.now();
|
||||
user.persist();
|
||||
|
||||
return Optional.of(user);
|
||||
}
|
||||
|
||||
public Optional<User> authenticate(String username, String password) {
|
||||
if (username == null || password == null) return Optional.empty();
|
||||
User user = User.findByUsernameCaseInsensitive(username.trim());
|
||||
if (user == null) return Optional.empty();
|
||||
if (!BcryptUtil.matches(password, user.passwordHash)) return Optional.empty();
|
||||
return Optional.of(user);
|
||||
}
|
||||
|
||||
public String buildToken(UUID userId) {
|
||||
return Jwt.issuer(issuer)
|
||||
.subject(userId.toString())
|
||||
.groups(Set.of("user"))
|
||||
.expiresIn(Duration.ofSeconds(86400))
|
||||
.sign();
|
||||
}
|
||||
|
||||
public Optional<AuthMeResponse> getUserFromToken(JsonWebToken jwt) {
|
||||
if (jwt == null || jwt.getSubject() == null) return Optional.empty();
|
||||
try {
|
||||
UUID userId = UUID.fromString(jwt.getSubject());
|
||||
User user = User.findById(userId);
|
||||
if (user == null) return Optional.empty();
|
||||
return Optional.of(new AuthMeResponse(user.id, user.username, user.createdAt));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public static AuthMeResponse toAuthMe(User user) {
|
||||
return new AuthMeResponse(user.id, user.username, user.createdAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
public class Credentials {
|
||||
public String username;
|
||||
public String password;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import io.smallrye.jwt.auth.principal.JWTParser;
|
||||
import io.smallrye.jwt.auth.principal.ParseException;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
|
||||
import jakarta.ws.rs.core.Cookie;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@ApplicationScoped
|
||||
public class JwtCookieAuth {
|
||||
|
||||
@Inject
|
||||
JWTParser parser;
|
||||
|
||||
@ConfigProperty(name = "app.auth.cookie-name")
|
||||
String cookieName;
|
||||
|
||||
public Optional<JsonWebToken> extractToken(HttpHeaders headers) {
|
||||
String auth = headers.getHeaderString("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer ")) {
|
||||
return Optional.of(parseToken(auth.substring(7)));
|
||||
}
|
||||
Map<String, Cookie> cookies = headers.getCookies();
|
||||
Cookie cookie = cookies.get(cookieName);
|
||||
if (cookie != null && cookie.getValue() != null && !cookie.getValue().isEmpty()) {
|
||||
return Optional.of(parseToken(cookie.getValue()));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private JsonWebToken parseToken(String token) {
|
||||
try {
|
||||
return parser.parse(token);
|
||||
} catch (ParseException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User extends PanacheEntityBase {
|
||||
|
||||
@Id
|
||||
public UUID id;
|
||||
|
||||
@Column(unique = true, nullable = false, length = 30)
|
||||
public String username;
|
||||
|
||||
@Column(name = "password_hash", nullable = false, length = 100)
|
||||
public String passwordHash;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
public Instant createdAt;
|
||||
|
||||
public static User findByUsername(String username) {
|
||||
return find("username", username.toLowerCase()).firstResult();
|
||||
}
|
||||
|
||||
public static User findByUsernameCaseInsensitive(String username) {
|
||||
return find("LOWER(username) = ?1", username.toLowerCase()).firstResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.l2.shots.auth;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_state")
|
||||
public class UserState extends PanacheEntityBase {
|
||||
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
public UUID userId;
|
||||
|
||||
@Column(name = "state_json", nullable = false, columnDefinition = "TEXT")
|
||||
public String stateJson;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
public Instant updatedAt;
|
||||
|
||||
public static UserState findByUserId(UUID userId) {
|
||||
return findById(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import com.l2.shots.auth.JwtCookieAuth;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Path("/api/history")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class HistoryResource {
|
||||
|
||||
@Inject
|
||||
HistoryService historyService;
|
||||
|
||||
@Inject
|
||||
JwtCookieAuth jwtCookieAuth;
|
||||
|
||||
@POST
|
||||
@Path("/runs")
|
||||
public Response saveRun(@Context HttpHeaders headers, RunIn input) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
if (input == null || input.items == null || input.snapshot == null) {
|
||||
return Response.status(400).entity("{\"error\":\"payload inválido\"}").build();
|
||||
}
|
||||
if (input.items.size() > 100) {
|
||||
return Response.status(400).entity("{\"error\":\"demasiados items\"}").build();
|
||||
}
|
||||
if (input.label != null && input.label.length() > 100) {
|
||||
return Response.status(400).entity("{\"error\":\"label demasiado largo\"}").build();
|
||||
}
|
||||
if (input.totalCristalesUsed <= 0) {
|
||||
return Response.status(400).entity("{\"error\":\"no hay cristales usados\"}").build();
|
||||
}
|
||||
|
||||
RunSummary saved = historyService.saveRun(userId.get(), input);
|
||||
return Response.status(201).entity(saved).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/runs")
|
||||
public Response list(@Context HttpHeaders headers) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
List<RunSummary> runs = historyService.listForUser(userId.get());
|
||||
return Response.ok(runs).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/runs/{id}")
|
||||
public Response get(@Context HttpHeaders headers, @PathParam("id") String idStr) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
UUID id;
|
||||
try {
|
||||
id = UUID.fromString(idStr);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(400).entity("{\"error\":\"id inválido\"}").build();
|
||||
}
|
||||
|
||||
return historyService.getById(userId.get(), id)
|
||||
.map(d -> Response.ok(d).build())
|
||||
.orElse(Response.status(404).build());
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/runs/{id}")
|
||||
public Response delete(@Context HttpHeaders headers, @PathParam("id") String idStr) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
UUID id;
|
||||
try {
|
||||
id = UUID.fromString(idStr);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(400).entity("{\"error\":\"id inválido\"}").build();
|
||||
}
|
||||
|
||||
boolean deleted = historyService.deleteForUser(userId.get(), id);
|
||||
return deleted ? Response.noContent().build() : Response.status(404).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/stats")
|
||||
public Response stats(@Context HttpHeaders headers) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
HistoryStats stats = historyService.computeStats(userId.get());
|
||||
return Response.ok(stats).build();
|
||||
}
|
||||
|
||||
private Optional<UUID> extractUserId(HttpHeaders headers) {
|
||||
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
|
||||
if (jwt.isEmpty()) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(UUID.fromString(jwt.get().getSubject()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@ApplicationScoped
|
||||
public class HistoryService {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public List<RunSummary> listForUser(UUID userId) {
|
||||
return ProductionRun.<ProductionRun>list("userId = ?1 ORDER BY createdAt DESC", userId)
|
||||
.stream()
|
||||
.map(RunSummary::new)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public Optional<RunDetails> getById(UUID userId, UUID id) {
|
||||
ProductionRun entity = ProductionRun.find("id = ?1 AND userId = ?2", id, userId).firstResult();
|
||||
if (entity == null) return Optional.empty();
|
||||
try {
|
||||
List<RunItem> items = mapper.readValue(entity.itemsJson, mapper.getTypeFactory()
|
||||
.constructCollectionType(List.class, RunItem.class));
|
||||
RunSnapshot snapshot = mapper.readValue(entity.snapshotJson, RunSnapshot.class);
|
||||
return Optional.of(new RunDetails(entity, items, snapshot));
|
||||
} catch (JsonProcessingException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RunSummary saveRun(UUID userId, RunIn input) {
|
||||
ProductionRun entity = new ProductionRun();
|
||||
entity.id = UUID.randomUUID();
|
||||
entity.userId = userId;
|
||||
entity.createdAt = Instant.now();
|
||||
entity.label = input.label;
|
||||
entity.totalCost = input.totalCost;
|
||||
entity.totalSale = input.totalSale;
|
||||
entity.totalProfit = input.totalProfit;
|
||||
entity.totalShots = input.totalShots;
|
||||
entity.totalCristalesUsed = input.totalCristalesUsed;
|
||||
entity.totalOreUsed = input.totalOreUsed;
|
||||
try {
|
||||
entity.itemsJson = mapper.writeValueAsString(input.items);
|
||||
entity.snapshotJson = mapper.writeValueAsString(input.snapshot);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to serialize run payload", e);
|
||||
}
|
||||
entity.persist();
|
||||
return new RunSummary(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean deleteForUser(UUID userId, UUID id) {
|
||||
return ProductionRun.delete("id = ?1 AND userId = ?2", id, userId) > 0;
|
||||
}
|
||||
|
||||
public HistoryStats computeStats(UUID userId) {
|
||||
List<ProductionRun> runs = ProductionRun.list(
|
||||
"userId = ?1 ORDER BY createdAt DESC", userId);
|
||||
|
||||
HistoryStats stats = new HistoryStats();
|
||||
stats.totalRuns = runs.size();
|
||||
|
||||
if (runs.isEmpty()) {
|
||||
stats.totalCost = 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 totalSale = 0;
|
||||
long totalProfit = 0;
|
||||
long totalShots = 0;
|
||||
ProductionRun best = runs.get(0);
|
||||
ProductionRun worst = runs.get(0);
|
||||
|
||||
for (ProductionRun r : runs) {
|
||||
totalCost += r.totalCost;
|
||||
totalSale += r.totalSale;
|
||||
totalProfit += r.totalProfit;
|
||||
totalShots += r.totalShots;
|
||||
if (r.totalProfit > best.totalProfit) best = r;
|
||||
if (r.totalProfit < worst.totalProfit) worst = r;
|
||||
}
|
||||
|
||||
stats.totalCost = totalCost;
|
||||
stats.totalSale = totalSale;
|
||||
stats.totalProfit = totalProfit;
|
||||
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 sum10 = 0;
|
||||
for (int i = 0; i < n10; i++) {
|
||||
sum10 += 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
public class HistoryStats {
|
||||
public int totalRuns;
|
||||
public long totalCost;
|
||||
public long totalSale;
|
||||
public long totalProfit;
|
||||
public long totalShots;
|
||||
public long avgProfit;
|
||||
public long avgCost;
|
||||
public long avgSale;
|
||||
public RunSummary bestRun;
|
||||
public RunSummary worstRun;
|
||||
public long last5Avg;
|
||||
public long last10Avg;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "production_runs", indexes = {
|
||||
@Index(name = "idx_runs_user_created", columnList = "user_id, created_at")
|
||||
})
|
||||
public class ProductionRun extends PanacheEntityBase {
|
||||
|
||||
@Id
|
||||
public UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
public UUID userId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
public Instant createdAt;
|
||||
|
||||
@Column(length = 100)
|
||||
public String label;
|
||||
|
||||
@Column(name = "total_cost", nullable = false)
|
||||
public long totalCost;
|
||||
|
||||
@Column(name = "total_sale", nullable = false)
|
||||
public long totalSale;
|
||||
|
||||
@Column(name = "total_profit", nullable = false)
|
||||
public long totalProfit;
|
||||
|
||||
@Column(name = "total_shots", nullable = false)
|
||||
public long totalShots;
|
||||
|
||||
@Column(name = "total_cristales_used", nullable = false)
|
||||
public long totalCristalesUsed;
|
||||
|
||||
@Column(name = "total_ore_used", nullable = false)
|
||||
public long totalOreUsed;
|
||||
|
||||
@Column(name = "items_json", nullable = false, columnDefinition = "TEXT")
|
||||
public String itemsJson;
|
||||
|
||||
@Column(name = "snapshot_json", nullable = false, columnDefinition = "TEXT")
|
||||
public String snapshotJson;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class RunDetails extends RunSummary {
|
||||
public List<RunItem> items;
|
||||
public RunSnapshot snapshot;
|
||||
|
||||
public RunDetails() {}
|
||||
|
||||
public RunDetails(ProductionRun r, List<RunItem> items, RunSnapshot snapshot) {
|
||||
super(r);
|
||||
this.items = items;
|
||||
this.snapshot = snapshot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class RunIn {
|
||||
public String label;
|
||||
public long totalCost;
|
||||
public long totalSale;
|
||||
public long totalProfit;
|
||||
public long totalShots;
|
||||
public long totalCristalesUsed;
|
||||
public long totalOreUsed;
|
||||
public List<RunItem> items;
|
||||
public RunSnapshot snapshot;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
public class RunItem {
|
||||
public String tipo;
|
||||
public String grado;
|
||||
public int cristalesDisponibles;
|
||||
public int cristalesUsados;
|
||||
public int oreNecesario;
|
||||
public int crafteosPosibles;
|
||||
public int shotsObtenidos;
|
||||
public long costoTotal;
|
||||
public long valorVenta;
|
||||
public 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class RunSnapshot {
|
||||
public Map<String, Object> insumos;
|
||||
public List<Map<String, Object>> formulas;
|
||||
|
||||
public RunSnapshot() {}
|
||||
|
||||
public RunSnapshot(Map<String, Object> insumos, List<Map<String, Object>> formulas) {
|
||||
this.insumos = insumos;
|
||||
this.formulas = formulas;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.l2.shots.history;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public class RunSummary {
|
||||
public UUID id;
|
||||
public Instant createdAt;
|
||||
public String label;
|
||||
public long totalCost;
|
||||
public long totalSale;
|
||||
public long totalProfit;
|
||||
public long totalShots;
|
||||
public long totalCristalesUsed;
|
||||
public long totalOreUsed;
|
||||
|
||||
public RunSummary() {}
|
||||
|
||||
public RunSummary(ProductionRun r) {
|
||||
this.id = r.id;
|
||||
this.createdAt = r.createdAt;
|
||||
this.label = r.label;
|
||||
this.totalCost = r.totalCost;
|
||||
this.totalSale = r.totalSale;
|
||||
this.totalProfit = r.totalProfit;
|
||||
this.totalShots = r.totalShots;
|
||||
this.totalCristalesUsed = r.totalCristalesUsed;
|
||||
this.totalOreUsed = r.totalOreUsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.l2.shots.state;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class AppState {
|
||||
|
||||
public Insumos insumos;
|
||||
public List<FormulaDto> formulas;
|
||||
public Map<String, Map<String, Integer>> disponibles;
|
||||
|
||||
public AppState() {}
|
||||
|
||||
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 Map<String, Integer> cristales;
|
||||
public int soulOre;
|
||||
public int spiritOre;
|
||||
public Map<String, Map<String, Integer>> venta;
|
||||
|
||||
public Insumos() {}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.l2.shots.state;
|
||||
|
||||
import com.l2.shots.auth.JwtCookieAuth;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Path("/api/state")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class StateResource {
|
||||
|
||||
@Inject
|
||||
StateService stateService;
|
||||
|
||||
@Inject
|
||||
JwtCookieAuth jwtCookieAuth;
|
||||
|
||||
@GET
|
||||
public Response get(@Context HttpHeaders headers) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
Optional<AppState> state = stateService.getForUser(userId.get());
|
||||
return state.map(s -> Response.ok(s).build())
|
||||
.orElse(Response.status(404).build());
|
||||
}
|
||||
|
||||
@PUT
|
||||
public Response put(@Context HttpHeaders headers, AppState state) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
if (state == null || state.insumos == null || state.formulas == null || state.disponibles == null) {
|
||||
return Response.status(400).entity("{\"error\":\"estado inválido\"}").build();
|
||||
}
|
||||
if (state.formulas.size() > 200) {
|
||||
return Response.status(400).entity("{\"error\":\"estado demasiado grande\"}").build();
|
||||
}
|
||||
stateService.saveForUser(userId.get(), state);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
public Response reset(@Context HttpHeaders headers) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
stateService.deleteForUser(userId.get());
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
private Optional<UUID> extractUserId(HttpHeaders headers) {
|
||||
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
|
||||
if (jwt.isEmpty()) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(UUID.fromString(jwt.get().getSubject()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.l2.shots.state;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.l2.shots.auth.UserState;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
@ApplicationScoped
|
||||
public class StateService {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public Optional<AppState> getForUser(java.util.UUID userId) {
|
||||
UserState entity = UserState.findByUserId(userId);
|
||||
if (entity == null) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(mapper.readValue(entity.stateJson, AppState.class));
|
||||
} catch (JsonProcessingException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void saveForUser(java.util.UUID userId, AppState state) {
|
||||
try {
|
||||
String json = mapper.writeValueAsString(state);
|
||||
UserState entity = UserState.findByUserId(userId);
|
||||
if (entity == null) {
|
||||
entity = new UserState();
|
||||
entity.userId = userId;
|
||||
}
|
||||
entity.stateJson = json;
|
||||
entity.updatedAt = Instant.now();
|
||||
entity.persist();
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to serialize state", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteForUser(java.util.UUID userId) {
|
||||
UserState.deleteById(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
quarkus.http.port=8080
|
||||
quarkus.http.host=0.0.0.0
|
||||
|
||||
quarkus.application.name=shot-crafter-calculator
|
||||
|
||||
# H2 file-based
|
||||
quarkus.datasource.db-kind=h2
|
||||
quarkus.datasource.jdbc.url=jdbc:h2:file:./data/shots;DB_CLOSE_DELAY=-1
|
||||
quarkus.datasource.username=sa
|
||||
quarkus.datasource.password=
|
||||
quarkus.hibernate-orm.database.generation=update
|
||||
quarkus.hibernate-orm.log.sql=false
|
||||
|
||||
# JWT
|
||||
mp.jwt.verify.issuer=shot-crafter-calculator
|
||||
mp.jwt.verify.publickey.location=publicKey.pem
|
||||
smallrye.jwt.sign.key.location=privateKey.pem
|
||||
|
||||
# Cookie auth
|
||||
app.auth.cookie-name=auth-token
|
||||
app.auth.cookie-max-age-seconds=86400
|
||||
|
||||
# Security
|
||||
quarkus.http.auth.proactive=false
|
||||
|
||||
%native.quarkus.native.resources.includes=META-INF/resources/.*,publicKey.pem,privateKey.pem
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4Rey1Bjlao2e9
|
||||
6AT++5zUVYZC+g3UIL29Nd/FG64+YZublZ8z9BEIG2IMm39B6XwgpyTIhVvW/lR1
|
||||
qSEcBaVPjcJVx3grx3GCbqZ+00BlJM/jwRUFMRybNZ9pCmWcWW2JTBhHPjGtfFBd
|
||||
kZS3kr0htccsWbILJUJlfwSyt2+rNwGNLBJfMoJBjmWjytK5wtgOTxReaUELHRqf
|
||||
hn6EukIbmyQtATDXF0Xor/MWquGrYK29oNT/R5w2oMKV4IQtDPn/Es0xkFl+nrh9
|
||||
FfgjOhOEctrf9KvPdwQIKdfhrQ/TH3iVzUxijNUOIemKoxmjf4KCz5boJHPPgSsq
|
||||
A49fQ5xFAgMBAAECggEACugB51brzGxbxqwgUOfFyPVvjFruDZjgfKiJysnldAYP
|
||||
5n33sw9Qy2jffT0zeMxfud5apCRBzXA2bH5VminQC8IpTIK79I9fLafsjRi7Y3Zx
|
||||
zW+PQG7p3AUo9FfBWtHE1LmfEucLRqgoaNlQctWprFXsvg29psDjn0viFHfHPAax
|
||||
iDe3l0ftILoUF62UEisJ8aAbd+tRPnN6uGD+f6b95+wCeRdK6WlA/ZATxie+O7A5
|
||||
u/P0ID2WdVAiANB4MVo71zMEr3fXkHWX4NerlA3MGLGYCmkN28xFpvifPh1v84OX
|
||||
oNHu0v7o88xxfHPmP/Zn138Uzad8N3uKb9n8XYzdMQKBgQDZUJNW/s6eQcq83Lpb
|
||||
yRjMyB+uq4M1XVTLX9wENdG3UxI0Vfv7n5WWz+Gd2telyRryrDjzGWupTm0/HsuO
|
||||
BrNfX2pwwuCdrbnTtdixMRImTipRxRXiRSMSn6pFqfpt3zrt+JzD1GHAtY3BcEi5
|
||||
dvoLnTiSzGZw2pb7rwuyeVsdWQKBgQDZE5VpX2NYvdm3BwoOp/KllJxgttFVRbKs
|
||||
KbfRVF1sQ68dBU/w2aUOF8dNxXzFc+nU+M8F4zz0YGbcsEhzkjKV8S/gRxiaso6c
|
||||
Qqeba8/OxqeztQnHbVh6bbSFfG9i0+FXlJVqKttjitVB0QZB3gyLdkmL0frksrvL
|
||||
F3UddwZ8zQKBgHTwWPjdUM30VWZf2KB/jCrWHcZeYNKckH6H7NsPIvTlbMxg4KG8
|
||||
dECdSKkrFBQQLcIcTuDx8u8+Vqc6qQqaLHfL3nkjRL9UtsRn/F0NLNkUAs3Roj8K
|
||||
OR9Sb8vg9fOdxhY8TA9M//U1PTy0cU3r6g3J4qGMACwGVGzG+yJlD1SxAoGBALyp
|
||||
756QX+jdwB35yTzprNNKMQtBePhSxjIpY/BUEYop3UUsu8jJcFGqSvcF4CZAUwdd
|
||||
Y5hrYivGqT+/GokPlFWLNKAJSpIRBC89IyzKa+b78v8WJjSkjVSCinXFq41KNzyG
|
||||
D8IhE2IVZLl6MKUIlwCSwuL5kcQ4r0yYy5nbO9E1AoGAVATm7YeZxFd3WIZwMtk1
|
||||
RkqMMGM6jZHk5aaHMR0YIsja4jSQjWu86y7y653nnxsVr08PnMESTREmEiyWNUC6
|
||||
94VxRSqc0HSUXHsE5w1ig2rRJg4fhrgDiQeabXUHuldYIc4Dyfps92QXguVLBhde
|
||||
o1gonQ1xfEz2HcfnDFwytGM=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuEXstQY5WqNnvegE/vuc
|
||||
1FWGQvoN1CC9vTXfxRuuPmGbm5WfM/QRCBtiDJt/Qel8IKckyIVb1v5UdakhHAWl
|
||||
T43CVcd4K8dxgm6mftNAZSTP48EVBTEcmzWfaQplnFltiUwYRz4xrXxQXZGUt5K9
|
||||
IbXHLFmyCyVCZX8EsrdvqzcBjSwSXzKCQY5lo8rSucLYDk8UXmlBCx0an4Z+hLpC
|
||||
G5skLQEw1xdF6K/zFqrhq2CtvaDU/0ecNqDCleCELQz5/xLNMZBZfp64fRX4IzoT
|
||||
hHLa3/Srz3cECCnX4a0P0x94lc1MYozVDiHpiqMZo3+Cgs+W6CRzz4ErKgOPX0Oc
|
||||
RQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
Reference in New Issue
Block a user