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>
358 lines
11 KiB
Go
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),
|
|
})
|
|
}
|