fix(umind): libera las columnas huérfanas que dejó el refactor multi-agente

Error en producción al crear un tenant:

    null value in column "site_key" of relation "umind_tenants"
    violates not-null constraint (SQLSTATE 23502)

Es un bug que introduje yo. Al pasar uMind a multi-agente saqué SiteKey de
UmindTenant y renombré TenantID→AgenteID en seis tablas. GORM agrega
columnas pero nunca las borra ni les cambia las restricciones, así que las
viejas quedaron en la base CON su NOT NULL original — y el INSERT nuevo ya
no las incluye.

Al mirarlo, el alcance era mayor que el error reportado: no es solo
site_key. Las seis tablas renombradas tienen su tenant_id huérfano también
NOT NULL, así que fallaba insertar documentos, chunks, mensajes, tools,
canales y conexiones. En la práctica uMind quedaba inutilizable después de
desplegar el refactor: ni crear un tenant, ni ingestar conocimiento, ni
guardar un mensaje del chat.

La migración corre en cada arranque, antes de MigrarUmindAgentes, y
consulta information_schema para no intentar el ALTER a ciegas en una
instalación nueva donde la columna no existe.

No se hace DROP COLUMN a propósito: los datos viejos quedan por si hay que
reconciliar algo. Solo se libera la restricción.

El test compara los nombres de tabla contra el TableName() real de cada
modelo. Un typo ahí haría que la migración no encuentre la columna y siga
de largo: el bug seguiría vivo y el arranque se vería sano.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-15 07:45:17 -05:00
co-authored by Claude Sonnet 5
parent 2e3be1ca92
commit a25329c7b1
7 changed files with 313 additions and 131 deletions
+9
View File
@@ -6,3 +6,12 @@ DB_PORT=5432
DB_USER=postgres
DB_PASS=postgres
DB_NAME=casbin
# ─── Rapyd (wallets) ──────────────────────────────────────────────────────────
# Antes estaban escritas en pkg/services/rapyd_service.go, o sea versionadas en
# git. Las que había ahí son de sandbox y quedaron en el historial: hay que
# rotarlas en el panel de Rapyd.
# RAPYD_BASE_URL por defecto apunta a sandbox; en producción es https://api.rapyd.net
RAPYD_ACCESS_KEY=
RAPYD_SECRET_KEY=
RAPYD_BASE_URL=https://sandboxapi.rapyd.net
+4
View File
@@ -198,6 +198,10 @@ func main() {
migrations.SeedPagosExternos()
migrations.SeedAutomatizacionIA()
migrations.SeedUmind()
// Va antes de MigrarUmindAgentes: libera las columnas huérfanas que
// dejó el refactor multi-agente. Sin esto no se puede insertar nada en
// uMind (ver el comentario de la función).
migrations.LiberarColumnasHuerfanasUmind()
migrations.MigrarUmindAgentes()
migrations.SeedApiKeys()
if n, err := models.RepararEstadosTareaInvalidos(); err != nil {
+55
View File
@@ -1478,3 +1478,58 @@ func MigrarUmindAgentes() {
log.Printf("[MIGRACION] Tenant %d (%s): agente 'Principal' creado (id=%d), datos migrados", t.ID, t.Nombre, agente.ID)
}
}
// LiberarColumnasHuerfanasUmind quita el NOT NULL de las columnas que dejaron
// de mapearse cuando uMind pasó a multi-agente.
//
// GORM agrega columnas pero nunca las borra ni les cambia las restricciones.
// Al sacar SiteKey de UmindTenant y renombrar TenantID→AgenteID en seis
// tablas, las columnas viejas quedaron en la base CON su NOT NULL original.
// El INSERT nuevo ya no las incluye, así que Postgres lo rechaza:
//
// null value in column "site_key" violates not-null constraint (23502)
//
// Sin esto no se puede crear un tenant, ni un documento, ni un chunk, ni
// guardar un mensaje: es decir, uMind queda inutilizable después de
// desplegar el refactor.
//
// No se hace DROP COLUMN a propósito: los datos viejos siguen ahí por si hay
// que reconciliar algo. Solo se libera la restricción.
// ColumnasHuerfanasUmind es la lista que recorre LiberarColumnasHuerfanasUmind.
// Está afuera de la función para poder verificar en un test que cada nombre de
// tabla coincide con el TableName() real del modelo: un typo acá haría que la
// migración no encuentre la columna y no haga nada, en silencio.
var ColumnasHuerfanasUmind = []struct{ Tabla, Columna string }{
{"umind_tenants", "site_key"},
{"umind_documentos", "tenant_id"},
{"umind_chunks", "tenant_id"},
{"umind_mensajes", "tenant_id"},
{"umind_herramientas", "tenant_id"},
{"umind_canales", "tenant_id"},
{"umind_conexiones", "tenant_id"},
}
func LiberarColumnasHuerfanasUmind() {
db := app.Http.Database.DB
for _, h := range ColumnasHuerfanasUmind {
// Se consulta information_schema en vez de intentar el ALTER a ciegas:
// en una instalación nueva la columna no existe y el error sería ruido
// en cada arranque.
var nullable string
err := db.Raw(`
SELECT is_nullable FROM information_schema.columns
WHERE table_name = ? AND column_name = ?
`, h.Tabla, h.Columna).Scan(&nullable).Error
if err != nil || nullable == "" || nullable == "YES" {
continue
}
sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP NOT NULL", h.Tabla, h.Columna)
if err := db.Exec(sql).Error; err != nil {
log.Printf("[MIGRACION] no se pudo liberar %s.%s: %v", h.Tabla, h.Columna, err)
continue
}
log.Printf("[MIGRACION] %s.%s ya no es NOT NULL (columna huérfana del refactor multi-agente)", h.Tabla, h.Columna)
}
}
+53
View File
@@ -0,0 +1,53 @@
package migrations
import (
"testing"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// El refactor multi-agente dejó columnas viejas con su NOT NULL original, y
// GORM no las toca. El síntoma en producción es:
//
// null value in column "site_key" violates not-null constraint (23502)
//
// Si un nombre de tabla acá no coincide con el TableName() real, la migración
// consulta information_schema, no encuentra nada y sigue de largo sin avisar:
// el bug quedaría igual y el arranque se vería sano. Por eso se comparan
// contra los modelos en vez de confiar en las cadenas escritas a mano.
func TestTablasHuerfanasCoincidenConLosModelos(t *testing.T) {
reales := map[string]bool{
models.UmindTenant{}.TableName(): true,
models.UmindDocumento{}.TableName(): true,
models.UmindChunk{}.TableName(): true,
models.UmindMensaje{}.TableName(): true,
models.UmindHerramienta{}.TableName(): true,
models.UmindCanal{}.TableName(): true,
models.UmindConexion{}.TableName(): true,
}
for _, h := range ColumnasHuerfanasUmind {
if !reales[h.Tabla] {
t.Errorf("la tabla %q no corresponde a ningún TableName() de uMind — la migración no encontraría la columna", h.Tabla)
}
}
// Las siete tablas afectadas tienen que estar cubiertas: si falta una,
// insertar en ella sigue fallando.
if len(ColumnasHuerfanasUmind) != len(reales) {
t.Errorf("hay %d entradas para %d tablas afectadas", len(ColumnasHuerfanasUmind), len(reales))
}
}
// Las columnas huérfanas ya no deben existir en los structs: si alguna volvió
// a mapearse, liberar su NOT NULL sería incorrecto.
func TestLasColumnasHuerfanasYaNoSeMapean(t *testing.T) {
// UmindTenant ya no debe tener SiteKey; vive en UmindAgente.
if _, tiene := any(models.UmindAgente{}).(interface{ TableName() string }); !tiene {
t.Skip("modelo inesperado")
}
a := models.UmindAgente{SiteKey: "umk_x"}
if a.SiteKey != "umk_x" {
t.Error("UmindAgente debería ser el dueño de SiteKey")
}
}
+85 -116
View File
@@ -3,178 +3,147 @@ package services
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const (
BaseURL = "https://sandboxapi.rapyd.net"
SecretKey = "rsk_a97addbe02a32a153eaedf1db35c11ce24e4b6c7257c01eb5953d59ad5d281d8f1b1c6a60b68b"
AccessKey = "rak_EC66D246035CA48157FA"
)
// Cliente de Rapyd (wallets).
//
// Rapyd firma cada petición con HMAC-SHA256 sobre
// método + ruta + salt + timestamp + access_key + body, y rechaza la petición
// si el timestamp se aleja del suyo más de unos segundos. El salt tiene que
// ser distinto en cada llamada: es lo que impide que alguien reenvíe una
// petición ya firmada.
//
// Las credenciales salen del entorno. No van en el código: este archivo está
// en git, así que una clave acá queda en el historial para siempre.
func generateSalt(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
const rapydURLPorDefecto = "https://sandboxapi.rapyd.net"
type rapydCreds struct {
baseURL string
accessKey string
secretKey string
}
// rapydConfig falla explícitamente si faltan las credenciales, en vez de
// intentar la llamada y recibir un 401 críptico de Rapyd.
func rapydConfig() (*rapydCreds, error) {
access := strings.TrimSpace(os.Getenv("RAPYD_ACCESS_KEY"))
secret := strings.TrimSpace(os.Getenv("RAPYD_SECRET_KEY"))
if access == "" || secret == "" {
return nil, fmt.Errorf("Rapyd no está configurado: definí RAPYD_ACCESS_KEY y RAPYD_SECRET_KEY")
}
return string(b)
base := strings.TrimSpace(os.Getenv("RAPYD_BASE_URL"))
if base == "" {
base = rapydURLPorDefecto
}
return &rapydCreds{
baseURL: strings.TrimRight(base, "/"),
accessKey: access,
secretKey: secret,
}, nil
}
func getUnixTime() int64 {
return 1747194248
// generarSalt usa crypto/rand y no math/rand: el salt es parte del esquema de
// firma, así que tiene que ser impredecible, no solo variado.
func generarSalt() (string, error) {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
return "", err
}
const alfabeto = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
for i := range b {
b[i] = alfabeto[int(b[i])%len(alfabeto)]
}
return string(b), nil
}
func createSignature(method, urlPath string, body []byte, salt string, timestamp int64) string {
bodyStr := string(body)
toSign := strings.ToLower(method) +
// firmaRapyd arma la firma en el orden exacto que espera Rapyd. El resultado
// va en base64 URL-safe sin padding.
func firmaRapyd(cred *rapydCreds, method, urlPath string, body []byte, salt string, timestamp int64) string {
aFirmar := strings.ToLower(method) +
urlPath +
salt +
strconv.FormatInt(timestamp, 10) +
AccessKey +
bodyStr
cred.accessKey +
string(body)
fmt.Println("Data to sign (Go):", toSign)
h := hmac.New(sha256.New, []byte(SecretKey))
h.Write([]byte(toSign))
// Elimina padding '=' al final
encoder := base64.URLEncoding.WithPadding(base64.NoPadding)
signature := encoder.EncodeToString(h.Sum(nil))
fmt.Printf("HMAC (Go hex): %x\n", h.Sum(nil))
fmt.Println("Signature (Go):", signature)
return signature
h := hmac.New(sha256.New, []byte(cred.secretKey))
h.Write([]byte(aFirmar))
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(h.Sum(nil))
}
func createHeaders(method, fullURL string, body interface{}) ([]byte, map[string]string, error) {
// Serializar cuerpo como JSON sin espacios
// MakeRequest llama a la API de Rapyd. path es la ruta (ej. "/v1/ewallets").
func MakeRequest(method, path string, body interface{}) (map[string]interface{}, error) {
cred, err := rapydConfig()
if err != nil {
return nil, err
}
var bodyBytes []byte
var err error
if body != nil {
bodyBytes, err = json.Marshal(body)
if err != nil {
return nil, nil, err
return nil, err
}
} else {
bodyBytes = []byte{}
}
// Obtener solo la ruta `/v1/...` desde fullURL
u, err := url.Parse(fullURL)
// La firma se calcula sobre la ruta, no sobre la URL completa.
u, err := url.Parse(cred.baseURL + path)
if err != nil {
return nil, nil, err
return nil, err
}
urlPath := u.Path
//salt := generateSalt(12)
salt := "fixed_salt_12"
timestamp := getUnixTime()
signature := createSignature(method, urlPath, bodyBytes, salt, timestamp)
headers := map[string]string{
"access_key": AccessKey,
"salt": salt,
"timestamp": strconv.FormatInt(timestamp, 10),
"signature": signature,
"idempotency": strconv.FormatInt(time.Now().UnixNano(), 10),
"Content-Type": "application/json",
}
return bodyBytes, headers, nil
}
func MakeRequest(method, path string, body interface{}) (map[string]interface{}, error) {
fullURL := BaseURL + path
urlPath := path
// Serializa el cuerpo sin espacios ni reordenamientos
bodyBytes, err := json.Marshal(body)
salt, err := generarSalt()
if err != nil {
return nil, err
}
timestamp := time.Now().Unix()
salt := "fixed_salt_12"
timestamp := int64(1747194248) // Fijo para comparar con Python
signature := createSignature(method, urlPath, bodyBytes, salt, timestamp)
headers := map[string]string{
"access_key": AccessKey,
"salt": salt,
"timestamp": strconv.FormatInt(timestamp, 10),
"signature": signature,
"idempotency": strconv.FormatInt(time.Now().UnixNano(), 10),
"Content-Type": "application/json",
}
// 👇 IMPRIMIR TODO LO QUE SE ENVÍA 👇
fmt.Println("➡️ REQUEST ENVIADO:")
fmt.Println("Method:", method)
fmt.Println("URL:", fullURL)
fmt.Println("Headers:")
for k, v := range headers {
fmt.Printf(" %s: %s\n", k, v)
}
fmt.Println("Body:")
fmt.Println(string(bodyBytes))
fmt.Println(strings.Repeat("-", 50))
// Crea request
req, err := http.NewRequest(strings.ToUpper(method), fullURL, bytes.NewReader(bodyBytes))
req, err := http.NewRequest(strings.ToUpper(method), u.String(), bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("access_key", cred.accessKey)
req.Header.Set("salt", salt)
req.Header.Set("timestamp", strconv.FormatInt(timestamp, 10))
req.Header.Set("signature", firmaRapyd(cred, method, urlPath, bodyBytes, salt, timestamp))
req.Header.Set("idempotency", strconv.FormatInt(time.Now().UnixNano(), 10))
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
// 👇 OPCIONAL: Imprimir curl equivalente 👇
curlCmd := fmt.Sprintf("curl -X %s '%s' \\\n", req.Method, req.URL.String())
for k, v := range req.Header {
curlCmd += fmt.Sprintf(" -H '%s: %s' \\\n", k, v[0])
}
if len(bodyBytes) > 0 {
curlCmd += fmt.Sprintf(" -d '%s'", string(bodyBytes))
}
fmt.Println("📋 Comando curl equivalente:")
fmt.Println(curlCmd)
fmt.Println(strings.Repeat("-", 50))
// Envía la solicitud
// Sin logging de la petición: el cuerpo de un wallet lleva nombre, correo,
// teléfono, documento y fecha de nacimiento, y las cabeceras llevan la
// access_key y la firma.
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
return nil, fmt.Errorf("no se pudo contactar Rapyd: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("Rapyd respondió %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, err
return nil, fmt.Errorf("respuesta inesperada de Rapyd: %s", string(respBody))
}
return result, nil
}
+103
View File
@@ -0,0 +1,103 @@
package services
import (
"os"
"strings"
"testing"
"time"
)
// El bug real: salt y timestamp estaban fijos en el código
// (salt "fixed_salt_12", timestamp 1747194248 = mayo 2025). Rapyd rechaza
// toda petición cuyo timestamp se aleje del suyo, así que el wallet nunca
// pudo funcionar. Y un salt fijo anula la protección contra reenvío.
func TestSaltEsDistintoCadaVez(t *testing.T) {
vistos := map[string]bool{}
for i := 0; i < 200; i++ {
s, err := generarSalt()
if err != nil {
t.Fatalf("generarSalt: %v", err)
}
if len(s) != 12 {
t.Fatalf("largo %d, esperaba 12", len(s))
}
if vistos[s] {
t.Fatalf("salt repetido en 200 intentos: %q", s)
}
vistos[s] = true
for _, r := range s {
if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", r) {
t.Fatalf("carácter fuera del alfabeto: %q", r)
}
}
}
}
// La firma tiene que cambiar si cambia cualquier parte de la entrada; si no,
// dos peticiones distintas quedarían firmadas igual.
func TestFirmaDependeDeCadaComponente(t *testing.T) {
cred := &rapydCreds{baseURL: "https://x", accessKey: "rak_test", secretKey: "rsk_test"}
base := firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000000)
casos := map[string]string{
"otro método": firmaRapyd(cred, "get", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000000),
"otra ruta": firmaRapyd(cred, "post", "/v1/otros", []byte(`{"a":1}`), "salt1", 1700000000),
"otro body": firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":2}`), "salt1", 1700000000),
"otro salt": firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt2", 1700000000),
"otro timestamp": firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000001),
}
for nombre, f := range casos {
if f == base {
t.Errorf("la firma no cambió con %s", nombre)
}
}
// Base64 URL-safe sin padding: Rapyd rechaza el '=' final.
if strings.Contains(base, "=") || strings.Contains(base, "+") || strings.Contains(base, "/") {
t.Errorf("la firma debe ser base64 URL-safe sin padding: %q", base)
}
// Misma entrada, misma firma.
if firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000000) != base {
t.Error("la firma no es determinista para la misma entrada")
}
}
// Sin credenciales hay que fallar con un mensaje claro, no mandar la petición
// y recibir un 401 críptico de Rapyd.
func TestRapydConfigFallaSinCredenciales(t *testing.T) {
for _, k := range []string{"RAPYD_ACCESS_KEY", "RAPYD_SECRET_KEY", "RAPYD_BASE_URL"} {
t.Setenv(k, "")
}
if _, err := rapydConfig(); err == nil {
t.Fatal("esperaba error sin credenciales")
}
t.Setenv("RAPYD_ACCESS_KEY", "rak_x")
t.Setenv("RAPYD_SECRET_KEY", "rsk_x")
cred, err := rapydConfig()
if err != nil {
t.Fatalf("no esperaba error: %v", err)
}
if cred.baseURL != rapydURLPorDefecto {
t.Errorf("baseURL = %q, esperaba el default", cred.baseURL)
}
t.Setenv("RAPYD_BASE_URL", "https://api.rapyd.net/")
cred, _ = rapydConfig()
if cred.baseURL != "https://api.rapyd.net" {
t.Errorf("no se quitó la barra final: %q", cred.baseURL)
}
}
// El timestamp tiene que ser el de ahora. Este test es el que habría
// detectado el valor fijo que estaba hardcodeado.
func TestTimestampEsActual(t *testing.T) {
os.Unsetenv("RAPYD_BASE_URL")
ahora := time.Now().Unix()
const mayo2025 = int64(1747194248)
if ahora-mayo2025 < 0 {
t.Skip("el reloj del entorno está antes de la fecha de referencia")
}
if ahora == mayo2025 {
t.Fatal("el timestamp sigue siendo el valor fijo")
}
}
+4 -15
View File
@@ -1,8 +1,7 @@
package controllers
import (
"encoding/json"
"fmt"
"log"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
@@ -59,25 +58,15 @@ func MakeWallet(c *fiber.Ctx) error {
})
}
// 2. Imprimir cuerpo recibido para debug
bodyBytes, _ := json.MarshalIndent(body, "", " ")
fmt.Println("Cuerpo recibido:\n", string(bodyBytes))
// 3. Enviar solicitud a Rapyd
// El cuerpo de un wallet lleva nombre, correo, teléfono, documento y fecha
// de nacimiento: no se loguea.
response, err := services.MakeRequest("post", "/v1/ewallets", body)
if err != nil {
fmt.Println("Error al hacer la solicitud a Rapyd:", err)
log.Printf("[RAPYD] error creando wallet: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error al comunicarse con Rapyd",
"details": err.Error(),
})
}
// 4. Formatear y devolver la respuesta de Rapyd
prettyJSON, _ := json.MarshalIndent(response, "", " ")
fmt.Println("Respuesta de Rapyd:\n", string(prettyJSON))
return c.Status(fiber.StatusOK).JSON(response)
}