Refactor: solo SQLite, limiti auto, UI statistiche nuova

Rimosso completamente il supporto a PostgreSQL: ora tutte le statistiche e i dati persistenti usano solo SQLite, con percorso configurabile tramite DATA_PATH per Docker/volumi. Aggiunta gestione avanzata delle statistiche per prodotto, limiti consigliati calcolati automaticamente e applicabili dalla UI. Rinnovata la pagina Statistiche con tabelle aste recenti e prodotti, rimosso il supporto a grafici legacy e a "Puntate Gratuite". Migliorata la ricerca e la gestione delle aste nel browser, aggiunta diagnostica avanzata e logging dettagliato per il database. Aggiornati Dockerfile e docker-compose: l'app è ora self-contained e pronta per l'uso senza database esterni.
This commit is contained in:
2026-01-23 16:56:03 +01:00
parent 21a1d57cab
commit a0ec72f6c0
19 changed files with 2311 additions and 968 deletions

View File

@@ -56,6 +56,10 @@ ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV Kestrel__EnableHttps=false
# Database path - tutti i database SQLite e dati persistenti
# Può essere sovrascritto nel docker-compose per mappare un volume persistente
ENV DATA_PATH=/app/Data
# Autenticazione applicazione (OBBLIGATORIO)
ENV ADMIN_USERNAME=admin
ENV ADMIN_PASSWORD=

View File

@@ -37,8 +37,14 @@ namespace AutoBidder.Models
public double MaxPrice { get; set; } = 0;
public int MinResets { get; set; } = 0; // Numero minimo reset prima di puntare
public int MaxResets { get; set; } = 0; // Numero massimo reset (0 = illimitati)
/// <summary>
/// [OBSOLETO] Numero massimo di puntate consentite - Non più utilizzato nell'UI
/// Mantenuto per retrocompatibilità con salvataggi JSON esistenti
/// </summary>
[Obsolete("MaxClicks non è più utilizzato. Usa invece la logica di limiti per prodotto.")]
[JsonPropertyName("MaxClicks")]
public int MaxClicks { get; set; } = 0; // Numero massimo di puntate consentite (0 = illimitato)
public int MaxClicks { get; set; } = 0;
// Stato asta
public bool IsActive { get; set; } = true;

View File

@@ -0,0 +1,120 @@
namespace AutoBidder.Models
{
/// <summary>
/// Record per le statistiche aggregate di un prodotto nel database
/// </summary>
public class ProductStatisticsRecord
{
public string ProductKey { get; set; } = string.Empty;
public string ProductName { get; set; } = string.Empty;
// Contatori
public int TotalAuctions { get; set; }
public int WonAuctions { get; set; }
public int LostAuctions { get; set; }
// Statistiche prezzo
public double AvgFinalPrice { get; set; }
public double? MinFinalPrice { get; set; }
public double? MaxFinalPrice { get; set; }
// Statistiche puntate
public double AvgBidsToWin { get; set; }
public int? MinBidsToWin { get; set; }
public int? MaxBidsToWin { get; set; }
// Statistiche reset
public double AvgResets { get; set; }
public int? MinResets { get; set; }
public int? MaxResets { get; set; }
// Limiti consigliati (calcolati dall'algoritmo)
public double? RecommendedMinPrice { get; set; }
public double? RecommendedMaxPrice { get; set; }
public int? RecommendedMinResets { get; set; }
public int? RecommendedMaxResets { get; set; }
public int? RecommendedMaxBids { get; set; }
// JSON con statistiche per fascia oraria
public string? HourlyStatsJson { get; set; }
// Metadata
public string? LastUpdated { get; set; }
/// <summary>
/// Calcola il win rate come percentuale
/// </summary>
public double WinRate => TotalAuctions > 0 ? (double)WonAuctions / TotalAuctions * 100 : 0;
}
/// <summary>
/// Risultato asta esteso con tutti i campi per analytics
/// </summary>
public class AuctionResultExtended
{
public int Id { get; set; }
public string AuctionId { get; set; } = "";
public string AuctionName { get; set; } = "";
public double FinalPrice { get; set; }
public int BidsUsed { get; set; }
public bool Won { get; set; }
public string Timestamp { get; set; } = "";
public double? BuyNowPrice { get; set; }
public double? ShippingCost { get; set; }
public double? TotalCost { get; set; }
public double? Savings { get; set; }
// Campi estesi per analytics
public string? WinnerUsername { get; set; }
public int? ClosedAtHour { get; set; }
public string? ProductKey { get; set; }
public int? TotalResets { get; set; }
public int? WinnerBidsUsed { get; set; }
}
/// <summary>
/// Limiti consigliati per un'asta basati sulle statistiche storiche
/// </summary>
public class RecommendedLimits
{
public double MinPrice { get; set; }
public double MaxPrice { get; set; }
public int MinResets { get; set; }
public int MaxResets { get; set; }
public int MaxBids { get; set; }
/// <summary>
/// Confidence score (0-100) - quanto sono affidabili questi limiti
/// </summary>
public int ConfidenceScore { get; set; }
/// <summary>
/// Numero di aste usate per calcolare i limiti
/// </summary>
public int SampleSize { get; set; }
/// <summary>
/// Fascia oraria migliore per vincere (0-23)
/// </summary>
public int? BestHourToPlay { get; set; }
/// <summary>
/// Win rate medio per questo prodotto
/// </summary>
public double? AverageWinRate { get; set; }
}
/// <summary>
/// Statistiche per fascia oraria
/// </summary>
public class HourlyStats
{
public int Hour { get; set; }
public int TotalAuctions { get; set; }
public int WonAuctions { get; set; }
public double AvgFinalPrice { get; set; }
public double AvgBidsUsed { get; set; }
public double WinRate => TotalAuctions > 0 ? (double)WonAuctions / TotalAuctions * 100 : 0;
}
}

View File

@@ -4,6 +4,7 @@
@using AutoBidder.Services
@inject BidooBrowserService BrowserService
@inject ApplicationStateService AppState
@inject AuctionMonitor AuctionMonitor
@inject IJSRuntime JSRuntime
@implements IDisposable
@@ -25,6 +26,13 @@
<i class="bi @(isLoading ? "bi-arrow-clockwise spin" : "bi-arrow-clockwise")"></i>
Aggiorna
</button>
@if (auctions.Count > 0)
{
<button class="btn btn-outline-danger" @onclick="ClearAllAuctions" title="Rimuove tutte le aste caricate e ferma il polling">
<i class="bi bi-trash"></i>
Pulisci Tutto
</button>
}
</div>
</div>
@@ -75,6 +83,41 @@
</div>
</div>
<!-- ? NUOVO: Search Bar -->
<div class="card mb-4 shadow-sm">
<div class="card-body">
<div class="row g-3 align-items-center">
<div class="col-md-8">
<div class="input-group input-group-lg">
<span class="input-group-text bg-primary text-white border-0">
<i class="bi bi-search"></i>
</span>
<input type="text"
class="form-control form-control-lg border-0"
placeholder="Cerca per nome asta, prezzo, vincitore..."
@bind="searchQuery"
@bind:event="oninput"
@bind:after="OnSearchChanged" />
@if (!string.IsNullOrEmpty(searchQuery))
{
<button class="btn btn-outline-secondary border-0"
@onclick="ClearSearch"
title="Cancella ricerca">
<i class="bi bi-x-lg"></i>
</button>
}
</div>
</div>
<div class="col-md-4">
<div class="stats-mini">
<span class="text-muted">Risultati filtrati:</span>
<span class="fw-bold text-info ms-2">@filteredAuctions.Count</span>
</div>
</div>
</div>
</div>
</div>
<!-- Loading State -->
@if (isLoading)
{
@@ -95,6 +138,16 @@
</div>
</div>
}
else if (filteredAuctions.Count == 0 && !string.IsNullOrEmpty(searchQuery))
{
<div class="text-center py-5 animate-fade-in">
<i class="bi bi-search text-muted" style="font-size: 4rem;"></i>
<p class="text-muted mt-3">Nessuna asta trovata per "<strong>@searchQuery</strong>"</p>
<button class="btn btn-outline-secondary" @onclick="ClearSearch">
<i class="bi bi-x-circle me-2"></i>Cancella Ricerca
</button>
</div>
}
else if (auctions.Count == 0)
{
<div class="text-center py-5 animate-fade-in">
@@ -109,7 +162,7 @@
{
<!-- Auctions Grid -->
<div class="auction-grid animate-fade-in">
@foreach (var auction in auctions)
@foreach (var auction in filteredAuctions)
{
<div class="auction-card @(auction.IsMonitored ? "monitored" : "") @(auction.IsSold ? "sold" : "")">
<!-- Image -->
@@ -241,6 +294,7 @@
@code {
private List<BidooCategoryInfo> categories = new();
private List<BidooBrowserAuction> auctions = new();
private List<BidooBrowserAuction> filteredAuctions = new();
private int selectedCategoryIndex = 0;
private int currentPage = 0;
@@ -249,6 +303,9 @@
private bool canLoadMore = true;
private string? errorMessage = null;
// ? NUOVO: Ricerca
private string searchQuery = "";
private System.Threading.Timer? stateUpdateTimer;
private CancellationTokenSource? cts;
private bool isUpdatingInBackground = false;
@@ -319,6 +376,9 @@
{
await BrowserService.UpdateAuctionStatesAsync(auctions, cts.Token);
}
// ? NUOVO: Applica filtro ricerca
ApplySearchFilter();
}
catch (OperationCanceledException)
{
@@ -336,6 +396,46 @@
}
}
// ? NUOVO: Metodo per applicare il filtro di ricerca
private void ApplySearchFilter()
{
if (string.IsNullOrWhiteSpace(searchQuery))
{
filteredAuctions = auctions.ToList();
return;
}
var query = searchQuery.ToLowerInvariant().Trim();
filteredAuctions = auctions.Where(a =>
// Cerca nel nome
a.Name.ToLowerInvariant().Contains(query) ||
// Cerca nel prezzo corrente
a.CurrentPrice.ToString("F2").Contains(query) ||
// Cerca nel prezzo buy-now
(a.BuyNowPrice > 0 && a.BuyNowPrice.ToString("F2").Contains(query)) ||
// Cerca nel nome dell'ultimo puntatore
(!string.IsNullOrEmpty(a.LastBidder) && a.LastBidder.ToLowerInvariant().Contains(query)) ||
// Cerca nell'ID asta
a.AuctionId.Contains(query)
).ToList();
}
// ? NUOVO: Callback quando cambia la ricerca
private void OnSearchChanged()
{
ApplySearchFilter();
StateHasChanged();
}
// ? NUOVO: Pulisce la ricerca
private void ClearSearch()
{
searchQuery = "";
ApplySearchFilter();
StateHasChanged();
}
private async Task LoadMoreAuctions()
{
if (categories.Count == 0 || selectedCategoryIndex < 0 || auctions.Count == 0)
@@ -364,6 +464,9 @@
// Aggiorna stati delle nuove aste
await BrowserService.UpdateAuctionStatesAsync(newAuctions, cts.Token);
// ? NUOVO: Riapplica filtro dopo caricamento
ApplySearchFilter();
}
}
catch (Exception ex)
@@ -407,6 +510,15 @@
await LoadAuctions();
}
private void ClearAllAuctions()
{
// Cancella le aste e ferma il timer
cts?.Cancel();
auctions.Clear();
filteredAuctions.Clear();
StateHasChanged();
}
private void UpdateMonitoredStatus()
{
var monitoredIds = AppState.Auctions.Select(a => a.AuctionId).ToHashSet();
@@ -420,23 +532,48 @@
{
if (browserAuction.IsMonitored) return;
// ?? Carica impostazioni di default
var settings = AutoBidder.Utilities.SettingsManager.Load();
var auctionInfo = new AuctionInfo
{
AuctionId = browserAuction.AuctionId,
Name = browserAuction.Name,
OriginalUrl = browserAuction.Url,
BuyNowPrice = (double)browserAuction.BuyNowPrice,
// ?? FIX: Applica valori dalle impostazioni
BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs,
CheckAuctionOpenBeforeBid = settings.DefaultCheckAuctionOpenBeforeBid,
MinPrice = settings.DefaultMinPrice,
MaxPrice = settings.DefaultMaxPrice,
MaxClicks = settings.DefaultMaxClicks,
MinResets = settings.DefaultMinResets,
MaxResets = settings.DefaultMaxResets,
IsActive = true,
IsPaused = true, // Start paused
AddedAt = DateTime.UtcNow
};
AppState.AddAuction(auctionInfo);
// ?? FIX CRITICO: Registra l'asta nel monitor!
AuctionMonitor.AddAuction(auctionInfo);
browserAuction.IsMonitored = true;
// Save to disk
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
// ?? FIX CRITICO: Avvia il monitor se non è già attivo
if (!AppState.IsMonitoringActive)
{
AuctionMonitor.Start();
AppState.IsMonitoringActive = true;
Console.WriteLine($"[AuctionBrowser] Monitor auto-started for paused auction: {auctionInfo.Name}");
}
StateHasChanged();
}

View File

@@ -1,33 +0,0 @@
@page "/freebids"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
<PageTitle>Puntate Gratuite - AutoBidder</PageTitle>
<div class="freebids-container animate-fade-in p-4">
<div class="d-flex align-items-center mb-4 animate-fade-in-down">
<i class="bi bi-gift-fill text-warning me-3" style="font-size: 2.5rem;"></i>
<h2 class="mb-0 fw-bold">Puntate Gratuite</h2>
</div>
<!-- Feature Under Development Notice - Conciso -->
<div class="alert alert-info border-0 shadow-sm animate-scale-in">
<div class="d-flex align-items-center">
<i class="bi bi-tools me-3" style="font-size: 2rem;"></i>
<div class="flex-grow-1">
<h5 class="mb-2"><strong>Funzionalità in Sviluppo</strong></h5>
<p class="mb-0">
Sistema di rilevamento, raccolta e utilizzo automatico delle puntate gratuite di Bidoo.
<br />
<small class="text-muted">Disponibile in una prossima versione con statistiche dettagliate.</small>
</p>
</div>
</div>
</div>
</div>
<style>
.freebids-container {
max-width: 1200px;
margin: 0 auto;
}
</style>

View File

@@ -87,7 +87,7 @@
<th class="col-prezzo"><i class="bi bi-currency-euro"></i> Prezzo</th>
<th class="col-timer"><i class="bi bi-clock"></i> Timer</th>
<th class="col-ultimo"><i class="bi bi-person"></i> Ultimo</th>
<th class="col-click"><i class="bi bi-hand-index"></i> Click</th>
<th class="col-click"><i class="bi bi-hand-index" style="font-size: 0.85rem;"></i> Puntate</th>
<th class="col-ping"><i class="bi bi-speedometer"></i> Ping</th>
<th class="col-azioni"><i class="bi bi-gear"></i> Azioni</th>
</tr>
@@ -95,7 +95,7 @@
<tbody>
@foreach (var auction in auctions)
{
<tr class="@GetRowClass(auction) table-row-enter transition-all"
<tr class="@GetRowClass(auction) @(selectedAuction == auction ? "selected-row" : "") table-row-enter transition-all"
@onclick="() => SelectAuction(auction)"
style="cursor: pointer;">
<td class="col-stato">
@@ -107,7 +107,7 @@
<td class="col-prezzo @GetPriceClass(auction)">@GetPriceDisplay(auction)</td>
<td class="col-timer">@GetTimerDisplay(auction)</td>
<td class="col-ultimo">@GetLastBidder(auction)</td>
<td class="col-click"><span class="badge bg-info">@GetMyBidsCount(auction)</span></td>
<td class="col-click bids-column fw-bold">@GetMyBidsCount(auction)</td>
<td class="col-ping">@GetPingDisplay(auction)</td>
<td class="col-azioni">
<div class="btn-group btn-group-sm" @onclick:stopPropagation="true">
@@ -230,6 +230,9 @@
<button class="btn btn-outline-secondary" @onclick="() => CopyToClipboard(selectedAuction.OriginalUrl)" title="Copia">
<i class="bi bi-clipboard"></i>
</button>
<button class="btn btn-outline-primary" @onclick="() => OpenAuctionInNewTab(selectedAuction.OriginalUrl)" title="Apri in nuova scheda">
<i class="bi bi-box-arrow-up-right"></i>
</button>
</div>
</div>
@@ -272,6 +275,31 @@
Verifica asta aperta prima di puntare
</label>
</div>
<!-- Pulsante Applica Limiti Consigliati -->
<div class="mt-3 pt-3 border-top">
<button class="btn btn-outline-primary w-100"
@onclick="ApplyRecommendedLimitsToSelected"
disabled="@isLoadingRecommendations">
@if (isLoadingRecommendations)
{
<span class="spinner-border spinner-border-sm me-2"></span>
<span>Caricamento...</span>
}
else
{
<i class="bi bi-magic me-2"></i>
<span>Applica Limiti Consigliati</span>
}
</button>
@if (!string.IsNullOrEmpty(recommendationMessage))
{
<div class="alert @(recommendationSuccess ? "alert-success" : "alert-warning") mt-2 mb-0 py-2 small">
<i class="bi @(recommendationSuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>
@recommendationMessage
</div>
}
</div>
</div>
</div>
@@ -417,12 +445,14 @@
// Crea una copia locale per evitare modifiche durante l'enumerazione
var recentBidsCopy = selectedAuction.RecentBids.ToList();
// Calcola statistiche puntatori
var bidderStats = recentBidsCopy
.GroupBy(b => b.Username)
.Select(g => new { Username = g.Key, Count = g.Count(), IsMe = g.First().IsMyBid })
.OrderByDescending(s => s.Count)
.ToList();
var totalBids = recentBidsCopy.Count;
<div class="table-responsive">
<table class="table table-sm table-striped">
@@ -438,9 +468,9 @@
@for (int i = 0; i < bidderStats.Count; i++)
{
var bidder = bidderStats[i];
var percentage = (bidder.Count * 100.0 / recentBidsCopy.Count);
var percentage = (bidder.Count * 100.0 / totalBids);
<tr class="@(bidder.IsMe ? "table-success" : "")">
<td><span class="badge bg-primary">#{i + 1}</span></td>
<td><span class="badge bg-primary">#@(i + 1)</span></td>
<td>
@bidder.Username
@if (bidder.IsMe)

View File

@@ -13,6 +13,7 @@ namespace AutoBidder.Pages
{
[Inject] private ApplicationStateService AppState { get; set; } = default!;
[Inject] private HtmlCacheService HtmlCache { get; set; } = default!;
[Inject] private StatsService StatsService { get; set; } = default!;
private List<AuctionInfo> auctions => AppState.Auctions.ToList();
private AuctionInfo? selectedAuction
@@ -41,6 +42,11 @@ namespace AutoBidder.Pages
private double sessionShopCredit;
private int sessionAuctionsWon;
// Recommended limits
private bool isLoadingRecommendations = false;
private string? recommendationMessage = null;
private bool recommendationSuccess = false;
protected override void OnInitialized()
{
// Sottoscrivi agli eventi del servizio di stato (ASYNC)
@@ -631,6 +637,19 @@ namespace AutoBidder.Pages
}
}
private async Task OpenAuctionInNewTab(string url)
{
try
{
await JSRuntime.InvokeVoidAsync("window.open", url, "_blank");
AddLog("Asta aperta in nuova scheda");
}
catch (Exception ex)
{
AddLog($"Errore apertura: {ex.Message}");
}
}
// Helper methods per stili e classi
private string GetRowClass(AuctionInfo auction)
{
@@ -1086,5 +1105,67 @@ namespace AutoBidder.Pages
await RemoveSelectedAuctionWithConfirm();
}
}
private async Task ApplyRecommendedLimitsToSelected()
{
if (selectedAuction == null) return;
isLoadingRecommendations = true;
recommendationMessage = null;
StateHasChanged();
try
{
var limits = await StatsService.GetRecommendedLimitsAsync(selectedAuction.Name);
if (limits == null || limits.SampleSize == 0)
{
recommendationMessage = "Nessun dato storico disponibile per questo prodotto. Completa alcune aste per generare raccomandazioni.";
recommendationSuccess = false;
}
else if (limits.ConfidenceScore < 30)
{
// Applica comunque ma con avviso
selectedAuction.MinPrice = limits.MinPrice;
selectedAuction.MaxPrice = limits.MaxPrice;
selectedAuction.MinResets = limits.MinResets;
selectedAuction.MaxResets = limits.MaxResets;
selectedAuction.MaxClicks = limits.MaxBids;
SaveAuctions();
recommendationMessage = $"Limiti applicati con bassa confidenza ({limits.ConfidenceScore}%) - basati su {limits.SampleSize} aste";
recommendationSuccess = true;
}
else
{
// Applica limiti con buona confidenza
selectedAuction.MinPrice = limits.MinPrice;
selectedAuction.MaxPrice = limits.MaxPrice;
selectedAuction.MinResets = limits.MinResets;
selectedAuction.MaxResets = limits.MaxResets;
selectedAuction.MaxClicks = limits.MaxBids;
SaveAuctions();
var hourInfo = limits.BestHourToPlay.HasValue
? $" | Ora migliore: {limits.BestHourToPlay}:00"
: "";
recommendationMessage = $"? Limiti applicati (confidenza {limits.ConfidenceScore}%, {limits.SampleSize} aste){hourInfo}";
recommendationSuccess = true;
}
}
catch (Exception ex)
{
recommendationMessage = $"Errore: {ex.Message}";
recommendationSuccess = false;
}
finally
{
isLoadingRecommendations = false;
StateHasChanged();
}
}
}
}

View File

@@ -12,7 +12,7 @@
<i class="bi bi-gear-fill text-primary" style="font-size: 2rem;"></i>
<div>
<h2 class="mb-0 fw-bold">Impostazioni</h2>
<small class="text-muted">Configura sessione, comportamento aste, limiti e database statistiche.</small>
<small class="text-muted">Configura sessione, comportamento aste e limiti.</small>
</div>
</div>
@@ -208,78 +208,6 @@
</div>
</div>
</div>
<!-- CONFIGURAZIONE DATABASE -->
<div class="accordion-item">
<h2 class="accordion-header" id="heading-db">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-db" aria-expanded="false" aria-controls="collapse-db">
<i class="bi bi-database-fill me-2"></i> Configurazione Database
</button>
</h2>
<div id="collapse-db" class="accordion-collapse collapse" aria-labelledby="heading-db" data-bs-parent="#settingsAccordion">
<div class="accordion-body">
<div class="alert alert-info border-0 shadow-sm">
<i class="bi bi-info-circle-fill me-2"></i>
PostgreSQL per statistiche avanzate; SQLite come fallback.
</div>
<div class="form-check form-switch mb-3">
<input type="checkbox" class="form-check-input" id="usePostgres" @bind="settings.UsePostgreSQL" />
<label class="form-check-label" for="usePostgres">Usa PostgreSQL per statistiche avanzate</label>
</div>
@if (settings.UsePostgreSQL)
{
<div class="mb-3">
<label class="form-label fw-bold"><i class="bi bi-link-45deg"></i> Connection string</label>
<input type="text" class="form-control font-monospace" @bind="settings.PostgresConnectionString" />
</div>
<div class="row g-2 mb-3">
<div class="col-12 col-md-6">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="autoCreateSchema" @bind="settings.AutoCreateDatabaseSchema" />
<label class="form-check-label" for="autoCreateSchema">Auto-crea schema se mancante</label>
</div>
</div>
<div class="col-12 col-md-6">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="fallbackSqlite" @bind="settings.FallbackToSQLite" />
<label class="form-check-label" for="fallbackSqlite">Fallback a SQLite se non disponibile</label>
</div>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<button class="btn btn-primary" @onclick="TestDatabaseConnection" disabled="@isTestingConnection">
@if (isTestingConnection)
{
<span class="spinner-border spinner-border-sm me-2"></span>
<span>Test...</span>
}
else
{
<i class="bi bi-wifi"></i>
<span>Test connessione</span>
}
</button>
@if (!string.IsNullOrEmpty(dbTestResult))
{
<span class="@(dbTestSuccess ? "text-success" : "text-danger")">
<i class="bi bi-@(dbTestSuccess ? "check-circle-fill" : "x-circle-fill") me-1"></i>
@dbTestResult
</span>
}
</div>
}
<div class="mt-3">
<button class="btn btn-secondary text-white" @onclick="SaveSettings"><i class="bi bi-check-lg"></i> Salva</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -309,10 +237,6 @@
private string? connectionError;
private bool isConnecting;
private bool isTestingConnection;
private string? dbTestResult;
private bool dbTestSuccess;
private AutoBidder.Utilities.AppSettings settings = new();
private System.Threading.Timer? updateTimer;
@@ -492,55 +416,6 @@
settings.DefaultNewAuctionState = state;
}
private async Task TestDatabaseConnection()
{
isTestingConnection = true;
dbTestResult = null;
dbTestSuccess = false;
StateHasChanged();
try
{
var connString = settings.PostgresConnectionString;
if (string.IsNullOrWhiteSpace(connString))
{
dbTestResult = "Connection string vuota";
dbTestSuccess = false;
return;
}
await using var conn = new Npgsql.NpgsqlConnection(connString);
await conn.OpenAsync();
await using var cmd = new Npgsql.NpgsqlCommand("SELECT version()", conn);
var version = await cmd.ExecuteScalarAsync();
var versionStr = version?.ToString() ?? "";
var versionNumber = versionStr.Contains("PostgreSQL") ? versionStr.Split(' ')[1] : "Unknown";
dbTestResult = $"Connessione OK (PostgreSQL {versionNumber})";
dbTestSuccess = true;
await conn.CloseAsync();
}
catch (Npgsql.NpgsqlException ex)
{
dbTestResult = $"Errore PostgreSQL: {ex.Message}";
dbTestSuccess = false;
}
catch (Exception ex)
{
dbTestResult = $"Errore: {ex.Message}";
dbTestSuccess = false;
}
finally
{
isTestingConnection = false;
StateHasChanged();
}
}
private string GetRemainingBidsClass()
{
if (remainingBids < 50) return "bg-danger";

View File

@@ -1,17 +1,21 @@
@page "/statistics"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using AutoBidder.Models
@using AutoBidder.Services
@inject StatsService StatsService
@inject IJSRuntime JSRuntime
@inject DatabaseService DatabaseService
<PageTitle>Statistiche - AutoBidder</PageTitle>
<div class="statistics-container animate-fade-in p-4">
<div class="d-flex align-items-center justify-content-between mb-4 animate-fade-in-down">
<div class="statistics-container p-4">
<div class="d-flex align-items-center justify-content-between mb-4">
<div class="d-flex align-items-center">
<i class="bi bi-bar-chart-fill text-primary me-3" style="font-size: 2.5rem;"></i>
<h2 class="mb-0 fw-bold">Statistiche</h2>
</div>
<button class="btn btn-primary hover-lift" @onclick="RefreshStats" disabled="@isLoading">
@if (StatsService.IsAvailable)
{
<button class="btn btn-primary" @onclick="RefreshStats" disabled="@isLoading">
@if (isLoading)
{
<span class="spinner-border spinner-border-sm me-2"></span>
@@ -22,217 +26,211 @@
}
Aggiorna
</button>
}
</div>
@if (errorMessage != null)
@if (!StatsService.IsAvailable)
{
<div class="alert alert-danger border-0 shadow-sm animate-shake mb-4">
<i class="bi bi-exclamation-triangle-fill me-2"></i> @errorMessage
<div class="alert alert-warning shadow-sm">
<div class="d-flex align-items-start">
<i class="bi bi-database-x me-3" style="font-size: 2rem;"></i>
<div>
<h5 class="mb-2 fw-bold">Statistiche non disponibili</h5>
<p class="mb-0">Il database per le statistiche non è stato configurato o non è accessibile.</p>
</div>
</div>
</div>
}
@if (isLoading)
else if (isLoading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;"></div>
<p class="mt-3 text-muted">Caricamento statistiche...</p>
</div>
}
else if (totalStats != null)
else
{
<!-- CARD TOTALI -->
<div class="row g-3 mb-4 animate-fade-in-up">
<div class="col-md-3">
<div class="stats-card card border-0 shadow-sm hover-lift">
<div class="card-body text-center">
<i class="bi bi-hand-index-fill text-primary" style="font-size: 2.5rem;"></i>
<h3 class="mt-3 mb-1 fw-bold">@totalStats.TotalBidsUsed</h3>
<p class="text-muted mb-0">Puntate Usate</p>
<small class="text-muted">€@((totalStats.TotalBidsUsed * 0.20).ToString("F2"))</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card card border-0 shadow-sm hover-lift">
<div class="card-body text-center">
<i class="bi bi-trophy-fill text-warning" style="font-size: 2.5rem;"></i>
<h3 class="mt-3 mb-1 fw-bold">@totalStats.TotalAuctionsWon</h3>
<p class="text-muted mb-0">Aste Vinte</p>
<small class="text-muted">Win Rate: @totalStats.WinRate.ToString("F1")%</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card card border-0 shadow-sm hover-lift">
<div class="card-body text-center">
<i class="bi bi-piggy-bank-fill text-success" style="font-size: 2.5rem;"></i>
<h3 class="mt-3 mb-1 fw-bold">€@totalStats.TotalSavings.ToString("F2")</h3>
<p class="text-muted mb-0">Risparmio Totale</p>
<small class="text-muted">ROI: @roi.ToString("F1")%</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card card border-0 shadow-sm hover-lift">
<div class="card-body text-center">
<i class="bi bi-speedometer text-info" style="font-size: 2.5rem;"></i>
<h3 class="mt-3 mb-1 fw-bold">@totalStats.AverageBidsPerAuction.ToString("F1")</h3>
<p class="text-muted mb-0">Puntate/Asta Media</p>
<small class="text-muted">Latency: @totalStats.AverageLatency.ToString("F0")ms</small>
</div>
</div>
</div>
</div>
<!-- GRAFICI -->
<div class="row g-4 mb-4 animate-fade-in-up delay-100">
<div class="col-lg-8">
<div class="card border-0 shadow-sm">
<div class="row g-4">
<!-- COLONNA SINISTRA: Aste Recenti -->
<div class="col-lg-7">
<div class="card shadow-sm h-100">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-graph-up me-2"></i>Spesa Giornaliera (Ultimi 30 Giorni)</h5>
</div>
<div class="card-body">
<canvas id="moneyChart" height="80"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="bi bi-pie-chart me-2"></i>Aste Vinte vs Perse</h5>
</div>
<div class="card-body">
<canvas id="winsChart"></canvas>
</div>
</div>
</div>
</div>
<!-- ASTE RECENTI -->
@if (recentResults != null && recentResults.Any())
{
<div class="card border-0 shadow-sm animate-fade-in-up delay-200">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Aste Recenti</h5>
<h5 class="mb-0">
<i class="bi bi-clock-history me-2"></i>
Aste Terminate Recenti
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead>
@if (recentAuctions == null || !recentAuctions.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
<p class="mt-3">Nessuna asta terminata salvata</p>
</div>
}
else
{
<div class="table-responsive" style="max-height: 600px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Asta</th>
<th>Prezzo Finale</th>
<th>Puntate</th>
<th>Risultato</th>
<th>Risparmio</th>
<th>Nome</th>
<th class="text-end">Prezzo</th>
<th class="text-end">Puntate</th>
<th>Vincitore</th>
<th class="text-center">Stato</th>
<th>Data</th>
</tr>
</thead>
<tbody>
@foreach (var result in recentResults.Take(10))
@foreach (var auction in recentAuctions)
{
<tr class="@(result.Won ? "table-success" : "")">
<td class="fw-semibold">@result.AuctionName</td>
<td>€@result.FinalPrice.ToString("F2")</td>
<td><span class="badge bg-info">@result.BidsUsed</span></td>
<td>
@if (result.Won)
<tr class="@(auction.Won ? "table-success-subtle" : "")">
<td><small>@auction.AuctionName</small></td>
<td class="text-end fw-bold">€@auction.FinalPrice.ToString("F2")</td>
<td class="text-end">@auction.BidsUsed</td>
<td><small class="text-muted">@(auction.WinnerUsername ?? "-")</small></td>
<td class="text-center">
@if (auction.Won)
{
<span class="badge bg-success"><i class="bi bi-trophy-fill"></i> Vinta</span>
<span class="badge bg-success">? Vinta</span>
}
else
{
<span class="badge bg-secondary"><i class="bi bi-x-circle"></i> Persa</span>
<span class="badge bg-secondary">? Persa</span>
}
</td>
<td class="@(result.Savings > 0 ? "text-success fw-bold" : "text-danger")">
@if (result.Savings.HasValue)
{
@((result.Savings.Value > 0 ? "+" : "") + "€" + result.Savings.Value.ToString("F2"))
}
else
{
<span class="text-muted">-</span>
}
</td>
<td class="text-muted small">@DateTime.Parse(result.Timestamp).ToString("dd/MM HH:mm")</td>
<td><small class="text-muted">@FormatTimestamp(auction.Timestamp)</small></td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- COLONNA DESTRA: Statistiche Prodotti -->
<div class="col-lg-5">
<div class="card shadow-sm h-100">
<div class="card-header bg-success text-white">
<h5 class="mb-0">
<i class="bi bi-box-seam me-2"></i>
Prodotti Salvati
</h5>
</div>
<div class="card-body p-0">
@if (products == null || !products.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
<p class="mt-3">Nessun prodotto salvato</p>
</div>
}
else
{
<div class="alert alert-info border-0 shadow-sm animate-scale-in">
<i class="bi bi-info-circle-fill me-2"></i>
Nessuna statistica disponibile. Completa alcune aste per vedere le statistiche.
<div class="table-responsive" style="max-height: 600px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Prodotto</th>
<th class="text-center">Aste</th>
<th class="text-center">Win%</th>
<th class="text-end">Limiti €</th>
<th class="text-center">Azioni</th>
</tr>
</thead>
<tbody>
@foreach (var product in products)
{
var winRate = product.TotalAuctions > 0
? (product.WonAuctions * 100.0 / product.TotalAuctions)
: 0;
<tr>
<td>
<small class="fw-bold">@product.ProductName</small>
<br/>
<small class="text-muted">@product.TotalAuctions totali (@product.WonAuctions vinte)</small>
</td>
<td class="text-center fw-bold">
@product.TotalAuctions
</td>
<td class="text-center fw-bold">
<span class="@(winRate >= 50 ? "text-success" : "text-danger")">
@winRate.ToString("F0")%
</span>
</td>
<td class="text-end">
@if (product.RecommendedMinPrice.HasValue && product.RecommendedMaxPrice.HasValue)
{
<small class="text-muted">
@product.RecommendedMinPrice.Value.ToString("F2") - @product.RecommendedMaxPrice.Value.ToString("F2")
</small>
}
else
{
<small class="text-muted">-</small>
}
</td>
<td class="text-center">
@if (product.RecommendedMinPrice.HasValue && product.RecommendedMaxPrice.HasValue)
{
<button class="btn btn-sm btn-primary"
@onclick="() => ApplyLimitsToProduct(product)"
title="Applica limiti a tutte le aste di questo prodotto">
<i class="bi bi-check2-circle"></i> Applica
</button>
}
else
{
<small class="text-muted">N/D</small>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<style>
.statistics-container {
max-width: 1600px;
margin: 0 auto;
</div>
</div>
</div>
}
.stats-card {
transition: all 0.3s ease;
}
.stats-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2) !important;
}
</style>
</div>
@code {
private TotalStats? totalStats;
private List<AuctionResult>? recentResults;
private string? errorMessage;
private bool isLoading = false;
private double roi = 0;
private bool isLoading = true;
private List<AuctionResultExtended>? recentAuctions;
private List<ProductStatisticsRecord>? products;
[Inject] private AuctionMonitor AuctionMonitor { get; set; } = default!;
[Inject] private ApplicationStateService AppState { get; set; } = default!;
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
await RefreshStats();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && totalStats != null)
{
await RenderCharts();
}
}
private async Task RefreshStats()
{
try
{
isLoading = true;
errorMessage = null;
StateHasChanged();
// Carica statistiche
totalStats = await StatsService.GetTotalStatsAsync();
roi = await StatsService.CalculateROIAsync();
recentResults = await StatsService.GetRecentAuctionResultsAsync(20);
// Render grafici dopo il caricamento
if (totalStats != null)
try
{
await RenderCharts();
}
// Carica aste recenti (ultime 50)
recentAuctions = await DatabaseService.GetRecentAuctionResultsAsync(50);
// Carica prodotti con statistiche
products = await DatabaseService.GetAllProductStatisticsAsync();
}
catch (Exception ex)
{
errorMessage = $"Errore caricamento statistiche: {ex.Message}";
Console.WriteLine($"[ERROR] Statistics: {ex}");
Console.WriteLine($"[Statistics] Error loading data: {ex.Message}");
}
finally
{
@@ -241,26 +239,52 @@
}
}
private async Task RenderCharts()
private string FormatTimestamp(string timestamp)
{
if (DateTime.TryParse(timestamp, out var dt))
{
return dt.ToString("dd/MM HH:mm");
}
return timestamp;
}
private async Task ApplyLimitsToProduct(ProductStatisticsRecord product)
{
try
{
var chartData = await StatsService.GetChartDataAsync(30);
// Trova tutte le aste con questo ProductKey nel monitor
var matchingAuctions = AppState.Auctions
.Where(a => ProductStatisticsService.GenerateProductKey(a.Name) == product.ProductKey)
.ToList();
// Render grafico spesa
await JSRuntime.InvokeVoidAsync("renderMoneyChart",
chartData.Labels,
chartData.MoneySpent,
chartData.Savings);
if (!matchingAuctions.Any())
{
await JSRuntime.InvokeVoidAsync("alert", $"Nessuna asta trovata per '{product.ProductName}'");
return;
}
// Render grafico wins
await JSRuntime.InvokeVoidAsync("renderWinsChart",
totalStats!.TotalAuctionsWon,
totalStats!.TotalAuctionsLost);
// Applica i limiti
foreach (var auction in matchingAuctions)
{
auction.MinPrice = product.RecommendedMinPrice ?? 0;
auction.MaxPrice = product.RecommendedMaxPrice ?? 0;
auction.MinResets = product.RecommendedMinResets ?? 0;
auction.MaxResets = product.RecommendedMaxResets ?? 0;
}
// Salva
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
await JSRuntime.InvokeVoidAsync("alert",
$"? Limiti applicati a {matchingAuctions.Count} aste di '{product.ProductName}'\n\n" +
$"Min: €{product.RecommendedMinPrice:F2}\n" +
$"Max: €{product.RecommendedMaxPrice:F2}\n" +
$"Min Reset: {product.RecommendedMinResets}\n" +
$"Max Reset: {product.RecommendedMaxResets}");
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Render charts: {ex.Message}");
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
}
}
}

View File

@@ -1,13 +1,9 @@
using AutoBidder.Services;
using AutoBidder.Services;
using AutoBidder.Data;
using AutoBidder.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.DataProtection;
using System.Data.Common;
var builder = WebApplication.CreateBuilder(args);
@@ -23,7 +19,7 @@ else
}
// Configura Kestrel solo per HTTPS opzionale
// HTTP è gestito da ASPNETCORE_URLS (default: http://+:8080 nel Dockerfile)
// HTTP è gestito da ASPNETCORE_URLS (default: http://+:8080 nel Dockerfile)
var enableHttps = builder.Configuration.GetValue<bool>("Kestrel:EnableHttps", false);
if (enableHttps)
@@ -77,12 +73,29 @@ else
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
// ============================================
// CONFIGURAZIONE DATABASE - Path configurabile via DATA_PATH
// ============================================
// Determina il path base per tutti i database e dati persistenti
// DATA_PATH può essere impostato nel docker-compose per usare un volume persistente
var dataBasePath = Environment.GetEnvironmentVariable("DATA_PATH");
if (string.IsNullOrEmpty(dataBasePath))
{
// Fallback: usa directory relativa all'applicazione
dataBasePath = Path.Combine(AppContext.BaseDirectory, "Data");
}
// Crea directory se non esiste
if (!Directory.Exists(dataBasePath))
{
Directory.CreateDirectory(dataBasePath);
}
Console.WriteLine($"[Startup] Data path: {dataBasePath}");
// Configura Data Protection per evitare CryptographicException
var dataProtectionPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AutoBidder",
"DataProtection-Keys"
);
var dataProtectionPath = Path.Combine(dataBasePath, "DataProtection-Keys");
if (!Directory.Exists(dataProtectionPath))
{
@@ -93,16 +106,8 @@ builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath))
.SetApplicationName("AutoBidder");
// ============================================
// CONFIGURAZIONE AUTENTICAZIONE E SICUREZZA
// ============================================
// Database per Identity (SQLite)
var identityDbPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AutoBidder",
"identity.db"
);
var identityDbPath = Path.Combine(dataBasePath, "identity.db");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
@@ -163,71 +168,6 @@ if (!builder.Environment.IsDevelopment())
});
}
// Configura Database SQLite per statistiche (fallback locale)
builder.Services.AddDbContext<StatisticsContext>(options =>
{
var dbPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AutoBidder",
"statistics.db"
);
// Crea directory se non esiste
var directory = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
options.UseSqlite($"Data Source={dbPath}");
});
// Configura Database PostgreSQL per statistiche avanzate
var usePostgres = builder.Configuration.GetValue<bool>("Database:UsePostgres", false);
if (usePostgres)
{
try
{
var connString = builder.Environment.IsProduction()
? builder.Configuration.GetConnectionString("PostgresStatsProduction")
: builder.Configuration.GetConnectionString("PostgresStats");
// Sostituisci variabili ambiente in production
if (builder.Environment.IsProduction())
{
connString = connString?
.Replace("${POSTGRES_USER}", Environment.GetEnvironmentVariable("POSTGRES_USER") ?? "autobidder")
.Replace("${POSTGRES_PASSWORD}", Environment.GetEnvironmentVariable("POSTGRES_PASSWORD") ?? "");
}
if (!string.IsNullOrEmpty(connString))
{
builder.Services.AddDbContext<AutoBidder.Data.PostgresStatsContext>(options =>
{
options.UseNpgsql(connString, npgsqlOptions =>
{
npgsqlOptions.EnableRetryOnFailure(3);
npgsqlOptions.CommandTimeout(30);
});
});
Console.WriteLine("[Startup] PostgreSQL configured for statistics");
}
else
{
Console.WriteLine("[Startup] PostgreSQL connection string not found - using SQLite only");
}
}
catch (Exception ex)
{
Console.WriteLine($"[Startup] PostgreSQL configuration failed: {ex.Message} - using SQLite only");
}
}
else
{
Console.WriteLine("[Startup] PostgreSQL disabled in configuration - using SQLite only");
}
// Registra servizi applicazione come Singleton per condividere stato
var htmlCacheService = new HtmlCacheService(
maxConcurrentRequests: 3,
@@ -245,23 +185,7 @@ builder.Services.AddSingleton(sp => new SessionService(auctionMonitor.GetApiClie
builder.Services.AddSingleton<DatabaseService>();
builder.Services.AddSingleton<ApplicationStateService>();
builder.Services.AddSingleton<BidooBrowserService>();
builder.Services.AddScoped<StatsService>(sp =>
{
var db = sp.GetRequiredService<DatabaseService>();
// Prova a ottenere PostgreSQL context (potrebbe essere null)
AutoBidder.Data.PostgresStatsContext? postgresDb = null;
try
{
postgresDb = sp.GetService<AutoBidder.Data.PostgresStatsContext>();
}
catch
{
// PostgreSQL non disponibile, usa solo SQLite
}
return new StatsService(db, postgresDb);
});
builder.Services.AddScoped<StatsService>();
builder.Services.AddScoped<AuctionStateService>();
// Configura SignalR per real-time updates
@@ -352,139 +276,77 @@ using (var scope = app.Services.CreateScope())
// Verifica salute database
var isHealthy = await databaseService.CheckDatabaseHealthAsync();
Console.WriteLine($"[DB] Database health check: {(isHealthy ? "OK" : "FAILED")}");
// 🆕 Esegui diagnostica completa se ci sono problemi o se richiesto
var runDiagnostics = Environment.GetEnvironmentVariable("DB_DIAGNOSTICS")?.ToLower() == "true";
if (!isHealthy || runDiagnostics)
{
Console.WriteLine("[DB] Running full diagnostics...");
await databaseService.RunDatabaseDiagnosticsAsync();
}
}
catch (Exception ex)
{
Console.WriteLine($"[DB ERROR] Failed to initialize database: {ex.Message}");
Console.WriteLine($"[DB ERROR] Stack trace: {ex.StackTrace}");
}
}
// Crea database statistiche se non esiste (senza migrations)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<StatisticsContext>();
// In caso di errore, esegui sempre la diagnostica
try
{
// Log percorso database
var connection = db.Database.GetDbConnection();
Console.WriteLine($"[STATS DB] Database path: {connection.DataSource}");
// Verifica se database esiste
var dbExists = db.Database.CanConnect();
Console.WriteLine($"[STATS DB] Database exists: {dbExists}");
// Forza creazione tabelle se non esistono
if (!dbExists || !db.ProductStats.Any())
{
Console.WriteLine("[STATS DB] Creating database schema...");
db.Database.EnsureDeleted(); // Elimina database vecchio
db.Database.EnsureCreated(); // Ricrea con schema aggiornato
Console.WriteLine("[STATS DB] Database schema created successfully");
}
else
{
Console.WriteLine($"[STATS DB] Database already exists with {db.ProductStats.Count()} records");
}
}
catch (Exception ex)
{
Console.WriteLine($"[STATS DB ERROR] Failed to initialize database: {ex.Message}");
Console.WriteLine($"[STATS DB ERROR] Stack trace: {ex.StackTrace}");
// Prova a ricreare forzatamente
try
{
Console.WriteLine("[STATS DB] Attempting forced recreation...");
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
Console.WriteLine("[STATS DB] Forced recreation successful");
}
catch (Exception ex2)
{
Console.WriteLine($"[STATS DB ERROR] Forced recreation failed: {ex2.Message}");
}
}
}
// Inizializza PostgreSQL per statistiche avanzate
using (var scope = app.Services.CreateScope())
{
try
{
var postgresDb = scope.ServiceProvider.GetService<AutoBidder.Data.PostgresStatsContext>();
if (postgresDb != null)
{
Console.WriteLine("[PostgreSQL] Initializing PostgreSQL statistics database...");
var autoCreateSchema = app.Configuration.GetValue<bool>("Database:AutoCreateSchema", true);
if (autoCreateSchema)
{
// Usa il metodo EnsureSchemaAsync che gestisce la creazione automatica
var schemaCreated = await postgresDb.EnsureSchemaAsync();
if (schemaCreated)
{
// Valida che tutte le tabelle siano state create
var schemaValid = await postgresDb.ValidateSchemaAsync();
if (schemaValid)
{
Console.WriteLine("[PostgreSQL] Statistics features ENABLED");
}
else
{
Console.WriteLine("[PostgreSQL] Schema validation failed");
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (missing tables)");
}
}
else
{
Console.WriteLine("[PostgreSQL] Cannot connect to database");
Console.WriteLine("[PostgreSQL] Statistics features will use SQLite fallback");
}
}
else
{
Console.WriteLine("[PostgreSQL] Auto-create schema disabled");
// Prova comunque a validare lo schema esistente
try
{
var schemaValid = await postgresDb.ValidateSchemaAsync();
if (schemaValid)
{
Console.WriteLine("[PostgreSQL] Existing schema validated successfully");
Console.WriteLine("[PostgreSQL] Statistics features ENABLED");
}
else
{
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)");
}
await databaseService.RunDatabaseDiagnosticsAsync();
}
catch
{
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)");
// Ignora errori nella diagnostica stessa
}
}
}
else
{
Console.WriteLine("[PostgreSQL] Not configured - Statistics will use SQLite only");
}
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Initialization failed: {ex.Message}");
Console.WriteLine($"[PostgreSQL ERROR] Stack trace: {ex.StackTrace}");
Console.WriteLine($"[PostgreSQL] Statistics features will use SQLite fallback");
}
}
// ??? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato
// ?? NUOVO: Collega evento OnAuctionCompleted per salvare statistiche
{
var dbService = app.Services.GetRequiredService<DatabaseService>();
auctionMonitor.OnAuctionCompleted += async (auction, state, won) =>
{
try
{
Console.WriteLine($"");
Console.WriteLine($"╔════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ [EVENTO] Asta Terminata - Salvataggio Statistiche");
Console.WriteLine($"╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Asta: {auction.Name}");
Console.WriteLine($"║ ID: {auction.AuctionId}");
Console.WriteLine($"║ Stato: {(won ? " VINTA" : " PERSA")}");
Console.WriteLine($"╚════════════════════════════════════════════════════════════════");
Console.WriteLine($"");
// Crea un nuovo scope per StatsService (è Scoped)
using var scope = app.Services.CreateScope();
var statsService = scope.ServiceProvider.GetRequiredService<StatsService>();
await statsService.RecordAuctionCompletedAsync(auction, state, won);
// ✅ CORRETTO: Log di successo SOLO se non ci sono eccezioni
Console.WriteLine($"");
Console.WriteLine($"[EVENTO] ✓ Asta salvata con successo nel database");
Console.WriteLine($"");
}
catch (Exception ex)
{
Console.WriteLine($"");
Console.WriteLine($"[EVENTO ERROR] ✗ Errore durante salvataggio statistiche:");
Console.WriteLine($"[EVENTO ERROR] {ex.Message}");
Console.WriteLine($"[EVENTO ERROR] Stack: {ex.StackTrace}");
Console.WriteLine($"");
}
};
Console.WriteLine("[STARTUP] OnAuctionCompleted event handler registered");
}
// ? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato
using (var scope = app.Services.CreateScope())
{
try
@@ -519,15 +381,26 @@ using (var scope = app.Services.CreateScope())
// Gestisci comportamento di avvio
if (settings.RememberAuctionStates)
{
// Modalità "Ricorda Stato": mantiene lo stato salvato di ogni asta
var activeAuctions = savedAuctions.Where(a => a.IsActive && !a.IsPaused).ToList();
// Modalità "Ricorda Stato": mantiene lo stato salvato di ogni asta
// 🔥 FIX CRITICO: Avvia monitor anche per aste in pausa (IsActive=true)
var activeAuctions = savedAuctions.Where(a => a.IsActive).ToList();
var resumeAuctions = savedAuctions.Where(a => a.IsActive && !a.IsPaused).ToList();
var pausedAuctions = savedAuctions.Where(a => a.IsActive && a.IsPaused).ToList();
if (activeAuctions.Any())
{
Console.WriteLine($"[STARTUP] Resuming monitoring for {activeAuctions.Count} active auctions");
Console.WriteLine($"[STARTUP] Starting monitor for {activeAuctions.Count} active auctions ({resumeAuctions.Count} active, {pausedAuctions.Count} paused)");
monitor.Start();
appState.IsMonitoringActive = true;
appState.AddLog($"[STARTUP] Ripristinato stato salvato: {activeAuctions.Count} aste attive");
if (pausedAuctions.Any())
{
appState.AddLog($"[STARTUP] Ripristinate {resumeAuctions.Count} aste attive + {pausedAuctions.Count} in pausa (polling attivo)");
}
else
{
appState.AddLog($"[STARTUP] Ripristinato stato salvato: {resumeAuctions.Count} aste attive");
}
}
else
{
@@ -537,7 +410,7 @@ using (var scope = app.Services.CreateScope())
}
else
{
// Modalità "Default": applica DefaultStartAuctionsOnLoad a tutte le aste
// Modalità "Default": applica DefaultStartAuctionsOnLoad a tutte le aste
switch (settings.DefaultStartAuctionsOnLoad)
{
case "Active":
@@ -597,7 +470,7 @@ if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// Abilita HSTS solo se HTTPS è attivo
// Abilita HSTS solo se HTTPS è attivo
if (enableHttps)
{
app.UseHsts();
@@ -608,7 +481,7 @@ else
app.UseDeveloperExceptionPage();
}
// Abilita HTTPS redirection solo se HTTPS è configurato
// Abilita HTTPS redirection solo se HTTPS è configurato
if (enableHttps)
{
app.UseHttpsRedirection();

View File

@@ -23,6 +23,12 @@ namespace AutoBidder.Services
public event Action<string>? OnLog;
public event Action<string>? OnResetCountChanged;
/// <summary>
/// Evento fired quando un'asta termina (vinta, persa o chiusa).
/// Parametri: AuctionInfo, AuctionState finale, bool won (true se vinta dall'utente)
/// </summary>
public event Action<AuctionInfo, AuctionState, bool>? OnAuctionCompleted;
public AuctionMonitor()
{
_apiClient = new BidooApiClient();
@@ -101,13 +107,53 @@ namespace AutoBidder.Services
var auction = _auctions.FirstOrDefault(a => a.AuctionId == auctionId);
if (auction != null)
{
// ?? Se l'asta è terminata, salva le statistiche prima di rimuoverla
if (!auction.IsActive && auction.LastState != null)
{
OnLog?.Invoke($"[STATS] Asta terminata rilevata: {auction.Name} - Salvataggio statistiche in corso...");
try
{
// Determina se è stata vinta dall'utente
var won = IsAuctionWonByUser(auction);
OnLog?.Invoke($"[STATS] Asta {auction.Name} - Stato: {(won ? "VINTA" : "PERSA")}");
// Emetti evento per salvare le statistiche
// Questo trigger sarà gestito in Program.cs con scraping HTML
OnAuctionCompleted?.Invoke(auction, auction.LastState, won);
}
catch (Exception ex)
{
OnLog?.Invoke($"[STATS ERROR] Errore durante salvataggio statistiche per {auction.Name}: {ex.Message}");
}
}
else
{
OnLog?.Invoke($"[REMOVE] Rimozione asta non terminata: {auction.Name} (non salvata nelle statistiche)");
}
_auctions.Remove(auction);
// ? RIMOSSO: Log ridondante - viene già loggato da MainWindow con più dettagli
// OnLog?.Invoke($"[-] Asta rimossa: {auction.Name}");
}
}
}
/// <summary>
/// Determina se l'asta è stata vinta dall'utente corrente
/// </summary>
private bool IsAuctionWonByUser(AuctionInfo auction)
{
if (auction.LastState == null) return false;
var session = _apiClient.GetSession();
var username = session?.Username;
if (string.IsNullOrEmpty(username)) return false;
// Controlla se l'ultimo puntatore è l'utente
return auction.LastState.LastBidder?.Equals(username, StringComparison.OrdinalIgnoreCase) == true;
}
public IReadOnlyList<AuctionInfo> GetAuctions()
{
lock (_auctions)
@@ -116,6 +162,62 @@ namespace AutoBidder.Services
}
}
/// <summary>
/// Applica i limiti consigliati a un'asta specifica
/// </summary>
public bool ApplyLimitsToAuction(string auctionId, double minPrice, double maxPrice, int minResets, int maxResets, int maxBids)
{
lock (_auctions)
{
var auction = _auctions.FirstOrDefault(a => a.AuctionId == auctionId);
if (auction == null) return false;
auction.MinPrice = minPrice;
auction.MaxPrice = maxPrice;
auction.MinResets = minResets;
auction.MaxResets = maxResets;
auction.MaxClicks = maxBids;
OnLog?.Invoke($"[LIMITS] Aggiornati limiti per {auction.Name}: MinPrice={minPrice:F2}, MaxPrice={maxPrice:F2}, MinResets={minResets}, MaxResets={maxResets}, MaxBids={maxBids}");
return true;
}
}
/// <summary>
/// Applica i limiti consigliati a tutte le aste con lo stesso ProductKey
/// </summary>
public int ApplyLimitsToProductAuctions(string productKey, double minPrice, double maxPrice, int minResets, int maxResets, int maxBids)
{
int count = 0;
lock (_auctions)
{
foreach (var auction in _auctions)
{
var auctionProductKey = ProductStatisticsService.GenerateProductKey(auction.Name);
if (auctionProductKey == productKey)
{
auction.MinPrice = minPrice;
auction.MaxPrice = maxPrice;
auction.MinResets = minResets;
auction.MaxResets = maxResets;
auction.MaxClicks = maxBids;
count++;
OnLog?.Invoke($"[LIMITS] Aggiornati limiti per {auction.Name}");
}
}
}
if (count > 0)
{
OnLog?.Invoke($"[LIMITS] Applicati limiti a {count} aste con productKey={productKey}");
}
return count;
}
public void Start()
{
if (_monitoringTask != null && !_monitoringTask.IsCompleted)
@@ -247,6 +349,7 @@ namespace AutoBidder.Services
return;
}
auction.PollingLatencyMs = state.PollingLatencyMs;
// ? AGGIORNATO: Aggiorna storia puntate mantenendo quelle vecchie
@@ -262,7 +365,10 @@ namespace AutoBidder.Services
string statusMsg = state.Status == AuctionStatus.EndedWon ? "VINTA" :
state.Status == AuctionStatus.EndedLost ? "Persa" : "Chiusa";
bool won = state.Status == AuctionStatus.EndedWon;
auction.IsActive = false;
auction.LastState = state; // Salva stato finale per statistiche
auction.AddLog($"[ASTA TERMINATA] {statusMsg}");
OnLog?.Invoke($"[FINE] [{auction.AuctionId}] Asta {statusMsg} - Polling fermato");
@@ -277,6 +383,18 @@ namespace AutoBidder.Services
});
OnAuctionUpdated?.Invoke(state);
// ?? Emetti evento per salvare statistiche
try
{
OnAuctionCompleted?.Invoke(auction, state, won);
OnLog?.Invoke($"[STATS] Evento OnAuctionCompleted emesso per {auction.Name}");
}
catch (Exception ex)
{
OnLog?.Invoke($"[STATS ERROR] Errore in OnAuctionCompleted: {ex.Message}");
}
return;
}
@@ -473,7 +591,11 @@ namespace AutoBidder.Services
private bool ShouldBid(AuctionInfo auction, AuctionState state)
{
// ?? CONTROLLO 0: Verifica convenienza (se dati disponibili)
if (auction.CalculatedValue != null &&
// ?? IMPORTANTE: Applica solo se BuyNowPrice è valido (> 0)
// Se BuyNowPrice == 0, significa errore scraping - non bloccare le puntate
if (auction.BuyNowPrice.HasValue &&
auction.BuyNowPrice.Value > 0 &&
auction.CalculatedValue != null &&
auction.CalculatedValue.Savings.HasValue &&
!auction.CalculatedValue.IsWorthIt)
{

View File

@@ -219,6 +219,14 @@ namespace AutoBidder.Services
// Parse aste dall'HTML (fragment AJAX)
auctions = ParseAuctionsFromHtml(html);
// ?? FIX: Filtra solo aste di puntate se categoria "Aste di Puntate" (TabId = 1)
if (category.IsSpecialCategory && category.TabId == 1)
{
var before = auctions.Count;
auctions = auctions.Where(a => a.IsCreditAuction).ToList();
Console.WriteLine($"[BidooBrowser] Filtrate {before} -> {auctions.Count} aste di puntate (IsCreditAuction = true)");
}
Console.WriteLine($"[BidooBrowser] Trovate {auctions.Count} aste nella categoria {category.DisplayName}");
}
catch (Exception ex)

View File

@@ -1,26 +1,81 @@
using Microsoft.Data.Sqlite;
using Microsoft.Data.Sqlite;
using System;
using System.IO;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per gestione database SQLite con auto-creazione tabelle e migrations
/// Servizio per gestione database SQLite con auto-creazione tabelle e migrations.
/// Il path del database può essere configurato tramite variabile ambiente DATA_PATH
/// per supportare volumi Docker persistenti.
/// </summary>
public class DatabaseService : IDisposable
{
private readonly string _connectionString;
private readonly string _databasePath;
private bool _isInitialized = false;
private bool _isAvailable = false;
private string? _initializationError;
/// <summary>
/// Indica se il database è stato inizializzato correttamente
/// </summary>
public bool IsInitialized => _isInitialized;
/// <summary>
/// Indica se il database è disponibile e funzionante
/// </summary>
public bool IsAvailable => _isAvailable;
/// <summary>
/// Eventuale errore di inizializzazione
/// </summary>
public string? InitializationError => _initializationError;
/// <summary>
/// Path del database
/// </summary>
public string DatabasePath => _databasePath;
/// <summary>
/// Crea una nuova istanza di DatabaseService.
/// Il path del database viene determinato in questo ordine:
/// 1. Variabile ambiente DATA_PATH (per Docker)
/// 2. Directory "data" relativa all'eseguibile (fallback)
/// </summary>
public DatabaseService()
{
// Crea directory data se non esiste
var dataDir = Path.Combine(AppContext.BaseDirectory, "data");
Directory.CreateDirectory(dataDir);
// Determina il path base per i dati
// DATA_PATH può essere impostato nel docker-compose per usare un volume persistente
var dataBasePath = Environment.GetEnvironmentVariable("DATA_PATH");
_databasePath = Path.Combine(dataDir, "autobidder.db");
if (string.IsNullOrEmpty(dataBasePath))
{
// Fallback: usa directory relativa all'applicazione (coerente con Program.cs)
dataBasePath = Path.Combine(AppContext.BaseDirectory, "Data");
}
// Verifica se la directory esiste o può essere creata
try
{
Directory.CreateDirectory(dataBasePath);
_isAvailable = true;
}
catch (Exception ex)
{
_isAvailable = false;
_initializationError = $"Impossibile creare directory dati: {ex.Message}";
Console.WriteLine($"[DatabaseService ERROR] {_initializationError}");
}
_databasePath = Path.Combine(dataBasePath, "autobidder.db");
_connectionString = $"Data Source={_databasePath}";
Console.WriteLine($"[DatabaseService] Database path: {_databasePath}");
Console.WriteLine($"[DatabaseService] Available: {_isAvailable}");
}
/// <summary>
@@ -28,21 +83,83 @@ namespace AutoBidder.Services
/// </summary>
public async Task InitializeDatabaseAsync()
{
if (!_isAvailable)
{
Console.WriteLine("[DatabaseService] Skipping initialization - database not available");
return;
}
try
{
Console.WriteLine("[DatabaseService] ===== INIZIO INIZIALIZZAZIONE DATABASE =====");
Console.WriteLine($"[DatabaseService] Apertura connessione a: {_databasePath}");
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
Console.WriteLine("[DatabaseService] ✓ Connessione aperta con successo");
// Abilita foreign keys
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText = "PRAGMA foreign_keys = ON;";
await cmd.ExecuteNonQueryAsync();
Console.WriteLine("[DatabaseService] ✓ Foreign keys abilitati");
}
// Crea tabelle se non esistono
Console.WriteLine("[DatabaseService] Creazione tabelle base...");
await CreateTablesAsync(connection);
Console.WriteLine("[DatabaseService] ✓ Tabelle base create");
// Esegui migrations
Console.WriteLine("[DatabaseService] Esecuzione migrations...");
await RunMigrationsAsync(connection);
Console.WriteLine("[DatabaseService] ✓ Migrations completate");
_isInitialized = true;
_isAvailable = true;
Console.WriteLine("[DatabaseService] ===== INIZIALIZZAZIONE COMPLETATA =====");
}
catch (SqliteException sqlEx)
{
_isInitialized = false;
_isAvailable = false;
_initializationError = $"SQLite Error: {sqlEx.Message} (Code: {sqlEx.SqliteErrorCode}, Extended: {sqlEx.SqliteExtendedErrorCode})";
Console.WriteLine("");
Console.WriteLine("╔════════════════════════════════════════════════════════════════");
Console.WriteLine("║ [DATABASE ERROR] Errore SQLite Durante Inizializzazione");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Messaggio: {sqlEx.Message}");
Console.WriteLine($"║ SQLite Error Code: {sqlEx.SqliteErrorCode}");
Console.WriteLine($"║ SQLite Extended Code: {sqlEx.SqliteExtendedErrorCode}");
Console.WriteLine($"║ Database Path: {_databasePath}");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Stack Trace:");
Console.WriteLine($"{sqlEx.StackTrace}");
Console.WriteLine("╚════════════════════════════════════════════════════════════════");
Console.WriteLine("");
}
catch (Exception ex)
{
_isInitialized = false;
_isAvailable = false;
_initializationError = $"Errore inizializzazione database: {ex.Message}";
Console.WriteLine("");
Console.WriteLine("╔════════════════════════════════════════════════════════════════");
Console.WriteLine("║ [DATABASE ERROR] Errore Generico Durante Inizializzazione");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Tipo: {ex.GetType().Name}");
Console.WriteLine($"║ Messaggio: {ex.Message}");
Console.WriteLine($"║ Database Path: {_databasePath}");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Stack Trace:");
Console.WriteLine($"{ex.StackTrace}");
Console.WriteLine("╚════════════════════════════════════════════════════════════════");
Console.WriteLine("");
}
}
/// <summary>
@@ -122,7 +239,7 @@ namespace AutoBidder.Services
FOREIGN KEY (AuctionId) REFERENCES Auctions(AuctionId) ON DELETE CASCADE
);
-- Tabella statistiche prodotti (già esistente da StatsService)
-- Tabella statistiche prodotti (già esistente da StatsService)
CREATE TABLE IF NOT EXISTS ProductStats (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
ProductKey TEXT NOT NULL,
@@ -155,12 +272,13 @@ namespace AutoBidder.Services
private async Task RunMigrationsAsync(SqliteConnection connection)
{
var currentVersion = await GetDatabaseVersionAsync(connection);
Console.WriteLine($"[DatabaseService] Versione database corrente: {currentVersion}");
// Migrations in ordine crescente
var migrations = new[]
{
new Migration(1, "Initial schema", async (conn) => {
// Schema già creato in CreateTablesAsync
// Schema già creato in CreateTablesAsync
await Task.CompletedTask;
}),
@@ -336,6 +454,148 @@ namespace AutoBidder.Services
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
await cmd.ExecuteNonQueryAsync();
}),
new Migration(8, "Enhance AuctionResults for analytics", async (conn) => {
var sql = @"
-- Aggiungi colonne per analytics avanzate
ALTER TABLE AuctionResults ADD COLUMN WinnerUsername TEXT;
ALTER TABLE AuctionResults ADD COLUMN ClosedAtHour INTEGER;
ALTER TABLE AuctionResults ADD COLUMN ProductKey TEXT;
ALTER TABLE AuctionResults ADD COLUMN TotalResets INTEGER DEFAULT 0;
ALTER TABLE AuctionResults ADD COLUMN WinnerBidsUsed INTEGER;
-- Indici per query per prodotto e fascia oraria
CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey ON AuctionResults(ProductKey);
CREATE INDEX IF NOT EXISTS idx_auctionresults_hour ON AuctionResults(ClosedAtHour);
";
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
await cmd.ExecuteNonQueryAsync();
}),
new Migration(9, "Create ProductStatistics table for auto-limits", async (conn) => {
var sql = @"
-- Tabella statistiche aggregate per prodotto
CREATE TABLE IF NOT EXISTS ProductStatistics (
ProductKey TEXT PRIMARY KEY,
ProductName TEXT NOT NULL,
TotalAuctions INTEGER NOT NULL DEFAULT 0,
WonAuctions INTEGER NOT NULL DEFAULT 0,
LostAuctions INTEGER NOT NULL DEFAULT 0,
AvgFinalPrice REAL DEFAULT 0,
MinFinalPrice REAL,
MaxFinalPrice REAL,
AvgBidsToWin REAL DEFAULT 0,
MinBidsToWin INTEGER,
MaxBidsToWin INTEGER,
AvgResets REAL DEFAULT 0,
MinResets INTEGER,
MaxResets INTEGER,
-- Limiti consigliati (calcolati dall'algoritmo)
RecommendedMinPrice REAL,
RecommendedMaxPrice REAL,
RecommendedMinResets INTEGER,
RecommendedMaxResets INTEGER,
RecommendedMaxBids INTEGER,
-- Statistiche per fascia oraria (JSON)
HourlyStatsJson TEXT,
-- Metadata
LastUpdated TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CreatedAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indici per performance
CREATE INDEX IF NOT EXISTS idx_productstats_name ON ProductStatistics(ProductName);
CREATE INDEX IF NOT EXISTS idx_productstats_updated ON ProductStatistics(LastUpdated DESC);
";
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
await cmd.ExecuteNonQueryAsync();
}),
new Migration(10, "Add performance indexes for analytics queries", async (conn) => {
var sql = @"
-- ✅ Indici compositi per query su ProductKey + Won (usato in algoritmo limiti)
CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_won
ON AuctionResults(ProductKey, Won);
-- ✅ Indice per query temporali filtrate per vincita
CREATE INDEX IF NOT EXISTS idx_auctionresults_timestamp_won
ON AuctionResults(Timestamp DESC, Won);
-- ✅ Indice per query per fascia oraria
CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_hour
ON AuctionResults(ProductKey, ClosedAtHour);
-- ✅ Indice per ordinamento per prezzo finale
CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_price
ON AuctionResults(ProductKey, FinalPrice);
-- ✅ Indice per query puntate vincitore
CREATE INDEX IF NOT EXISTS idx_auctionresults_winnerbids
ON AuctionResults(ProductKey, WinnerBidsUsed)
WHERE WinnerBidsUsed IS NOT NULL;
-- ✅ Indice per query reset totali
CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_resets
ON AuctionResults(ProductKey, TotalResets)
WHERE TotalResets IS NOT NULL;
";
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
await cmd.ExecuteNonQueryAsync();
}),
new Migration(11, "Remove foreign key constraint from AuctionResults", async (conn) => {
// SQLite non supporta DROP CONSTRAINT, quindi dobbiamo ricreare la tabella
var sql = @"
-- 1. Rinomina la tabella esistente
ALTER TABLE AuctionResults RENAME TO AuctionResults_old;
-- 2. Ricrea la tabella SENZA foreign key
CREATE TABLE AuctionResults (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
AuctionId TEXT NOT NULL,
AuctionName TEXT NOT NULL,
FinalPrice REAL NOT NULL,
BidsUsed INTEGER NOT NULL,
Won INTEGER NOT NULL DEFAULT 0,
Timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
BuyNowPrice REAL,
ShippingCost REAL,
TotalCost REAL,
Savings REAL,
WinnerUsername TEXT,
ClosedAtHour INTEGER,
ProductKey TEXT,
TotalResets INTEGER DEFAULT 0,
WinnerBidsUsed INTEGER
);
-- 3. Copia i dati dalla vecchia tabella
INSERT INTO AuctionResults
SELECT * FROM AuctionResults_old;
-- 4. Elimina la vecchia tabella
DROP TABLE AuctionResults_old;
-- 5. Ricrea gli indici
CREATE INDEX IF NOT EXISTS idx_auctionresults_timestamp ON AuctionResults(Timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_auctionresults_won ON AuctionResults(Won);
CREATE INDEX IF NOT EXISTS idx_auctionresults_auctionid ON AuctionResults(AuctionId);
CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey ON AuctionResults(ProductKey);
CREATE INDEX IF NOT EXISTS idx_auctionresults_hour ON AuctionResults(ClosedAtHour);
CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_won ON AuctionResults(ProductKey, Won);
CREATE INDEX IF NOT EXISTS idx_auctionresults_timestamp_won ON AuctionResults(Timestamp DESC, Won);
CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_hour ON AuctionResults(ProductKey, ClosedAtHour);
CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_price ON AuctionResults(ProductKey, FinalPrice);
CREATE INDEX IF NOT EXISTS idx_auctionresults_winnerbids ON AuctionResults(ProductKey, WinnerBidsUsed) WHERE WinnerBidsUsed IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_resets ON AuctionResults(ProductKey, TotalResets) WHERE TotalResets IS NOT NULL;
";
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
await cmd.ExecuteNonQueryAsync();
})
};
@@ -343,8 +603,50 @@ namespace AutoBidder.Services
{
if (migration.Version > currentVersion)
{
try
{
Console.WriteLine($"[DatabaseService] ┌─ Applicazione Migration {migration.Version}: {migration.Description}");
await migration.Execute(connection);
await SetDatabaseVersionAsync(connection, migration.Version, migration.Description);
Console.WriteLine($"[DatabaseService] └─ ✓ Migration {migration.Version} completata");
}
catch (SqliteException sqlEx)
{
Console.WriteLine("");
Console.WriteLine($"╔════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ [MIGRATION ERROR] Errore in Migration {migration.Version}");
Console.WriteLine($"╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Descrizione: {migration.Description}");
Console.WriteLine($"║ SQLite Error Code: {sqlEx.SqliteErrorCode}");
Console.WriteLine($"║ SQLite Extended Code: {sqlEx.SqliteExtendedErrorCode}");
Console.WriteLine($"║ Messaggio: {sqlEx.Message}");
Console.WriteLine($"╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Stack Trace:");
Console.WriteLine($"{sqlEx.StackTrace}");
Console.WriteLine($"╚════════════════════════════════════════════════════════════════");
Console.WriteLine("");
throw; // Re-throw per fermare inizializzazione
}
catch (Exception ex)
{
Console.WriteLine("");
Console.WriteLine($"╔════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ [MIGRATION ERROR] Errore Generico in Migration {migration.Version}");
Console.WriteLine($"╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Descrizione: {migration.Description}");
Console.WriteLine($"║ Tipo: {ex.GetType().Name}");
Console.WriteLine($"║ Messaggio: {ex.Message}");
Console.WriteLine($"╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Stack Trace:");
Console.WriteLine($"{ex.StackTrace}");
Console.WriteLine($"╚════════════════════════════════════════════════════════════════");
Console.WriteLine("");
throw;
}
}
else
{
Console.WriteLine($"[DatabaseService] ⊘ Migration {migration.Version} già applicata");
}
}
}
@@ -380,16 +682,49 @@ namespace AutoBidder.Services
/// Ottiene una connessione al database
/// </summary>
public async Task<SqliteConnection> GetConnectionAsync()
{
try
{
var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
return connection;
}
catch (SqliteException sqlEx)
{
Console.WriteLine("");
Console.WriteLine("╔════════════════════════════════════════════════════════════════");
Console.WriteLine("║ [CONNECTION ERROR] Impossibile aprire connessione database");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Database Path: {_databasePath}");
Console.WriteLine($"║ SQLite Error Code: {sqlEx.SqliteErrorCode}");
Console.WriteLine($"║ SQLite Extended Code: {sqlEx.SqliteExtendedErrorCode}");
Console.WriteLine($"║ Messaggio: {sqlEx.Message}");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
// Suggerimenti specifici per errori comuni
if (sqlEx.SqliteErrorCode == 5) // SQLITE_BUSY
{
Console.WriteLine("║ SUGGERIMENTO: Database locked - altra connessione attiva");
Console.WriteLine("║ Potrebbe essere un problema di concorrenza");
}
else if (sqlEx.SqliteErrorCode == 14) // SQLITE_CANTOPEN
{
Console.WriteLine("║ SUGGERIMENTO: Impossibile aprire il file database");
Console.WriteLine($"║ Verifica permessi su: {_databasePath}");
}
Console.WriteLine("╚════════════════════════════════════════════════════════════════");
Console.WriteLine("");
throw;
}
}
/// <summary>
/// Esegue una query SQL e ritorna il numero di righe affette
/// </summary>
public async Task<int> ExecuteNonQueryAsync(string sql, params SqliteParameter[] parameters)
{
try
{
await using var connection = await GetConnectionAsync();
await using var cmd = connection.CreateCommand();
@@ -400,11 +735,42 @@ namespace AutoBidder.Services
}
return await cmd.ExecuteNonQueryAsync();
}
catch (SqliteException sqlEx)
{
Console.WriteLine("");
Console.WriteLine("╔════════════════════════════════════════════════════════════════");
Console.WriteLine("║ [QUERY ERROR] Errore SQLite in ExecuteNonQueryAsync");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ SQLite Error Code: {sqlEx.SqliteErrorCode}");
Console.WriteLine($"║ SQLite Extended Code: {sqlEx.SqliteExtendedErrorCode}");
Console.WriteLine($"║ Messaggio: {sqlEx.Message}");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ SQL Query (primi 500 chars):");
Console.WriteLine($"║ {(sql.Length > 500 ? sql.Substring(0, 500) + "..." : sql)}");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
if (parameters != null && parameters.Length > 0)
{
Console.WriteLine($"║ Parametri:");
foreach (var param in parameters)
{
Console.WriteLine($"║ {param.ParameterName} = {param.Value}");
}
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
}
Console.WriteLine($"║ Stack Trace:");
Console.WriteLine($"{sqlEx.StackTrace}");
Console.WriteLine("╚════════════════════════════════════════════════════════════════");
Console.WriteLine("");
throw;
}
}
/// <summary>
/// Esegue una query SQL e ritorna un valore scalare
/// </summary>
public async Task<object?> ExecuteScalarAsync(string sql, params SqliteParameter[] parameters)
{
try
{
await using var connection = await GetConnectionAsync();
await using var cmd = connection.CreateCommand();
@@ -415,6 +781,23 @@ namespace AutoBidder.Services
}
return await cmd.ExecuteScalarAsync();
}
catch (SqliteException sqlEx)
{
Console.WriteLine("");
Console.WriteLine("╔════════════════════════════════════════════════════════════════");
Console.WriteLine("║ [QUERY ERROR] Errore SQLite in ExecuteScalarAsync");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ SQLite Error Code: {sqlEx.SqliteErrorCode}");
Console.WriteLine($"║ SQLite Extended Code: {sqlEx.SqliteExtendedErrorCode}");
Console.WriteLine($"║ Messaggio: {sqlEx.Message}");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ SQL Query (primi 500 chars):");
Console.WriteLine($"║ {(sql.Length > 500 ? sql.Substring(0, 500) + "..." : sql)}");
Console.WriteLine("╚════════════════════════════════════════════════════════════════");
Console.WriteLine("");
throw;
}
}
/// <summary>
/// Verifica la salute del database
@@ -429,12 +812,130 @@ namespace AutoBidder.Services
var result = await cmd.ExecuteScalarAsync();
return Convert.ToInt32(result) > 0;
}
catch
catch (Exception ex)
{
Console.WriteLine($"[DatabaseService] Health check failed: {ex.Message}");
return false;
}
}
/// <summary>
/// Diagnostica completa del database - mostra tutte le tabelle, indici e versione
/// </summary>
public async Task RunDatabaseDiagnosticsAsync()
{
try
{
Console.WriteLine("");
Console.WriteLine("╔════════════════════════════════════════════════════════════════");
Console.WriteLine("║ [DIAGNOSTICA] Analisi Database SQLite");
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Path: {_databasePath}");
Console.WriteLine($"║ Disponibile: {_isAvailable}");
Console.WriteLine($"║ Inizializzato: {_isInitialized}");
if (File.Exists(_databasePath))
{
var fileInfo = new FileInfo(_databasePath);
Console.WriteLine($"║ Dimensione: {FormatBytes(fileInfo.Length)}");
Console.WriteLine($"║ Creato: {fileInfo.CreationTime}");
Console.WriteLine($"║ Modificato: {fileInfo.LastWriteTime}");
}
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
await using var connection = await GetConnectionAsync();
// Versione database
var version = await GetDatabaseVersionAsync(connection);
Console.WriteLine($"║ Versione Schema: {version}");
// Lista tabelle
await using var cmdTables = connection.CreateCommand();
cmdTables.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;";
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine("║ Tabelle presenti:");
await using var readerTables = await cmdTables.ExecuteReaderAsync();
while (await readerTables.ReadAsync())
{
var tableName = readerTables.GetString(0);
// Conta record per tabella
await using var cmdCount = connection.CreateCommand();
cmdCount.CommandText = $"SELECT COUNT(*) FROM [{tableName}];";
var count = Convert.ToInt32(await cmdCount.ExecuteScalarAsync());
Console.WriteLine($"║ • {tableName,-30} ({count,6} record)");
}
// Lista indici
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine("║ Indici presenti:");
await using var cmdIndexes = connection.CreateCommand();
cmdIndexes.CommandText = "SELECT name, tbl_name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%' ORDER BY tbl_name, name;";
await using var readerIndexes = await cmdIndexes.ExecuteReaderAsync();
string? currentTable = null;
while (await readerIndexes.ReadAsync())
{
var indexName = readerIndexes.GetString(0);
var tableName = readerIndexes.GetString(1);
if (currentTable != tableName)
{
currentTable = tableName;
Console.WriteLine($"║ [{tableName}]");
}
Console.WriteLine($"║ - {indexName}");
}
// Migrations applicate
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine("║ Migrations applicate:");
await using var cmdMigrations = connection.CreateCommand();
cmdMigrations.CommandText = "SELECT Version, AppliedAt, Description FROM DatabaseVersion ORDER BY Version;";
await using var readerMigrations = await cmdMigrations.ExecuteReaderAsync();
while (await readerMigrations.ReadAsync())
{
var ver = readerMigrations.GetInt32(0);
var appliedAt = readerMigrations.GetString(1);
var desc = readerMigrations.GetString(2);
Console.WriteLine($"║ v{ver}: {desc}");
Console.WriteLine($"║ Applicata: {appliedAt}");
}
Console.WriteLine("╚════════════════════════════════════════════════════════════════");
Console.WriteLine("");
}
catch (Exception ex)
{
Console.WriteLine("╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ ERRORE durante diagnostica: {ex.Message}");
Console.WriteLine("╚════════════════════════════════════════════════════════════════");
Console.WriteLine("");
}
}
private static string FormatBytes(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
/// <summary>
/// Ottiene informazioni sul database
/// </summary>
@@ -535,15 +1036,20 @@ namespace AutoBidder.Services
}
/// <summary>
/// Salva risultato asta
/// Salva risultato asta con dati completi per analytics
/// </summary>
public async Task SaveAuctionResultAsync(string auctionId, string auctionName, double finalPrice, int bidsUsed, bool won,
double? buyNowPrice = null, double? shippingCost = null, double? totalCost = null, double? savings = null)
double? buyNowPrice = null, double? shippingCost = null, double? totalCost = null, double? savings = null,
string? winnerUsername = null, int? totalResets = null, int? winnerBidsUsed = null, string? productKey = null)
{
var closedAtHour = DateTime.UtcNow.Hour;
var sql = @"
INSERT INTO AuctionResults
(AuctionId, AuctionName, FinalPrice, BidsUsed, Won, BuyNowPrice, ShippingCost, TotalCost, Savings, Timestamp)
VALUES (@auctionId, @auctionName, @finalPrice, @bidsUsed, @won, @buyNowPrice, @shippingCost, @totalCost, @savings, @timestamp);
(AuctionId, AuctionName, FinalPrice, BidsUsed, Won, BuyNowPrice, ShippingCost, TotalCost, Savings, Timestamp,
WinnerUsername, ClosedAtHour, ProductKey, TotalResets, WinnerBidsUsed)
VALUES (@auctionId, @auctionName, @finalPrice, @bidsUsed, @won, @buyNowPrice, @shippingCost, @totalCost, @savings, @timestamp,
@winnerUsername, @closedAtHour, @productKey, @totalResets, @winnerBidsUsed);
";
await ExecuteNonQueryAsync(sql,
@@ -556,10 +1062,244 @@ namespace AutoBidder.Services
new SqliteParameter("@shippingCost", (object?)shippingCost ?? DBNull.Value),
new SqliteParameter("@totalCost", (object?)totalCost ?? DBNull.Value),
new SqliteParameter("@savings", (object?)savings ?? DBNull.Value),
new SqliteParameter("@timestamp", DateTime.UtcNow.ToString("O"))
new SqliteParameter("@timestamp", DateTime.UtcNow.ToString("O")),
new SqliteParameter("@winnerUsername", (object?)winnerUsername ?? DBNull.Value),
new SqliteParameter("@closedAtHour", closedAtHour),
new SqliteParameter("@productKey", (object?)productKey ?? DBNull.Value),
new SqliteParameter("@totalResets", (object?)totalResets ?? DBNull.Value),
new SqliteParameter("@winnerBidsUsed", (object?)winnerBidsUsed ?? DBNull.Value)
);
}
/// <summary>
/// Aggiorna o inserisce statistiche aggregate per un prodotto
/// </summary>
public async Task UpsertProductStatisticsAsync(ProductStatisticsRecord stats)
{
var sql = @"
INSERT INTO ProductStatistics
(ProductKey, ProductName, TotalAuctions, WonAuctions, LostAuctions,
AvgFinalPrice, MinFinalPrice, MaxFinalPrice,
AvgBidsToWin, MinBidsToWin, MaxBidsToWin,
AvgResets, MinResets, MaxResets,
RecommendedMinPrice, RecommendedMaxPrice, RecommendedMinResets, RecommendedMaxResets, RecommendedMaxBids,
HourlyStatsJson, LastUpdated)
VALUES (@productKey, @productName, @totalAuctions, @wonAuctions, @lostAuctions,
@avgFinalPrice, @minFinalPrice, @maxFinalPrice,
@avgBidsToWin, @minBidsToWin, @maxBidsToWin,
@avgResets, @minResets, @maxResets,
@recMinPrice, @recMaxPrice, @recMinResets, @recMaxResets, @recMaxBids,
@hourlyJson, @lastUpdated)
ON CONFLICT(ProductKey) DO UPDATE SET
ProductName = @productName,
TotalAuctions = @totalAuctions,
WonAuctions = @wonAuctions,
LostAuctions = @lostAuctions,
AvgFinalPrice = @avgFinalPrice,
MinFinalPrice = @minFinalPrice,
MaxFinalPrice = @maxFinalPrice,
AvgBidsToWin = @avgBidsToWin,
MinBidsToWin = @minBidsToWin,
MaxBidsToWin = @maxBidsToWin,
AvgResets = @avgResets,
MinResets = @minResets,
MaxResets = @maxResets,
RecommendedMinPrice = @recMinPrice,
RecommendedMaxPrice = @recMaxPrice,
RecommendedMinResets = @recMinResets,
RecommendedMaxResets = @recMaxResets,
RecommendedMaxBids = @recMaxBids,
HourlyStatsJson = @hourlyJson,
LastUpdated = @lastUpdated;
";
await ExecuteNonQueryAsync(sql,
new SqliteParameter("@productKey", stats.ProductKey),
new SqliteParameter("@productName", stats.ProductName),
new SqliteParameter("@totalAuctions", stats.TotalAuctions),
new SqliteParameter("@wonAuctions", stats.WonAuctions),
new SqliteParameter("@lostAuctions", stats.LostAuctions),
new SqliteParameter("@avgFinalPrice", stats.AvgFinalPrice),
new SqliteParameter("@minFinalPrice", (object?)stats.MinFinalPrice ?? DBNull.Value),
new SqliteParameter("@maxFinalPrice", (object?)stats.MaxFinalPrice ?? DBNull.Value),
new SqliteParameter("@avgBidsToWin", stats.AvgBidsToWin),
new SqliteParameter("@minBidsToWin", (object?)stats.MinBidsToWin ?? DBNull.Value),
new SqliteParameter("@maxBidsToWin", (object?)stats.MaxBidsToWin ?? DBNull.Value),
new SqliteParameter("@avgResets", stats.AvgResets),
new SqliteParameter("@minResets", (object?)stats.MinResets ?? DBNull.Value),
new SqliteParameter("@maxResets", (object?)stats.MaxResets ?? DBNull.Value),
new SqliteParameter("@recMinPrice", (object?)stats.RecommendedMinPrice ?? DBNull.Value),
new SqliteParameter("@recMaxPrice", (object?)stats.RecommendedMaxPrice ?? DBNull.Value),
new SqliteParameter("@recMinResets", (object?)stats.RecommendedMinResets ?? DBNull.Value),
new SqliteParameter("@recMaxResets", (object?)stats.RecommendedMaxResets ?? DBNull.Value),
new SqliteParameter("@recMaxBids", (object?)stats.RecommendedMaxBids ?? DBNull.Value),
new SqliteParameter("@hourlyJson", (object?)stats.HourlyStatsJson ?? DBNull.Value),
new SqliteParameter("@lastUpdated", DateTime.UtcNow.ToString("O"))
);
}
/// <summary>
/// Ottiene statistiche per un prodotto specifico
/// </summary>
public async Task<ProductStatisticsRecord?> GetProductStatisticsAsync(string productKey)
{
var sql = @"
SELECT ProductKey, ProductName, TotalAuctions, WonAuctions, LostAuctions,
AvgFinalPrice, MinFinalPrice, MaxFinalPrice,
AvgBidsToWin, MinBidsToWin, MaxBidsToWin,
AvgResets, MinResets, MaxResets,
RecommendedMinPrice, RecommendedMaxPrice, RecommendedMinResets, RecommendedMaxResets, RecommendedMaxBids,
HourlyStatsJson, LastUpdated
FROM ProductStatistics
WHERE ProductKey = @productKey;
";
await using var connection = await GetConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("@productKey", productKey);
await using var reader = await cmd.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return new ProductStatisticsRecord
{
ProductKey = reader.GetString(0),
ProductName = reader.GetString(1),
TotalAuctions = reader.GetInt32(2),
WonAuctions = reader.GetInt32(3),
LostAuctions = reader.GetInt32(4),
AvgFinalPrice = reader.GetDouble(5),
MinFinalPrice = reader.IsDBNull(6) ? null : reader.GetDouble(6),
MaxFinalPrice = reader.IsDBNull(7) ? null : reader.GetDouble(7),
AvgBidsToWin = reader.GetDouble(8),
MinBidsToWin = reader.IsDBNull(9) ? null : reader.GetInt32(9),
MaxBidsToWin = reader.IsDBNull(10) ? null : reader.GetInt32(10),
AvgResets = reader.GetDouble(11),
MinResets = reader.IsDBNull(12) ? null : reader.GetInt32(12),
MaxResets = reader.IsDBNull(13) ? null : reader.GetInt32(13),
RecommendedMinPrice = reader.IsDBNull(14) ? null : reader.GetDouble(14),
RecommendedMaxPrice = reader.IsDBNull(15) ? null : reader.GetDouble(15),
RecommendedMinResets = reader.IsDBNull(16) ? null : reader.GetInt32(16),
RecommendedMaxResets = reader.IsDBNull(17) ? null : reader.GetInt32(17),
RecommendedMaxBids = reader.IsDBNull(18) ? null : reader.GetInt32(18),
HourlyStatsJson = reader.IsDBNull(19) ? null : reader.GetString(19),
LastUpdated = reader.GetString(20)
};
}
return null;
}
/// <summary>
/// Ottiene tutti i risultati aste per un prodotto specifico
/// </summary>
public async Task<List<AuctionResultExtended>> GetAuctionResultsByProductAsync(string productKey, int limit = 100)
{
var sql = @"
SELECT Id, AuctionId, AuctionName, FinalPrice, BidsUsed, Won, Timestamp,
BuyNowPrice, ShippingCost, TotalCost, Savings,
WinnerUsername, ClosedAtHour, ProductKey, TotalResets, WinnerBidsUsed
FROM AuctionResults
WHERE ProductKey = @productKey
ORDER BY Timestamp DESC
LIMIT @limit;
";
var results = new List<AuctionResultExtended>();
await using var connection = await GetConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("@productKey", productKey);
cmd.Parameters.AddWithValue("@limit", limit);
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(ReadAuctionResultExtended(reader));
}
return results;
}
/// <summary>
/// Ottiene tutti i prodotti con statistiche
/// </summary>
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
{
var sql = @"
SELECT ProductKey, ProductName, TotalAuctions, WonAuctions, LostAuctions,
AvgFinalPrice, MinFinalPrice, MaxFinalPrice,
AvgBidsToWin, MinBidsToWin, MaxBidsToWin,
AvgResets, MinResets, MaxResets,
RecommendedMinPrice, RecommendedMaxPrice, RecommendedMinResets, RecommendedMaxResets, RecommendedMaxBids,
HourlyStatsJson, LastUpdated
FROM ProductStatistics
ORDER BY TotalAuctions DESC;
";
var results = new List<ProductStatisticsRecord>();
await using var connection = await GetConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(new ProductStatisticsRecord
{
ProductKey = reader.GetString(0),
ProductName = reader.GetString(1),
TotalAuctions = reader.GetInt32(2),
WonAuctions = reader.GetInt32(3),
LostAuctions = reader.GetInt32(4),
AvgFinalPrice = reader.GetDouble(5),
MinFinalPrice = reader.IsDBNull(6) ? null : reader.GetDouble(6),
MaxFinalPrice = reader.IsDBNull(7) ? null : reader.GetDouble(7),
AvgBidsToWin = reader.GetDouble(8),
MinBidsToWin = reader.IsDBNull(9) ? null : reader.GetInt32(9),
MaxBidsToWin = reader.IsDBNull(10) ? null : reader.GetInt32(10),
AvgResets = reader.GetDouble(11),
MinResets = reader.IsDBNull(12) ? null : reader.GetInt32(12),
MaxResets = reader.IsDBNull(13) ? null : reader.GetInt32(13),
RecommendedMinPrice = reader.IsDBNull(14) ? null : reader.GetDouble(14),
RecommendedMaxPrice = reader.IsDBNull(15) ? null : reader.GetDouble(15),
RecommendedMinResets = reader.IsDBNull(16) ? null : reader.GetInt32(16),
RecommendedMaxResets = reader.IsDBNull(17) ? null : reader.GetInt32(17),
RecommendedMaxBids = reader.IsDBNull(18) ? null : reader.GetInt32(18),
HourlyStatsJson = reader.IsDBNull(19) ? null : reader.GetString(19),
LastUpdated = reader.GetString(20)
});
}
return results;
}
private AuctionResultExtended ReadAuctionResultExtended(Microsoft.Data.Sqlite.SqliteDataReader reader)
{
return new AuctionResultExtended
{
Id = reader.GetInt32(0),
AuctionId = reader.GetString(1),
AuctionName = reader.GetString(2),
FinalPrice = reader.GetDouble(3),
BidsUsed = reader.GetInt32(4),
Won = reader.GetInt32(5) == 1,
Timestamp = reader.GetString(6),
BuyNowPrice = reader.IsDBNull(7) ? null : reader.GetDouble(7),
ShippingCost = reader.IsDBNull(8) ? null : reader.GetDouble(8),
TotalCost = reader.IsDBNull(9) ? null : reader.GetDouble(9),
Savings = reader.IsDBNull(10) ? null : reader.GetDouble(10),
WinnerUsername = reader.IsDBNull(11) ? null : reader.GetString(11),
ClosedAtHour = reader.IsDBNull(12) ? null : reader.GetInt32(12),
ProductKey = reader.IsDBNull(13) ? null : reader.GetString(13),
TotalResets = reader.IsDBNull(14) ? null : reader.GetInt32(14),
WinnerBidsUsed = reader.IsDBNull(15) ? null : reader.GetInt32(15)
};
}
/// <summary>
/// Ottiene statistiche giornaliere per un range di date
/// </summary>
@@ -599,19 +1339,20 @@ namespace AutoBidder.Services
}
/// <summary>
/// Ottiene risultati aste recenti
/// Ottiene risultati aste recenti con campi estesi per analytics
/// </summary>
public async Task<List<AuctionResult>> GetRecentAuctionResultsAsync(int limit = 50)
public async Task<List<AuctionResultExtended>> GetRecentAuctionResultsAsync(int limit = 50)
{
var sql = @"
SELECT Id, AuctionId, AuctionName, FinalPrice, BidsUsed, Won, Timestamp,
BuyNowPrice, ShippingCost, TotalCost, Savings
BuyNowPrice, ShippingCost, TotalCost, Savings,
WinnerUsername, ClosedAtHour, ProductKey, TotalResets, WinnerBidsUsed
FROM AuctionResults
ORDER BY Timestamp DESC
LIMIT @limit;
";
var results = new List<AuctionResult>();
var results = new List<AuctionResultExtended>();
await using var connection = await GetConnectionAsync();
await using var cmd = connection.CreateCommand();
@@ -621,20 +1362,7 @@ namespace AutoBidder.Services
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(new AuctionResult
{
Id = reader.GetInt32(0),
AuctionId = reader.GetString(1),
AuctionName = reader.GetString(2),
FinalPrice = reader.GetDouble(3),
BidsUsed = reader.GetInt32(4),
Won = reader.GetInt32(5) == 1,
Timestamp = reader.GetString(6),
BuyNowPrice = reader.IsDBNull(7) ? null : reader.GetDouble(7),
ShippingCost = reader.IsDBNull(8) ? null : reader.GetDouble(8),
TotalCost = reader.IsDBNull(9) ? null : reader.GetDouble(9),
Savings = reader.IsDBNull(10) ? null : reader.GetDouble(10)
});
results.Add(ReadAuctionResultExtended(reader));
}
return results;
@@ -653,6 +1381,7 @@ namespace AutoBidder.Services
public int Version { get; }
public string Description { get; }
private readonly Func<SqliteConnection, Task> _execute;
private string? _lastSqlExecuted;
public Migration(int version, string description, Func<SqliteConnection, Task> execute)
{
@@ -662,9 +1391,26 @@ namespace AutoBidder.Services
}
public async Task Execute(SqliteConnection connection)
{
try
{
await _execute(connection);
}
catch (SqliteException sqlEx)
{
// Log dettagliato dell'SQL che ha causato l'errore
Console.WriteLine($"[Migration {Version}] ✗ ERRORE durante esecuzione");
// Se possibile, estrae l'SQL dal messaggio di errore
if (sqlEx.Message.Contains("duplicate column"))
{
Console.WriteLine($"[Migration {Version}] PROBABILE CAUSA: Colonna già esistente (migration già applicata parzialmente)");
Console.WriteLine($"[Migration {Version}] SOLUZIONE: Verifica versione database o elimina e ricrea il database");
}
throw;
}
}
}
}

View File

@@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per calcolare statistiche aggregate per prodotto e generare limiti consigliati.
/// L'algoritmo analizza le aste storiche per determinare i parametri ottimali.
/// </summary>
public class ProductStatisticsService
{
private readonly DatabaseService _db;
public ProductStatisticsService(DatabaseService db)
{
_db = db;
}
/// <summary>
/// Genera una chiave univoca normalizzata per raggruppare prodotti simili.
/// Rimuove varianti, numeri di serie, colori ecc.
/// </summary>
public static string GenerateProductKey(string productName)
{
if (string.IsNullOrWhiteSpace(productName))
return "unknown";
var normalized = productName.ToLowerInvariant().Trim();
// Rimuovi contenuto tra parentesi (varianti, colori, capacità)
normalized = Regex.Replace(normalized, @"\([^)]*\)", "");
normalized = Regex.Replace(normalized, @"\[[^\]]*\]", "");
// Rimuovi colori comuni
var colors = new[] { "nero", "bianco", "grigio", "rosso", "blu", "verde", "oro", "argento",
"black", "white", "gray", "red", "blue", "green", "gold", "silver",
"space gray", "midnight", "starlight" };
foreach (var color in colors)
{
normalized = Regex.Replace(normalized, $@"\b{color}\b", "", RegexOptions.IgnoreCase);
}
// Rimuovi capacità storage (64gb, 128gb, 256gb, ecc.)
normalized = Regex.Replace(normalized, @"\b\d+\s*(gb|tb|mb)\b", "", RegexOptions.IgnoreCase);
// Rimuovi numeri di serie e codici prodotto
normalized = Regex.Replace(normalized, @"\b[A-Z]{2,}\d{3,}\b", "", RegexOptions.IgnoreCase);
// Normalizza spazi e caratteri speciali
normalized = Regex.Replace(normalized, @"[^a-z0-9\s]", " ");
normalized = Regex.Replace(normalized, @"\s+", "_");
normalized = normalized.Trim('_');
// Limita lunghezza
if (normalized.Length > 50)
normalized = normalized.Substring(0, 50);
return string.IsNullOrEmpty(normalized) ? "unknown" : normalized;
}
/// <summary>
/// Aggiorna le statistiche aggregate per un prodotto dopo una nuova asta completata
/// </summary>
public async Task UpdateProductStatisticsAsync(string productKey, string productName)
{
try
{
// Ottieni tutti i risultati per questo prodotto
var results = await _db.GetAuctionResultsByProductAsync(productKey, 500);
if (results.Count == 0)
{
Console.WriteLine($"[ProductStats] No results found for product: {productKey}");
return;
}
// Calcola statistiche aggregate
var wonResults = results.Where(r => r.Won).ToList();
var lostResults = results.Where(r => !r.Won).ToList();
var stats = new ProductStatisticsRecord
{
ProductKey = productKey,
ProductName = productName,
TotalAuctions = results.Count,
WonAuctions = wonResults.Count,
LostAuctions = lostResults.Count
};
// Statistiche prezzo (usa aste vinte per calcolare i target)
if (wonResults.Any())
{
stats.AvgFinalPrice = wonResults.Average(r => r.FinalPrice);
stats.MinFinalPrice = wonResults.Min(r => r.FinalPrice);
stats.MaxFinalPrice = wonResults.Max(r => r.FinalPrice);
}
else if (results.Any())
{
stats.AvgFinalPrice = results.Average(r => r.FinalPrice);
stats.MinFinalPrice = results.Min(r => r.FinalPrice);
stats.MaxFinalPrice = results.Max(r => r.FinalPrice);
}
// Statistiche puntate (usa WinnerBidsUsed se disponibile, altrimenti BidsUsed)
var bidsData = wonResults
.Where(r => r.WinnerBidsUsed.HasValue || r.BidsUsed > 0)
.Select(r => r.WinnerBidsUsed ?? r.BidsUsed)
.ToList();
if (bidsData.Any())
{
stats.AvgBidsToWin = bidsData.Select(b => (double)b).Average();
stats.MinBidsToWin = bidsData.Min();
stats.MaxBidsToWin = bidsData.Max();
}
// Statistiche reset
var resetData = wonResults.Where(r => r.TotalResets.HasValue).Select(r => r.TotalResets!.Value).ToList();
if (resetData.Any())
{
stats.AvgResets = resetData.Select(r => (double)r).Average();
stats.MinResets = resetData.Min();
stats.MaxResets = resetData.Max();
}
// Calcola limiti consigliati
var limits = CalculateRecommendedLimits(results);
stats.RecommendedMinPrice = limits.MinPrice;
stats.RecommendedMaxPrice = limits.MaxPrice;
stats.RecommendedMinResets = limits.MinResets;
stats.RecommendedMaxResets = limits.MaxResets;
stats.RecommendedMaxBids = limits.MaxBids;
// Calcola statistiche per fascia oraria
var hourlyStats = CalculateHourlyStats(results);
stats.HourlyStatsJson = JsonSerializer.Serialize(hourlyStats);
// Salva nel database
await _db.UpsertProductStatisticsAsync(stats);
Console.WriteLine($"[ProductStats] Updated stats for {productKey}: {stats.TotalAuctions} auctions, WinRate={stats.WinRate:F1}%");
}
catch (Exception ex)
{
Console.WriteLine($"[ProductStats ERROR] Failed to update stats for {productKey}: {ex.Message}");
}
}
/// <summary>
/// Calcola i limiti consigliati basandosi sui dati storici
/// </summary>
public RecommendedLimits CalculateRecommendedLimits(List<AutoBidder.Models.AuctionResultExtended> results)
{
var limits = new RecommendedLimits
{
SampleSize = results.Count
};
if (results.Count < 3)
{
limits.ConfidenceScore = 0;
return limits;
}
var wonResults = results.Where(r => r.Won).ToList();
if (wonResults.Count == 0)
{
// Nessuna vittoria: usa tutti i risultati con margine conservativo
limits.ConfidenceScore = 10;
limits.MinPrice = results.Min(r => r.FinalPrice) * 0.8;
limits.MaxPrice = results.Max(r => r.FinalPrice) * 1.2;
return limits;
}
// Calcola percentili sui prezzi delle aste vinte
var prices = wonResults.Select(r => r.FinalPrice).OrderBy(p => p).ToList();
limits.MinPrice = CalculatePercentile(prices, 10); // 10° percentile - entrare presto
limits.MaxPrice = CalculatePercentile(prices, 90); // 90° percentile - limite sicuro
// Calcola limiti reset
var resets = wonResults.Where(r => r.TotalResets.HasValue).Select(r => r.TotalResets!.Value).ToList();
if (resets.Any())
{
var avgResets = resets.Average();
var stdDev = CalculateStandardDeviation(resets.Select(r => (double)r).ToList());
limits.MinResets = Math.Max(0, (int)(avgResets - stdDev)); // Media - 1 stddev
limits.MaxResets = (int)(avgResets + stdDev); // Media + 1 stddev
}
// Calcola limiti puntate
var bids = wonResults
.Where(r => r.WinnerBidsUsed.HasValue || r.BidsUsed > 0)
.Select(r => r.WinnerBidsUsed ?? r.BidsUsed)
.OrderBy(b => b)
.ToList();
if (bids.Any())
{
// 90° percentile + 10% buffer
limits.MaxBids = (int)(CalculatePercentile(bids.Select(b => (double)b).ToList(), 90) * 1.1);
}
// Trova la fascia oraria migliore
var hourlyWins = wonResults
.Where(r => r.ClosedAtHour.HasValue)
.GroupBy(r => r.ClosedAtHour!.Value)
.Select(g => new { Hour = g.Key, Wins = g.Count() })
.OrderByDescending(x => x.Wins)
.FirstOrDefault();
if (hourlyWins != null)
{
limits.BestHourToPlay = hourlyWins.Hour;
}
// Win rate
limits.AverageWinRate = results.Count > 0 ? (double)wonResults.Count / results.Count * 100 : 0;
// Confidence score basato sul sample size
limits.ConfidenceScore = results.Count switch
{
>= 50 => 95,
>= 30 => 85,
>= 20 => 70,
>= 10 => 50,
>= 5 => 30,
_ => 15
};
return limits;
}
/// <summary>
/// Calcola statistiche aggregate per ogni fascia oraria
/// </summary>
private List<HourlyStats> CalculateHourlyStats(List<AutoBidder.Models.AuctionResultExtended> results)
{
var stats = new List<HourlyStats>();
var grouped = results
.Where(r => r.ClosedAtHour.HasValue)
.GroupBy(r => r.ClosedAtHour!.Value);
foreach (var group in grouped)
{
var hourResults = group.ToList();
var wonInHour = hourResults.Where(r => r.Won).ToList();
stats.Add(new HourlyStats
{
Hour = group.Key,
TotalAuctions = hourResults.Count,
WonAuctions = wonInHour.Count,
AvgFinalPrice = hourResults.Any() ? hourResults.Average(r => r.FinalPrice) : 0,
AvgBidsUsed = hourResults.Any() ? hourResults.Average(r => r.BidsUsed) : 0
});
}
return stats.OrderBy(s => s.Hour).ToList();
}
/// <summary>
/// Ottiene le statistiche per un prodotto
/// </summary>
public async Task<ProductStatisticsRecord?> GetProductStatisticsAsync(string productKey)
{
return await _db.GetProductStatisticsAsync(productKey);
}
/// <summary>
/// Ottiene tutti i prodotti con statistiche
/// </summary>
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
{
return await _db.GetAllProductStatisticsAsync();
}
/// <summary>
/// Ottiene i limiti consigliati per un prodotto
/// </summary>
public async Task<RecommendedLimits?> GetRecommendedLimitsAsync(string productKey)
{
var stats = await _db.GetProductStatisticsAsync(productKey);
if (stats == null)
return null;
return new RecommendedLimits
{
MinPrice = stats.RecommendedMinPrice ?? 0,
MaxPrice = stats.RecommendedMaxPrice ?? 0,
MinResets = stats.RecommendedMinResets ?? 0,
MaxResets = stats.RecommendedMaxResets ?? 0,
MaxBids = stats.RecommendedMaxBids ?? 0,
ConfidenceScore = stats.TotalAuctions switch
{
>= 50 => 95,
>= 30 => 85,
>= 20 => 70,
>= 10 => 50,
>= 5 => 30,
_ => 15
},
SampleSize = stats.TotalAuctions,
AverageWinRate = stats.WinRate
};
}
// Helpers per calcoli statistici
private static double CalculatePercentile(List<double> sortedData, int percentile)
{
if (sortedData.Count == 0) return 0;
if (sortedData.Count == 1) return sortedData[0];
double index = (percentile / 100.0) * (sortedData.Count - 1);
int lower = (int)Math.Floor(index);
int upper = (int)Math.Ceiling(index);
if (lower == upper) return sortedData[lower];
return sortedData[lower] + (sortedData[upper] - sortedData[lower]) * (index - lower);
}
private static double CalculateStandardDeviation(List<double> data)
{
if (data.Count < 2) return 0;
double avg = data.Average();
double sumSquares = data.Sum(d => Math.Pow(d - avg, 2));
return Math.Sqrt(sumSquares / (data.Count - 1));
}
}
}

View File

@@ -2,64 +2,145 @@ using System;
using System.Linq;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Data;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per calcolo e gestione statistiche avanzate
/// Usa PostgreSQL per statistiche persistenti e SQLite locale come fallback
/// Servizio per calcolo e gestione statistiche.
/// Usa esclusivamente il database SQLite interno gestito da DatabaseService.
/// Le statistiche sono disabilitate se il database non è disponibile.
/// </summary>
public class StatsService
{
private readonly DatabaseService _db;
private readonly PostgresStatsContext? _postgresDb;
private readonly bool _postgresAvailable;
public StatsService(DatabaseService db, PostgresStatsContext? postgresDb = null)
/// <summary>
/// Indica se le statistiche sono disponibili (database SQLite funzionante)
/// </summary>
public bool IsAvailable => _db.IsAvailable && _db.IsInitialized;
/// <summary>
/// Messaggio di errore se le statistiche non sono disponibili
/// </summary>
public string? ErrorMessage => !IsAvailable ? _db.InitializationError ?? "Database non disponibile" : null;
/// <summary>
/// Path del database SQLite
/// </summary>
public string DatabasePath => _db.DatabasePath;
private ProductStatisticsService? _productStatsService;
public StatsService(DatabaseService db)
{
_db = db;
_postgresDb = postgresDb;
_postgresAvailable = false;
_productStatsService = new ProductStatisticsService(db);
// Verifica disponibilità PostgreSQL
if (_postgresDb != null)
// Log stato database SQLite
Console.WriteLine($"[StatsService] Database available: {_db.IsAvailable}");
Console.WriteLine($"[StatsService] Database initialized: {_db.IsInitialized}");
if (!_db.IsAvailable)
{
try
{
_postgresAvailable = _postgresDb.Database.CanConnect();
var status = _postgresAvailable ? "AVAILABLE" : "UNAVAILABLE";
Console.WriteLine($"[StatsService] PostgreSQL status: {status}");
}
catch (Exception ex)
{
Console.WriteLine($"[StatsService] PostgreSQL connection failed: {ex.Message}");
}
}
else
{
Console.WriteLine("[StatsService] PostgreSQL not configured - using SQLite only");
Console.WriteLine($"[StatsService] Database error: {_db.InitializationError}");
}
}
/// <summary>
/// Registra il completamento di un'asta (sia su PostgreSQL che SQLite)
/// Registra il completamento di un'asta con tutti i dati per analytics
/// Include scraping HTML per ottenere le puntate del vincitore
/// </summary>
public async Task RecordAuctionCompletedAsync(AuctionInfo auction, bool won)
public async Task RecordAuctionCompletedAsync(AuctionInfo auction, AuctionState state, bool won)
{
// Skip se database non disponibile
if (!IsAvailable)
{
Console.WriteLine("[StatsService] Skipping record - database not available");
return;
}
try
{
Console.WriteLine($"[StatsService] ====== INIZIO SALVATAGGIO ASTA TERMINATA ======");
Console.WriteLine($"[StatsService] Asta: {auction.Name} (ID: {auction.AuctionId})");
Console.WriteLine($"[StatsService] Stato: {(won ? "VINTA" : "PERSA")}");
var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
var bidsUsed = auction.BidsUsedOnThisAuction ?? 0;
var bidCost = auction.BidCost;
var moneySpent = bidsUsed * bidCost;
var finalPrice = auction.LastState?.Price ?? 0;
var finalPrice = state.Price;
var buyNowPrice = auction.BuyNowPrice;
var shippingCost = auction.ShippingCost ?? 0;
// Dati aggiuntivi per analytics
var winnerUsername = state.LastBidder;
var totalResets = auction.ResetCount;
var productKey = ProductStatisticsService.GenerateProductKey(auction.Name);
Console.WriteLine($"[StatsService] Prezzo finale: €{finalPrice:F2}");
Console.WriteLine($"[StatsService] Puntate usate (utente): {bidsUsed}");
Console.WriteLine($"[StatsService] Vincitore: {winnerUsername ?? "N/A"}");
Console.WriteLine($"[StatsService] Reset totali: {totalResets}");
// ?? SCRAPING HTML: Ottieni puntate del vincitore dalla pagina dell'asta
int? winnerBidsUsed = null;
if (!string.IsNullOrEmpty(winnerUsername))
{
Console.WriteLine($"[StatsService] Avvio scraping HTML per ottenere puntate del vincitore...");
winnerBidsUsed = await ScrapeWinnerBidsFromAuctionPageAsync(auction.OriginalUrl);
// ? VALIDAZIONE: Verifica che i dati estratti siano ragionevoli
if (winnerBidsUsed.HasValue)
{
if (winnerBidsUsed.Value < 0)
{
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: Puntate negative ({winnerBidsUsed.Value}) - Dato scartato");
winnerBidsUsed = null;
}
else if (winnerBidsUsed.Value > 50000)
{
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: Puntate sospette ({winnerBidsUsed.Value} > 50000) - Dato scartato");
winnerBidsUsed = null;
}
else if (winnerBidsUsed.Value == 0 && totalResets > 10)
{
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: 0 puntate con {totalResets} reset - Dato sospetto");
winnerBidsUsed = null;
}
else
{
Console.WriteLine($"[StatsService] ? Puntate vincitore estratte da HTML: {winnerBidsUsed.Value} (validazione OK)");
}
}
// Fallback se validazione fallita o scraping non riuscito
if (!winnerBidsUsed.HasValue)
{
Console.WriteLine($"[StatsService] ? Impossibile estrarre puntate valide del vincitore da HTML");
// Fallback: conta da RecentBids (meno affidabile)
if (auction.RecentBids != null)
{
winnerBidsUsed = auction.RecentBids
.Count(b => b.Username?.Equals(winnerUsername, StringComparison.OrdinalIgnoreCase) == true);
if (winnerBidsUsed.Value > 0)
{
Console.WriteLine($"[StatsService] Fallback: puntate vincitore da RecentBids: {winnerBidsUsed.Value}");
}
else
{
Console.WriteLine($"[StatsService] ? Fallback fallito: nessuna puntata trovata in RecentBids");
winnerBidsUsed = null;
}
}
}
}
double? totalCost = null;
double? savings = null;
@@ -67,9 +148,14 @@ namespace AutoBidder.Services
{
totalCost = finalPrice + moneySpent + shippingCost;
savings = (buyNowPrice.Value + shippingCost) - totalCost.Value;
Console.WriteLine($"[StatsService] Costo totale: €{totalCost:F2}");
Console.WriteLine($"[StatsService] Risparmio: €{savings:F2}");
}
// Salva su SQLite (sempre)
Console.WriteLine($"[StatsService] Salvataggio nel database...");
// Salva risultato asta con tutti i campi
await _db.SaveAuctionResultAsync(
auction.AuctionId,
auction.Name,
@@ -79,9 +165,16 @@ namespace AutoBidder.Services
buyNowPrice,
shippingCost,
totalCost,
savings
savings,
winnerUsername,
totalResets,
winnerBidsUsed,
productKey
);
Console.WriteLine($"[StatsService] ? Risultato asta salvato");
// Aggiorna statistiche giornaliere
await _db.SaveDailyStatAsync(
today,
bidsUsed,
@@ -89,229 +182,159 @@ namespace AutoBidder.Services
won ? 1 : 0,
won ? 0 : 1,
savings ?? 0,
auction.LastState?.PollingLatencyMs
state.PollingLatencyMs
);
// Salva su PostgreSQL se disponibile
if (_postgresAvailable && _postgresDb != null)
Console.WriteLine($"[StatsService] ? Statistiche giornaliere aggiornate");
// Aggiorna statistiche aggregate per prodotto
if (_productStatsService != null)
{
await SaveToPostgresAsync(auction, won, finalPrice, bidsUsed, totalCost, savings);
Console.WriteLine($"[StatsService] Aggiornamento statistiche prodotto (key: {productKey})...");
await _productStatsService.UpdateProductStatisticsAsync(productKey, auction.Name);
Console.WriteLine($"[StatsService] ? Statistiche prodotto aggiornate");
}
Console.WriteLine($"[StatsService] Recorded auction {auction.Name} - Won: {won}, Bids: {bidsUsed}, Savings: {savings:F2}€");
Console.WriteLine($"[StatsService] ====== SALVATAGGIO COMPLETATO ======");
}
catch (Exception ex)
{
Console.WriteLine($"[StatsService ERROR] Failed to record auction: {ex.Message}");
Console.WriteLine($"[StatsService ERROR] Stack: {ex.StackTrace}");
}
}
/// <summary>
/// Salva asta conclusa su PostgreSQL
/// Scarica l'HTML della pagina dell'asta e estrae le puntate del vincitore
/// </summary>
private async Task SaveToPostgresAsync(AuctionInfo auction, bool won, double finalPrice, int bidsUsed, double? totalCost, double? savings)
private async Task<int?> ScrapeWinnerBidsFromAuctionPageAsync(string auctionUrl)
{
if (_postgresDb == null) return;
try
{
var completedAuction = new CompletedAuction
using var httpClient = new HttpClient();
// ? RIDOTTO: Timeout da 10s ? 5s per evitare rallentamenti
httpClient.Timeout = TimeSpan.FromSeconds(5);
// Headers browser-like per evitare rilevamento come bot
httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
httpClient.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
httpClient.DefaultRequestHeaders.Add("Accept-Language", "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7");
Console.WriteLine($"[StatsService] Downloading HTML from: {auctionUrl} (timeout: 5s)");
var html = await httpClient.GetStringAsync(auctionUrl);
Console.WriteLine($"[StatsService] HTML scaricato ({html.Length} chars), parsing...");
// Usa il metodo esistente di ClosedAuctionsScraper per estrarre le puntate
var bidsUsed = ExtractBidsUsedFromHtml(html);
return bidsUsed;
}
catch (TaskCanceledException)
{
AuctionId = auction.AuctionId,
ProductName = auction.Name,
FinalPrice = (decimal)finalPrice,
BuyNowPrice = auction.BuyNowPrice.HasValue ? (decimal)auction.BuyNowPrice.Value : null,
ShippingCost = auction.ShippingCost.HasValue ? (decimal)auction.ShippingCost.Value : null,
TotalBids = auction.LastState?.MyBidsCount ?? bidsUsed, // Usa MyBidsCount se disponibile
MyBidsCount = bidsUsed,
ResetCount = auction.ResetCount,
Won = won,
WinnerUsername = won ? "ME" : auction.LastState?.LastBidder,
CompletedAt = DateTime.UtcNow,
AverageLatency = auction.LastState != null ? (decimal)auction.LastState.PollingLatencyMs : null, // PollingLatencyMs è int, non nullable
Savings = savings.HasValue ? (decimal)savings.Value : null,
TotalCost = totalCost.HasValue ? (decimal)totalCost.Value : null,
CreatedAt = DateTime.UtcNow
};
_postgresDb.CompletedAuctions.Add(completedAuction);
await _postgresDb.SaveChangesAsync();
// Aggiorna statistiche prodotto
await UpdateProductStatisticsAsync(auction, won, bidsUsed, finalPrice);
// Aggiorna metriche giornaliere
await UpdateDailyMetricsAsync(DateTime.UtcNow.Date, bidsUsed, auction.BidCost, won, savings ?? 0);
Console.WriteLine($"[PostgreSQL] Saved auction {auction.Name} to PostgreSQL");
Console.WriteLine($"[StatsService] ? Timeout durante download HTML (>5s) - URL: {auctionUrl}");
return null;
}
catch (HttpRequestException ex)
{
Console.WriteLine($"[StatsService] ? Errore HTTP durante scraping: {ex.Message}");
return null;
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to save auction: {ex.Message}");
Console.WriteLine($"[StatsService] ? Errore generico durante scraping: {ex.Message}");
return null;
}
}
/// <summary>
/// Aggiorna statistiche prodotto in PostgreSQL
/// Estrae le puntate usate dall'HTML (stesso algoritmo di ClosedAuctionsScraper)
/// </summary>
private async Task UpdateProductStatisticsAsync(AuctionInfo auction, bool won, int bidsUsed, double finalPrice)
private int? ExtractBidsUsedFromHtml(string html)
{
if (_postgresDb == null) return;
if (string.IsNullOrEmpty(html)) return null;
try
{
var productKey = GenerateProductKey(auction.Name);
var stat = await _postgresDb.ProductStatistics.FirstOrDefaultAsync(p => p.ProductKey == productKey);
// 1) Look for the explicit bids-used span: <p ...><span>628</span> Puntate utilizzate</p>
var match = System.Text.RegularExpressions.Regex.Match(html,
"class=\\\"bids-used\\\"[^>]*>[^<]*<span[^>]*>(?<n>[0-9]{1,7})</span>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
if (stat == null)
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val1))
{
stat = new ProductStatistic
{
ProductKey = productKey,
ProductName = auction.Name,
TotalAuctions = 0,
MinBidsSeen = int.MaxValue,
MaxBidsSeen = 0,
CompetitionLevel = "Medium"
};
_postgresDb.ProductStatistics.Add(stat);
Console.WriteLine($"[StatsService] Puntate estratte (metodo 1 - bids-used class): {val1}");
return val1;
}
stat.TotalAuctions++;
stat.AverageFinalPrice = ((stat.AverageFinalPrice * (stat.TotalAuctions - 1)) + (decimal)finalPrice) / stat.TotalAuctions;
stat.AverageResets = ((stat.AverageResets * (stat.TotalAuctions - 1)) + auction.ResetCount) / stat.TotalAuctions;
// 2) Look for numeric followed by 'Puntate utilizzate' or similar
match = System.Text.RegularExpressions.Regex.Match(html,
"(?<n>[0-9]{1,7})\\s*(?:Puntate utilizzate|Puntate usate|puntate utilizzate|puntate usate|puntate)\\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (won)
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val2))
{
stat.AverageWinningBids = ((stat.AverageWinningBids * Math.Max(1, stat.TotalAuctions - 1)) + bidsUsed) / stat.TotalAuctions;
Console.WriteLine($"[StatsService] Puntate estratte (metodo 2 - pattern testo): {val2}");
return val2;
}
stat.MinBidsSeen = Math.Min(stat.MinBidsSeen, bidsUsed);
stat.MaxBidsSeen = Math.Max(stat.MaxBidsSeen, bidsUsed);
stat.RecommendedMaxBids = (int)(stat.AverageWinningBids * 1.5m); // 50% buffer
stat.RecommendedMaxPrice = stat.AverageFinalPrice * 1.2m; // 20% buffer
stat.LastUpdated = DateTime.UtcNow;
// 3) Fallbacks
match = System.Text.RegularExpressions.Regex.Match(html,
"(?<n>[0-9]+)\\s*(?:puntate|Puntate|puntate usate|puntate_usate|pt\\.?|pts)\\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
// Determina livello competizione
if (stat.AverageWinningBids > 50) stat.CompetitionLevel = "High";
else if (stat.AverageWinningBids < 20) stat.CompetitionLevel = "Low";
else stat.CompetitionLevel = "Medium";
await _postgresDb.SaveChangesAsync();
}
catch (Exception ex)
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val3))
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to update product statistics: {ex.Message}");
Console.WriteLine($"[StatsService] Puntate estratte (metodo 3 - fallback): {val3}");
return val3;
}
Console.WriteLine($"[StatsService] Nessun pattern trovato per le puntate nell'HTML");
return null;
}
/// <summary>
/// Registra il completamento di un'asta (overload semplificato per compatibilità)
/// </summary>
public async Task RecordAuctionCompletedAsync(AuctionInfo auction, bool won)
{
if (auction.LastState != null)
{
await RecordAuctionCompletedAsync(auction, auction.LastState, won);
}
else
{
Console.WriteLine("[StatsService] Cannot record auction - LastState is null");
}
}
/// <summary>
/// Aggiorna metriche giornaliere in PostgreSQL
/// Ottiene i limiti consigliati per un prodotto
/// </summary>
private async Task UpdateDailyMetricsAsync(DateTime date, int bidsUsed, double bidCost, bool won, double savings)
public async Task<RecommendedLimits?> GetRecommendedLimitsAsync(string productName)
{
if (_postgresDb == null) return;
if (_productStatsService == null) return null;
try
{
var metric = await _postgresDb.DailyMetrics.FirstOrDefaultAsync(m => m.Date.Date == date.Date);
if (metric == null)
{
metric = new DailyMetric { Date = date.Date };
_postgresDb.DailyMetrics.Add(metric);
}
metric.TotalBidsUsed += bidsUsed;
metric.MoneySpent += (decimal)(bidsUsed * bidCost);
if (won) metric.AuctionsWon++; else metric.AuctionsLost++;
metric.TotalSavings += (decimal)savings;
var totalAuctions = metric.AuctionsWon + metric.AuctionsLost;
if (totalAuctions > 0)
{
metric.WinRate = ((decimal)metric.AuctionsWon / totalAuctions) * 100;
}
if (metric.MoneySpent > 0)
{
metric.ROI = (metric.TotalSavings / metric.MoneySpent) * 100;
}
await _postgresDb.SaveChangesAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to update daily metrics: {ex.Message}");
}
var productKey = ProductStatisticsService.GenerateProductKey(productName);
return await _productStatsService.GetRecommendedLimitsAsync(productKey);
}
/// <summary>
/// Genera chiave univoca per prodotto
/// Ottiene tutte le statistiche prodotto
/// </summary>
private string GenerateProductKey(string productName)
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
{
var normalized = productName.ToLowerInvariant()
.Replace(" ", "_")
.Replace("-", "_");
return System.Text.RegularExpressions.Regex.Replace(normalized, "[^a-z0-9_]", "");
if (_productStatsService == null) return new List<ProductStatisticsRecord>();
return await _productStatsService.GetAllProductStatisticsAsync();
}
/// <summary>
/// Ottiene raccomandazioni strategiche da PostgreSQL
/// </summary>
public async Task<List<StrategicInsight>> GetStrategicInsightsAsync(string? productKey = null)
{
if (!_postgresAvailable || _postgresDb == null)
{
return new List<StrategicInsight>();
}
try
{
var query = _postgresDb.StrategicInsights.Where(i => i.IsActive);
if (!string.IsNullOrEmpty(productKey))
{
query = query.Where(i => i.ProductKey == productKey);
}
return await query.OrderByDescending(i => i.ConfidenceLevel).ToListAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to get insights: {ex.Message}");
return new List<StrategicInsight>();
}
}
/// <summary>
/// Ottiene performance puntatori da PostgreSQL
/// </summary>
public async Task<List<BidderPerformance>> GetTopCompetitorsAsync(int limit = 10)
{
if (!_postgresAvailable || _postgresDb == null)
{
return new List<BidderPerformance>();
}
try
{
return await _postgresDb.BidderPerformances
.OrderByDescending(b => b.WinRate)
.Take(limit)
.ToListAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to get competitors: {ex.Message}");
return new List<BidderPerformance>();
}
}
// Metodi esistenti per compatibilità SQLite
// Metodi per query statistiche
public async Task<List<DailyStat>> GetDailyStatsAsync(int days = 30)
{
if (!IsAvailable)
{
return new List<DailyStat>();
}
var to = DateTime.UtcNow;
var from = to.AddDays(-days);
return await _db.GetDailyStatsAsync(from, to);
@@ -319,6 +342,11 @@ namespace AutoBidder.Services
public async Task<TotalStats> GetTotalStatsAsync()
{
if (!IsAvailable)
{
return new TotalStats();
}
var stats = await GetDailyStatsAsync(365);
return new TotalStats
@@ -338,13 +366,23 @@ namespace AutoBidder.Services
};
}
public async Task<List<AuctionResult>> GetRecentAuctionResultsAsync(int limit = 50)
public async Task<List<AuctionResultExtended>> GetRecentAuctionResultsAsync(int limit = 50)
{
if (!IsAvailable)
{
return new List<AuctionResultExtended>();
}
return await _db.GetRecentAuctionResultsAsync(limit);
}
public async Task<double> CalculateROIAsync()
{
if (!IsAvailable)
{
return 0;
}
var stats = await GetTotalStatsAsync();
if (stats.TotalMoneySpent <= 0)
@@ -355,11 +393,22 @@ namespace AutoBidder.Services
public async Task<ChartData> GetChartDataAsync(int days = 30)
{
if (!IsAvailable)
{
return new ChartData
{
Labels = new List<string>(),
MoneySpent = new List<double>(),
Savings = new List<double>()
};
}
var stats = await GetDailyStatsAsync(days);
var allDates = new List<DailyStat>();
var startDate = DateTime.UtcNow.AddDays(-days);
for (int i = 0; i < days; i++)
{
var date = startDate.AddDays(i).ToString("yyyy-MM-dd");
@@ -387,11 +436,6 @@ namespace AutoBidder.Services
Savings = allDates.Select(s => s.TotalSavings).ToList()
};
}
/// <summary>
/// Indica se il database PostgreSQL è disponibile
/// </summary>
public bool IsPostgresAvailable => _postgresAvailable;
}
// Classi esistenti per compatibilità

View File

@@ -24,11 +24,6 @@
<span>Esplora Aste</span>
</NavLink>
<NavLink class="nav-menu-item" href="freebids">
<i class="bi bi-gift"></i>
<span>Puntate Gratuite</span>
</NavLink>
<NavLink class="nav-menu-item" href="statistics">
<i class="bi bi-bar-chart"></i>
<span>Statistiche</span>

View File

@@ -70,27 +70,6 @@ namespace AutoBidder.Utilities
/// Default: "Normal" (uso giornaliero - errori e warning)
/// </summary>
public string MinLogLevel { get; set; } = "Normal";
// CONFIGURAZIONE DATABASE POSTGRESQL
/// <summary>
/// Abilita l'uso di PostgreSQL per statistiche avanzate
/// </summary>
public bool UsePostgreSQL { get; set; } = true;
/// <summary>
/// Connection string PostgreSQL
/// </summary>
public string PostgresConnectionString { get; set; } = "Host=localhost;Port=5432;Database=autobidder_stats;Username=autobidder;Password=autobidder_password";
/// <summary>
/// Auto-crea schema database se mancante
/// </summary>
public bool AutoCreateDatabaseSchema { get; set; } = true;
/// <summary>
/// Fallback automatico a SQLite se PostgreSQL non disponibile
/// </summary>
public bool FallbackToSQLite { get; set; } = true;
}
public static class SettingsManager

View File

@@ -1,31 +1,6 @@
version: '3.8'
services:
# ================================================
# PostgreSQL Database (statistiche avanzate)
# ================================================
postgres:
image: postgres:16-alpine
container_name: autobidder-postgres
environment:
POSTGRES_DB: autobidder_stats
POSTGRES_USER: ${POSTGRES_USER:-autobidder}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-autobidder_password}
POSTGRES_INITDB_ARGS: --encoding=UTF8
volumes:
- postgres-data:/var/lib/postgresql/data
- ./postgres-backups:/backups
ports:
- "5432:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-autobidder} -d autobidder_stats"]
interval: 10s
timeout: 5s
retries: 5
networks:
- autobidder-network
# ================================================
# AutoBidder Application
# ================================================
@@ -37,37 +12,29 @@ services:
BUILD_CONFIGURATION: Release
image: gitea.encke-hake.ts.net/alby96/autobidder:latest
container_name: autobidder
depends_on:
postgres:
condition: service_healthy
ports:
- "${APP_PORT:-5000}:8080" # Host:Container (HTTP only)
volumes:
# Persistent data (SQLite, backups, logs)
# Persistent data (SQLite databases, backups, logs, keys)
# Tutti i dati persistenti sono salvati in questo volume
- ./Data:/app/Data
# PostgreSQL backups
- ./postgres-backups:/app/Data/backups
environment:
# ASP.NET Core
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:8080
# ============================================
# DATABASE PATH - Volume persistente Docker
# ============================================
# Tutti i database SQLite e dati persistenti usano questo path
- DATA_PATH=/app/Data
# Autenticazione applicazione (SICUREZZA)
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
# PostgreSQL connection
- ConnectionStrings__PostgreSQL=Host=postgres;Port=5432;Database=autobidder_stats;Username=${POSTGRES_USER:-autobidder};Password=${POSTGRES_PASSWORD:-autobidder_password}
# Database settings
- Database__UsePostgres=${USE_POSTGRES:-true}
- Database__AutoCreateSchema=true
- Database__FallbackToSQLite=true
# Logging
- Logging__LogLevel__Default=${LOG_LEVEL:-Information}
- Logging__LogLevel__Microsoft.EntityFrameworkCore=Warning
# Timezone
- TZ=Europe/Rome
@@ -81,10 +48,6 @@ services:
networks:
- autobidder-network
volumes:
postgres-data:
driver: local
networks:
autobidder-network:
driver: bridge

View File

@@ -1,4 +1,33 @@
/* === MODERN PAGE STYLES (append to app-wpf.css) === */
/* === MODERN PAGE STYLES (append to app-wpf.css) === */
/* ✅ NUOVO: Stili per selezione riga e colonna puntate */
.table-hover tbody tr {
cursor: pointer;
transition: all 0.2s ease;
}
.table-hover tbody tr.selected-row {
background-color: rgba(76, 175, 80, 0.15) !important;
border-left: 4px solid #4CAF50;
}
.table-hover tbody tr:hover:not(.selected-row) {
background-color: rgba(255, 255, 255, 0.05);
}
/* Colonna Puntate - testo grassetto e leggibile */
.bids-column {
font-weight: bold !important;
color: var(--text-primary) !important;
}
/* Larghezza colonna puntate leggermente maggiore */
.col-click {
min-width: 85px;
width: 85px;
white-space: nowrap;
}
.page-header {
display: flex;
align-items: center;