package services import ( "encoding/json" "fmt" "log" "net/http" "sort" "strconv" "time" "github.com/robfig/cron/v3" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) type cronProcInfo struct { Nombre string `json:"nombre"` CPU float64 `json:"cpu"` RAMMB float64 `json:"ram_mb"` } var cronScheduler *cron.Cron // IniciarCron arranca el scheduler de tareas. Llamar desde app.go o main.go. func IniciarCron() { cronScheduler = cron.New() // Vencimientos próximos — todos los días a las 8:00 AM if _, err := cronScheduler.AddFunc("0 8 * * *", ProcesarVencimientosProximos); err != nil { log.Printf("[CRON] Error registrando tarea vencimientos_proximo: %v", err) return } // Contratos ya vencidos — todos los días a las 9:00 AM if _, err := cronScheduler.AddFunc("0 9 * * *", ProcesarYaVencidos); err != nil { log.Printf("[CRON] Error registrando tarea ya_vencido: %v", err) return } // Polling Bold — cada 15 minutos para detectar pagos sin webhook if _, err := cronScheduler.AddFunc("*/15 * * * *", VerificarPagosBoldPendientes); err != nil { log.Printf("[CRON] Error registrando tarea bold_polling: %v", err) return } // Salud de servidores — cada 5 minutos if _, err := cronScheduler.AddFunc("*/5 * * * *", VerificarSaludServidores); err != nil { log.Printf("[CRON] Error registrando tarea salud_servidores: %v", err) return } // Purga de historial de métricas — cada noche a las 3 AM (retención 7 días) if _, err := cronScheduler.AddFunc("0 3 * * *", func() { if err := models.PurgarMetricasHistory(7); err != nil { log.Printf("[CRON] Error purgando historial métricas: %v", err) } else { log.Println("[CRON] Historial de métricas purgado (>7 días)") } if err := models.PurgarUrlMonitorLogs(7); err != nil { log.Printf("[CRON] Error purgando logs URL monitor: %v", err) } }); err != nil { log.Printf("[CRON] Error registrando tarea purga_metricas: %v", err) return } // Monitor de URLs — cada minuto if _, err := cronScheduler.AddFunc("* * * * *", VerificarUrlMonitors); err != nil { log.Printf("[CRON] Error registrando tarea url_monitor: %v", err) return } cronScheduler.Start() log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min") } // DetenerCron para graceful shutdown func DetenerCron() { if cronScheduler != nil { cronScheduler.Stop() } } // ProcesarVencimientos mantiene compatibilidad para llamadas manuales func ProcesarVencimientos() { ProcesarVencimientosProximos() ProcesarYaVencidos() } // ProcesarVencimientosProximos procesa reglas de tipo "vencimiento_proximo" func ProcesarVencimientosProximos() { log.Println("[CRON] Procesando vencimientos próximos...") reglas, err := models.GetReglasByTipoEvento("vencimiento_proximo") if err != nil { log.Printf("[CRON] Error obteniendo reglas vencimiento_proximo: %v", err) return } for _, regla := range reglas { contratos, err := models.GetContratosProximosVencer(regla.DiasAntes) if err != nil { log.Printf("[CRON] Error obteniendo contratos para regla %d: %v", regla.ID, err) continue } procesarContratos(®la, contratos) } log.Println("[CRON] Vencimientos próximos finalizado") } // ProcesarYaVencidos procesa reglas de tipo "ya_vencido" func ProcesarYaVencidos() { log.Println("[CRON] Procesando contratos ya vencidos...") reglas, err := models.GetReglasByTipoEvento("ya_vencido") if err != nil { log.Printf("[CRON] Error obteniendo reglas ya_vencido: %v", err) return } if len(reglas) == 0 { return } contratos, err := models.GetContratosYaVencidos() if err != nil { log.Printf("[CRON] Error obteniendo contratos vencidos: %v", err) return } for _, regla := range reglas { procesarContratos(®la, contratos) } log.Println("[CRON] Ya vencidos finalizado") } // procesarContratos aplica filtros y envía notificaciones para una regla y lista de contratos func procesarContratos(regla *models.NotificacionRegla, contratos []models.Contrato) { if len(contratos) == 0 { return } // Filtrar por AplicaA var filtrados []models.Contrato for _, c := range contratos { switch regla.AplicaA { case "renovable": for _, s := range c.Servicios { if s.Tipo == "renovable" { filtrados = append(filtrados, c) break } } case "unico": for _, s := range c.Servicios { if s.Tipo == "unico" { filtrados = append(filtrados, c) break } } default: filtrados = append(filtrados, c) } } if len(filtrados) == 0 { return } // Agrupar por cliente porCliente := make(map[uint][]models.Contrato) for _, c := range filtrados { porCliente[c.ClienteID] = append(porCliente[c.ClienteID], c) } for clienteID, grupoContratos := range porCliente { if models.YaEnviadoHoy(clienteID, regla.ID) { log.Printf("[CRON] Ya enviado hoy a cliente %d para regla %d — saltando", clienteID, regla.ID) continue } cliente := &grupoContratos[0].Cliente if err := EnviarNotificacionGrupo(regla, cliente, grupoContratos, ""); err != nil { log.Printf("[CRON] Error enviando a cliente %d: %v", clienteID, err) } else { log.Printf("[CRON] Enviado a cliente %d (%s) — %d contrato(s)", clienteID, cliente.Email, len(grupoContratos)) } } } // VerificarPagosBoldPendientes consulta la API de Bold para cada contrato que tiene un // enlace de pago generado pero aún no confirmado. Se ejecuta cada 15 minutos via cron. // Esto compensa la ausencia de webhooks de Bold. func VerificarPagosBoldPendientes() { contratos, err := models.GetContratosConEnlacePendiente() if err != nil { log.Printf("[BOLD-POLL] Error obteniendo contratos pendientes: %v", err) return } if len(contratos) == 0 { return } boldCfg, err := models.GetBoldConfig() if err != nil { log.Printf("[BOLD-POLL] Sin configuración Bold activa: %v", err) return } log.Printf("[BOLD-POLL] Verificando %d contrato(s) con enlace pendiente...", len(contratos)) for _, c := range contratos { paid, paymentID, monto, err := CheckBoldLinkPaid(boldCfg, c.EnlacePagoLinkID) if err != nil { log.Printf("[BOLD-POLL] Error consultando link %s (contrato %d): %v", c.EnlacePagoLinkID, c.ID, err) continue } if !paid { continue } ref := fmt.Sprintf("contrato-%d", c.ID) log.Printf("[BOLD-POLL] Pago detectado para %s (payment_id=%s, monto=%d)", ref, paymentID, monto) if ok, _ := models.MarcarContratoPagado(c.ID); ok { go EnviarCorreoConfirmacionPago(c.ID, "bold") log.Printf("[BOLD-POLL] Contrato %d marcado como pagado", c.ID) } // Enriquecer callback log si existe go models.EnrichBoldCallbackLog(ref, c.Cliente.Email, monto) // Guardar en webhook log como API_CHECK para trazabilidad notifID := "api-check-" + paymentID if paymentID != "" && !models.IsBoldNotificationDuplicate(notifID) { _ = models.SaveBoldWebhookLog(models.BoldWebhookLog{ NotificationID: notifID, Tipo: "API_CHECK", PaymentID: paymentID, Referencia: ref, PayerEmail: c.Cliente.Email, Monto: monto, Procesado: true, }) } } log.Println("[BOLD-POLL] Verificación completada") } // ─── Salud de Servidores ───────────────────────────────────────────────────── // VerificarSaludServidores corre cada 5 min y genera notificaciones del sistema // cuando detecta: agente caído, recurso al límite o VPS próximo a vencer. func VerificarSaludServidores() { cfg := models.GetServidorAlertaUmbral() if !cfg.Activo { return } servidores, _, err := models.GetAllServidores(1000, 0, "") if err != nil { log.Printf("[CRON-SRV] Error obteniendo servidores: %v", err) return } for _, srv := range servidores { // 1. Agente caído (sin ping en X minutos) if srv.AgentToken != "" && srv.AgentLastSeen != nil { minutos := time.Since(*srv.AgentLastSeen).Minutes() if minutos > float64(cfg.MinutosSinPing) { titulo := fmt.Sprintf("🔴 Sin señal: %s", srv.Nombre) if !models.YaExisteAlertaServidor(titulo, 1) { crearNotifServidor(titulo, fmt.Sprintf("El agente no reporta hace %.0f minutos (umbral: %d min)", minutos, cfg.MinutosSinPing), "servidor_caido") } } } // 2. Recursos (CPU / RAM / Disco) al límite if srv.MetricasJson != "" { var m struct { CPU struct{ Porcentaje float64 `json:"porcentaje"` } `json:"cpu"` RAM struct{ Porcentaje float64 `json:"porcentaje"` } `json:"ram"` Discos []struct { Porcentaje float64 `json:"porcentaje"` Ruta string `json:"ruta"` } `json:"discos"` TopProcs []cronProcInfo `json:"top_procs"` } if jsonErr := json.Unmarshal([]byte(srv.MetricasJson), &m); jsonErr == nil { checkRecurso(srv.Nombre, "CPU", m.CPU.Porcentaje, cfg.UmbralCPU, m.TopProcs, "cpu") checkRecurso(srv.Nombre, "RAM", m.RAM.Porcentaje, cfg.UmbralRAM, m.TopProcs, "ram") for _, d := range m.Discos { label := "Disco" if d.Ruta != "" && d.Ruta != "/" { label = "Disco " + d.Ruta } checkRecurso(srv.Nombre, label, d.Porcentaje, cfg.UmbralDisco, nil, "") } } } // 3. Vencimiento próximo if srv.Vencimiento != "" && cfg.DiasAnteVencimiento > 0 { vence, parseErr := time.Parse("2006-01-02", srv.Vencimiento) if parseErr == nil { dias := int(time.Until(vence).Hours() / 24) if dias >= 0 && dias <= cfg.DiasAnteVencimiento { titulo := fmt.Sprintf("📅 %s vence en %s", srv.Nombre, strconv.Itoa(dias)+" días") if dias == 0 { titulo = fmt.Sprintf("📅 %s vence HOY", srv.Nombre) } if !models.YaExisteAlertaServidor(titulo, 12) { crearNotifServidor(titulo, fmt.Sprintf("Fecha de vencimiento: %s. Renueva a tiempo.", srv.Vencimiento), "servidor_vence_pronto") } } } } } } func checkRecurso(nombre, recurso string, valor float64, umbral int, procs []cronProcInfo, ordenar string) { if valor < float64(umbral) { return } titulo := fmt.Sprintf("⚠️ %s alto en %s (%.0f%%)", recurso, nombre, valor) if !models.YaExisteAlertaServidor(titulo, 1) { cuerpo := fmt.Sprintf("%s al %.0f%% (umbral: %d%%)", recurso, valor, umbral) if len(procs) > 0 { cp := make([]cronProcInfo, len(procs)) copy(cp, procs) if ordenar == "cpu" { sort.Slice(cp, func(i, j int) bool { return cp[i].CPU > cp[j].CPU }) } else { sort.Slice(cp, func(i, j int) bool { return cp[i].RAMMB > cp[j].RAMMB }) } if len(cp) > 3 { cp = cp[:3] } cuerpo += "\n\nTop procesos:" for i, p := range cp { if ordenar == "cpu" { cuerpo += fmt.Sprintf("\n%d. %s — CPU: %.1f%%, RAM: %.0f MB", i+1, p.Nombre, p.CPU, p.RAMMB) } else { cuerpo += fmt.Sprintf("\n%d. %s — RAM: %.0f MB, CPU: %.1f%%", i+1, p.Nombre, p.RAMMB, p.CPU) } } } crearNotifServidor(titulo, cuerpo, "servidor_recurso_alto") } } func crearNotifServidor(titulo, cuerpo, evento string) { // Verificar si la notificación del sistema está habilitada para este evento evtCfg := models.GetNotifConfig(evento, "admin") // Por defecto, si no hay config, crear notificación de sistema igualmente if evtCfg == nil || evtCfg.CanalSistema { _ = models.CreateSistemaNotif(&models.SistemaNotificacion{ TipoUsuario: "admin", Titulo: titulo, Cuerpo: cuerpo, Url: "/app/servidor-dashboard", Icono: "🖥️", }) } // Telegram (si está configurado para este evento) if evtCfg != nil && evtCfg.CanalTelegram { msg := fmt.Sprintf("%s\n%s", titulo, cuerpo) sendTelegramAdmin(msg) } } // ─── Monitor de URLs ────────────────────────────────────────────────────────── func VerificarUrlMonitors() { monitors, err := models.GetActiveUrlMonitors() if err != nil || len(monitors) == 0 { return } now := time.Now() for _, m := range monitors { m := m if m.UltimoCheckAt != nil { if now.Sub(*m.UltimoCheckAt).Minutes() < float64(m.IntervaloMin) { continue } } go ejecutarChequeoURL(m) } } func EjecutarChequeoURLPublic(m models.UrlMonitor) { ejecutarChequeoURL(m) } func ejecutarChequeoURL(m models.UrlMonitor) { statusCode, latMs, err := doHttpCheck(m.URL, m.TimeoutSeg) ok := err == nil && statusCode >= 200 && statusCode < 400 errStr := "" if err != nil { errStr = err.Error() } anteriorOk := m.UltimoOk == nil || *m.UltimoOk models.UpdateUrlMonitorStatus(m.ID, statusCode, latMs, ok) models.InsertUrlMonitorLog(m.ID, statusCode, latMs, ok, errStr) if m.UltimoOk == nil { return // primer chequeo, no alertar } if !ok && anteriorOk { detalle := errStr if detalle == "" { detalle = fmt.Sprintf("HTTP %d", statusCode) } msg := fmt.Sprintf("🔴 Sitio caído\n%s\n%s\n\nError: %s", escapeTelegramHTML(m.Nombre), escapeTelegramHTML(m.URL), escapeTelegramHTML(detalle)) sendTelegramAdmin(msg) log.Printf("[URL-MON] DOWN %s — %s", m.Nombre, detalle) } else if ok && !anteriorOk { msg := fmt.Sprintf("✅ Sitio recuperado\n%s\n%s\nLatencia: %dms", escapeTelegramHTML(m.Nombre), escapeTelegramHTML(m.URL), latMs) sendTelegramAdmin(msg) log.Printf("[URL-MON] UP %s — %dms", m.Nombre, latMs) } } func doHttpCheck(url string, timeoutSeg int) (int, int64, error) { if timeoutSeg <= 0 { timeoutSeg = 10 } client := &http.Client{ Timeout: time.Duration(timeoutSeg) * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return fmt.Errorf("demasiadas redirecciones") } return nil }, } start := time.Now() req, err := http.NewRequest(http.MethodHead, url, nil) if err != nil { return 0, 0, err } req.Header.Set("User-Agent", "usite-monitor/1.0") resp, err := client.Do(req) latMs := time.Since(start).Milliseconds() if err != nil || (resp != nil && resp.StatusCode == http.StatusMethodNotAllowed) { if resp != nil { resp.Body.Close() } // Reintentar con GET start = time.Now() req2, _ := http.NewRequest(http.MethodGet, url, nil) req2.Header.Set("User-Agent", "usite-monitor/1.0") resp2, err2 := client.Do(req2) latMs = time.Since(start).Milliseconds() if err2 != nil { return 0, latMs, err2 } resp2.Body.Close() return resp2.StatusCode, latMs, nil } resp.Body.Close() return resp.StatusCode, latMs, nil }