Files
Lizandro GuarnizoandClaude Sonnet 5 08265510ea feat(umind): mide el consumo de IA, OCR y transcripción por tenant
Es la base del cobro por uso: hasta ahora no había ninguna medición de
consumo en todo el repo.

- UmindUso registra cada evento facturable con el costo YA calculado al
  precio vigente del plan. Congelarlo evita que subir un precio revalúe
  consumo pasado, que haría indefendible una factura ante un reclamo.
- callAI devuelve los tokens que reportó el proveedor (campo usage, igual
  en todos los OpenAI-compatibles; input+output en Anthropic). Se mide
  cada ronda de tool-calling, no solo la última: todas gastan tokens.
- ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no
  medir, que es lo que pasan los botones "Probar" del panel de staff.
- Aviso al superar el tope del plan, una vez por mes y sin cortar el
  servicio. El flag de "ya avisé" es en memoria a propósito.
- GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle.
- Test del cálculo de costo por tipo, incluida fracción de 1k tokens y
  tenant sin plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:41:23 -05:00

421 lines
12 KiB
Go
Executable File

package controllers
import (
"fmt"
"math"
"net"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// RENDER
func Servidor(c *fiber.Ctx) error {
data := fiber.Map{
"user": c.Locals("user").(map[string]interface{}),
"modules": c.Locals("userModules"),
}
if err := c.Render("servidor", data, "layouts/main"); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Error rendering template",
"error": err.Error(),
})
}
return nil
}
// ServidorDashboard renders the server dashboard view
func ServidorDashboard(c *fiber.Ctx) error {
data := fiber.Map{
"user": c.Locals("user").(map[string]interface{}),
"modules": c.Locals("userModules"),
}
if err := c.Render("servidor_dashboard", data, "layouts/main"); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Error rendering template",
"error": err.Error(),
})
}
return nil
}
func GetServidor(c *fiber.Ctx) error {
pageStr := c.Query("page", "1")
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
searchQuery := c.Query("search", "")
limitStr := c.Query("limit", "10")
limit, _ := strconv.Atoi(limitStr)
offset := (page - 1) * limit
records, total, err := models.GetAllServidores(limit, offset, searchQuery)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Error retrieving records",
"error": err.Error(),
})
}
// Build connection count map per server
type conxCount struct {
ServidorID uint `gorm:"column:servidor_id"`
Count int64 `gorm:"column:count"`
}
var counts []conxCount
serverIDs := make([]uint, len(records))
for i, s := range records {
serverIDs[i] = s.ID
}
if len(serverIDs) > 0 {
app.Http.Database.DB.Table("conx_db").
Select("servidor_id, count(*) as count").
Where("servidor_id IN ? AND deleted_at IS NULL", serverIDs).
Group("servidor_id").
Scan(&counts)
}
countMap := make(map[uint]int64)
for _, cc := range counts {
countMap[cc.ServidorID] = cc.Count
}
// Attach conx_count to each record
type servidorWithCount struct {
models.Servidor
ConxCount int64 `json:"conx_count"`
}
enriched := make([]servidorWithCount, len(records))
for i, s := range records {
enriched[i] = servidorWithCount{Servidor: s, ConxCount: countMap[s.ID]}
}
totalPages := int(math.Ceil(float64(total) / float64(limit)))
return c.JSON(fiber.Map{
"registros": enriched,
"total": total,
"totalPages": totalPages,
"page": page,
"limit": limit,
})
}
func GetServidorSelect(c *fiber.Ctx) error {
records, err := models.GetAllServidoresSelect()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Error retrieving records",
"error": err.Error(),
})
}
return c.JSON(fiber.Map{
"registros": records,
})
}
func CreateServidor(c *fiber.Ctx) error {
var m models.Servidor
if err := c.BodyParser(&m); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Error parsing request body",
"error": err.Error(),
})
}
if err := models.CreateServidor(m); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Error creating servidor",
"error": err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(m)
}
func UpdateServidor(c *fiber.Ctx) error {
uid, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "ID inválido",
"error": err.Error(),
})
}
var m models.Servidor
if err := c.BodyParser(&m); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Error al parsear el cuerpo de la solicitud",
"error": err.Error(),
})
}
m.ID = uint(uid)
// Cargar el registro actual para detectar cambios que deben propagarse a Hostinger
var actual models.Servidor
app.Http.Database.DB.First(&actual, uid)
nombreAnterior := actual.Nombre
// Usar map para Updates y así no perder campos con valor cero (incluido hostinger_vps_id=nil)
updateMap := map[string]interface{}{
"nombre": m.Nombre,
"ip_servidor": m.IpServidor,
"so": m.So,
"vencimiento": m.Vencimiento,
"ram": m.Ram,
"nucleos": m.Nucleos,
"disco": m.Disco,
"ultimo_ping": m.UltimoPing,
"prov_servidor_id": m.ProvServidorID,
"tipo_servidor_id": m.TipoServidorID,
"hostinger_vps_id": m.HostingerVpsID,
"hostinger_subscription_id": m.HostingerSubscriptionID,
}
if err := app.Http.Database.DB.Model(&models.Servidor{}).Where("id = ?", uid).Updates(updateMap).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Error al actualizar el servidor",
"error": err.Error(),
})
}
// Sincronizar hostname en Hostinger si está vinculado y el nombre cambió
if actual.HostingerVpsID != nil && m.Nombre != "" && m.Nombre != nombreAnterior {
if client, hErr := hostingerClient(); hErr == nil {
_ = client.SetVPSHostname(*actual.HostingerVpsID, m.Nombre)
}
}
return c.Status(fiber.StatusOK).JSON(m)
}
// SyncServidorFromHostinger sincroniza los datos del VPS de Hostinger hacia el servidor local.
// Actualiza: ip_servidor, ram, nucleos, disco, hostinger_state, vencimiento (si hay suscripción).
// La verdad de las specs físicas siempre viene de Hostinger.
func SyncServidorFromHostinger(c *fiber.Ctx) error {
id := c.Params("id")
db := app.Http.Database.DB
var servidor models.Servidor
if err := db.First(&servidor, id).Error; err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "servidor no encontrado"})
}
if servidor.HostingerVpsID == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el servidor no tiene hostinger_vps_id configurado"})
}
client, err := hostingerClient()
if err != nil {
return c.Status(fiber.StatusFailedDependency).JSON(fiber.Map{"error": "No se encontró configuración activa de Hostinger"})
}
vps, err := client.GetVPSByID(*servidor.HostingerVpsID)
if err != nil {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"error": err.Error()})
}
if vps.ID == 0 {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"error": "Hostinger no devolvio datos para ese VPS ID"})
}
updates := map[string]interface{}{
"hostinger_state": vps.State,
}
// IP principal (primer IPv4)
if len(vps.IPV4) > 0 && vps.IPV4[0].Address != "" {
updates["ip_servidor"] = vps.IPV4[0].Address
}
// RAM: Hostinger devuelve en MB → convertir a GB
if vps.RAMBytes > 0 {
gbRam := float64(vps.RAMBytes) / 1024.0
updates["ram"] = fmt.Sprintf("%.0f GB", gbRam)
}
// CPU cores
if vps.CPU > 0 {
updates["nucleos"] = fmt.Sprintf("%d", vps.CPU)
}
// Disco: Hostinger devuelve en MB (igual que memory) → convertir a GB
if vps.DiskBytes > 0 {
gbDisk := vps.DiskBytes / 1024
updates["disco"] = fmt.Sprintf("%d GB", gbDisk)
}
// Vencimiento: resolución en cascada
// 1) subscription_id que viene directo del VPS (el más confiable)
// 2) subscription_id ya guardado en el servidor local
// 3) fallback fuzzy por nombre
subID := vps.SubscriptionID
if subID == "" && servidor.HostingerSubscriptionID != "" {
subID = servidor.HostingerSubscriptionID
}
if subID != "" {
// Lookup directo — sin ambigüedad
if sub, sErr := client.GetSubscriptionByID(subID); sErr == nil {
// Para suscripciones activas con auto-renovación, expires_at viene vacío;
// en ese caso usar next_billing_at como fecha de vencimiento.
venc := sub.ExpiresAt
if venc == "" {
venc = sub.NextBillingAt
}
if venc != "" && len(venc) >= 10 {
updates["vencimiento"] = venc[:10]
}
updates["hostinger_subscription_id"] = subID
}
} else {
// Fallback: buscar por nombre si aún no tenemos subscription_id
if subs, sErr := client.GetOrders(); sErr == nil {
for _, sub := range subs {
venc := sub.ExpiresAt
if venc == "" {
venc = sub.NextBillingAt
}
if venc != "" && len(venc) >= 10 &&
strings.Contains(strings.ToLower(sub.Name), strings.ToLower(vps.Hostname)) {
updates["vencimiento"] = venc[:10]
updates["hostinger_subscription_id"] = sub.ID
break
}
}
}
}
if err := db.Model(&servidor).Updates(updates).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo guardar", "detalle": err.Error()})
}
db.First(&servidor, id)
return c.JSON(fiber.Map{"ok": true, "servidor": servidor, "campos_actualizados": updates})
}
func DeleteServidor(c *fiber.Ctx) error {
uid, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "ID inválido",
"error": err.Error(),
})
}
var m models.Servidor
m.ID = uint(uid)
if err := models.DeleteServidor(m); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Error al eliminar el servidor",
"error": err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Servidor eliminado exitosamente",
})
}
// GetServidorDashboard retorna un servidor con todas sus conexiones de BD
func GetServidorDashboard(c *fiber.Ctx) error {
servidorID := c.Params("id")
uid, err := strconv.ParseUint(servidorID, 10, 32)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "ID inválido",
})
}
// Obtener servidor
var servidor models.Servidor
db := app.Http.Database.DB
if err := db.Preload("ProvServidor").Preload("TipoServidor").First(&servidor, uid).Error; err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "Servidor no encontrado",
})
}
// Obtener conexiones de BD asociadas
var conexiones []models.ConxDb
if err := db.Preload("Servidor").Preload("TipoDb").Where("servidor_id = ?", uid).Find(&conexiones).Error; err != nil {
conexiones = []models.ConxDb{}
}
return c.JSON(fiber.Map{
"servidor": servidor,
"conexiones": conexiones,
})
}
// PingConexion tests the connection to a database and returns the response time
func PingConexion(c *fiber.Ctx) error {
conexionID := c.Params("id")
uid, err := strconv.ParseUint(conexionID, 10, 32)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"exitoso": false,
"error": "ID inválido",
})
}
var conexion models.ConxDb
db := app.Http.Database.DB
if err := db.Preload("TipoDb").Preload("Servidor").First(&conexion, uid).Error; err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"exitoso": false,
"error": "Conexión no encontrada",
})
}
host := conexion.HostEfectivo()
puerto := conexion.Puerto
// Primero intentar conexión real a la BD (más fiable que solo TCP)
inicio := time.Now()
testErr := services.TestDBConnection(conexion)
if testErr == nil {
return c.JSON(fiber.Map{
"exitoso": true,
"tiempo": time.Since(inicio).Milliseconds(),
"host": host,
"puerto": puerto,
"tipo": conexion.TipoDb.Nombre,
"error": "",
})
}
// Si la BD no conecta, probar al menos TCP (puerto abierto)
tcpAddr := net.JoinHostPort(host, puerto)
inicioTCP := time.Now()
conn, errTCP := net.DialTimeout("tcp", tcpAddr, 3*time.Second)
if errTCP == nil {
conn.Close()
return c.JSON(fiber.Map{
"exitoso": false,
"tiempo": time.Since(inicioTCP).Milliseconds(),
"host": host,
"puerto": puerto,
"tipo": conexion.TipoDb.Nombre,
// Puerto abierto pero la BD rechaza la conexión
"error": fmt.Sprintf("Puerto %s abierto pero la BD no responde: %s", puerto, testErr.Error()),
})
}
return c.JSON(fiber.Map{
"exitoso": false,
"tiempo": int64(0),
"host": host,
"puerto": puerto,
"tipo": conexion.TipoDb.Nombre,
"error": fmt.Sprintf("No se puede alcanzar %s:%s — verifica que el host sea accesible desde este servidor. Tip: si el DB solo escucha en localhost del servidor, configura el campo 'Host' de la conexión.", host, puerto),
})
}