game_manager_lib\services/
cache.rs

1//! Módulo de cache para metadados de APIs externas
2//!
3//! Gerencia cache persistente em SQLite para respostas de RAWG e Steam,
4//! reduzindo chamadas desnecessárias e melhorando performance.
5
6use crate::constants::{
7    CACHE_RAWG_GAME_TTL_DAYS, CACHE_RAWG_LIST_TTL_DAYS, CACHE_STEAM_PLAYTIME_TTL_DAYS,
8    CACHE_STEAM_REVIEWS_TTL_DAYS, CACHE_STEAM_STORE_TTL_DAYS,
9};
10use rusqlite::{params, Connection};
11use std::time::{SystemTime, UNIX_EPOCH};
12use tracing::{info, warn};
13
14/// Estrutura de estatísticas do cache
15#[derive(Debug, serde::Serialize)]
16pub struct CacheStats {
17    pub total_entries: i32,
18    pub rawg_entries: i32,
19    pub steam_entries: i32,
20    pub expired_entries: i32,
21}
22
23/// Obtém timestamp atual em segundos
24fn current_timestamp() -> i64 {
25    SystemTime::now()
26        .duration_since(UNIX_EPOCH)
27        .unwrap()
28        .as_secs() as i64
29}
30
31/// Inicializa o banco de cache e cria o schema
32pub fn initialize_cache_db(conn: &Connection) -> Result<(), String> {
33    conn.execute(
34        "CREATE TABLE IF NOT EXISTS api_cache (
35            source TEXT NOT NULL,
36            external_id TEXT NOT NULL,
37            payload TEXT NOT NULL,
38            updated_at INTEGER NOT NULL,
39            PRIMARY KEY (source, external_id)
40        )",
41        [],
42    )
43    .map_err(|e| format!("Erro ao criar tabela api_cache: {}", e))?;
44
45    // Índice para facilitar queries de limpeza por data
46    conn.execute(
47        "CREATE INDEX IF NOT EXISTS idx_cache_updated
48         ON api_cache(source, updated_at)",
49        [],
50    )
51    .map_err(|e| format!("Erro ao criar índice: {}", e))?;
52
53    Ok(())
54}
55
56/// Determina TTL baseado no tipo de dado (granular)
57fn get_ttl_for_cache_type(cache_key: &str) -> i64 {
58    // TTL de 1 dia para buscas/listas de jogos Em Alta, Gratuitos, Lançamentos, etc.
59    if cache_key.contains("_list_") {
60        return CACHE_RAWG_LIST_TTL_DAYS * 24 * 60 * 60;
61    }
62    if cache_key.starts_with("rawg_") {
63        CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60
64    } else if cache_key.starts_with("store_") {
65        CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60
66    } else if cache_key.starts_with("reviews_") {
67        CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60
68    } else if cache_key.starts_with("playtime_") {
69        CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60
70    } else {
71        7 * 24 * 60 * 60 // default 7 dias
72    }
73}
74
75/// Verifica se o cache está expirado baseado no TTL do tipo de dado
76fn is_cache_expired(cache_key: &str, updated_at: i64) -> bool {
77    let now = current_timestamp();
78    let ttl_seconds = get_ttl_for_cache_type(cache_key);
79
80    (now - updated_at) > ttl_seconds
81}
82
83/// Busca dados em cache
84///
85/// Retorna None se:
86/// - Dados não existem
87/// - Cache expirou
88pub fn get_cached_api_data(conn: &Connection, source: &str, external_id: &str) -> Option<String> {
89    let result: Result<(String, i64), rusqlite::Error> = conn.query_row(
90        "SELECT payload, updated_at FROM api_cache
91         WHERE source = ?1 AND external_id = ?2",
92        params![source, external_id],
93        |row| Ok((row.get(0)?, row.get(1)?)),
94    );
95
96    match result {
97        Ok((payload, updated_at)) => {
98            // Usa a chave completa (external_id) para determinar TTL
99            let full_key = external_id;
100            if is_cache_expired(full_key, updated_at) {
101                None
102            } else {
103                Some(payload)
104            }
105        }
106        Err(rusqlite::Error::QueryReturnedNoRows) => None,
107        Err(e) => {
108            warn!("Erro ao buscar cache: {}", e);
109            None
110        }
111    }
112}
113
114/// Salva dados no cache
115pub fn save_cached_api_data(
116    conn: &Connection,
117    source: &str,
118    external_id: &str,
119    payload: &str,
120) -> Result<(), String> {
121    let now = current_timestamp();
122
123    conn.execute(
124        "INSERT OR REPLACE INTO api_cache (source, external_id, payload, updated_at)
125         VALUES (?1, ?2, ?3, ?4)",
126        params![source, external_id, payload, now],
127    )
128    .map_err(|e| format!("Erro ao salvar cache: {}", e))?;
129
130    Ok(())
131}
132
133/// Remove entradas expiradas do cache (limpeza granular)
134pub fn cleanup_expired_cache(conn: &Connection) -> Result<usize, String> {
135    let now = current_timestamp();
136
137    // Diferentes cutoffs para diferentes tipos
138    let rawg_cutoff = now - (CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60);
139    let store_cutoff = now - (CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60);
140    let reviews_cutoff = now - (CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60);
141    let playtime_cutoff = now - (CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60);
142
143    let deleted = conn
144        .execute(
145            "DELETE FROM api_cache
146             WHERE (source = 'rawg' AND external_id LIKE 'search_%' AND updated_at < ?1)
147                OR (source = 'steam' AND external_id LIKE 'store_%' AND updated_at < ?2)
148                OR (source = 'steam' AND external_id LIKE 'reviews_%' AND updated_at < ?3)
149                OR (source = 'steam' AND external_id LIKE 'playtime_%' AND updated_at < ?4)",
150            params![rawg_cutoff, store_cutoff, reviews_cutoff, playtime_cutoff],
151        )
152        .map_err(|e| format!("Erro ao limpar cache: {}", e))?;
153
154    if deleted > 0 {
155        info!("Cache cleanup: {} entradas removidas", deleted);
156    }
157
158    Ok(deleted)
159}
160
161/// Busca dados em cache IGNORANDO a validade (para modo Offline)
162///
163/// Retorna Some(payload) se existir, independente da data.
164pub fn get_stale_api_data(conn: &Connection, source: &str, external_id: &str) -> Option<String> {
165    let result: Result<String, rusqlite::Error> = conn.query_row(
166        "SELECT payload FROM api_cache
167         WHERE source = ?1 AND external_id = ?2",
168        params![source, external_id],
169        |row| row.get(0),
170    );
171
172    result.ok() // Retorna o dado se existir, ou None se nunca foi salvo
173}
174
175/// Retorna estatísticas do cache
176pub fn get_cache_stats(conn: &Connection) -> Result<CacheStats, String> {
177    let total: i32 = conn
178        .query_row("SELECT COUNT(*) FROM api_cache", [], |row| row.get(0))
179        .unwrap_or(0);
180
181    let rawg: i32 = conn
182        .query_row(
183            "SELECT COUNT(*) FROM api_cache WHERE source = 'rawg'",
184            [],
185            |row| row.get(0),
186        )
187        .unwrap_or(0);
188
189    let steam: i32 = conn
190        .query_row(
191            "SELECT COUNT(*) FROM api_cache WHERE source = 'steam'",
192            [],
193            |row| row.get(0),
194        )
195        .unwrap_or(0);
196
197    let now = current_timestamp();
198    let rawg_cutoff = now - (CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60);
199    let store_cutoff = now - (CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60);
200    let reviews_cutoff = now - (CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60);
201    let playtime_cutoff = now - (CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60);
202
203    let expired: i32 = conn
204        .query_row(
205            "SELECT COUNT(*) FROM api_cache
206             WHERE (source = 'rawg' AND external_id LIKE 'search_%' AND updated_at < ?1)
207                OR (source = 'steam' AND external_id LIKE 'store_%' AND updated_at < ?2)
208                OR (source = 'steam' AND external_id LIKE 'reviews_%' AND updated_at < ?3)
209                OR (source = 'steam' AND external_id LIKE 'playtime_%' AND updated_at < ?4)",
210            params![rawg_cutoff, store_cutoff, reviews_cutoff, playtime_cutoff],
211            |row| row.get(0),
212        )
213        .unwrap_or(0);
214
215    Ok(CacheStats {
216        total_entries: total,
217        rawg_entries: rawg,
218        steam_entries: steam,
219        expired_entries: expired,
220    })
221}