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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
96cd24f17d
commit
08265510ea
@@ -157,6 +157,7 @@ func main() {
|
||||
&models.UmindConexion{},
|
||||
&models.UmindEventoLog{},
|
||||
&models.UmindPlan{},
|
||||
&models.UmindUso{},
|
||||
// API Keys de /api/v2 (token + IP obligatoria + scopes)
|
||||
&models.ApiKey{},
|
||||
// Integraciones: OCR y transcripción de audio (servicios propios)
|
||||
|
||||
@@ -128,6 +128,7 @@ func Migrate() {
|
||||
&models.UmindConexion{},
|
||||
&models.UmindEventoLog{},
|
||||
&models.UmindPlan{},
|
||||
&models.UmindUso{},
|
||||
// API Keys de /api/v2 (token + IP obligatoria + scopes)
|
||||
&models.ApiKey{},
|
||||
// Integraciones: OCR y transcripción de audio (servicios propios)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Tipos de consumo medible.
|
||||
const (
|
||||
UsoTipoIA = "ia"
|
||||
UsoTipoOCR = "ocr"
|
||||
UsoTipoWhisper = "whisper"
|
||||
)
|
||||
|
||||
// UmindUso es una línea de consumo facturable. El Costo se congela con el
|
||||
// precio vigente del plan al momento de registrarlo: subir un precio nunca
|
||||
// revalúa consumo ya ocurrido, que es lo que haría imposible defender una
|
||||
// factura ante un reclamo.
|
||||
//
|
||||
// TenantID está desnormalizado a propósito (se puede derivar del agente) para
|
||||
// poder sumar el consumo de un ciclo sin joins.
|
||||
type UmindUso struct {
|
||||
gorm.Model
|
||||
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index"`
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:10;index"`
|
||||
Cantidad float64 `json:"cantidad" gorm:"column:cantidad"`
|
||||
Unidad string `json:"unidad" gorm:"column:unidad;size:20"`
|
||||
Costo float64 `json:"costo" gorm:"column:costo"`
|
||||
Moneda string `json:"moneda" gorm:"column:moneda;size:3"`
|
||||
// FacturadoAt null = pendiente de cobrar en el próximo ciclo.
|
||||
FacturadoAt *time.Time `json:"facturado_at" gorm:"column:facturado_at;index"`
|
||||
}
|
||||
|
||||
func (UmindUso) TableName() string { return "umind_uso" }
|
||||
|
||||
// RegistrarUsoUmind nunca devuelve error, igual que RegistrarEventoUmind: es
|
||||
// contabilidad lateral y jamás debe tumbar la respuesta al visitante. Si
|
||||
// falla, queda en el log para reconciliar a mano.
|
||||
func RegistrarUsoUmind(agenteID uint, tipo string, cantidad float64, unidad string) {
|
||||
if agenteID == 0 || cantidad <= 0 {
|
||||
return
|
||||
}
|
||||
agente, err := GetUmindAgenteByID(agenteID)
|
||||
if err != nil {
|
||||
log.Printf("[UMIND_USO] agente %d no encontrado, no se registra el consumo: %v", agenteID, err)
|
||||
return
|
||||
}
|
||||
|
||||
plan := GetPlanDeTenant(agente.TenantID)
|
||||
moneda := "COP"
|
||||
if plan != nil {
|
||||
moneda = plan.Moneda
|
||||
}
|
||||
costo := costoDeUso(plan, tipo, cantidad)
|
||||
|
||||
uso := &UmindUso{
|
||||
TenantID: agente.TenantID, AgenteID: agenteID,
|
||||
Tipo: tipo, Cantidad: cantidad, Unidad: unidad,
|
||||
Costo: costo, Moneda: moneda,
|
||||
}
|
||||
if err := app.Http.Database.DB.Create(uso).Error; err != nil {
|
||||
log.Printf("[UMIND_USO] no se pudo registrar consumo del agente %d (%s %.2f %s): %v", agenteID, tipo, cantidad, unidad, err)
|
||||
}
|
||||
}
|
||||
|
||||
// costoDeUso aplica la tarifa del plan. Sin plan (tenants viejos) o tipo no
|
||||
// tarifado, el consumo se registra pero no cuesta.
|
||||
func costoDeUso(plan *UmindPlan, tipo string, cantidad float64) float64 {
|
||||
if plan == nil {
|
||||
return 0
|
||||
}
|
||||
switch tipo {
|
||||
case UsoTipoIA:
|
||||
return cantidad / 1000 * plan.PrecioPor1kTokens
|
||||
case UsoTipoOCR:
|
||||
return cantidad * plan.PrecioPorOCR
|
||||
case UsoTipoWhisper:
|
||||
return cantidad * plan.PrecioPorTranscripcion
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetUsoUmind lista el consumo de un tenant en un rango. tipo vacío = todos.
|
||||
func GetUsoUmind(tenantID uint, desde, hasta time.Time, tipo string) ([]UmindUso, error) {
|
||||
var items []UmindUso
|
||||
db := app.Http.Database.DB.Where("tenant_id = ? AND created_at >= ? AND created_at < ?", tenantID, desde, hasta)
|
||||
if tipo != "" {
|
||||
db = db.Where("tipo = ?", tipo)
|
||||
}
|
||||
err := db.Order("created_at DESC").Limit(1000).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// ResumenUso es el agregado por tipo que se muestra en el panel y se adjunta
|
||||
// al correo de cobro.
|
||||
type ResumenUso struct {
|
||||
Tipo string `json:"tipo"`
|
||||
Unidad string `json:"unidad"`
|
||||
Cantidad float64 `json:"cantidad"`
|
||||
Costo float64 `json:"costo"`
|
||||
Eventos int64 `json:"eventos"`
|
||||
}
|
||||
|
||||
func GetResumenUso(tenantID uint, desde, hasta time.Time) ([]ResumenUso, error) {
|
||||
var out []ResumenUso
|
||||
err := app.Http.Database.DB.Model(&UmindUso{}).
|
||||
Select("tipo, MAX(unidad) AS unidad, SUM(cantidad) AS cantidad, SUM(costo) AS costo, COUNT(*) AS eventos").
|
||||
Where("tenant_id = ? AND created_at >= ? AND created_at < ? AND deleted_at IS NULL", tenantID, desde, hasta).
|
||||
Group("tipo").Scan(&out).Error
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SumarUsoPendiente devuelve el consumo todavía no facturado de un tenant —
|
||||
// es lo que se le suma a la mensualidad al generar el link de cobro.
|
||||
func SumarUsoPendiente(tenantID uint) (float64, error) {
|
||||
var total float64
|
||||
err := app.Http.Database.DB.Model(&UmindUso{}).
|
||||
Where("tenant_id = ? AND facturado_at IS NULL AND deleted_at IS NULL", tenantID).
|
||||
Select("COALESCE(SUM(costo), 0)").Scan(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// MarcarUsoFacturado cierra el consumo pendiente de un tenant. Es idempotente
|
||||
// por construcción: el filtro facturado_at IS NULL hace que una segunda
|
||||
// llamada (webhook de pago duplicado) no encuentre nada que marcar.
|
||||
func MarcarUsoFacturado(tenantID uint) error {
|
||||
ahora := time.Now()
|
||||
return app.Http.Database.DB.Model(&UmindUso{}).
|
||||
Where("tenant_id = ? AND facturado_at IS NULL", tenantID).
|
||||
Update("facturado_at", ahora).Error
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
// El costo se congela con el precio del plan al momento de registrar el
|
||||
// consumo. Si esta cuenta se rompe, se le cobra de más o de menos a un
|
||||
// cliente real, así que va con test.
|
||||
func TestCalculoCostoPorTipo(t *testing.T) {
|
||||
plan := &UmindPlan{
|
||||
PrecioPor1kTokens: 2.5,
|
||||
PrecioPorOCR: 10,
|
||||
PrecioPorTranscripcion: 40,
|
||||
}
|
||||
|
||||
casos := []struct {
|
||||
tipo string
|
||||
cantidad float64
|
||||
esperado float64
|
||||
}{
|
||||
{UsoTipoIA, 1000, 2.5}, // exactamente 1k tokens
|
||||
{UsoTipoIA, 500, 1.25}, // fracción de 1k, no se redondea hacia arriba
|
||||
{UsoTipoIA, 3200, 8.0}, // varios miles
|
||||
{UsoTipoOCR, 1, 10}, // una imagen
|
||||
{UsoTipoOCR, 3, 30}, // varias
|
||||
{UsoTipoWhisper, 1, 40}, // una transcripción
|
||||
{"desconocido", 100, 0}, // tipo no tarifado no cobra nada
|
||||
}
|
||||
|
||||
for _, cas := range casos {
|
||||
got := costoDeUso(plan, cas.tipo, cas.cantidad)
|
||||
if got != cas.esperado {
|
||||
t.Errorf("costoDeUso(%s, %.0f) = %.4f, esperaba %.4f", cas.tipo, cas.cantidad, got, cas.esperado)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sin plan asignado (tenants viejos) el consumo se registra pero no cuesta —
|
||||
// no debe explotar ni inventar un precio.
|
||||
func TestCalculoCostoSinPlan(t *testing.T) {
|
||||
if got := costoDeUso(nil, UsoTipoIA, 5000); got != 0 {
|
||||
t.Errorf("costoDeUso sin plan = %.4f, esperaba 0", got)
|
||||
}
|
||||
}
|
||||
@@ -134,18 +134,18 @@ type CFZoneAccount struct {
|
||||
|
||||
// CFZone representa una zona (dominio) en Cloudflare.
|
||||
type CFZone struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Paused bool `json:"paused"`
|
||||
Type string `json:"type"`
|
||||
NameServers []string `json:"name_servers"`
|
||||
OriginalNS []string `json:"original_name_servers"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
ModifiedOn string `json:"modified_on"`
|
||||
ActivatedOn string `json:"activated_on"`
|
||||
Account CFZoneAccount `json:"account"`
|
||||
Plan CFPlan `json:"plan"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Paused bool `json:"paused"`
|
||||
Type string `json:"type"`
|
||||
NameServers []string `json:"name_servers"`
|
||||
OriginalNS []string `json:"original_name_servers"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
ModifiedOn string `json:"modified_on"`
|
||||
ActivatedOn string `json:"activated_on"`
|
||||
Account CFZoneAccount `json:"account"`
|
||||
Plan CFPlan `json:"plan"`
|
||||
}
|
||||
|
||||
// CFPlan representa el plan de una zona.
|
||||
@@ -162,30 +162,30 @@ type CFDNSRecordSettings struct {
|
||||
|
||||
// CFDNSRecord representa un registro DNS.
|
||||
type CFDNSRecord struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Settings CFDNSRecordSettings `json:"settings,omitempty"`
|
||||
PrivateRouting bool `json:"private_routing,omitempty"`
|
||||
Proxied bool `json:"proxied"`
|
||||
Proxiable bool `json:"proxiable"`
|
||||
TTL int `json:"ttl"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
ModifiedOn string `json:"modified_on"`
|
||||
CommentModifiedOn string `json:"comment_modified_on,omitempty"`
|
||||
TagsModifiedOn string `json:"tags_modified_on,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Settings CFDNSRecordSettings `json:"settings,omitempty"`
|
||||
PrivateRouting bool `json:"private_routing,omitempty"`
|
||||
Proxied bool `json:"proxied"`
|
||||
Proxiable bool `json:"proxiable"`
|
||||
TTL int `json:"ttl"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
ModifiedOn string `json:"modified_on"`
|
||||
CommentModifiedOn string `json:"comment_modified_on,omitempty"`
|
||||
TagsModifiedOn string `json:"tags_modified_on,omitempty"`
|
||||
}
|
||||
|
||||
// CFSSLStatus representa un certificate pack de una zona.
|
||||
type CFSSLStatus struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // universal, advanced, custom, sni_custom
|
||||
Type string `json:"type"` // universal, advanced, custom, sni_custom
|
||||
Hosts []string `json:"hosts"`
|
||||
Status string `json:"status"` // active, pending_validation, deleted
|
||||
Status string `json:"status"` // active, pending_validation, deleted
|
||||
ValidationMethod string `json:"validation_method,omitempty"`
|
||||
ValidityDays int `json:"validity_days,omitempty"`
|
||||
CertificateAuthority string `json:"certificate_authority,omitempty"`
|
||||
@@ -243,10 +243,10 @@ type CFTokenVerify struct {
|
||||
|
||||
// CFTokenPolicy representa una política de permisos del token.
|
||||
type CFTokenPolicy struct {
|
||||
ID string `json:"id"`
|
||||
Effect string `json:"effect"`
|
||||
Resources map[string]string `json:"resources"`
|
||||
PermGroups []CFTokenPermGroup `json:"permission_groups"`
|
||||
ID string `json:"id"`
|
||||
Effect string `json:"effect"`
|
||||
Resources map[string]string `json:"resources"`
|
||||
PermGroups []CFTokenPermGroup `json:"permission_groups"`
|
||||
}
|
||||
|
||||
// CFTokenPermGroup un grupo de permisos.
|
||||
@@ -478,14 +478,14 @@ func (c *CloudflareClient) doRequest(method, path string, payload interface{}, d
|
||||
|
||||
// CFDNSRecordInput es el payload para crear o actualizar un registro DNS.
|
||||
type CFDNSRecordInput struct {
|
||||
Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA…
|
||||
Name string `json:"name"` // Nombre del registro (ej. "www" o "@")
|
||||
Content string `json:"content"` // Valor del registro
|
||||
TTL int `json:"ttl"` // 1 = automático, o segundos (min 60)
|
||||
Proxied bool `json:"proxied"` // true = nube naranja
|
||||
Priority int `json:"priority,omitempty"` // Solo para MX / SRV
|
||||
Comment string `json:"comment,omitempty"` // Comentario descriptivo
|
||||
Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"])
|
||||
Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA…
|
||||
Name string `json:"name"` // Nombre del registro (ej. "www" o "@")
|
||||
Content string `json:"content"` // Valor del registro
|
||||
TTL int `json:"ttl"` // 1 = automático, o segundos (min 60)
|
||||
Proxied bool `json:"proxied"` // true = nube naranja
|
||||
Priority int `json:"priority,omitempty"` // Solo para MX / SRV
|
||||
Comment string `json:"comment,omitempty"` // Comentario descriptivo
|
||||
Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"])
|
||||
Settings *CFDNSRecordSettings `json:"settings,omitempty"` // Configuraciones adicionales
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ var ocrHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// ExtraerTextoOCR manda una imagen al servicio propio de OCR y devuelve el
|
||||
// texto extraído. mimeType ej: "image/png", "image/jpeg".
|
||||
func ExtraerTextoOCR(imagenBytes []byte, mimeType string) (string, error) {
|
||||
//
|
||||
// agenteID identifica a quién cobrarle la imagen procesada; 0 = no medir, que
|
||||
// es lo que pasa el botón "Probar" del panel de staff (no es de ningún cliente).
|
||||
func ExtraerTextoOCR(agenteID uint, imagenBytes []byte, mimeType string) (string, error) {
|
||||
cfg, err := models.GetOcrConfig()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("el servicio de OCR no está configurado (Integraciones → OCR)")
|
||||
@@ -63,5 +66,6 @@ func ExtraerTextoOCR(imagenBytes []byte, mimeType string) (string, error) {
|
||||
}
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
RegistrarUso(agenteID, models.UsoTipoOCR, 1, "imagen")
|
||||
return out.Text, nil
|
||||
}
|
||||
|
||||
@@ -31,9 +31,10 @@ type DispatchPayload struct {
|
||||
// asociados al contrato. Se ejecuta en goroutine separada desde los webhooks.
|
||||
//
|
||||
// Cadena de vinculación:
|
||||
// Contrato → contrato_servicios (m2m) → servicios.id
|
||||
// servicios.id ↔ saas_productos.servicio_id → saas_productos.id
|
||||
// saas_productos.id → saas_api_configs.saas_id (activo = true)
|
||||
//
|
||||
// Contrato → contrato_servicios (m2m) → servicios.id
|
||||
// servicios.id ↔ saas_productos.servicio_id → saas_productos.id
|
||||
// saas_productos.id → saas_api_configs.saas_id (activo = true)
|
||||
func DispatchSaasPaymentNotification(contratoID uint, payerEmail, fuente string, monto float64, moneda string) {
|
||||
referencia := fmt.Sprintf("contrato-%d", contratoID)
|
||||
|
||||
|
||||
@@ -103,6 +103,13 @@ type agentChatResp struct {
|
||||
Message agentMessage `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
// Usage lo devuelven todos los proveedores OpenAI-compatibles con el mismo
|
||||
// nombre de campo, así que no hace falta un caso por proveedor.
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
@@ -1277,7 +1284,11 @@ type anthropicReq struct {
|
||||
type anthropicResp struct {
|
||||
Content []anthropicContentBlock `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Error *struct {
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
@@ -1286,7 +1297,9 @@ type anthropicResp struct {
|
||||
// ─── Llamada al AI con function calling ──────────────────────────────────────
|
||||
|
||||
// callAI despacha al provider correcto (Anthropic o OpenAI-compatible).
|
||||
func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
|
||||
// El segundo valor son los tokens totales que reportó el proveedor (0 si no
|
||||
// los informa) — lo usa uMind para medir el consumo facturable.
|
||||
func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) {
|
||||
if strings.ToLower(ai.Provider) == "anthropic" {
|
||||
return callAnthropicAI(ai, messages, tools)
|
||||
}
|
||||
@@ -1294,7 +1307,7 @@ func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*a
|
||||
}
|
||||
|
||||
// callOpenAICompatibleAI usa el formato de OpenAI (también vale para qwen, groq, deepseek, etc.)
|
||||
func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
|
||||
func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) {
|
||||
baseURL := ai.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = ProviderDefaultURL(ai.Provider)
|
||||
@@ -1312,7 +1325,7 @@ func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools
|
||||
payload, _ := json.Marshal(reqBody)
|
||||
req, err := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+ai.ClaveEnClaro())
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -1320,27 +1333,27 @@ func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
|
||||
var chatResp agentChatResp
|
||||
if err := json.Unmarshal(raw, &chatResp); err != nil {
|
||||
return nil, fmt.Errorf("respuesta inesperada del AI: %s", string(raw[:min(200, len(raw))]))
|
||||
return nil, 0, fmt.Errorf("respuesta inesperada del AI: %s", string(raw[:min(200, len(raw))]))
|
||||
}
|
||||
if chatResp.Error != nil {
|
||||
return nil, fmt.Errorf("error del AI: %s", chatResp.Error.Message)
|
||||
return nil, 0, fmt.Errorf("error del AI: %s", chatResp.Error.Message)
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, fmt.Errorf("el AI no devolvió respuesta")
|
||||
return nil, 0, fmt.Errorf("el AI no devolvió respuesta")
|
||||
}
|
||||
msg := chatResp.Choices[0].Message
|
||||
return &msg, nil
|
||||
return &msg, chatResp.Usage.TotalTokens, nil
|
||||
}
|
||||
|
||||
// callAnthropicAI llama a la API nativa de Anthropic con tool use.
|
||||
func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
|
||||
func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) {
|
||||
// Convertir herramientas al formato Anthropic
|
||||
anthropicTools := make([]anthropicTool, len(tools))
|
||||
for i, t := range tools {
|
||||
@@ -1459,7 +1472,7 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent
|
||||
payload, _ := json.Marshal(reqBody)
|
||||
req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("x-api-key", ai.ClaveEnClaro())
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
@@ -1468,17 +1481,17 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
|
||||
var anthropicRsp anthropicResp
|
||||
if err := json.Unmarshal(raw, &anthropicRsp); err != nil {
|
||||
return nil, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))]))
|
||||
return nil, 0, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))]))
|
||||
}
|
||||
if anthropicRsp.Error != nil {
|
||||
return nil, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message)
|
||||
return nil, 0, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message)
|
||||
}
|
||||
|
||||
// Convertir respuesta Anthropic → agentMessage (formato interno OpenAI)
|
||||
@@ -1512,7 +1525,7 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent
|
||||
result.Content = strings.Join(textParts, "\n")
|
||||
}
|
||||
result.ToolCalls = toolCalls
|
||||
return result, nil
|
||||
return result, anthropicRsp.Usage.InputTokens + anthropicRsp.Usage.OutputTokens, nil
|
||||
}
|
||||
|
||||
func ProviderDefaultURL(provider string) string {
|
||||
@@ -1683,7 +1696,7 @@ Comandos: /reset · /instancias · /ayuda`, nil
|
||||
// Loop de function calling (máximo 6 rondas)
|
||||
var finalResponse string
|
||||
for round := 0; round < 6; round++ {
|
||||
aiMsg, err := callAI(ai, messages, tools)
|
||||
aiMsg, _, err := callAI(ai, messages, tools)
|
||||
if err != nil {
|
||||
log.Printf("[AGENT] Error llamando AI round %d: %v", round, err)
|
||||
return "Error al contactar el sistema de IA. Intenta de nuevo.", err
|
||||
|
||||
@@ -283,12 +283,15 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
|
||||
|
||||
var finalResponse string
|
||||
for round := 0; round < 3; round++ {
|
||||
aiMsg, err := callAI(&ai, messages, tools)
|
||||
aiMsg, tokens, err := callAI(&ai, messages, tools)
|
||||
if err != nil {
|
||||
log.Printf("[UMIND] Error llamando AI (agente %d) round %d: %v", agente.ID, round, err)
|
||||
models.RegistrarEventoUmind(agente.ID, "error", "ai", fmt.Sprintf("Error contactando el AI (ronda %d)", round), err.Error())
|
||||
return "", fmt.Errorf("error al contactar el sistema de IA")
|
||||
}
|
||||
// Se mide cada ronda, no solo la última: las rondas de tool-calling
|
||||
// consumen tokens reales aunque el visitante solo vea una respuesta.
|
||||
RegistrarUso(agente.ID, models.UsoTipoIA, float64(tokens), "tokens")
|
||||
|
||||
if len(aiMsg.ToolCalls) == 0 {
|
||||
content := ""
|
||||
|
||||
@@ -68,7 +68,7 @@ func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID,
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo descargar el audio de Telegram: %w", err)
|
||||
}
|
||||
texto, err = TranscribirAudioSelfHosted(data, "audio.ogg")
|
||||
texto, err = TranscribirAudioSelfHosted(canal.AgenteID, data, "audio.ogg")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID,
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo descargar la imagen de Telegram: %w", err)
|
||||
}
|
||||
texto, err = ExtraerTextoOCR(data, "image/jpeg")
|
||||
texto, err = ExtraerTextoOCR(canal.AgenteID, data, "image/jpeg")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo st
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo descargar el audio de WhatsApp: %w", err)
|
||||
}
|
||||
texto, err = TranscribirAudioSelfHosted(data, "audio.ogg")
|
||||
texto, err = TranscribirAudioSelfHosted(canal.AgenteID, data, "audio.ogg")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo st
|
||||
if mimeType == "" {
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
texto, err = ExtraerTextoOCR(data, mimeType)
|
||||
texto, err = ExtraerTextoOCR(canal.AgenteID, data, mimeType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// avisosTope recuerda a qué tenants ya se les avisó en el ciclo actual, para
|
||||
// no mandar un aviso por cada mensaje una vez pasado el tope. Se pierde al
|
||||
// reiniciar (y entonces se vuelve a avisar una vez): aceptable para un aviso,
|
||||
// y mucho más simple que una columna de estado en el tenant.
|
||||
//
|
||||
// ponytail: en memoria y por proceso. Si algún día corren varias réplicas,
|
||||
// cada una avisa una vez — mover el flag a la BD si eso molesta.
|
||||
var avisosTope struct {
|
||||
sync.Mutex
|
||||
ultimo map[uint]string // tenantID → "2026-08" del último aviso
|
||||
}
|
||||
|
||||
// RegistrarUso persiste el consumo y avisa si el tenant superó el tope de su
|
||||
// plan. El aviso NO corta el servicio: el agente sigue respondiendo.
|
||||
func RegistrarUso(agenteID uint, tipo string, cantidad float64, unidad string) {
|
||||
if agenteID == 0 || cantidad <= 0 {
|
||||
return
|
||||
}
|
||||
models.RegistrarUsoUmind(agenteID, tipo, cantidad, unidad)
|
||||
|
||||
agente, err := models.GetUmindAgenteByID(agenteID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
plan := models.GetPlanDeTenant(agente.TenantID)
|
||||
if plan == nil || plan.TopeConsumoMensual <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
desde, hasta := cicloActual()
|
||||
resumen, err := models.GetResumenUso(agente.TenantID, desde, hasta)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
total := 0.0
|
||||
for _, r := range resumen {
|
||||
total += r.Costo
|
||||
}
|
||||
if total < plan.TopeConsumoMensual {
|
||||
return
|
||||
}
|
||||
avisarTopeConsumo(agente.TenantID, plan, total, desde)
|
||||
}
|
||||
|
||||
// cicloActual devuelve el mes calendario en curso. Se usa el mes natural (no
|
||||
// la fecha de alta del contrato) porque es lo que el cliente entiende al
|
||||
// mirar "consumo de agosto" en su panel.
|
||||
func cicloActual() (time.Time, time.Time) {
|
||||
ahora := time.Now()
|
||||
desde := time.Date(ahora.Year(), ahora.Month(), 1, 0, 0, 0, 0, ahora.Location())
|
||||
return desde, desde.AddDate(0, 1, 0)
|
||||
}
|
||||
|
||||
func avisarTopeConsumo(tenantID uint, plan *models.UmindPlan, total float64, desde time.Time) {
|
||||
periodo := desde.Format("2006-01")
|
||||
|
||||
avisosTope.Lock()
|
||||
if avisosTope.ultimo == nil {
|
||||
avisosTope.ultimo = map[uint]string{}
|
||||
}
|
||||
yaAvisado := avisosTope.ultimo[tenantID] == periodo
|
||||
avisosTope.ultimo[tenantID] = periodo
|
||||
avisosTope.Unlock()
|
||||
if yaAvisado {
|
||||
return
|
||||
}
|
||||
|
||||
tenant, err := models.GetUmindTenantByID(tenantID)
|
||||
nombre := fmt.Sprintf("tenant %d", tenantID)
|
||||
if err == nil {
|
||||
nombre = tenant.Nombre
|
||||
}
|
||||
|
||||
log.Printf("[UMIND_USO] %s superó el tope de consumo del plan %s: %.2f %s de %.2f",
|
||||
nombre, plan.Nombre, total, plan.Moneda, plan.TopeConsumoMensual)
|
||||
|
||||
sendTelegramAdmin(fmt.Sprintf(
|
||||
"📊 <b>Tope de consumo superado</b>\nCliente: <b>%s</b>\nPlan: %s\nConsumo del mes: <b>%s %.2f</b> (tope %s %.2f)\n\n<i>El servicio sigue activo — el excedente se cobra en el próximo ciclo.</i>",
|
||||
escapeTelegramHTML(nombre), escapeTelegramHTML(plan.Nombre),
|
||||
plan.Moneda, total, plan.Moneda, plan.TopeConsumoMensual))
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
const websmsAPIBase = "https://api.labsmobile.com/json/send"
|
||||
|
||||
type WebSmsRequest struct {
|
||||
Message string `json:"message"`
|
||||
TPOA string `json:"tpoa,omitempty"`
|
||||
Message string `json:"message"`
|
||||
TPOA string `json:"tpoa,omitempty"`
|
||||
Recipient []WebSmsRecipient `json:"recipient"`
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func SendWebSms(cfg *models.WebSmsConfig, para, mensaje string) (*WebSmsResponse
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(cfg.Username + ":" + cfg.ApiToken))
|
||||
|
||||
req := WebSmsRequest{
|
||||
Message: mensaje,
|
||||
Message: mensaje,
|
||||
Recipient: []WebSmsRecipient{{MSISDN: para}},
|
||||
}
|
||||
if cfg.Sender != "" {
|
||||
|
||||
@@ -16,7 +16,13 @@ var whisperAsrHTTPClient = &http.Client{Timeout: 120 * time.Second} // audio lar
|
||||
|
||||
// TranscribirAudioSelfHosted manda un archivo de audio al servicio propio de
|
||||
// transcripción (whisper-asr-webservice, Basic Auth) y devuelve el texto.
|
||||
func TranscribirAudioSelfHosted(audioBytes []byte, filename string) (string, error) {
|
||||
//
|
||||
// agenteID identifica a quién cobrarle la transcripción; 0 = no medir.
|
||||
//
|
||||
// ponytail: se cobra por transcripción, no por minuto de audio —
|
||||
// whisper-asr-webservice con response_format=json no devuelve la duración. Si
|
||||
// hace falta cobrar por minuto, pedirle verbose_json y sumar los segments.
|
||||
func TranscribirAudioSelfHosted(agenteID uint, audioBytes []byte, filename string) (string, error) {
|
||||
cfg, err := models.GetWhisperAsrConfig()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("el servicio de transcripción no está configurado (Integraciones → Whisper ASR)")
|
||||
@@ -63,5 +69,6 @@ func TranscribirAudioSelfHosted(audioBytes []byte, filename string) (string, err
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("respuesta inesperada del servicio de transcripción: %s", string(raw))
|
||||
}
|
||||
RegistrarUso(agenteID, models.UsoTipoWhisper, 1, "transcripcion")
|
||||
return out.Text, nil
|
||||
}
|
||||
|
||||
@@ -116,9 +116,9 @@ func CleanCloudflareToken(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"message": "Token re-guardado limpio",
|
||||
"chars_before": before,
|
||||
"chars_after": after,
|
||||
"message": "Token re-guardado limpio",
|
||||
"chars_before": before,
|
||||
"chars_after": after,
|
||||
"chars_removed": before - after,
|
||||
})
|
||||
}
|
||||
@@ -244,11 +244,11 @@ func VerifyCloudflareToken(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"ok": true,
|
||||
"auth_type": "global_key",
|
||||
"user_email": user.Email,
|
||||
"user_id": user.ID,
|
||||
"token": tokenDiag,
|
||||
"ok": true,
|
||||
"auth_type": "global_key",
|
||||
"user_email": user.Email,
|
||||
"user_id": user.ID,
|
||||
"token": tokenDiag,
|
||||
"permissions_test": fiber.Map{
|
||||
"zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg},
|
||||
"dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID},
|
||||
@@ -311,10 +311,15 @@ func VerifyCloudflareToken(c *fiber.Ctx) error {
|
||||
"token_prev": preview,
|
||||
"was_dirty": dirty,
|
||||
"detail_error": detailErrMsg,
|
||||
"policies": func() interface{} { if detail != nil { return detail.Policies }; return nil }(),
|
||||
"policies": func() interface{} {
|
||||
if detail != nil {
|
||||
return detail.Policies
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
"permissions_test": fiber.Map{
|
||||
"zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg},
|
||||
"dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID},
|
||||
"zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg},
|
||||
"dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID},
|
||||
},
|
||||
"hint": hint,
|
||||
})
|
||||
@@ -408,7 +413,12 @@ func GetCloudflareSSL(c *fiber.Ctx) error {
|
||||
"source": "universal_ssl",
|
||||
"universal_ssl": fiber.Map{
|
||||
"enabled": universal.Enabled,
|
||||
"note": "Plan actual no expone certificate_packs. Universal SSL: " + func() string { if universal.Enabled { return "ACTIVO" }; return "INACTIVO" }(),
|
||||
"note": "Plan actual no expone certificate_packs. Universal SSL: " + func() string {
|
||||
if universal.Enabled {
|
||||
return "ACTIVO"
|
||||
}
|
||||
return "INACTIVO"
|
||||
}(),
|
||||
},
|
||||
"packs_error": packsErr.Error(),
|
||||
})
|
||||
|
||||
@@ -67,15 +67,15 @@ func CreateFactura(c *fiber.Ctx) error {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "cliente_id es requerido"})
|
||||
}
|
||||
f := &models.Factura{
|
||||
ClienteID: req.ClienteID,
|
||||
ProyectoID: req.ProyectoID,
|
||||
Numero: req.Numero,
|
||||
Descripcion: req.Descripcion,
|
||||
Monto: req.Monto,
|
||||
Moneda: req.Moneda,
|
||||
Estado: req.Estado,
|
||||
Visible: req.Visible,
|
||||
Notas: req.Notas,
|
||||
ClienteID: req.ClienteID,
|
||||
ProyectoID: req.ProyectoID,
|
||||
Numero: req.Numero,
|
||||
Descripcion: req.Descripcion,
|
||||
Monto: req.Monto,
|
||||
Moneda: req.Moneda,
|
||||
Estado: req.Estado,
|
||||
Visible: req.Visible,
|
||||
Notas: req.Notas,
|
||||
FechaEmision: time.Now(),
|
||||
}
|
||||
if req.FechaEmision != "" {
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestOcrConfigHandler(c *fiber.Ctx) error {
|
||||
if mimeType == "" {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
texto, err := services.ExtraerTextoOCR(data, mimeType)
|
||||
texto, err := services.ExtraerTextoOCR(0, data, mimeType)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
@@ -597,4 +597,3 @@ func SavePaypalConfigWeb(c *fiber.Ctx) error {
|
||||
}
|
||||
return c.JSON(fiber.Map{"message": "Configuración PayPal guardada"})
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +401,9 @@ func DownloadEntregable(c *fiber.Ctx) error {
|
||||
|
||||
func UpdateEntregableVisibilidad(c *fiber.Ctx) error {
|
||||
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
|
||||
type Req struct{ Visible bool `json:"visible"` }
|
||||
type Req struct {
|
||||
Visible bool `json:"visible"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -530,7 +532,9 @@ func GetTickets(c *fiber.Ctx) error {
|
||||
|
||||
func UpdateTicketEstadoAdmin(c *fiber.Ctx) error {
|
||||
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
||||
type Req struct{ Estado string `json:"estado"` }
|
||||
type Req struct {
|
||||
Estado string `json:"estado"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -543,7 +547,9 @@ func UpdateTicketEstadoAdmin(c *fiber.Ctx) error {
|
||||
|
||||
func AdminResponderTicket(c *fiber.Ctx) error {
|
||||
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
||||
type Req struct{ Contenido string `json:"contenido"` }
|
||||
type Req struct {
|
||||
Contenido string `json:"contenido"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
|
||||
@@ -103,7 +103,6 @@ func RequestPasswordResetPost(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// todo: Generar contrasenas aleatorias /do/generate-password
|
||||
func generatePassword(length int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&"
|
||||
|
||||
@@ -17,7 +17,7 @@ func CreateNewRole(c *fiber.Ctx) error {
|
||||
func RemoveRole(c *fiber.Ctx) error {
|
||||
var role models.Role
|
||||
c.BodyParser(&role)
|
||||
|
||||
|
||||
app.Http.Database.
|
||||
Delete(&models.RoleAndPermission{})
|
||||
app.Http.Auth.Enforcer.LoadPolicy()
|
||||
@@ -30,10 +30,10 @@ func AssignRoleToUser(c *fiber.Ctx) error {
|
||||
c.BodyParser(&roleRequest)
|
||||
role := models.RoleAndPermission{
|
||||
Ptype: "g",
|
||||
V0: fmt.Sprintf("%d", roleRequest.UserID),
|
||||
V1: roleRequest.Role,
|
||||
V0: fmt.Sprintf("%d", roleRequest.UserID),
|
||||
V1: roleRequest.Role,
|
||||
}
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1}).Error
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1}).Error
|
||||
if err != nil {
|
||||
app.Http.Database.Create(&role)
|
||||
app.Http.Auth.Enforcer.LoadPolicy()
|
||||
@@ -60,7 +60,7 @@ func ChangeRoleForUser(c *fiber.Ctx) error {
|
||||
var role models.RoleRequest
|
||||
var role1 models.RoleAndPermission
|
||||
c.BodyParser(&role)
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: "g",V0: fmt.Sprintf("%d", role.UserID), V1: role.OldRole}).Error
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: "g", V0: fmt.Sprintf("%d", role.UserID), V1: role.OldRole}).Error
|
||||
if err == nil {
|
||||
role1.V1 = role.Role
|
||||
role1.DeletedAt = gorm.DeletedAt{Valid: false}
|
||||
@@ -77,13 +77,13 @@ func AddPermissionOnRole(c *fiber.Ctx) error {
|
||||
c.BodyParser(&permission)
|
||||
if permission.Role != "" && permission.Module != "" && permission.Action != "" {
|
||||
role := models.RoleAndPermission{
|
||||
Ptype: "p",
|
||||
V0: permission.Role,
|
||||
V1: permission.Module,
|
||||
V2: permission.Action,
|
||||
Ptype: "p",
|
||||
V0: permission.Role,
|
||||
V1: permission.Module,
|
||||
V2: permission.Action,
|
||||
Category: "permission",
|
||||
}
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1, V2: role.V2}).Error
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1, V2: role.V2}).Error
|
||||
if err != nil {
|
||||
app.Http.Database.Create(&role)
|
||||
}
|
||||
@@ -93,13 +93,13 @@ func AddPermissionOnRole(c *fiber.Ctx) error {
|
||||
if permission.Role != "" && permission.Route != "" && permission.Method != "" {
|
||||
fmt.Println(1)
|
||||
role := models.RoleAndPermission{
|
||||
Ptype: "p",
|
||||
V0: permission.Role,
|
||||
V1: permission.Route,
|
||||
V2: permission.Method,
|
||||
Ptype: "p",
|
||||
V0: permission.Role,
|
||||
V1: permission.Route,
|
||||
V2: permission.Method,
|
||||
Category: "route",
|
||||
}
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1, V2: role.V2}).Error
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1, V2: role.V2}).Error
|
||||
if err != nil {
|
||||
app.Http.Database.Create(&role)
|
||||
}
|
||||
|
||||
@@ -349,9 +349,9 @@ func SaasWebhookInHandler(c *fiber.Ctx) error {
|
||||
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),
|
||||
"ok": true,
|
||||
"integracion": cfg.Nombre,
|
||||
"saas_api_id": cfg.ID,
|
||||
"message": fmt.Sprintf("Webhook recibido para integración: %s", cfg.Nombre),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,17 +167,17 @@ func UpdateServidor(c *fiber.Ctx) error {
|
||||
|
||||
// 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,
|
||||
"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 {
|
||||
|
||||
@@ -36,13 +36,12 @@ func GetTipoServidor(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"registros": records,
|
||||
"total": total,
|
||||
"registros": records,
|
||||
"total": total,
|
||||
"totalPages": 1,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func CreateTipoServidor(c *fiber.Ctx) error {
|
||||
var m models.TipoServidor
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
@@ -611,3 +612,65 @@ func GetUmindEventosHandler(c *fiber.Ctx) error {
|
||||
}
|
||||
return c.JSON(fiber.Map{"items": items})
|
||||
}
|
||||
|
||||
// ─── Consumo ────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetUmindUsoHandler devuelve el consumo de un tenant en un rango de fechas:
|
||||
// el resumen agregado por tipo (lo que se factura) y el detalle línea a línea.
|
||||
// Sin fechas, el rango es el mes en curso.
|
||||
func GetUmindUsoHandler(c *fiber.Ctx) error {
|
||||
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
|
||||
if err != nil || tenantID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
||||
}
|
||||
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
|
||||
|
||||
resumen, err := models.GetResumenUso(uint(tenantID), desde, hasta)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
detalle, err := models.GetUsoUmind(uint(tenantID), desde, hasta, c.Query("tipo"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
pendiente, _ := models.SumarUsoPendiente(uint(tenantID))
|
||||
|
||||
total := 0.0
|
||||
for _, r := range resumen {
|
||||
total += r.Costo
|
||||
}
|
||||
|
||||
out := fiber.Map{
|
||||
"desde": desde.Format("2006-01-02"), "hasta": hasta.Format("2006-01-02"),
|
||||
"resumen": resumen, "detalle": detalle,
|
||||
"total_periodo": total, "pendiente_facturar": pendiente,
|
||||
}
|
||||
if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
|
||||
out["plan"] = fiber.Map{
|
||||
"nombre": plan.Nombre, "moneda": plan.Moneda,
|
||||
"precio_mensual": plan.PrecioMensual, "tope_consumo_mensual": plan.TopeConsumoMensual,
|
||||
"max_agentes": plan.MaxAgentes,
|
||||
}
|
||||
}
|
||||
return c.JSON(out)
|
||||
}
|
||||
|
||||
// rangoFechas parsea el filtro del panel. Ante una fecha inválida cae al mes
|
||||
// en curso en vez de devolver error: es un filtro de lectura, no vale la pena
|
||||
// romperle la pantalla al usuario por un query param mal escrito.
|
||||
func rangoFechas(desdeStr, hastaStr string) (time.Time, time.Time) {
|
||||
ahora := time.Now()
|
||||
desde := time.Date(ahora.Year(), ahora.Month(), 1, 0, 0, 0, 0, ahora.Location())
|
||||
hasta := desde.AddDate(0, 1, 0)
|
||||
|
||||
if d, err := time.Parse("2006-01-02", desdeStr); err == nil {
|
||||
desde = d
|
||||
}
|
||||
if h, err := time.Parse("2006-01-02", hastaStr); err == nil {
|
||||
hasta = h.AddDate(0, 0, 1) // inclusivo: "hasta el 31" incluye el 31 entero
|
||||
}
|
||||
if !hasta.After(desde) {
|
||||
hasta = desde.AddDate(0, 1, 0)
|
||||
}
|
||||
return desde, hasta
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
|
||||
func UserInfo(c *fiber.Ctx) error {
|
||||
users, err := models.GetUserById(c.Params("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -189,8 +189,13 @@ func ApiSendSms(c *fiber.Ctx) error {
|
||||
Para: b.Numero,
|
||||
Mensaje: b.Mensaje,
|
||||
Status: status,
|
||||
MsgID: func() string { if resp != nil { return resp.MsgID() }; return "" }(),
|
||||
Error: errStr,
|
||||
MsgID: func() string {
|
||||
if resp != nil {
|
||||
return resp.MsgID()
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Error: errStr,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestWhisperAsrConfigHandler(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
||||
}
|
||||
|
||||
texto, err := services.TranscribirAudioSelfHosted(data, fh.Filename)
|
||||
texto, err := services.TranscribirAudioSelfHosted(0, data, fh.Filename)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Get("/umind/conexiones/callback/:proveedor", controllers.UmindOAuthCallbackHandler)
|
||||
protected.Delete("/umind/conexiones/:id", middlewares.SoloAdmin, controllers.DeleteUmindConexionHandler)
|
||||
protected.Get("/umind/eventos", controllers.GetUmindEventosHandler)
|
||||
protected.Get("/umind/uso", controllers.GetUmindUsoHandler)
|
||||
// Planes: definen el límite de agentes y los precios por consumo. Solo staff.
|
||||
protected.Get("/umind-planes", middlewares.MenuMiddleware, controllers.UmindPlanesPage)
|
||||
protected.Get("/umind-planes/list", controllers.GetUmindPlanesHandler)
|
||||
|
||||
Reference in New Issue
Block a user