fix: resolver problemas identificados no code review

Correções de segurança:
- SQL Injection: usar prepared statements em ranking_repository.py
- Validação de entrada para parâmetros page/size

Correções de bugs:
- Bônus de continuidade: 15→20 pts para 8+ anos (conforme especificação)
- Memory leak: limpar _consultores após processamento do ranking

Melhorias de robustez:
- Substituir bare except por exceções específicas
- Threading.Lock para padrão singleton thread-safe
- Pool Oracle com configuração otimizada (timeout/getmode)
- ES client com timeouts diferenciados e verificação is_closed
- Logging para tipos de coordenação desconhecidos

Correções frontend:
- Polling com timeout máximo de 5 minutos
- useEffect cleanup para setTimeout
- React.memo e useMemo para otimização de performance
This commit is contained in:
Frederico Castro
2025-12-15 07:11:28 -03:00
parent d639b82087
commit df3d03d3b2
10 changed files with 91 additions and 37 deletions

View File

@@ -51,7 +51,7 @@ class ProcessarRankingJob:
job_status.finalizar(sucesso=True) job_status.finalizar(sucesso=True)
return { resultado_final = {
"sucesso": True, "sucesso": True,
"total_processados": resultado.get("processados", len(entries)), "total_processados": resultado.get("processados", len(entries)),
"total_batches": resultado.get("batches", 0), "total_batches": resultado.get("batches", 0),
@@ -59,9 +59,15 @@ class ProcessarRankingJob:
"estatisticas": estatisticas "estatisticas": estatisticas
} }
self._consultores = []
return resultado_final
except Exception as e: except Exception as e:
self._consultores = []
job_status.finalizar(sucesso=False, erro=str(e)) job_status.finalizar(sucesso=False, erro=str(e))
raise RuntimeError(f"Erro ao processar ranking: {e}") logger.error(f"Erro ao processar ranking: {e}", exc_info=True)
raise RuntimeError(f"Erro ao processar ranking: {e}") from e
async def _processar_batch(self, docs: list, progress: dict) -> None: async def _processar_batch(self, docs: list, progress: dict) -> None:
for doc in docs: for doc in docs:

View File

@@ -91,9 +91,10 @@ class CalculadorPontuacao:
bonus = 0 bonus = 0
# Bônus de continuidade (escalonado, não cumulativo) - apenas CONS_ATIVO # Bônus de continuidade (escalonado, não cumulativo) - apenas CONS_ATIVO
# Spec: 8+ anos = 20 pts (era 15, corrigido conforme especificação)
if codigo == "CONS_ATIVO": if codigo == "CONS_ATIVO":
if consultoria.anos_consecutivos >= 8: if consultoria.anos_consecutivos >= 8:
bonus += 15 bonus += 20
elif consultoria.anos_consecutivos >= 5: elif consultoria.anos_consecutivos >= 5:
bonus += 10 bonus += 10
elif consultoria.anos_consecutivos >= 3: elif consultoria.anos_consecutivos >= 3:

View File

@@ -30,7 +30,12 @@ class ElasticsearchClient:
"Accept": "application/json" "Accept": "application/json"
}, },
verify=self.verify_ssl, verify=self.verify_ssl,
timeout=120.0 timeout=httpx.Timeout(
connect=10.0,
read=60.0,
write=30.0,
pool=10.0,
)
) )
async def close(self) -> None: async def close(self) -> None:
@@ -39,8 +44,8 @@ class ElasticsearchClient:
@property @property
def client(self) -> httpx.AsyncClient: def client(self) -> httpx.AsyncClient:
if not self._client: if not self._client or self._client.is_closed:
raise RuntimeError("Cliente Elasticsearch não conectado. Execute connect() primeiro.") raise RuntimeError("Cliente Elasticsearch não conectado ou fechado. Execute connect() primeiro.")
return self._client return self._client
async def buscar_por_id(self, id_pessoa: int) -> Optional[dict]: async def buscar_por_id(self, id_pessoa: int) -> Optional[dict]:

View File

@@ -21,13 +21,20 @@ class OracleClient:
user=self.user, user=self.user,
password=self.password, password=self.password,
dsn=self.dsn, dsn=self.dsn,
min=2, min=1,
max=10, max=20,
increment=1, increment=5,
timeout=30,
wait_timeout=10,
getmode=oracledb.POOL_GETMODE_TIMEDWAIT,
) )
self._connected = True self._connected = True
logger.info("Pool Oracle conectado com sucesso")
except oracledb.Error as e:
logger.error(f"Oracle database error: {e}", exc_info=True)
self._connected = False
except Exception as e: except Exception as e:
logger.warning(f"Oracle: {e}") logger.error(f"Oracle connection error: {e}", exc_info=True)
self._connected = False self._connected = False
def close(self) -> None: def close(self) -> None:
@@ -45,10 +52,12 @@ class OracleClient:
def get_connection(self): def get_connection(self):
if not self._pool: if not self._pool:
raise RuntimeError("Pool Oracle não conectado. Execute connect() primeiro.") raise RuntimeError("Pool Oracle não conectado. Execute connect() primeiro.")
conn = self._pool.acquire() conn = None
try: try:
conn = self._pool.acquire()
yield conn yield conn
finally: finally:
if conn:
self._pool.release(conn) self._pool.release(conn)
def executar_query(self, query: str, params: Optional[dict] = None) -> List[Dict[str, Any]]: def executar_query(self, query: str, params: Optional[dict] = None) -> List[Dict[str, Any]]:

View File

@@ -99,11 +99,19 @@ class RankingOracleRepository:
""" """
Busca ranking paginado ordenado por posição. Busca ranking paginado ordenado por posição.
""" """
if page < 1:
page = 1
if size < 1 or size > 10000:
size = 50
offset = (page - 1) * size offset = (page - 1) * size
limit_end = offset + size limit_end = offset + size
where_clause = "" where_clause = ""
params = {} params = {
"offset": offset,
"limit_end": limit_end,
}
if filtro_ativo is not None: if filtro_ativo is not None:
where_clause = "AND ATIVO = :ativo" where_clause = "AND ATIVO = :ativo"
@@ -128,7 +136,7 @@ class RankingOracleRepository:
FROM TB_RANKING_CONSULTOR FROM TB_RANKING_CONSULTOR
WHERE 1=1 {where_clause} WHERE 1=1 {where_clause}
) )
WHERE RN > {offset} AND RN <= {limit_end} WHERE RN > :offset AND RN <= :limit_end
""" """
results = self.client.executar_query(query, params) results = self.client.executar_query(query, params)

View File

@@ -63,6 +63,10 @@ class ConsultorRepositoryImpl(ConsultorRepository):
return "CAJ_MP" return "CAJ_MP"
elif "adjunt" in texto: elif "adjunt" in texto:
return "CAJ" return "CAJ"
elif "coordenador" in texto or "área" in texto or "area" in texto:
return "CA"
logger.warning(f"Tipo de coordenação desconhecido, inferindo CA: tipo='{tipo_coord}', texto='{texto[:100]}'")
return "CA" return "CA"
def _extrair_coordenacoes_capes(self, atuacoes: List[Dict[str, Any]]) -> List[CoordenacaoCapes]: def _extrair_coordenacoes_capes(self, atuacoes: List[Dict[str, Any]]) -> List[CoordenacaoCapes]:

View File

@@ -48,7 +48,7 @@ async def carregar_ranking_do_oracle() -> int:
for c in consultores: for c in consultores:
try: try:
detalhes = json.loads(c.json_detalhes) if isinstance(c.json_detalhes, str) else c.json_detalhes or {} detalhes = json.loads(c.json_detalhes) if isinstance(c.json_detalhes, str) else c.json_detalhes or {}
except: except (json.JSONDecodeError, TypeError, ValueError):
detalhes = {} detalhes = {}
entries.append( entries.append(
@@ -120,13 +120,13 @@ async def lifespan(app: FastAPI):
if scheduler: if scheduler:
try: try:
scheduler.parar() scheduler.parar()
except: except Exception:
pass pass
if oracle_client: if oracle_client:
try: try:
oracle_client.close() oracle_client.close()
except: except Exception:
pass pass
await es_client.close() await es_client.close()

View File

@@ -1,3 +1,6 @@
import threading
from typing import Optional
from ...infrastructure.elasticsearch.client import ElasticsearchClient from ...infrastructure.elasticsearch.client import ElasticsearchClient
from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl
from ...infrastructure.oracle.client import OracleClient from ...infrastructure.oracle.client import OracleClient
@@ -15,20 +18,23 @@ es_client = ElasticsearchClient(
verify_ssl=settings.ES_VERIFY_SSL, verify_ssl=settings.ES_VERIFY_SSL,
) )
oracle_client = OracleClient( oracle_client: Optional[OracleClient] = OracleClient(
user=settings.ORACLE_LOCAL_USER, user=settings.ORACLE_LOCAL_USER,
password=settings.ORACLE_LOCAL_PASSWORD, password=settings.ORACLE_LOCAL_PASSWORD,
dsn=settings.ORACLE_LOCAL_DSN, dsn=settings.ORACLE_LOCAL_DSN,
) if settings.ORACLE_LOCAL_USER and settings.ORACLE_LOCAL_DSN else None ) if settings.ORACLE_LOCAL_USER and settings.ORACLE_LOCAL_DSN else None
ranking_oracle_repo = RankingOracleRepository(oracle_client) if oracle_client else None ranking_oracle_repo: Optional[RankingOracleRepository] = RankingOracleRepository(oracle_client) if oracle_client else None
_repository: ConsultorRepositoryImpl = None _repository: Optional[ConsultorRepositoryImpl] = None
_processar_job: ProcessarRankingJob = None _processar_job: Optional[ProcessarRankingJob] = None
_lock = threading.Lock()
def get_repository() -> ConsultorRepositoryImpl: def get_repository() -> ConsultorRepositoryImpl:
global _repository global _repository
if _repository is None:
with _lock:
if _repository is None: if _repository is None:
_repository = ConsultorRepositoryImpl(es_client=es_client, oracle_client=oracle_client) _repository = ConsultorRepositoryImpl(es_client=es_client, oracle_client=oracle_client)
return _repository return _repository
@@ -48,6 +54,8 @@ def get_ranking_oracle_repo() -> RankingOracleRepository:
def get_processar_job() -> ProcessarRankingJob: def get_processar_job() -> ProcessarRankingJob:
global _processar_job global _processar_job
if _processar_job is None:
with _lock:
if _processar_job is None: if _processar_job is None:
_processar_job = ProcessarRankingJob( _processar_job = ProcessarRankingJob(
es_client=es_client, es_client=es_client,

View File

@@ -72,7 +72,11 @@ function App() {
setProcessMessage('Processamento do ranking já iniciado. Aguardando...'); setProcessMessage('Processamento do ranking já iniciado. Aguardando...');
} }
while (true) { const MAX_POLLING_TIME = 5 * 60 * 1000;
const POLLING_INTERVAL = 4000;
const startTime = Date.now();
while (Date.now() - startTime < MAX_POLLING_TIME) {
try { try {
const st = await rankingService.getStatus(); const st = await rankingService.getStatus();
setProcessMessage(st.mensagem || `Processando... ${st.progress || 0}%`); setProcessMessage(st.mensagem || `Processando... ${st.progress || 0}%`);
@@ -83,7 +87,11 @@ function App() {
} catch (e) { } catch (e) {
setProcessMessage('Aguardando status do processamento...'); setProcessMessage('Aguardando status do processamento...');
} }
await new Promise((r) => setTimeout(r, 4000)); await new Promise((r) => setTimeout(r, POLLING_INTERVAL));
}
if (Date.now() - startTime >= MAX_POLLING_TIME) {
throw new Error('Timeout: processamento demorou mais que 5 minutos');
} }
const response = await rankingService.getRanking(page, pageSize); const response = await rankingService.getRanking(page, pageSize);

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect, useMemo, memo } from 'react';
import './ConsultorCard.css'; import './ConsultorCard.css';
const SELOS = { const SELOS = {
@@ -123,7 +123,7 @@ const FORMULAS = {
}, },
bloco_c: { bloco_c: {
titulo: 'Consultoria', titulo: 'Consultoria',
descricao: 'CONS_ATIVO=150 | CONS_HIST=100 | CONS_FALECIDO=100\nTempo: 5 pts/ano (max 50)\nContinuidade 8a+=+20 (escalonado)\nRetorno (reativação): +15 (uma vez)', descricao: 'CONS_ATIVO=150 | CONS_HIST=100 | CONS_FALECIDO=100\nTempo: 5 pts/ano (max 50)\nContinuidade: 3a=+5, 5a=+10, 8a+=+20 (escalonado)\nRetorno (reativação): +15 (uma vez)',
}, },
bloco_d: { bloco_d: {
titulo: 'Premiacoes/Avaliacoes', titulo: 'Premiacoes/Avaliacoes',
@@ -183,15 +183,16 @@ const ScoreItemWithTooltip = ({ value, label, formula, style }) => (
</div> </div>
); );
const ConsultorCard = ({ consultor, highlight, selecionado, onToggleSelecionado }) => { const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecionado }) => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const cardRef = useRef(null); const cardRef = useRef(null);
useEffect(() => { useEffect(() => {
if (highlight && cardRef.current) { if (highlight && cardRef.current) {
setTimeout(() => { const timeoutId = setTimeout(() => {
cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100); }, 100);
return () => clearTimeout(timeoutId);
} }
}, [highlight]); }, [highlight]);
@@ -219,7 +220,7 @@ const ConsultorCard = ({ consultor, highlight, selecionado, onToggleSelecionado
const blocoD = pontuacao?.bloco_d || { total: consultor.bloco_d || 0 }; const blocoD = pontuacao?.bloco_d || { total: consultor.bloco_d || 0 };
const pontuacaoTotal = (blocoA.total || 0) + (blocoB.total || 0) + (blocoC.total || 0) + (blocoD.total || 0); const pontuacaoTotal = (blocoA.total || 0) + (blocoB.total || 0) + (blocoC.total || 0) + (blocoD.total || 0);
const selos = gerarSelos(consultor); const selos = useMemo(() => gerarSelos(consultor), [consultor]);
return ( return (
<div ref={cardRef} className={`ranking-card ${expanded ? 'expanded' : ''} ${highlight ? 'highlight' : ''} ${selecionado ? 'selecionado' : ''}`} onClick={() => setExpanded(!expanded)}> <div ref={cardRef} className={`ranking-card ${expanded ? 'expanded' : ''} ${highlight ? 'highlight' : ''} ${selecionado ? 'selecionado' : ''}`} onClick={() => setExpanded(!expanded)}>
@@ -421,9 +422,11 @@ const ConsultorCard = ({ consultor, highlight, selecionado, onToggleSelecionado
)} )}
</div> </div>
); );
}; });
const BlocoDetalhes = ({ titulo, bloco, cor }) => ( ConsultorCard.displayName = 'ConsultorCard';
const BlocoDetalhes = memo(({ titulo, bloco, cor }) => (
<div className="detail-section"> <div className="detail-section">
<h4 style={{ color: cor }}>{titulo}</h4> <h4 style={{ color: cor }}>{titulo}</h4>
<div className="score-breakdown"> <div className="score-breakdown">
@@ -468,6 +471,8 @@ const BlocoDetalhes = ({ titulo, bloco, cor }) => (
</div> </div>
</div> </div>
</div> </div>
); ));
BlocoDetalhes.displayName = 'BlocoDetalhes';
export default ConsultorCard; export default ConsultorCard;