Referencia técnica — Microservicio de análisis nutricional con IA · Java 21 + Spring Boot 3.3 + PostgreSQL + Redis
@RestController
@RequestMapping("/api/v1/predictions")
@Validated
class PredictionController {
private final PredictionService predictionService;
// Constructor injection — no field injection
PredictionController(PredictionService svc) {
this.predictionService = svc;
}
@PostMapping
ResponseEntity<PredictionResponse> analyze(
@Valid @RequestBody AnalysisRequest req) {
Prediction result = predictionService.analyze(req);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(PredictionResponse.from(result));
}
@GetMapping
ResponseEntity<Page<PredictionResponse>> history(
@RequestParam(defaultValue="0") int page,
@RequestParam(defaultValue="20") int size) {
return ResponseEntity.ok(
predictionService.history(
PageRequest.of(page, size,
Sort.by("createdAt").descending())
).map(PredictionResponse::from)
);
}
}
public interface PredictionRepository
extends JpaRepository<PredictionEntity, Long> {
// Historial paginado por tenant (cliente B2B)
@Query("select p from PredictionEntity p " +
"where p.tenantId = :tenantId " +
"order by p.createdAt desc")
Page<PredictionEntity> findByTenant(
@Param("tenantId") String tenantId,
Pageable pageable);
// Búsqueda por hash de ingredientes (cache miss check)
Optional<PredictionEntity> findByIngredientsHash(
String hash);
// Alertas pendientes de procesar
@Query("select p from PredictionEntity p " +
"where p.score >= :threshold " +
"and p.alertSent = false")
List<PredictionEntity> findPendingAlerts(
@Param("threshold") double threshold);
}
@Service
public class PredictionService {
private static final Logger log =
LoggerFactory.getLogger(PredictionService.class);
private final PredictionRepository repo;
private final MlClientService mlClient;
private final AlertService alertService;
@Transactional
public Prediction analyze(AnalysisRequest req) {
log.info("analyze tenant={} items={}",
req.tenantId(), req.ingredients().size());
String hash = IngredientHasher.hash(req.ingredients());
// Score via ML model con retry exponencial
double score = withRetry(
() -> mlClient.predict(req.ingredients()), 3);
PredictionEntity entity = new PredictionEntity(
req.tenantId(), hash, score, req.ingredients());
PredictionEntity saved = repo.save(entity);
// Alerta async si score alto (riesgo nutricional)
if (score >= 7.5) {
alertService.sendAsync(saved);
}
return Prediction.from(saved);
}
@Transactional(readOnly = true)
public Page<Prediction> history(Pageable pageable) {
return repo.findAll(pageable).map(Prediction::from);
}
}
// REQUEST — validado con Bean Validation
public record AnalysisRequest(
@NotBlank
String tenantId,
@NotEmpty @Size(max = 50)
List<@Valid IngredientItem> ingredients,
@Size(max = 200)
String productName
) {}
public record IngredientItem(
@NotBlank String name,
@Positive double grams,
@NotBlank String unit
) {}
// RESPONSE — projection sin datos internos
public record PredictionResponse(
Long id,
double nutriScore,
String grade, // A/B/C/D/E
Instant analyzedAt,
List<String> warnings
) {
static PredictionResponse from(Prediction p) {
return new PredictionResponse(
p.id(), p.score(),
NutriGrade.from(p.score()),
p.createdAt(), p.warnings()
);
}
}
@Service
public class PredictionCacheService {
// Cache por hash de ingredientes
// TTL 24h — recetas no cambian
@Cacheable(
value = "predictions",
key = "#hash",
unless = "#result == null"
)
public Optional<Prediction>
findByHash(String hash) {
return repo.findByIngredientsHash(hash)
.map(Prediction::from);
}
@CacheEvict(
value = "predictions",
key = "#hash"
)
public void evict(String hash) {}
}
@Service
public class AlertService {
private static final Logger log =
LoggerFactory.getLogger(AlertService.class);
// No bloquea el hilo del request
@Async("alertExecutor")
public CompletableFuture<Void>
sendAsync(PredictionEntity p) {
log.info("alert_send id={} score={}",
p.getId(), p.getScore());
try {
emailClient.send(
Alert.highRisk(p));
p.setAlertSent(true);
repo.save(p);
} catch (Exception ex) {
log.error("alert_failed id={}",
p.getId(), ex);
}
return CompletableFuture
.completedFuture(null);
}
}
@ControllerAdvice
class GlobalExceptionHandler {
// 400 — validación DTO
@ExceptionHandler
ResponseEntity<ProblemDetail>
handleValidation(
MethodArgumentNotValidException ex) {
var pd = ProblemDetail.forStatus(400);
pd.setTitle("Validation failed");
pd.setProperty("errors",
ex.getFieldErrors()
.stream()
.map(e -> e.getField() +
": " + e.getDefaultMessage())
.toList());
return ResponseEntity
.badRequest().body(pd);
}
// 429 — rate limit
@ExceptionHandler(RateLimitException.class)
ResponseEntity<ProblemDetail>
handleRateLimit() {
return ResponseEntity
.status(429)
.body(ProblemDetail
.forStatus(429));
}
}
@Component
public class ApiKeyRateLimitFilter
extends OncePerRequestFilter {
private final Map<String, Bucket> buckets =
new ConcurrentHashMap<>();
/*
* SECURITY: Usamos request.getRemoteAddr()
* porque el ALB de AWS está configurado con
* server.forward-headers-strategy=NATIVE
* → getRemoteAddr() devuelve la IP real del
* cliente, no la del proxy.
* NUNCA leer X-Forwarded-For directamente.
*/
@Override
protected void doFilterInternal(
HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) {
String apiKey = req.getHeader("X-Api-Key");
if (apiKey == null) {
res.setStatus(401); return;
}
Bucket bucket = buckets
.computeIfAbsent(apiKey, k ->
Bucket.builder()
.addLimit(Bandwidth.classic(
500,
Refill.greedy(500,
Duration.ofMinutes(1))
)).build());
if (bucket.tryConsume(1)) {
chain.doFilter(req, res);
} else {
res.setStatus(HttpStatus.TOO_MANY_REQUESTS
.value());
}
}
}
Configuración requerida en application.yaml:
# application-prod.yaml
server:
# OBLIGATORIO cuando hay ALB/nginx delante
forward-headers-strategy: NATIVE
spring:
cache:
type: redis
data:
redis:
host: ${REDIS_HOST}
port: 6379
mvc:
# Errores RFC 7807 (Spring Boot 3+)
problemdetails:
enabled: true
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
management:
tracing:
sampling:
probability: 1.0
metrics:
export:
prometheus:
enabled: true
@Component
public class RequestLoggingFilter
extends OncePerRequestFilter {
private static final Logger log =
LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
protected void doFilterInternal(...) {
long start = System.currentTimeMillis();
try {
chain.doFilter(req, res);
} finally {
long ms = System.currentTimeMillis()-start;
log.info(
"req method={} uri={} status={} ms={}",
req.getMethod(),
req.getRequestURI(),
res.getStatus(), ms);
}
}
}
Stack de observabilidad NutriScore:
| Capa | Herramienta | Estado |
|---|---|---|
| Logs | SLF4J + Logback JSON | ✓ activo |
| Métricas | Micrometer + Prometheus | ✓ activo |
| Trazas | Micrometer Tracing + OTel | → pendiente |
| Alertas | Grafana Alerting | ⚠ config |
Ejemplo de respuesta — NutriScore Grade C
// POST /api/v1/predictions → 201 Created
{
"id": 4821,
"nutriScore": 5.8,
"grade": "C",
"analyzedAt": "2026-06-18T10:24:33Z",
"warnings": [
"Alto contenido en sodio (1.2g/100g)",
"Azúcares añadidos detectados"
]
}
// Error 400 — RFC 7807 Problem Details
{
"type": "about:blank",
"title": "Validation failed",
"status": 400,
"errors": [
"ingredients[0].grams: must be positive",
"tenantId: must not be blank"
]
}
// GET /api/v1/predictions?page=0&size=5
{
"content": [...],
"totalElements": 847,
"totalPages": 170,
"size": 5
}