Files
soft_usite/rest/controllers/saas_api_controller.go
T
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

358 lines
11 KiB
Go

package controllers
import (
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/helpers"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// ─── Panel: lista de configuraciones ─────────────────────────────────────────
// SaasApiIndex renderiza la vista del panel de integraciones SaaS.
func SaasApiIndex(c *fiber.Ctx) error {
data := fiber.Map{
"user": c.Locals("user").(map[string]interface{}),
"modules": c.Locals("userModules"),
}
if err := c.Render("saas_api", data, "layouts/main"); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return nil
}
// GetSaasApiConfigs devuelve la lista paginada en JSON.
func GetSaasApiConfigs(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
if page < 1 {
page = 1
}
limit := 20
offset := (page - 1) * limit
items, total, err := models.GetAllSaasApiConfigs(limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
saasOpts, _ := models.GetAllSaasProductosSelect()
return c.JSON(fiber.Map{
"items": items,
"saas": saasOpts,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
"limit": limit,
})
}
// CreateSaasApiConfig crea una nueva configuración de integración.
func CreateSaasApiConfig(c *fiber.Ctx) error {
type Req struct {
SaasID uint `json:"saas_id"`
Nombre string `json:"nombre"`
Pasarela string `json:"pasarela"`
EndpointURL string `json:"endpoint_url"`
Metodo string `json:"metodo"`
ApiKeyHeader string `json:"api_key_header"`
ApiKeyValue string `json:"api_key_value"`
PayloadTemplate string `json:"payload_template"`
TimeoutSeg int `json:"timeout_seg"`
Activo bool `json:"activo"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.SaasID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "saas_id es requerido"})
}
if strings.TrimSpace(req.Nombre) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
}
if strings.TrimSpace(req.EndpointURL) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "endpoint_url es requerido"})
}
metodo := strings.ToUpper(strings.TrimSpace(req.Metodo))
if metodo == "" {
metodo = "POST"
}
pasarela := strings.ToLower(strings.TrimSpace(req.Pasarela))
if pasarela == "" {
pasarela = "ambas"
}
timeout := req.TimeoutSeg
if timeout <= 0 {
timeout = 10
}
item := models.SaasApiConfig{
SaasID: req.SaasID,
Nombre: req.Nombre,
Pasarela: pasarela,
EndpointURL: req.EndpointURL,
Metodo: metodo,
ApiKeyHeader: req.ApiKeyHeader,
ApiKeyValue: req.ApiKeyValue,
PayloadTemplate: req.PayloadTemplate,
TimeoutSeg: timeout,
Activo: req.Activo,
WebhookToken: helpers.RandomString(32),
}
if err := models.CreateSaasApiConfig(&item); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(item)
}
// UpdateSaasApiConfig actualiza una configuración existente.
func UpdateSaasApiConfig(c *fiber.Ctx) error {
idParam := c.Params("id")
id64, err := strconv.ParseUint(idParam, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
item, err := models.GetSaasApiConfigByID(uint(id64))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"})
}
type Req struct {
SaasID uint `json:"saas_id"`
Nombre string `json:"nombre"`
Pasarela string `json:"pasarela"`
EndpointURL string `json:"endpoint_url"`
Metodo string `json:"metodo"`
ApiKeyHeader string `json:"api_key_header"`
ApiKeyValue string `json:"api_key_value"`
PayloadTemplate string `json:"payload_template"`
TimeoutSeg int `json:"timeout_seg"`
Activo bool `json:"activo"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.SaasID != 0 {
item.SaasID = req.SaasID
}
if strings.TrimSpace(req.Nombre) != "" {
item.Nombre = req.Nombre
}
pasarela := strings.ToLower(strings.TrimSpace(req.Pasarela))
if pasarela != "" {
item.Pasarela = pasarela
}
if strings.TrimSpace(req.EndpointURL) != "" {
item.EndpointURL = req.EndpointURL
}
metodo := strings.ToUpper(strings.TrimSpace(req.Metodo))
if metodo != "" {
item.Metodo = metodo
}
item.ApiKeyHeader = req.ApiKeyHeader
// Solo actualizar api_key_value si se envió un valor (para no borrar el secreto con "")
if req.ApiKeyValue != "" {
item.ApiKeyValue = req.ApiKeyValue
}
item.PayloadTemplate = req.PayloadTemplate
if req.TimeoutSeg > 0 {
item.TimeoutSeg = req.TimeoutSeg
}
item.Activo = req.Activo
if err := models.UpdateSaasApiConfig(item); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(item)
}
// DeleteSaasApiConfig elimina una configuración.
func DeleteSaasApiConfig(c *fiber.Ctx) error {
idParam := c.Params("id")
id64, err := strconv.ParseUint(idParam, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := models.DeleteSaasApiConfig(uint(id64)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ProbaSaasApiConfig envía una petición de prueba al endpoint configurado.
// Body JSON opcional: { "payload": "..." } — si no se envía usa el payload_template tal cual.
func ProbaSaasApiConfig(c *fiber.Ctx) error {
idParam := c.Params("id")
id64, err := strconv.ParseUint(idParam, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
cfg, err := models.GetSaasApiConfigByID(uint(id64))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"})
}
type Req struct {
Payload string `json:"payload"`
}
var req Req
_ = c.BodyParser(&req)
body := strings.TrimSpace(req.Payload)
if body == "" {
body = strings.TrimSpace(cfg.PayloadTemplate)
}
timeout := cfg.TimeoutSeg
if timeout <= 0 {
timeout = 10
}
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
var httpReq *http.Request
metodo := strings.ToUpper(cfg.Metodo)
if metodo == "" {
metodo = "POST"
}
if body != "" && metodo != "GET" {
httpReq, err = http.NewRequest(metodo, cfg.EndpointURL, strings.NewReader(body))
if err != nil {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"error": "URL inválida: " + err.Error()})
}
httpReq.Header.Set("Content-Type", "application/json")
} else {
httpReq, err = http.NewRequest(metodo, cfg.EndpointURL, nil)
if err != nil {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"error": "URL inválida: " + err.Error()})
}
}
if cfg.ApiKeyHeader != "" && cfg.ApiKeyValue != "" {
httpReq.Header.Set(cfg.ApiKeyHeader, cfg.ApiKeyValue)
}
httpReq.Header.Set("User-Agent", "u-site-tester/1.0")
start := time.Now()
resp, err := client.Do(httpReq)
latency := time.Since(start).Milliseconds()
if err != nil {
return c.JSON(fiber.Map{
"ok": false,
"error": err.Error(),
"latency_ms": latency,
})
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) // máx 64 KB
return c.JSON(fiber.Map{
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"http_status": resp.StatusCode,
"body": string(respBytes),
"latency_ms": latency,
})
}
// ─── Panel: logs de despacho ──────────────────────────────────────────────────
// SaasDispatchLogIndex renderiza la vista de logs de despacho.
func SaasDispatchLogIndex(c *fiber.Ctx) error {
data := fiber.Map{
"user": c.Locals("user").(map[string]interface{}),
"modules": c.Locals("userModules"),
}
if err := c.Render("saas_dispatch_logs", data, "layouts/main"); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return nil
}
// GetSaasDispatchLogs devuelve los logs de despacho paginados.
func GetSaasDispatchLogs(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
if page < 1 {
page = 1
}
limit := 30
offset := (page - 1) * limit
items, total, err := models.GetSaasDispatchLogs(limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"items": items,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
"limit": limit,
})
}
// ─── Webhook entrante por token ───────────────────────────────────────────────
// SaasWebhookInHandler recibe llamadas entrantes del SaaS externo identificadas
// por el WebhookToken único de la integración. No requiere autenticación.
// Ruta pública: POST /webhooks/saas-in/:token
func SaasWebhookInHandler(c *fiber.Ctx) error {
token := strings.TrimSpace(c.Params("token"))
if token == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token requerido"})
}
cfg, err := models.GetSaasApiConfigByWebhookToken(token)
if err != nil {
// Responder 200 para evitar reintentos agresivos de sistemas externos
log.Printf("[SaasWebhookIn] Token desconocido: %s", token)
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": false, "msg": "token no reconocido"})
}
// Capturar headers relevantes como JSON
headersMap := map[string]string{}
c.Request().Header.VisitAll(func(k, v []byte) {
key := string(k)
// Solo guardar headers informativos, excluir cookies/auth
if key != "Cookie" && key != "Authorization" {
headersMap[key] = string(v)
}
})
headersJSON, _ := json.Marshal(headersMap)
rawBody := string(c.Body())
entry := models.SaasWebhookInLog{
SaasApiConfigID: cfg.ID,
Token: token,
Metodo: c.Method(),
IP: c.IP(),
Body: rawBody,
Headers: string(headersJSON),
}
_ = models.SaveSaasWebhookInLog(&entry)
log.Printf("[SaasWebhookIn] Integración '%s' (ID=%d) recibió callback desde IP=%s method=%s",
cfg.Nombre, cfg.ID, c.IP(), c.Method())
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"ok": true,
"integracion": cfg.Nombre,
"saas_api_id": cfg.ID,
"message": fmt.Sprintf("Webhook recibido para integración: %s", cfg.Nombre),
})
}