feat: Query Runner — editor SQL con historial, exportar CSV/JSON, árbol de tablas
This commit is contained in:
@@ -0,0 +1,637 @@
|
||||
# Integración Bold — Guía completa
|
||||
|
||||
Guía de referencia para integrar Bold (pasarela de pagos colombiana) en cualquier sistema backend.
|
||||
Basada en experiencia real de producción con PHP; los conceptos aplican a cualquier lenguaje.
|
||||
|
||||
---
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Credenciales y modos](#1-credenciales-y-modos)
|
||||
2. [Crear un Payment Link (API)](#2-crear-un-payment-link-api)
|
||||
3. [Consultar estado de un link](#3-consultar-estado-de-un-link)
|
||||
4. [Callback URL (retorno del usuario)](#4-callback-url-retorno-del-usuario)
|
||||
5. [Webhook — configuración](#5-webhook--configuración)
|
||||
6. [Webhook — verificación de firma](#6-webhook--verificación-de-firma)
|
||||
7. [Webhook — procesamiento del evento](#7-webhook--procesamiento-del-evento)
|
||||
8. [Estrategia de referencias múltiples](#8-estrategia-de-referencias-múltiples)
|
||||
9. [Idempotencia](#9-idempotencia)
|
||||
10. [Esquema de base de datos](#10-esquema-de-base-de-datos)
|
||||
11. [Flujo completo de extremo a extremo](#11-flujo-completo-de-extremo-a-extremo)
|
||||
12. [Errores frecuentes y soluciones](#12-errores-frecuentes-y-soluciones)
|
||||
|
||||
---
|
||||
|
||||
## 1. Credenciales y modos
|
||||
|
||||
Bold maneja dos entornos que se diferencian **únicamente por la API key**; el endpoint es el mismo.
|
||||
|
||||
| Parámetro | Descripción |
|
||||
|--------------------|-----------------------------------------------------------|
|
||||
| `bold_api_key` | Clave de la API (diferente para test y producción) |
|
||||
| `bold_secret_key` | Clave secreta para verificar la firma del webhook |
|
||||
| `bold_mode` | `"test"` / `"production"` (para lógica propia del sistema)|
|
||||
|
||||
> **Importante:** En modo **test** la clave secreta del webhook es una cadena vacía `""`.
|
||||
> En producción se usa la clave real del panel de Bold.
|
||||
|
||||
### Obtener las credenciales
|
||||
|
||||
1. Ingresa al panel de Bold → **Configuración → Integraciones → API**.
|
||||
2. Copia la **API Key** y la **Secret Key**.
|
||||
3. Para el webhook, registra tu URL en **Configuración → Webhooks**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Crear un Payment Link (API)
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://integrations.api.bold.co/online/link/v1
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
```
|
||||
Authorization: x-api-key {bold_api_key}
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
### Payload JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"amount_type": "CLOSE",
|
||||
"amount": {
|
||||
"currency": "COP",
|
||||
"total_amount": 25000
|
||||
},
|
||||
"description": "Descripción del producto o servicio",
|
||||
"callback_url": "https://tudominio.com/pago_exitoso.php",
|
||||
"payer_email": "cliente@ejemplo.com",
|
||||
"reference": "TU-REF-12345-1746123456"
|
||||
}
|
||||
```
|
||||
|
||||
> **Moneda COP:** Bold no usa centavos. `total_amount: 25000` equivale a $25.000 pesos.
|
||||
|
||||
#### Campos clave
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|----------------|---------|-----------------------------------------------------------------------------|
|
||||
| `amount_type` | string | `"CLOSE"` = monto fijo. `"OPEN"` = el usuario puede ingresar el monto. |
|
||||
| `total_amount` | int | Valor en pesos COP (sin centavos). |
|
||||
| `callback_url` | string | URL a la que Bold redirige al usuario tras el pago. |
|
||||
| `payer_email` | string | Pre-rellena el email en la pasarela. |
|
||||
| `reference` | string | **Tu referencia personalizada.** Llega al webhook. Max 60 chars, alfanumérico + guiones. |
|
||||
|
||||
### Respuesta exitosa (`HTTP 200`)
|
||||
|
||||
```json
|
||||
{
|
||||
"payload": {
|
||||
"payment_link": "LNK_abc123xyz",
|
||||
"url": "https://checkout.bold.co/payment/LNK_abc123xyz"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `payment_link` → ID interno de Bold (formato `LNK_xxx`). Guardarlo en tu base de datos.
|
||||
- `url` → URL a la que debes redirigir al usuario.
|
||||
|
||||
### Ejemplo en PHP
|
||||
|
||||
```php
|
||||
$payload = [
|
||||
'amount_type' => 'CLOSE',
|
||||
'amount' => ['currency' => 'COP', 'total_amount' => 25000],
|
||||
'description' => 'Inscripción al evento',
|
||||
'callback_url' => 'https://tudominio.com/pago_exitoso.php',
|
||||
'payer_email' => $email,
|
||||
'reference' => 'ERA-' . $usuarioId . '-' . time(),
|
||||
];
|
||||
|
||||
$ch = curl_init('https://integrations.api.bold.co/online/link/v1');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: x-api-key ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
// Manejar error
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$linkId = $data['payload']['payment_link'];
|
||||
$paymentUrl = $data['payload']['url'];
|
||||
|
||||
header('Location: ' . $paymentUrl);
|
||||
exit;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Consultar estado de un link
|
||||
|
||||
Útil si el webhook no llegó o como verificación extra en el callback.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
GET https://integrations.api.bold.co/online/link/v1/{payment_link}
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
```
|
||||
Authorization: x-api-key {bold_api_key}
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
### Respuesta relevante
|
||||
|
||||
```json
|
||||
{
|
||||
"payload": {
|
||||
"payment_link": "LNK_abc123xyz",
|
||||
"status": "ACTIVE",
|
||||
"transactions": [
|
||||
{
|
||||
"status": "APPROVED",
|
||||
"payment_method": "CARD",
|
||||
"amount": 25000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Callback URL (retorno del usuario)
|
||||
|
||||
Cuando el usuario termina (o abandona) el pago, Bold lo redirige a tu `callback_url` con parámetros GET.
|
||||
|
||||
> **Advertencia:** Bold puede usar distintos nombres de parámetro según la versión del checkout.
|
||||
> **Nunca confíes en un único parámetro.** Revisa todos los posibles:
|
||||
|
||||
```
|
||||
bold-order-id, order_id, payment_link, payment_link_id, reference, id
|
||||
```
|
||||
|
||||
### Estrategia robusta en PHP
|
||||
|
||||
```php
|
||||
// Recolectar todos los candidatos posibles
|
||||
$candidateRefs = [];
|
||||
foreach (['bold-order-id', 'order_id', 'payment_link', 'reference', 'id'] as $key) {
|
||||
$val = trim((string)($_GET[$key] ?? ''));
|
||||
if ($val !== '') {
|
||||
$candidateRefs[] = $val;
|
||||
}
|
||||
}
|
||||
// Agregar lo guardado en sesión antes de la redirección
|
||||
if (!empty($_SESSION['bold_order_id'])) {
|
||||
$candidateRefs[] = $_SESSION['bold_order_id'];
|
||||
}
|
||||
$candidateRefs = array_values(array_unique(array_filter($candidateRefs)));
|
||||
|
||||
// Buscar usuario por cualquiera de las referencias
|
||||
$usuario = null;
|
||||
foreach ($candidateRefs as $ref) {
|
||||
$usuarioId = buscarUsuarioIdPorReferencia($pdo, $ref);
|
||||
if ($usuarioId) {
|
||||
// Cargar el usuario y salir del loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Respaldo: usar ID de sesión si no se encontró por referencia
|
||||
if (!$usuario && !empty($_SESSION['usuario_id'])) {
|
||||
// Cargar por $_SESSION['usuario_id']
|
||||
}
|
||||
```
|
||||
|
||||
> **Nota crítica:** La callback se dispara **antes** del webhook. No es seguro marcar un pago
|
||||
> como aprobado solo por el callback; el origen de verdad es el **webhook**.
|
||||
> En el callback solo confirma que el usuario regresó y muestra una pantalla de "procesando".
|
||||
|
||||
---
|
||||
|
||||
## 5. Webhook — configuración
|
||||
|
||||
### Registrar la URL en Bold
|
||||
|
||||
Panel de Bold → **Configuración → Webhooks** → agregar URL:
|
||||
|
||||
```
|
||||
https://tudominio.com/webhook.php
|
||||
```
|
||||
|
||||
### Eventos disponibles
|
||||
|
||||
| Evento | Descripción |
|
||||
|------------------|------------------------------------|
|
||||
| `SALE_APPROVED` | Pago aprobado exitosamente |
|
||||
| `SALE_REJECTED` | Pago rechazado por la entidad |
|
||||
| `SALE_REVERSED` | Reverso / devolución |
|
||||
| `CHARGEBACK` | Contracargo iniciado |
|
||||
|
||||
Para la mayoría de casos solo necesitas `SALE_APPROVED`.
|
||||
|
||||
### Requisitos del receptor
|
||||
|
||||
- Debe responder **HTTP 200** en **menos de 2 segundos**.
|
||||
- Si la respuesta demora más, Bold reintentará el envío.
|
||||
- La URL debe ser HTTPS en producción.
|
||||
|
||||
---
|
||||
|
||||
## 6. Webhook — verificación de firma
|
||||
|
||||
Bold firma cada notificación con HMAC-SHA256. El header es `x-bold-signature`.
|
||||
|
||||
### Algoritmo de verificación
|
||||
|
||||
```
|
||||
1. Leer el body RAW (antes de hacer cualquier json_decode)
|
||||
2. Codificar en Base64: encoded = base64(rawBody)
|
||||
3. Calcular HMAC: computed = HMAC-SHA256(encoded, secretKey) → hexadecimal
|
||||
4. Comparar con el header x-bold-signature (timing-safe)
|
||||
```
|
||||
|
||||
> **Modo test:** la `secretKey` es `""` (cadena vacía).
|
||||
> **Modo producción:** usa la clave del panel de Bold.
|
||||
|
||||
### Implementación PHP
|
||||
|
||||
```php
|
||||
$rawBody = (string) file_get_contents('php://input');
|
||||
$signatureHeader = $_SERVER['HTTP_X_BOLD_SIGNATURE'] ?? '';
|
||||
|
||||
// Responder 200 ANTES de todo procesamiento
|
||||
http_response_code(200);
|
||||
header('Content-Type: application/json');
|
||||
header('Connection: close');
|
||||
$body = '{"ok":true}';
|
||||
header('Content-Length: ' . strlen($body));
|
||||
echo $body;
|
||||
if (function_exists('fastcgi_finish_request')) {
|
||||
fastcgi_finish_request();
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
|
||||
// --- A partir de aquí el cliente ya recibió 200 ---
|
||||
|
||||
if ($signatureHeader !== '') {
|
||||
$keyForHmac = ($boldMode === 'production') ? $secretKey : '';
|
||||
$encoded = base64_encode($rawBody);
|
||||
$computed = hash_hmac('sha256', $encoded, $keyForHmac);
|
||||
|
||||
if (!hash_equals($computed, $signatureHeader)) {
|
||||
// Firma inválida: loguear y salir
|
||||
exit;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Webhook — procesamiento del evento
|
||||
|
||||
### Estructura del payload JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "notif_uuid_unico",
|
||||
"type": "SALE_APPROVED",
|
||||
"subject": "TXN_xyz789",
|
||||
"data": {
|
||||
"payment_id": "TXN_xyz789",
|
||||
"amount": {
|
||||
"total": 25000,
|
||||
"currency": "COP"
|
||||
},
|
||||
"payer_email": "cliente@ejemplo.com",
|
||||
"metadata": {
|
||||
"reference": "ERA-42-1746123456"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Campos a extraer
|
||||
|
||||
| Campo | Ruta en JSON | Descripción |
|
||||
|----------------------------|---------------------------------------------|------------------------------------------|
|
||||
| `notification_id` | `event.id` | ID único de la notificación (idempotencia) |
|
||||
| `tipo` | `event.type` | Tipo de evento |
|
||||
| `payment_id` | `event.data.payment_id` o `event.subject` | ID de la transacción Bold |
|
||||
| `referencia` | `event.data.metadata.reference` | Tu referencia personalizada |
|
||||
| `payer_email` | `event.data.payer_email` | Email del pagador |
|
||||
| `amount` | `event.data.amount.total` | Monto en pesos COP |
|
||||
|
||||
### Flujo de procesamiento
|
||||
|
||||
```php
|
||||
$event = json_decode($rawBody, true);
|
||||
$notificationId = $event['id'] ?? '';
|
||||
$tipo = $event['type'] ?? '';
|
||||
$data = $event['data'] ?? [];
|
||||
|
||||
$paymentId = $data['payment_id'] ?? ($event['subject'] ?? '');
|
||||
$referencia = $data['metadata']['reference'] ?? '';
|
||||
$email = $data['payer_email'] ?? '';
|
||||
|
||||
// Solo procesar ventas aprobadas
|
||||
if ($tipo !== 'SALE_APPROVED') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verificar idempotencia (ver sección 9)
|
||||
// Resolver usuario (ver sección 8)
|
||||
// Marcar pago en la base de datos
|
||||
// Enviar correo de confirmación
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Estrategia de referencias múltiples
|
||||
|
||||
**Problema real:** Bold puede enviar en el webhook un `payment_id` diferente al `LNK_xxx`
|
||||
original del payment link. Si solo guardas `bold_order_id`, la búsqueda falla.
|
||||
|
||||
**Solución:** tabla de historial de referencias. Cada vez que aparece una referencia nueva
|
||||
asociada a un usuario, se registra.
|
||||
|
||||
### Tabla `usuarios_referencias_pago`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `usuarios_referencias_pago` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`usuario_id` INT NOT NULL,
|
||||
`referencia` VARCHAR(120) NOT NULL,
|
||||
`tipo` VARCHAR(40) NOT NULL DEFAULT 'desconocida',
|
||||
`origen` VARCHAR(40) NOT NULL DEFAULT 'sistema',
|
||||
`creado_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uq_referencia` (`referencia`),
|
||||
INDEX `idx_usuario` (`usuario_id`),
|
||||
CONSTRAINT `fk_ref_usuario`
|
||||
FOREIGN KEY (`usuario_id`) REFERENCES `usuarios`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
### Cuándo registrar referencias
|
||||
|
||||
| Momento | Referencias a guardar | `tipo` |
|
||||
|----------------------|--------------------------------------------------------|------------------|
|
||||
| Al crear el link | `LNK_xxx` (payment_link) | `payment_link` |
|
||||
| Al crear el link | `ERA-{id}-{ts}` (tu referencia) | `referencia_era` |
|
||||
| En el callback URL | Todos los params GET de Bold | `callback` |
|
||||
| En el webhook | `payment_id` del evento | `webhook_payment_id` |
|
||||
| En el webhook | `metadata.reference` del evento | `webhook_referencia` |
|
||||
|
||||
### Función de búsqueda por cualquier referencia
|
||||
|
||||
```php
|
||||
function buscarUsuarioIdPorReferencia(PDO $pdo, string $referencia): ?int {
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT usuario_id
|
||||
FROM usuarios_referencias_pago
|
||||
WHERE referencia = ?
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([trim($referencia)]);
|
||||
$row = $stmt->fetchColumn();
|
||||
return $row ? (int)$row : null;
|
||||
}
|
||||
```
|
||||
|
||||
### Cascada de búsqueda en el webhook
|
||||
|
||||
```php
|
||||
$usuarioId = null;
|
||||
|
||||
// 1. Por referencia personalizada ERA-{id}-{ts}
|
||||
if ($referencia !== '') {
|
||||
$usuarioId = buscarUsuarioIdPorReferencia($pdo, $referencia);
|
||||
}
|
||||
|
||||
// 2. Por payment_id de la transacción
|
||||
if (!$usuarioId && $paymentId !== '') {
|
||||
$usuarioId = buscarUsuarioIdPorReferencia($pdo, $paymentId);
|
||||
}
|
||||
|
||||
// 3. Por email (último recurso, solo si hay un único pendiente)
|
||||
if (!$usuarioId && $payerEmail !== '') {
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id FROM usuarios
|
||||
WHERE email = ? AND estado_pago = "pendiente"
|
||||
ORDER BY fecha_registro DESC LIMIT 1
|
||||
');
|
||||
$stmt->execute([$payerEmail]);
|
||||
$row = $stmt->fetchColumn();
|
||||
$usuarioId = $row ? (int)$row : null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Idempotencia
|
||||
|
||||
Bold puede reenviar la misma notificación varias veces (reintentos ante timeouts).
|
||||
Debes garantizar que procesar la misma notificación dos veces no cause efectos duplicados
|
||||
(doble correo, doble registro de pago, etc.).
|
||||
|
||||
### Tabla `webhook_log`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `webhook_log` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`notification_id` VARCHAR(64) NOT NULL,
|
||||
`tipo` VARCHAR(30) NOT NULL,
|
||||
`payment_id` VARCHAR(64) DEFAULT NULL,
|
||||
`referencia` VARCHAR(120) DEFAULT NULL,
|
||||
`usuario_id` INT DEFAULT NULL,
|
||||
`procesado` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`recibido_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uq_notification` (`notification_id`),
|
||||
INDEX `idx_usuario` (`usuario_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
### Patrón de INSERT IGNORE
|
||||
|
||||
```php
|
||||
// Intentar insertar. Si ya existe (UNIQUE conflict) → rowCount() = 0 → duplicado
|
||||
$ins = $pdo->prepare('
|
||||
INSERT IGNORE INTO webhook_log (notification_id, tipo, payment_id, referencia)
|
||||
VALUES (?, ?, ?, ?)
|
||||
');
|
||||
$ins->execute([$notificationId, $tipo, $paymentId, $referencia]);
|
||||
|
||||
if ($ins->rowCount() === 0) {
|
||||
// Notificación ya procesada anteriormente
|
||||
exit;
|
||||
}
|
||||
|
||||
// Continúa procesamiento...
|
||||
|
||||
// Al finalizar, marcar como procesado
|
||||
$pdo->prepare('UPDATE webhook_log SET procesado=1, usuario_id=? WHERE notification_id=?')
|
||||
->execute([$usuarioId, $notificationId]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Esquema de base de datos
|
||||
|
||||
Tablas mínimas necesarias:
|
||||
|
||||
```sql
|
||||
-- Tabla principal de usuarios/registros
|
||||
CREATE TABLE `usuarios` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`email` VARCHAR(255) NOT NULL,
|
||||
`token` VARCHAR(64) NOT NULL UNIQUE,
|
||||
`bold_order_id` VARCHAR(80) DEFAULT NULL, -- LNK_xxx principal
|
||||
`estado_pago` ENUM('pendiente','pagado') NOT NULL DEFAULT 'pendiente',
|
||||
`fecha_registro` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Historial de todas las referencias de Bold asociadas al usuario
|
||||
CREATE TABLE `usuarios_referencias_pago` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`usuario_id` INT NOT NULL,
|
||||
`referencia` VARCHAR(120) NOT NULL,
|
||||
`tipo` VARCHAR(40) NOT NULL DEFAULT 'desconocida',
|
||||
`origen` VARCHAR(40) NOT NULL DEFAULT 'sistema',
|
||||
`creado_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uq_referencia` (`referencia`),
|
||||
INDEX `idx_usuario` (`usuario_id`),
|
||||
CONSTRAINT `fk_ref_usuario`
|
||||
FOREIGN KEY (`usuario_id`) REFERENCES `usuarios`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Log de webhooks para idempotencia
|
||||
CREATE TABLE `webhook_log` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`notification_id` VARCHAR(64) NOT NULL,
|
||||
`tipo` VARCHAR(30) NOT NULL,
|
||||
`payment_id` VARCHAR(64) DEFAULT NULL,
|
||||
`referencia` VARCHAR(120) DEFAULT NULL,
|
||||
`usuario_id` INT DEFAULT NULL,
|
||||
`procesado` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`recibido_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uq_notification` (`notification_id`),
|
||||
INDEX `idx_usuario` (`usuario_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Flujo completo de extremo a extremo
|
||||
|
||||
```
|
||||
Usuario llena formulario
|
||||
│
|
||||
▼
|
||||
[Tu backend] genera referencia personalizada:
|
||||
ERA-{usuarioId}-{timestamp}
|
||||
│
|
||||
▼
|
||||
POST → API Bold /online/link/v1
|
||||
│
|
||||
▼ respuesta LNK_xxx + URL
|
||||
[Guardar en DB]:
|
||||
- usuarios.bold_order_id = LNK_xxx
|
||||
- referencias_pago: LNK_xxx (tipo: payment_link)
|
||||
- referencias_pago: ERA-... (tipo: referencia_era)
|
||||
- sesión: bold_order_id, usuario_id
|
||||
│
|
||||
▼
|
||||
Redirigir usuario a URL Bold (checkout)
|
||||
│
|
||||
Usuario paga
|
||||
│
|
||||
┌────┴────────────────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
callback_url (GET) webhook (POST) ← fuente de verdad
|
||||
Bold redirige al usuario Bold notifica el evento
|
||||
con params: bold-order-id, etc.
|
||||
│ │
|
||||
▼ ▼
|
||||
Mostrar pantalla "procesando" 1. Responder 200 INMEDIATAMENTE
|
||||
No marcar como pagado aún 2. Verificar firma HMAC
|
||||
3. Idempotencia (INSERT IGNORE)
|
||||
4. Resolver usuario por referencias
|
||||
5. UPDATE estado_pago = 'pagado'
|
||||
6. Enviar correo de confirmación
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Errores frecuentes y soluciones
|
||||
|
||||
### El webhook no llega
|
||||
|
||||
- Verificar que la URL esté registrada en el panel de Bold.
|
||||
- La URL debe ser **pública** (no localhost). Usar [ngrok](https://ngrok.com) para pruebas locales:
|
||||
```bash
|
||||
ngrok http 80
|
||||
# Registrar la URL https://xxxx.ngrok.io/webhook.php en Bold
|
||||
```
|
||||
- Confirmar que el servidor responde HTTP 200 en < 2 s.
|
||||
- Revisar el log de webhooks en el panel de Bold para ver reintentos.
|
||||
|
||||
### Firma inválida en modo test
|
||||
|
||||
El `secretKey` en modo test es `""` (cadena vacía). Asegúrate de usar eso, no la clave real.
|
||||
|
||||
```php
|
||||
$keyForHmac = ($boldMode === 'production') ? $secretKey : '';
|
||||
```
|
||||
|
||||
### No se encuentra el usuario en el webhook
|
||||
|
||||
El `payment_id` que llega en el webhook puede ser **diferente** al `LNK_xxx` creado con la API.
|
||||
Solución: usar la tabla de historial de referencias y la referencia personalizada `ERA-{id}-{ts}`.
|
||||
|
||||
### Bold reintenta y se procesan pagos dobles
|
||||
|
||||
Implementar idempotencia con `webhook_log` usando `INSERT IGNORE` sobre `notification_id`.
|
||||
|
||||
### HTTP 400 al crear el link
|
||||
|
||||
- Verificar que `total_amount` sea entero (no float).
|
||||
- `reference` no puede superar 60 caracteres ni contener caracteres especiales.
|
||||
- La `callback_url` debe ser una URL accesible públicamente.
|
||||
|
||||
### Error de timeout en curl
|
||||
|
||||
Bold tiene un timeout de respuesta. Si el servidor tarda más de 15 s, la llamada falla.
|
||||
Configurar `CURLOPT_TIMEOUT` en 15 y asegurar que la conexión a internet es estable.
|
||||
|
||||
---
|
||||
|
||||
## Referencias oficiales
|
||||
|
||||
- Documentación Bold: [https://developers.bold.co](https://developers.bold.co)
|
||||
- Panel Bold: [https://dashboard.bold.co](https://dashboard.bold.co)
|
||||
- API de links: `POST https://integrations.api.bold.co/online/link/v1`
|
||||
- Consultar link: `GET https://integrations.api.bold.co/online/link/v1/{id}`
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/pyroscope-io/pyroscope/pkg/agent/profiler"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/migrations"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/rest/routes"
|
||||
)
|
||||
@@ -47,6 +48,8 @@ func main() {
|
||||
// Seed automático (idempotente) de módulos del sistema
|
||||
migrations.SeedRenovaciones()
|
||||
migrations.SeedIntegraciones()
|
||||
// Auto-migrar query_history si no existe
|
||||
app.Http.Database.DB.AutoMigrate(&models.QueryHistory{})
|
||||
// Iniciar cron de vencimientos
|
||||
services.IniciarCron()
|
||||
defer services.DetenerCron()
|
||||
|
||||
@@ -53,6 +53,8 @@ func Migrate() {
|
||||
// Integraciones externas
|
||||
&models.HostingerConfig{},
|
||||
&models.CloudflareConfig{},
|
||||
// Query Runner
|
||||
&models.QueryHistory{},
|
||||
// Pasarelas de pago
|
||||
&models.BoldConfig{},
|
||||
&models.BoldWebhookLog{},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// QueryHistory almacena el historial de consultas ejecutadas por los usuarios.
|
||||
type QueryHistory struct {
|
||||
gorm.Model
|
||||
ConxDbID uint `json:"conx_db_id" gorm:"column:conx_db_id;index"`
|
||||
ConxDb ConxDb `json:"conx_db" gorm:"foreignKey:ConxDbID"`
|
||||
SQL string `json:"sql" gorm:"column:sql;type:text"`
|
||||
Status string `json:"status" gorm:"column:status"` // ok | error
|
||||
ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"`
|
||||
RowsAffect int64 `json:"rows_affect" gorm:"column:rows_affect"`
|
||||
DurationMs int64 `json:"duration_ms" gorm:"column:duration_ms"`
|
||||
ExecutedAt time.Time `json:"executed_at" gorm:"column:executed_at;autoCreateTime"`
|
||||
}
|
||||
|
||||
func (QueryHistory) TableName() string {
|
||||
return "query_history"
|
||||
}
|
||||
|
||||
// SaveQueryHistory guarda una entrada en el historial.
|
||||
func SaveQueryHistory(h QueryHistory) error {
|
||||
return app.Http.Database.DB.Create(&h).Error
|
||||
}
|
||||
|
||||
// GetQueryHistory devuelve el historial de una conexión con paginación.
|
||||
func GetQueryHistory(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) {
|
||||
var items []QueryHistory
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ?", conxDbID)
|
||||
db.Count(&total)
|
||||
err := db.Order("executed_at DESC").Limit(limit).Offset(offset).Find(&items).Error
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// DeleteQueryHistory elimina todo el historial de una conexión.
|
||||
func DeleteQueryHistory(conxDbID uint) error {
|
||||
return app.Http.Database.DB.Where("conx_db_id = ?", conxDbID).Delete(&QueryHistory{}).Error
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
_ "github.com/microsoft/go-mssqldb"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// QueryResult contiene el resultado de una consulta SQL.
|
||||
type QueryResult struct {
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]any `json:"rows"`
|
||||
RowCount int `json:"row_count"`
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
IsSelect bool `json:"is_select"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// openDynamicDB abre una conexión a la base de datos indicada por ConxDb.
|
||||
func openDynamicDB(c models.ConxDb) (*sql.DB, error) {
|
||||
driver := strings.ToLower(c.TipoDb.Nombre)
|
||||
host := c.Servidor.IpServidor
|
||||
port := c.Puerto
|
||||
user := c.Usuario
|
||||
pass := c.Password
|
||||
|
||||
var dsn string
|
||||
var driverName string
|
||||
|
||||
switch {
|
||||
case strings.Contains(driver, "postgres"):
|
||||
driverName = "postgres"
|
||||
dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s sslmode=disable", host, port, user, pass)
|
||||
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
|
||||
driverName = "mysql"
|
||||
dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/", user, pass, host, port)
|
||||
case strings.Contains(driver, "sqlite"):
|
||||
driverName = "sqlite3"
|
||||
dsn = host // para sqlite el host es la ruta del archivo
|
||||
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
|
||||
driverName = "sqlserver"
|
||||
dsn = fmt.Sprintf("sqlserver://%s:%s@%s:%s", user, pass, host, port)
|
||||
default:
|
||||
return nil, fmt.Errorf("driver no soportado: %s", driver)
|
||||
}
|
||||
|
||||
db, err := sql.Open(driverName, dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetConnMaxLifetime(30 * time.Second)
|
||||
db.SetMaxOpenConns(2)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// ExecuteSQL ejecuta SQL arbitrario contra la conexión y devuelve QueryResult.
|
||||
// También guarda en query_history.
|
||||
func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult {
|
||||
start := time.Now()
|
||||
|
||||
db, err := openDynamicDB(conx)
|
||||
if err != nil {
|
||||
saveHistory(conx.ID, sqlText, "error", err.Error(), 0, time.Since(start).Milliseconds())
|
||||
return QueryResult{Error: err.Error()}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Si se especifica una base de datos para seleccionar
|
||||
if database != "" {
|
||||
driver := strings.ToLower(conx.TipoDb.Nombre)
|
||||
if strings.Contains(driver, "postgres") {
|
||||
// En postgres se cambia con SET search_path o reconectando con dbname en DSN
|
||||
db2, err2 := openDynamicDBWithName(conx, database)
|
||||
if err2 == nil {
|
||||
db.Close()
|
||||
db = db2
|
||||
}
|
||||
} else {
|
||||
if _, err2 := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err2 != nil {
|
||||
saveHistory(conx.ID, sqlText, "error", err2.Error(), 0, time.Since(start).Milliseconds())
|
||||
return QueryResult{Error: err2.Error()}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(sqlText)
|
||||
isSelect := isSelectStatement(trimmed)
|
||||
|
||||
var result QueryResult
|
||||
result.IsSelect = isSelect
|
||||
|
||||
if isSelect {
|
||||
rows, err := db.Query(trimmed)
|
||||
if err != nil {
|
||||
elapsed := time.Since(start).Milliseconds()
|
||||
saveHistory(conx.ID, sqlText, "error", err.Error(), 0, elapsed)
|
||||
return QueryResult{Error: err.Error(), IsSelect: true}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, _ := rows.Columns()
|
||||
result.Columns = cols
|
||||
|
||||
for rows.Next() {
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
rows.Scan(ptrs...)
|
||||
row := make(map[string]any, len(cols))
|
||||
for i, col := range cols {
|
||||
v := vals[i]
|
||||
if b, ok := v.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = v
|
||||
}
|
||||
}
|
||||
result.Rows = append(result.Rows, row)
|
||||
}
|
||||
result.RowCount = len(result.Rows)
|
||||
} else {
|
||||
res, err := db.Exec(trimmed)
|
||||
elapsed := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
saveHistory(conx.ID, sqlText, "error", err.Error(), 0, elapsed)
|
||||
return QueryResult{Error: err.Error()}
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
result.AffectedRows = affected
|
||||
result.DurationMs = elapsed
|
||||
saveHistory(conx.ID, sqlText, "ok", "", affected, elapsed)
|
||||
return result
|
||||
}
|
||||
|
||||
result.DurationMs = time.Since(start).Milliseconds()
|
||||
saveHistory(conx.ID, sqlText, "ok", "", int64(result.RowCount), result.DurationMs)
|
||||
return result
|
||||
}
|
||||
|
||||
// ListDatabases devuelve la lista de bases de datos del servidor.
|
||||
func ListDatabases(conx models.ConxDb) ([]string, error) {
|
||||
db, err := openDynamicDB(conx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
driver := strings.ToLower(conx.TipoDb.Nombre)
|
||||
var query string
|
||||
switch {
|
||||
case strings.Contains(driver, "postgres"):
|
||||
query = "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname"
|
||||
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
|
||||
query = "SHOW DATABASES"
|
||||
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
|
||||
query = "SELECT name FROM sys.databases ORDER BY name"
|
||||
default:
|
||||
return []string{"main"}, nil
|
||||
}
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var dbs []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
rows.Scan(&name)
|
||||
dbs = append(dbs, name)
|
||||
}
|
||||
return dbs, nil
|
||||
}
|
||||
|
||||
// ListTables devuelve las tablas de una base de datos.
|
||||
func ListTables(conx models.ConxDb, database string) ([]string, error) {
|
||||
driver := strings.ToLower(conx.TipoDb.Nombre)
|
||||
|
||||
var db *sql.DB
|
||||
var err error
|
||||
|
||||
if strings.Contains(driver, "postgres") {
|
||||
db, err = openDynamicDBWithName(conx, database)
|
||||
} else {
|
||||
db, err = openDynamicDB(conx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var query string
|
||||
switch {
|
||||
case strings.Contains(driver, "postgres"):
|
||||
query = "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
|
||||
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
|
||||
if _, err := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query = "SHOW TABLES"
|
||||
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
|
||||
query = fmt.Sprintf("USE [%s]; SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME", database)
|
||||
default:
|
||||
query = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
}
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
rows.Scan(&name)
|
||||
tables = append(tables, name)
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
// TestConnection verifica si la conexión es válida.
|
||||
func TestDBConnection(conx models.ConxDb) error {
|
||||
db, err := openDynamicDB(conx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return db.Ping()
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func openDynamicDBWithName(c models.ConxDb, dbName string) (*sql.DB, error) {
|
||||
host := c.Servidor.IpServidor
|
||||
port := c.Puerto
|
||||
user := c.Usuario
|
||||
pass := c.Password
|
||||
driver := strings.ToLower(c.TipoDb.Nombre)
|
||||
|
||||
var dsn string
|
||||
switch {
|
||||
case strings.Contains(driver, "postgres"):
|
||||
dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", host, port, user, pass, dbName)
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetConnMaxLifetime(30 * time.Second)
|
||||
db.SetMaxOpenConns(2)
|
||||
return db, nil
|
||||
default:
|
||||
return openDynamicDB(c)
|
||||
}
|
||||
}
|
||||
|
||||
func isSelectStatement(sql string) bool {
|
||||
upper := strings.ToUpper(strings.TrimSpace(sql))
|
||||
keywords := []string{"SELECT ", "SHOW ", "DESCRIBE ", "EXPLAIN ", "WITH ", "PRAGMA "}
|
||||
for _, kw := range keywords {
|
||||
if strings.HasPrefix(upper, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func quoteIdentifier(name, driver string) string {
|
||||
d := strings.ToLower(driver)
|
||||
if strings.Contains(d, "postgres") {
|
||||
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||
}
|
||||
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||
}
|
||||
|
||||
func saveHistory(conxID uint, sqlText, status, errMsg string, rows, durationMs int64) {
|
||||
models.SaveQueryHistory(models.QueryHistory{
|
||||
ConxDbID: conxID,
|
||||
SQL: sqlText,
|
||||
Status: status,
|
||||
ErrorMsg: errMsg,
|
||||
RowsAffect: rows,
|
||||
DurationMs: durationMs,
|
||||
ExecutedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
@@ -68,6 +68,15 @@
|
||||
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Botón Query Runner -->
|
||||
<a :href="'/app/query-runner?conx_db_id=' + data.ID" title="Abrir editor SQL"
|
||||
class="inline-flex items-center justify-center w-5 h-5 text-blue-500 hover:text-blue-700 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor" class="w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
<div x-data="queryRunner" class="h-full bg-white rounded-lg shadow flex flex-col">
|
||||
|
||||
<!-- Overlay carga -->
|
||||
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-70 flex justify-center items-center z-50">
|
||||
<img src="../img/loading.gif" class="w-14 h-14" />
|
||||
</div>
|
||||
|
||||
<div class="flex h-full" style="min-height:calc(100vh - 80px)">
|
||||
|
||||
<!-- ══════════════════ SIDEBAR IZQUIERDO (árbol) ══════════════════ -->
|
||||
<aside class="w-64 shrink-0 border-r flex flex-col bg-gray-50">
|
||||
|
||||
<!-- Selector de conexión -->
|
||||
<div class="p-3 border-b">
|
||||
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Conexión DB</label>
|
||||
<select x-model="selectedConxId" @change="onConxChange()"
|
||||
class="w-full border rounded px-2 py-1.5 text-xs">
|
||||
<option value="">— Selecciona —</option>
|
||||
<template x-for="c in conexiones" :key="c.ID">
|
||||
<option :value="c.ID"
|
||||
x-text="(c.servidor?.nombre || c.servidor_id) + ' · ' + (c.tipo_db?.nombre || '') + ' :' + c.puerto">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<!-- Test conexión -->
|
||||
<button @click="testConn()" x-show="selectedConxId" :disabled="testLoading"
|
||||
class="mt-1.5 w-full text-xs py-1 rounded border border-gray-300 hover:bg-white transition flex items-center justify-center gap-1">
|
||||
<span x-show="!testLoading">⚡ Probar conexión</span>
|
||||
<span x-show="testLoading">Probando…</span>
|
||||
</button>
|
||||
<p x-show="testMsg" class="text-xs mt-1 font-medium"
|
||||
:class="testOk ? 'text-green-600' : 'text-red-500'" x-text="testMsg"></p>
|
||||
</div>
|
||||
|
||||
<!-- Selector de base de datos -->
|
||||
<div class="p-3 border-b" x-show="selectedConxId">
|
||||
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Base de datos</label>
|
||||
<select x-model="selectedDb" @change="loadTables()"
|
||||
class="w-full border rounded px-2 py-1.5 text-xs">
|
||||
<option value="">— Selecciona DB —</option>
|
||||
<template x-for="db in databases" :key="db">
|
||||
<option :value="db" x-text="db"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Árbol de tablas -->
|
||||
<div class="flex-1 overflow-y-auto p-2" x-show="tables.length > 0">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1">Tablas</p>
|
||||
<template x-for="t in tables" :key="t">
|
||||
<button @click="insertTable(t)"
|
||||
class="w-full text-left text-xs px-2 py-1 rounded hover:bg-[#e9f0cf] text-gray-700 truncate flex items-center gap-1">
|
||||
<svg class="w-3 h-3 shrink-0 text-[#8eb02f]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/>
|
||||
</svg>
|
||||
<span x-text="t"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje sin conexión -->
|
||||
<div x-show="!selectedConxId" class="flex-1 flex items-center justify-center p-4">
|
||||
<p class="text-xs text-gray-400 text-center">Selecciona una conexión para explorar bases de datos y tablas.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ══════════════════ ÁREA PRINCIPAL ══════════════════ -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
<!-- Barra de herramientas -->
|
||||
<div class="flex items-center gap-2 px-4 py-2.5 border-b bg-white">
|
||||
<button @click="runQuery()"
|
||||
:disabled="!selectedConxId || loadingQuery"
|
||||
class="flex items-center gap-1.5 px-4 py-1.5 text-white text-xs font-semibold rounded-lg transition disabled:opacity-40"
|
||||
style="background-color:#8eb02f"
|
||||
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||||
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
<span x-text="loadingQuery ? 'Ejecutando…' : 'Ejecutar'"></span>
|
||||
<kbd class="ml-1 text-[10px] opacity-70">Ctrl+↵</kbd>
|
||||
</button>
|
||||
|
||||
<button @click="clearEditor()" class="px-3 py-1.5 text-xs border rounded hover:bg-gray-50 transition">
|
||||
Limpiar
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Exportar -->
|
||||
<div class="flex items-center gap-1" x-show="results.length > 0">
|
||||
<span class="text-xs text-gray-400" x-text="results.length + ' filas'"></span>
|
||||
<button @click="exportData('csv')"
|
||||
class="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded hover:bg-green-50 transition">
|
||||
↓ CSV
|
||||
</button>
|
||||
<button @click="exportData('json')"
|
||||
class="px-3 py-1.5 text-xs border border-blue-300 text-blue-700 rounded hover:bg-blue-50 transition">
|
||||
↓ JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab historial -->
|
||||
<button @click="activeTab = activeTab === 'history' ? 'results' : 'history'"
|
||||
class="px-3 py-1.5 text-xs border rounded transition"
|
||||
:class="activeTab === 'history' ? 'bg-[#8eb02f] text-white border-[#8eb02f]' : 'hover:bg-gray-50'">
|
||||
Historial
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Editor SQL -->
|
||||
<div class="px-4 pt-3 pb-0">
|
||||
<div class="border rounded-lg overflow-hidden bg-gray-900">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 bg-gray-800 border-b border-gray-700">
|
||||
<span class="text-xs text-gray-400">SQL Editor</span>
|
||||
<div class="flex-1"></div>
|
||||
<span x-show="selectedDb" class="text-xs text-[#4ade80] font-mono" x-text="selectedDb"></span>
|
||||
</div>
|
||||
<textarea id="sql-editor" x-model="sqlText"
|
||||
@keydown.ctrl.enter.prevent="runQuery()"
|
||||
@keydown.meta.enter.prevent="runQuery()"
|
||||
placeholder="-- Escribe tu consulta SQL aquí -- Ctrl+Enter para ejecutar SELECT * FROM tabla LIMIT 100;"
|
||||
class="w-full bg-gray-900 text-gray-100 font-mono text-sm p-4 resize-none outline-none leading-relaxed"
|
||||
style="height:180px; tab-size:2;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel de resultados / historial -->
|
||||
<div class="flex-1 overflow-hidden flex flex-col px-4 pt-3 pb-4">
|
||||
|
||||
<!-- Status bar -->
|
||||
<div x-show="statusMsg" class="mb-2 px-3 py-1.5 rounded text-xs font-medium"
|
||||
:class="statusOk ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-600 border border-red-200'"
|
||||
x-text="statusMsg"></div>
|
||||
|
||||
<!-- ── TAB: Resultados ── -->
|
||||
<div x-show="activeTab === 'results'" class="flex-1 overflow-auto border rounded-lg">
|
||||
<div x-show="results.length === 0 && !loadingQuery" class="flex flex-col items-center justify-center h-40 text-gray-400 text-sm gap-2">
|
||||
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 5.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"/>
|
||||
</svg>
|
||||
<span>Sin resultados. Ejecuta una consulta.</span>
|
||||
</div>
|
||||
<table x-show="results.length > 0" class="table-auto w-full text-xs">
|
||||
<thead class="sticky top-0 bg-gray-50 z-10">
|
||||
<tr>
|
||||
<th class="py-2 px-3 border-b text-left font-semibold text-gray-500 w-10">#</th>
|
||||
<template x-for="col in columns" :key="col">
|
||||
<th class="py-2 px-3 border-b text-left font-semibold text-gray-600 whitespace-nowrap" x-text="col"></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="(row, idx) in results" :key="idx">
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="py-2 px-3 text-gray-400 select-none" x-text="idx+1"></td>
|
||||
<template x-for="col in columns" :key="col">
|
||||
<td class="py-2 px-3 font-mono max-w-xs truncate"
|
||||
:title="nullStr(row[col])"
|
||||
x-text="nullStr(row[col])"></td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ── TAB: Historial ── -->
|
||||
<div x-show="activeTab === 'history'" class="flex-1 overflow-auto border rounded-lg">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b bg-gray-50 sticky top-0">
|
||||
<span class="text-xs font-semibold text-gray-500">Historial de consultas</span>
|
||||
<button @click="clearHistory()" x-show="history.length > 0"
|
||||
class="text-xs text-red-500 hover:underline">Borrar historial</button>
|
||||
</div>
|
||||
<div x-show="history.length === 0" class="py-10 text-center text-xs text-gray-400">Sin historial para esta conexión.</div>
|
||||
<template x-for="(h, i) in history" :key="h.ID">
|
||||
<div class="px-3 py-2.5 border-b hover:bg-gray-50 group cursor-pointer"
|
||||
@click="sqlText = h.sql">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<pre class="text-xs font-mono text-gray-700 whitespace-pre-wrap break-all flex-1 max-h-12 overflow-hidden" x-text="h.sql"></pre>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
:class="h.status === 'ok' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'"
|
||||
x-text="h.status"></span>
|
||||
<span class="text-[10px] text-gray-400" x-text="h.duration_ms + 'ms'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="h.error_msg" class="text-[10px] text-red-400 mt-0.5 truncate" x-text="h.error_msg"></p>
|
||||
<p class="text-[10px] text-gray-400 mt-0.5" x-text="formatDate(h.executed_at)"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div x-show="toast.show" x-cloak x-transition
|
||||
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
|
||||
:class="toast.type === 'error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
|
||||
x-text="toast.msg"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('queryRunner', () => ({
|
||||
loading: false,
|
||||
loadingQuery: false,
|
||||
testLoading: false,
|
||||
selectedConxId: '',
|
||||
selectedDb: '',
|
||||
conexiones: [],
|
||||
databases: [],
|
||||
tables: [],
|
||||
sqlText: '',
|
||||
columns: [],
|
||||
results: [],
|
||||
history: [],
|
||||
activeTab: 'results',
|
||||
statusMsg: '',
|
||||
statusOk: true,
|
||||
testMsg: '',
|
||||
testOk: true,
|
||||
toast: { show: false, msg: '', type: 'ok' },
|
||||
|
||||
async init() {
|
||||
// Pre-cargar ID desde URL si viene de conx_db
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const preId = params.get('conx_db_id');
|
||||
|
||||
const res = await axios.get('/app/query-runner/connections');
|
||||
this.conexiones = res.data.data || [];
|
||||
|
||||
if (preId) {
|
||||
this.selectedConxId = parseInt(preId);
|
||||
await this.onConxChange();
|
||||
}
|
||||
},
|
||||
|
||||
async onConxChange() {
|
||||
this.databases = [];
|
||||
this.tables = [];
|
||||
this.selectedDb = '';
|
||||
this.results = [];
|
||||
this.columns = [];
|
||||
this.statusMsg = '';
|
||||
this.history = [];
|
||||
if (!this.selectedConxId) return;
|
||||
try {
|
||||
const res = await axios.get('/app/query-runner/databases?conx_db_id=' + this.selectedConxId);
|
||||
this.databases = res.data.data || [];
|
||||
} catch (e) {
|
||||
this.showToast(e.response?.data?.error || 'Error al cargar DBs', 'error');
|
||||
}
|
||||
await this.loadHistory();
|
||||
},
|
||||
|
||||
async loadTables() {
|
||||
this.tables = [];
|
||||
if (!this.selectedDb) return;
|
||||
try {
|
||||
const res = await axios.get('/app/query-runner/tables?conx_db_id=' + this.selectedConxId + '&db=' + encodeURIComponent(this.selectedDb));
|
||||
this.tables = res.data.data || [];
|
||||
} catch (e) {
|
||||
this.showToast(e.response?.data?.error || 'Error al cargar tablas', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async testConn() {
|
||||
this.testLoading = true;
|
||||
this.testMsg = '';
|
||||
try {
|
||||
await axios.get('/app/query-runner/test?conx_db_id=' + this.selectedConxId);
|
||||
this.testOk = true;
|
||||
this.testMsg = '✓ Conexión exitosa';
|
||||
} catch (e) {
|
||||
this.testOk = false;
|
||||
this.testMsg = '✗ ' + (e.response?.data?.error || 'Error de conexión');
|
||||
}
|
||||
this.testLoading = false;
|
||||
setTimeout(() => this.testMsg = '', 4000);
|
||||
},
|
||||
|
||||
async runQuery() {
|
||||
if (!this.selectedConxId || !this.sqlText.trim()) return;
|
||||
this.loadingQuery = true;
|
||||
this.statusMsg = '';
|
||||
this.results = [];
|
||||
this.columns = [];
|
||||
try {
|
||||
const res = await axios.post('/app/query-runner/run', {
|
||||
conx_db_id: parseInt(this.selectedConxId),
|
||||
database: this.selectedDb,
|
||||
sql: this.sqlText
|
||||
});
|
||||
const data = res.data;
|
||||
if (data.error) {
|
||||
this.statusOk = false;
|
||||
this.statusMsg = '✗ ' + data.error;
|
||||
} else if (data.is_select) {
|
||||
this.columns = data.columns || [];
|
||||
this.results = data.rows || [];
|
||||
this.statusOk = true;
|
||||
this.statusMsg = `✓ ${this.results.length} fila(s) — ${data.duration_ms}ms`;
|
||||
} else {
|
||||
this.statusOk = true;
|
||||
this.statusMsg = `✓ ${data.affected_rows} fila(s) afectadas — ${data.duration_ms}ms`;
|
||||
}
|
||||
} catch (e) {
|
||||
this.statusOk = false;
|
||||
this.statusMsg = '✗ ' + (e.response?.data?.error || 'Error al ejecutar');
|
||||
}
|
||||
this.loadingQuery = false;
|
||||
await this.loadHistory();
|
||||
},
|
||||
|
||||
async loadHistory() {
|
||||
if (!this.selectedConxId) return;
|
||||
try {
|
||||
const res = await axios.get('/app/query-runner/history?conx_db_id=' + this.selectedConxId);
|
||||
this.history = res.data.data || [];
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async clearHistory() {
|
||||
if (!confirm('¿Borrar todo el historial de esta conexión?')) return;
|
||||
try {
|
||||
await axios.delete('/app/query-runner/history?conx_db_id=' + this.selectedConxId);
|
||||
this.history = [];
|
||||
this.showToast('Historial borrado');
|
||||
} catch (e) {
|
||||
this.showToast('Error al borrar historial', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async exportData(fmt) {
|
||||
if (!this.sqlText.trim()) return;
|
||||
try {
|
||||
const res = await axios.post('/app/query-runner/export/' + fmt, {
|
||||
conx_db_id: parseInt(this.selectedConxId),
|
||||
database: this.selectedDb,
|
||||
sql: this.sqlText
|
||||
}, { responseType: 'blob' });
|
||||
const mime = fmt === 'csv' ? 'text/csv' : 'application/json';
|
||||
const blob = new Blob([res.data], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'query_result.' + fmt;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
this.showToast('Error al exportar', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
insertTable(name) {
|
||||
const sel = `SELECT * FROM ${name} LIMIT 100;`;
|
||||
this.sqlText = this.sqlText ? this.sqlText + '\n' + sel : sel;
|
||||
document.getElementById('sql-editor')?.focus();
|
||||
},
|
||||
|
||||
clearEditor() { this.sqlText = ''; this.results = []; this.columns = []; this.statusMsg = ''; },
|
||||
|
||||
nullStr(v) { return v === null || v === undefined ? 'NULL' : String(v); },
|
||||
|
||||
formatDate(d) {
|
||||
if (!d) return '';
|
||||
return new Date(d).toLocaleString();
|
||||
},
|
||||
|
||||
showToast(msg, type = 'ok') {
|
||||
this.toast = { show: true, msg, type };
|
||||
setTimeout(() => this.toast.show = false, 3500);
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,237 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"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"
|
||||
)
|
||||
|
||||
// QueryRunnerPage renderiza la vista del editor SQL.
|
||||
// GET /app/query-runner?conx_db_id=1
|
||||
func QueryRunnerPage(c *fiber.Ctx) error {
|
||||
conxIDStr := c.Query("conx_db_id", "")
|
||||
var conx models.ConxDb
|
||||
if conxIDStr != "" {
|
||||
id, _ := strconv.ParseUint(conxIDStr, 10, 32)
|
||||
app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").First(&conx, id)
|
||||
}
|
||||
|
||||
data := fiber.Map{
|
||||
"user": c.Locals("user").(map[string]interface{}),
|
||||
"modules": c.Locals("userModules"),
|
||||
"conx_db_id": conxIDStr,
|
||||
"conx": conx,
|
||||
}
|
||||
if err := c.Render("query_runner", data, "layouts/main"); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConxDbList devuelve todas las conexiones DB disponibles (para el selector).
|
||||
func GetConxDbList(c *fiber.Ctx) error {
|
||||
var items []models.ConxDb
|
||||
app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").Find(&items)
|
||||
return c.JSON(fiber.Map{"data": items})
|
||||
}
|
||||
|
||||
// GetDatabases lista las bases de datos de una conexión.
|
||||
// GET /app/query-runner/databases?conx_db_id=1
|
||||
func GetDatabases(c *fiber.Ctx) error {
|
||||
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
dbs, err := services.ListDatabases(conx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": dbs})
|
||||
}
|
||||
|
||||
// GetTables lista las tablas de una base de datos.
|
||||
// GET /app/query-runner/tables?conx_db_id=1&db=mydb
|
||||
func GetTables(c *fiber.Ctx) error {
|
||||
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
dbName := c.Query("db", "")
|
||||
tables, err := services.ListTables(conx, dbName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": tables})
|
||||
}
|
||||
|
||||
// RunQuery ejecuta una consulta SQL.
|
||||
// POST /app/query-runner/run
|
||||
// Body: { conx_db_id, database, sql }
|
||||
func RunQuery(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
ConxDbID uint `json:"conx_db_id"`
|
||||
Database string `json:"database"`
|
||||
SQL string `json:"sql"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
|
||||
}
|
||||
if strings.TrimSpace(body.SQL) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La consulta está vacía"})
|
||||
}
|
||||
|
||||
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
result := services.ExecuteSQL(conx, body.Database, body.SQL)
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
// TestConnection verifica que la conexión funciona.
|
||||
// GET /app/query-runner/test?conx_db_id=1
|
||||
func TestConnection(c *fiber.Ctx) error {
|
||||
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if err := services.TestDBConnection(conx); err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"ok": false, "error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// GetHistory devuelve el historial de una conexión.
|
||||
// GET /app/query-runner/history?conx_db_id=1&page=1
|
||||
func GetHistory(c *fiber.Ctx) error {
|
||||
conxIDStr := c.Query("conx_db_id", "0")
|
||||
conxID, _ := strconv.ParseUint(conxIDStr, 10, 32)
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 50
|
||||
offset := (page - 1) * limit
|
||||
|
||||
items, total, err := models.GetQueryHistory(uint(conxID), limit, offset)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
totalPages := int(math.Ceil(float64(total) / float64(limit)))
|
||||
return c.JSON(fiber.Map{
|
||||
"data": items,
|
||||
"total": total,
|
||||
"totalPages": totalPages,
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearHistory borra el historial de una conexión.
|
||||
// DELETE /app/query-runner/history?conx_db_id=1
|
||||
func ClearHistory(c *fiber.Ctx) error {
|
||||
conxIDStr := c.Query("conx_db_id", "0")
|
||||
conxID, _ := strconv.ParseUint(conxIDStr, 10, 32)
|
||||
if err := models.DeleteQueryHistory(uint(conxID)); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// ExportCSV exporta el resultado de una consulta como CSV.
|
||||
// POST /app/query-runner/export/csv
|
||||
func ExportCSV(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
ConxDbID uint `json:"conx_db_id"`
|
||||
Database string `json:"database"`
|
||||
SQL string `json:"sql"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
|
||||
}
|
||||
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
result := services.ExecuteSQL(conx, body.Database, body.SQL)
|
||||
if result.Error != "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": result.Error})
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := csv.NewWriter(&buf)
|
||||
w.Write(result.Columns)
|
||||
for _, row := range result.Rows {
|
||||
rec := make([]string, len(result.Columns))
|
||||
for i, col := range result.Columns {
|
||||
v := row[col]
|
||||
if v == nil {
|
||||
rec[i] = ""
|
||||
} else {
|
||||
rec[i] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
w.Write(rec)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
filename := fmt.Sprintf("query_%s.csv", time.Now().Format("20060102_150405"))
|
||||
c.Set("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Set("Content-Type", "text/csv; charset=utf-8")
|
||||
return c.SendStream(bytes.NewReader(buf.Bytes()), buf.Len())
|
||||
}
|
||||
|
||||
// ExportJSON exporta el resultado como JSON.
|
||||
// POST /app/query-runner/export/json
|
||||
func ExportJSON(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
ConxDbID uint `json:"conx_db_id"`
|
||||
Database string `json:"database"`
|
||||
SQL string `json:"sql"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
|
||||
}
|
||||
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
result := services.ExecuteSQL(conx, body.Database, body.SQL)
|
||||
if result.Error != "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": result.Error})
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(result.Rows, "", " ")
|
||||
filename := fmt.Sprintf("query_%s.json", time.Now().Format("20060102_150405"))
|
||||
c.Set("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Set("Content-Type", "application/json; charset=utf-8")
|
||||
return c.SendStream(bytes.NewReader(data), len(data))
|
||||
}
|
||||
|
||||
// ── helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func loadConxDb(idStr string) (models.ConxDb, error) {
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil || id == 0 {
|
||||
return models.ConxDb{}, fmt.Errorf("conx_db_id inválido")
|
||||
}
|
||||
var conx models.ConxDb
|
||||
if err := app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").First(&conx, id).Error; err != nil {
|
||||
return models.ConxDb{}, fmt.Errorf("conexión no encontrada: %w", err)
|
||||
}
|
||||
return conx, nil
|
||||
}
|
||||
@@ -20,6 +20,14 @@ func RunSeed(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := seedModulo("Conexiones", "Gestión de servidores y conexiones de base de datos", []seedEntry{
|
||||
{"Conexiones DB", "Lista y configuración de conexiones de base de datos", "/app/conexion_db"},
|
||||
{"Query Runner", "Editor SQL: ejecutar consultas, exportar y ver historial", "/app/query-runner"},
|
||||
{"Conexiones SSH", "Gestión de conexiones SSH a servidores", "/app/conexion_ssh"},
|
||||
}); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := seedModulo("Renovaciones", "Gestión de contratos, clientes y notificaciones de vencimiento", []seedEntry{
|
||||
{"Contratos", "Gestión de contratos y vencimientos", "/app/contratos"},
|
||||
{"Clientes", "Clientes y datos de contacto", "/app/clientes"},
|
||||
|
||||
+11
-1
@@ -97,7 +97,17 @@ func UserRoutes(app fiber.Router) {
|
||||
|
||||
// ─── Seed manual (admin) ────────────────────────────────────────────
|
||||
protected.Get("/run-seed", controllers.RunSeed)
|
||||
|
||||
// ─── Query Runner (editor SQL) ────────────────────────────────────────────
|
||||
protected.Get("/query-runner", middlewares.MenuMiddleware, controllers.QueryRunnerPage)
|
||||
protected.Get("/query-runner/connections", controllers.GetConxDbList)
|
||||
protected.Get("/query-runner/databases", controllers.GetDatabases)
|
||||
protected.Get("/query-runner/tables", controllers.GetTables)
|
||||
protected.Get("/query-runner/test", controllers.TestConnection)
|
||||
protected.Post("/query-runner/run", controllers.RunQuery)
|
||||
protected.Get("/query-runner/history", controllers.GetHistory)
|
||||
protected.Delete("/query-runner/history", controllers.ClearHistory)
|
||||
protected.Post("/query-runner/export/csv", controllers.ExportCSV)
|
||||
protected.Post("/query-runner/export/json", controllers.ExportJSON)
|
||||
// ─── Hostinger API ────────────────────────────────────────────────
|
||||
protected.Get("/hostinger", middlewares.MenuMiddleware, controllers.HostingerConfigPage)
|
||||
protected.Post("/hostinger/config", controllers.SaveHostingerConfig)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>Renovación de servicios — USITE</title>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background:#060a0f;">
|
||||
<!-- Email wrapper — compatible con clientes de correo -->
|
||||
<div style="margin:0; padding:32px 16px; background:#060a0f; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px; margin:0 auto;">
|
||||
<tr><td>
|
||||
|
||||
<!-- Card principal -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#0a0f16; border-radius:20px; overflow:hidden; border:1px solid #1a2840; box-shadow:0 24px 64px rgba(0,0,0,0.6);">
|
||||
|
||||
<!-- Accent bar superior -->
|
||||
<tr>
|
||||
<td style="height:3px; background:linear-gradient(90deg,#16a34a 0%,#22c55e 50%,#4ade80 100%); font-size:0; line-height:0;"> </td>
|
||||
</tr>
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding:36px 40px 32px; background:#0a0f16; border-bottom:1px solid #141f2e;">
|
||||
|
||||
<!-- Logo + badge row -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://u-site.app/img/USITE_PNG_8.webp" alt="USITE" height="28"
|
||||
style="display:block; height:28px; width:auto;" />
|
||||
</td>
|
||||
<td align="right">
|
||||
<span style="display:inline-block; background:#0d2218; border:1px solid #166534; border-radius:100px; padding:4px 12px; font-size:10px; letter-spacing:0.1em; color:#4ade80; text-transform:uppercase; font-weight:600;">● Renovación</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Título -->
|
||||
<h1 style="margin:28px 0 8px; font-size:24px; font-weight:600; color:#f1f5f9; letter-spacing:-0.03em; line-height:1.3;">
|
||||
Renueva antes de que venza
|
||||
</h1>
|
||||
<p style="margin:0; font-size:13.5px; color:#7a93a8; line-height:1.65;">
|
||||
Uno o más servicios están próximos a expirar. Actúa antes de la fecha límite para evitar interrupciones.
|
||||
</p>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:32px 40px;">
|
||||
|
||||
<!-- Saludo -->
|
||||
<p style="margin:0 0 28px; font-size:14px; color:#8faabb; line-height:1.75;">
|
||||
Hola <span style="color:#e2e8f0; font-weight:500;">{{.ClienteNombre}}</span> — para garantizar la continuidad digital de <span style="color:#e2e8f0; font-weight:500;">{{.ClienteEmpresa}}</span>, te recomendamos renovar antes de la fecha indicada.
|
||||
</p>
|
||||
|
||||
<!-- Info strip — 3 métricas -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-radius:12px; overflow:hidden; border:1px solid #141f2e; margin-bottom:28px;">
|
||||
<tr>
|
||||
<td width="33%" style="background:#0d1420; padding:18px 16px; border-right:1px solid #141f2e; vertical-align:top;">
|
||||
<p style="margin:0 0 5px; font-size:9.5px; letter-spacing:0.1em; text-transform:uppercase; color:#334155;">Vence</p>
|
||||
<p style="margin:0; font-size:13px; font-weight:600; color:#e2e8f0;">{{.FechaVencimiento}}</p>
|
||||
</td>
|
||||
<td width="33%" style="background:#0d1420; padding:18px 16px; border-right:1px solid #141f2e; vertical-align:top;">
|
||||
<p style="margin:0 0 5px; font-size:9.5px; letter-spacing:0.1em; text-transform:uppercase; color:#334155;">Días restantes</p>
|
||||
<p style="margin:0; font-size:13px; font-weight:600; color:#fbbf24;">{{.DiasRestantes}} días</p>
|
||||
</td>
|
||||
<td width="34%" style="background:#0d1420; padding:18px 16px; vertical-align:top;">
|
||||
<p style="margin:0 0 5px; font-size:9.5px; letter-spacing:0.1em; text-transform:uppercase; color:#334155;">Total a pagar</p>
|
||||
<p style="margin:0; font-size:16px; font-weight:700; color:#4ade80;">${{.Total}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Label servicios -->
|
||||
<p style="margin:0 0 10px; font-size:10px; letter-spacing:0.12em; text-transform:uppercase; color:#2d3f52; font-weight:600;">Servicios incluidos</p>
|
||||
|
||||
<!-- Lista de servicios -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border:1px solid #141f2e; border-radius:12px; overflow:hidden; margin-bottom:32px;">
|
||||
{{range .Servicios}}
|
||||
<tr>
|
||||
<td style="padding:13px 18px; border-bottom:1px solid #0f1a27; font-size:13px; color:#94a3b8;">
|
||||
<span style="display:inline-block; width:6px; height:6px; border-radius:50%; background:#1e3a2f; border:1px solid #166534; margin-right:10px; vertical-align:middle;"></span>
|
||||
{{.Nombre}}
|
||||
</td>
|
||||
<td style="padding:13px 18px; border-bottom:1px solid #0f1a27; font-size:13px; color:#4ade80; text-align:right; white-space:nowrap; font-weight:500;">{{.Moneda}} {{.Precio}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="#"
|
||||
style="display:inline-block; background:#22c55e; color:#052e16; padding:15px 40px; border-radius:12px; font-size:14px; font-weight:700; text-decoration:none; letter-spacing:0.02em;">
|
||||
Renovar servicios →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer note -->
|
||||
<tr>
|
||||
<td style="padding:20px 40px; border-top:1px solid #0f1a27;">
|
||||
<p style="margin:0; font-size:12px; color:#5a7a90; line-height:1.7;">
|
||||
Si ya realizaste el pago, ignora este mensaje. ¿Necesitas ayuda? Escríbenos directamente a través de <a href="https://vcard.u-site.app/usite" style="color:#22c55e; text-decoration:none;">nuestro canal de soporte</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer brand -->
|
||||
<tr>
|
||||
<td style="padding:18px 40px; background:#060a0f; border-top:1px solid #0f1a27;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<p style="margin:0; font-size:11px; color:#1e2d3d; letter-spacing:0.04em;">
|
||||
© USITE — Soluciones de Software a Medida
|
||||
</p>
|
||||
<p style="margin:4px 0 0; font-size:10px; color:#162030;">Mensaje automático · <a href="https://u-site.app" style="color:#1e3a2f; text-decoration:none;">u-site.app</a></p>
|
||||
</td>
|
||||
<td align="right">
|
||||
<img src="https://u-site.app/img/USITE_PNG_8.webp" alt="USITE" height="18"
|
||||
style="display:block; height:18px; width:auto; opacity:0.25;" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<!-- /Card principal -->
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user