fix(backup): corregir descarga y añadir restauración de BD
- Backend usa DATABASE_URL (resolvePgConnInfo) en vez de DB_* que no existían en el contenedor API, arreglando el fallo de pg_dump - Dockerfile backend: alpine 3.23 para pg_dump 18.4 (compatible con PostgreSQL 18 del servidor) - BackupDatabase ya no usa docker exec (no disponible en la imagen) - Nuevo endpoint POST /api/admin/restore para restaurar .sql o .zip vía psql - UI de Configuración: sección Restaurar Base de Datos - Regenerado go.sum para el build
This commit is contained in:
parent
ecd7a3cdf8
commit
a285021e6c
22 changed files with 7624 additions and 450 deletions
|
|
@ -6,14 +6,15 @@ import (
|
|||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rss2/backend/internal/config"
|
||||
"github.com/rss2/backend/internal/db"
|
||||
|
|
@ -637,17 +638,73 @@ func PatchEntityTipo(c *gin.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
// pgConnInfo holds PostgreSQL connection parameters for pg_dump
|
||||
type pgConnInfo struct {
|
||||
host, port, name, user, pass string
|
||||
}
|
||||
|
||||
// resolvePgConnInfo prefers DATABASE_URL (the variable the API container gets)
|
||||
// and falls back to the individual DB_* variables used by the workers.
|
||||
func resolvePgConnInfo() pgConnInfo {
|
||||
if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" {
|
||||
if u, err := url.Parse(dbURL); err == nil && u.Hostname() != "" {
|
||||
info := pgConnInfo{
|
||||
host: u.Hostname(),
|
||||
user: u.User.Username(),
|
||||
name: strings.TrimPrefix(u.Path, "/"),
|
||||
port: u.Port(),
|
||||
}
|
||||
if p, ok := u.User.Password(); ok {
|
||||
info.pass = p
|
||||
}
|
||||
if info.port == "" {
|
||||
info.port = "5432"
|
||||
}
|
||||
if info.user == "" {
|
||||
info.user = "postgres"
|
||||
}
|
||||
if info.name == "" {
|
||||
info.name = "postgres"
|
||||
}
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
info := pgConnInfo{
|
||||
host: os.Getenv("DB_HOST"),
|
||||
port: os.Getenv("DB_PORT"),
|
||||
name: os.Getenv("DB_NAME"),
|
||||
user: os.Getenv("DB_USER"),
|
||||
pass: os.Getenv("DB_PASS"),
|
||||
}
|
||||
if info.host == "" {
|
||||
info.host = "db"
|
||||
}
|
||||
if info.port == "" {
|
||||
info.port = "5432"
|
||||
}
|
||||
if info.name == "" {
|
||||
info.name = "rss"
|
||||
}
|
||||
if info.user == "" {
|
||||
info.user = "rss"
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// BackupDatabase runs pg_dump and returns the SQL as a downloadable file
|
||||
func BackupDatabase(c *gin.Context) {
|
||||
// Ejecutar pg_dump desde dentro del contenedor db para evitar problemas de red
|
||||
// Usamos docker exec para ejecutar el comando dentro del servicio db
|
||||
cmd := exec.Command("docker", "exec", "rss2_db", "pg_dump",
|
||||
"-U", os.Getenv("POSTGRES_USER"),
|
||||
"-d", os.Getenv("POSTGRES_DB"),
|
||||
info := resolvePgConnInfo()
|
||||
|
||||
cmd := exec.Command("pg_dump",
|
||||
"-h", info.host,
|
||||
"-p", info.port,
|
||||
"-U", info.user,
|
||||
"-d", info.name,
|
||||
"--no-password",
|
||||
"--format=plain",
|
||||
"--pghost=5432",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
|
||||
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
|
@ -671,32 +728,16 @@ func BackupDatabase(c *gin.Context) {
|
|||
|
||||
// BackupNewsZipped performs a pg_dump of news tables and returns a ZIP file
|
||||
func BackupNewsZipped(c *gin.Context) {
|
||||
dbHost := os.Getenv("DB_HOST")
|
||||
if dbHost == "" {
|
||||
dbHost = "db"
|
||||
}
|
||||
dbPort := os.Getenv("DB_PORT")
|
||||
if dbPort == "" {
|
||||
dbPort = "5432"
|
||||
}
|
||||
dbName := os.Getenv("DB_NAME")
|
||||
if dbName == "" {
|
||||
dbName = "rss"
|
||||
}
|
||||
dbUser := os.Getenv("DB_USER")
|
||||
if dbUser == "" {
|
||||
dbUser = "rss"
|
||||
}
|
||||
dbPass := os.Getenv("DB_PASS")
|
||||
info := resolvePgConnInfo()
|
||||
|
||||
// Tables to backup
|
||||
tables := []string{"noticias", "traducciones", "tags", "tags_noticia"}
|
||||
|
||||
args := []string{
|
||||
"-h", dbHost,
|
||||
"-p", dbPort,
|
||||
"-U", dbUser,
|
||||
"-d", dbName,
|
||||
"-h", info.host,
|
||||
"-p", info.port,
|
||||
"-U", info.user,
|
||||
"-d", info.name,
|
||||
"--no-password",
|
||||
}
|
||||
|
||||
|
|
@ -705,7 +746,7 @@ func BackupNewsZipped(c *gin.Context) {
|
|||
}
|
||||
|
||||
cmd := exec.Command("pg_dump", args...)
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPass))
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
|
||||
|
||||
var sqlOut bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
|
@ -748,3 +789,107 @@ func BackupNewsZipped(c *gin.Context) {
|
|||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusOK, "application/zip", buf.Bytes())
|
||||
}
|
||||
|
||||
// RestoreDatabase accepts an uploaded .sql or .zip backup and restores it via psql
|
||||
func RestoreDatabase(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "No file uploaded",
|
||||
"message": "Se requiere un archivo .sql o .zip",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uploaded, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to open uploaded file"})
|
||||
return
|
||||
}
|
||||
defer uploaded.Close()
|
||||
|
||||
data, err := io.ReadAll(uploaded)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read uploaded file"})
|
||||
return
|
||||
}
|
||||
|
||||
var sqlData []byte
|
||||
lowerName := strings.ToLower(file.Filename)
|
||||
switch {
|
||||
case strings.HasSuffix(lowerName, ".sql"):
|
||||
sqlData = data
|
||||
case strings.HasSuffix(lowerName, ".zip"):
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ZIP file", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, zf := range zr.File {
|
||||
if zf.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(zf.Name), ".sql") {
|
||||
continue
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to open SQL inside ZIP", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
sqlData, err = io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read SQL inside ZIP", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No SQL file found inside ZIP"})
|
||||
return
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Unsupported file type",
|
||||
"message": "Solo se permiten archivos .sql o .zip",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(sqlData) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Empty backup file"})
|
||||
return
|
||||
}
|
||||
|
||||
info := resolvePgConnInfo()
|
||||
|
||||
cmd := exec.Command("psql",
|
||||
"-h", info.host,
|
||||
"-p", info.port,
|
||||
"-U", info.user,
|
||||
"-d", info.name,
|
||||
"--no-password",
|
||||
"-v", "ON_ERROR_STOP=1",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
|
||||
cmd.Stdin = bytes.NewReader(sqlData)
|
||||
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Restore failed",
|
||||
"details": stderr.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Base de datos restaurada correctamente",
|
||||
"filename": file.Filename,
|
||||
"output": out.String(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
|
@ -397,11 +398,46 @@ func ImportFeeds(c *gin.Context) {
|
|||
}
|
||||
|
||||
reader := csv.NewReader(strings.NewReader(string(content)))
|
||||
_, err = reader.Read()
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid CSV format"})
|
||||
return
|
||||
}
|
||||
if len(records) == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Empty CSV file"})
|
||||
return
|
||||
}
|
||||
|
||||
colIndex := func(header []string, names ...string) int {
|
||||
for i, h := range header {
|
||||
h = strings.ToLower(strings.TrimSpace(h))
|
||||
for _, n := range names {
|
||||
if h == n {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
header := records[0]
|
||||
isHeader := colIndex(header, "url", "link", "feed", "nombre", "name", "titulo") != -1
|
||||
|
||||
var dataRows [][]string
|
||||
iNombre, iDesc, iURL, iCat, iPais, iLang, iActivo, iFallos := -1, -1, -1, -1, -1, -1, -1, -1
|
||||
if isHeader {
|
||||
iNombre = colIndex(header, "nombre", "name", "titulo", "title")
|
||||
iDesc = colIndex(header, "descripcion", "description")
|
||||
iURL = colIndex(header, "url", "link", "feed")
|
||||
iCat = colIndex(header, "categoria_id", "category_id", "categoria", "category")
|
||||
iPais = colIndex(header, "pais_id", "country_id", "pais", "country")
|
||||
iLang = colIndex(header, "idioma", "language", "lang")
|
||||
iActivo = colIndex(header, "activo", "active", "enabled")
|
||||
iFallos = colIndex(header, "fallos", "failures", "fails")
|
||||
dataRows = records[1:]
|
||||
} else {
|
||||
dataRows = records
|
||||
}
|
||||
|
||||
imported := 0
|
||||
skipped := 0
|
||||
|
|
@ -415,71 +451,115 @@ func ImportFeeds(c *gin.Context) {
|
|||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
|
||||
for {
|
||||
record, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
field := func(record []string, idx int) string {
|
||||
if idx < 0 || idx >= len(record) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(record[idx])
|
||||
}
|
||||
|
||||
if len(record) < 4 {
|
||||
for _, record := range dataRows {
|
||||
if len(record) == 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
nombre := strings.TrimSpace(record[1])
|
||||
url := strings.TrimSpace(record[3])
|
||||
var nombre, url string
|
||||
if isHeader {
|
||||
nombre = field(record, iNombre)
|
||||
url = field(record, iURL)
|
||||
} else if len(record) == 1 {
|
||||
url = field(record, 0)
|
||||
} else if len(record) == 2 {
|
||||
a := field(record, 0)
|
||||
b := field(record, 1)
|
||||
if looksLikeURL(a) {
|
||||
url, nombre = a, b
|
||||
} else {
|
||||
nombre, url = a, b
|
||||
}
|
||||
} else {
|
||||
nombre = field(record, 1)
|
||||
url = field(record, 3)
|
||||
}
|
||||
|
||||
if nombre == "" || url == "" {
|
||||
if url == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if nombre == "" {
|
||||
nombre = nombreFromURL(url)
|
||||
}
|
||||
|
||||
var descripcion *string
|
||||
if len(record) > 2 && strings.TrimSpace(record[2]) != "" {
|
||||
descripcionStr := strings.TrimSpace(record[2])
|
||||
descripcion = &descripcionStr
|
||||
}
|
||||
|
||||
var categoriaID *int64
|
||||
if len(record) > 4 && strings.TrimSpace(record[4]) != "" {
|
||||
catID, err := strconv.ParseInt(strings.TrimSpace(record[4]), 10, 64)
|
||||
if err == nil {
|
||||
categoriaID = &catID
|
||||
}
|
||||
}
|
||||
|
||||
var paisID *int64
|
||||
if len(record) > 6 && strings.TrimSpace(record[6]) != "" {
|
||||
pID, err := strconv.ParseInt(strings.TrimSpace(record[6]), 10, 64)
|
||||
if err == nil {
|
||||
paisID = &pID
|
||||
}
|
||||
}
|
||||
|
||||
var idioma *string
|
||||
if len(record) > 8 && strings.TrimSpace(record[8]) != "" {
|
||||
lang := strings.TrimSpace(record[8])
|
||||
if len(lang) > 2 {
|
||||
lang = lang[:2]
|
||||
}
|
||||
idioma = &lang
|
||||
}
|
||||
|
||||
activo := true
|
||||
if len(record) > 9 && strings.TrimSpace(record[9]) != "" {
|
||||
activo = strings.ToLower(strings.TrimSpace(record[9])) == "true"
|
||||
var fallos int64
|
||||
|
||||
if isHeader {
|
||||
if d := field(record, iDesc); d != "" {
|
||||
descripcion = &d
|
||||
}
|
||||
if v := field(record, iCat); v != "" {
|
||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
categoriaID = &id
|
||||
}
|
||||
}
|
||||
if v := field(record, iPais); v != "" {
|
||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
paisID = &id
|
||||
}
|
||||
}
|
||||
if l := field(record, iLang); l != "" {
|
||||
if len(l) > 2 {
|
||||
l = l[:2]
|
||||
}
|
||||
idioma = &l
|
||||
}
|
||||
if a := field(record, iActivo); a != "" {
|
||||
activo = strings.ToLower(a) == "true"
|
||||
}
|
||||
if f := field(record, iFallos); f != "" {
|
||||
if v, err := strconv.ParseInt(f, 10, 64); err == nil {
|
||||
fallos = v
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if d := field(record, 2); d != "" {
|
||||
descripcion = &d
|
||||
}
|
||||
if v := field(record, 4); v != "" {
|
||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
categoriaID = &id
|
||||
}
|
||||
}
|
||||
if v := field(record, 6); v != "" {
|
||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
paisID = &id
|
||||
}
|
||||
}
|
||||
if l := field(record, 8); l != "" {
|
||||
if len(l) > 2 {
|
||||
l = l[:2]
|
||||
}
|
||||
idioma = &l
|
||||
}
|
||||
if a := field(record, 9); a != "" {
|
||||
activo = strings.ToLower(a) == "true"
|
||||
}
|
||||
if f := field(record, 10); f != "" {
|
||||
if v, err := strconv.ParseInt(f, 10, 64); err == nil {
|
||||
fallos = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var fallos int64
|
||||
if len(record) > 10 && strings.TrimSpace(record[10]) != "" {
|
||||
f, err := strconv.ParseInt(strings.TrimSpace(record[10]), 10, 64)
|
||||
if err == nil {
|
||||
fallos = f
|
||||
}
|
||||
if _, err := tx.Exec(context.Background(), "SAVEPOINT sp"); err != nil {
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("Error for %s: %v", url, err))
|
||||
continue
|
||||
}
|
||||
|
||||
var existingID int64
|
||||
|
|
@ -490,22 +570,25 @@ func ImportFeeds(c *gin.Context) {
|
|||
WHERE id=$8`,
|
||||
nombre, descripcion, categoriaID, paisID, idioma, activo, fallos, existingID,
|
||||
)
|
||||
if err != nil {
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("Error updating %s: %v", url, err))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
_, err = tx.Exec(context.Background(), `
|
||||
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
nombre, descripcion, url, categoriaID, paisID, idioma, activo, fallos,
|
||||
)
|
||||
if err != nil {
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("Error inserting %s: %v", url, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
tx.Exec(context.Background(), "ROLLBACK TO SAVEPOINT sp")
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("Error upserting %s: %v", url, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(context.Background(), "RELEASE SAVEPOINT sp"); err != nil {
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("Error for %s: %v", url, err))
|
||||
continue
|
||||
}
|
||||
|
||||
imported++
|
||||
|
|
@ -525,6 +608,22 @@ func ImportFeeds(c *gin.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
func looksLikeURL(s string) bool {
|
||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://www.") || strings.HasPrefix(s, "https://www.") || strings.HasPrefix(s, "www.") || strings.HasPrefix(s, "feed://")
|
||||
}
|
||||
|
||||
func nombreFromURL(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||
u = "https://" + u
|
||||
}
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return u
|
||||
}
|
||||
return strings.TrimPrefix(parsed.Host, "www.")
|
||||
}
|
||||
|
||||
func stringOrEmpty(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -51,55 +51,45 @@ func SearchNews(c *gin.Context) {
|
|||
offset := (page - 1) * perPage
|
||||
|
||||
// Build dynamic query
|
||||
args := []interface{}{}
|
||||
argNum := 1
|
||||
args := []interface{}{lang}
|
||||
argNum := 2
|
||||
whereClause := "WHERE 1=1"
|
||||
|
||||
if query != "" {
|
||||
whereClause += " AND (n.titulo ILIKE $" + strconv.Itoa(argNum) + " OR n.resumen ILIKE $" + strconv.Itoa(argNum) + " OR n.contenido ILIKE $" + strconv.Itoa(argNum) + ")"
|
||||
whereClause += " AND (n.titulo ILIKE $" + strconv.Itoa(argNum) + " OR n.resumen ILIKE $" + strconv.Itoa(argNum) + ")"
|
||||
args = append(args, "%"+query+"%")
|
||||
argNum++
|
||||
}
|
||||
|
||||
if lang != "" {
|
||||
whereClause += " AND t.lang_to = $" + strconv.Itoa(argNum)
|
||||
args = append(args, lang)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if categoriaID != "" {
|
||||
whereClause += " AND n.categoria_id = $" + strconv.Itoa(argNum)
|
||||
catID, err := strconv.ParseInt(categoriaID, 10, 64)
|
||||
if err == nil {
|
||||
if catID, err := strconv.ParseInt(categoriaID, 10, 64); err == nil {
|
||||
whereClause += " AND n.categoria_id = $" + strconv.Itoa(argNum)
|
||||
args = append(args, catID)
|
||||
argNum++
|
||||
}
|
||||
}
|
||||
|
||||
if paisID != "" {
|
||||
whereClause += " AND n.pais_id = $" + strconv.Itoa(argNum)
|
||||
pID, err := strconv.ParseInt(paisID, 10, 64)
|
||||
if err == nil {
|
||||
if pID, err := strconv.ParseInt(paisID, 10, 64); err == nil {
|
||||
whereClause += " AND n.pais_id = $" + strconv.Itoa(argNum)
|
||||
args = append(args, pID)
|
||||
argNum++
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, perPage, offset)
|
||||
|
||||
sqlQuery := `
|
||||
SELECT n.id, n.titulo, n.resumen, n.contenido, n.url, n.imagen,
|
||||
n.feed_id, n.lang, n.categoria_id, n.pais_id, n.created_at, n.updated_at,
|
||||
COALESCE(t.titulo_trad, '') as titulo_trad,
|
||||
COALESCE(t.resumen_trad, '') as resumen_trad,
|
||||
t.lang_to as lang_trad,
|
||||
f.nombre as fuente_nombre
|
||||
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
|
||||
n.categoria_id, n.pais_id, n.fuente_nombre,
|
||||
t.titulo_trad,
|
||||
t.resumen_trad,
|
||||
t.lang_to as lang_trad
|
||||
FROM noticias n
|
||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $` + strconv.Itoa(argNum) + `
|
||||
LEFT JOIN feeds f ON f.id = n.feed_id
|
||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1
|
||||
` + whereClause + `
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT $` + strconv.Itoa(argNum+1) + ` OFFSET $` + strconv.Itoa(argNum+2)
|
||||
ORDER BY n.fecha DESC
|
||||
LIMIT $` + strconv.Itoa(argNum) + ` OFFSET $` + strconv.Itoa(argNum+1)
|
||||
|
||||
args = append(args, perPage, offset)
|
||||
|
||||
rows, err := db.GetPool().Query(ctx, sqlQuery, args...)
|
||||
if err != nil {
|
||||
|
|
@ -108,33 +98,38 @@ func SearchNews(c *gin.Context) {
|
|||
}
|
||||
defer rows.Close()
|
||||
|
||||
var newsList []models.NewsWithTranslations
|
||||
var newsList []NewsResponse
|
||||
for rows.Next() {
|
||||
var n models.NewsWithTranslations
|
||||
var imagen *string
|
||||
var n NewsResponse
|
||||
var imagenURL, fuenteNombre *string
|
||||
var categoriaIDp, paisIDp *int64
|
||||
|
||||
err := rows.Scan(
|
||||
&n.ID, &n.Title, &n.Summary, &n.Content, &n.URL, &imagen,
|
||||
&n.FeedID, &n.Lang, &n.CategoryID, &n.CountryID, &n.CreatedAt, &n.UpdatedAt,
|
||||
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
|
||||
&categoriaIDp, &paisIDp, &fuenteNombre,
|
||||
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if imagen != nil {
|
||||
n.ImageURL = imagen
|
||||
if imagenURL != nil {
|
||||
n.ImagenURL = imagenURL
|
||||
}
|
||||
if fuenteNombre != nil {
|
||||
n.FuenteNombre = *fuenteNombre
|
||||
}
|
||||
n.CategoriaID = categoriaIDp
|
||||
n.PaisID = paisIDp
|
||||
newsList = append(newsList, n)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
countArgs := args[:len(args)-2]
|
||||
|
||||
// Remove LIMIT/OFFSET from args for count
|
||||
var total int
|
||||
err = db.GetPool().QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM noticias n
|
||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $`+strconv.Itoa(argNum)+`
|
||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1
|
||||
`+whereClause, countArgs...).Scan(&total)
|
||||
if err != nil {
|
||||
total = len(newsList)
|
||||
|
|
@ -142,15 +137,13 @@ func SearchNews(c *gin.Context) {
|
|||
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
|
||||
response := models.NewsListResponse{
|
||||
News: newsList,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"news": newsList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"total_pages": totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
func GetStats(c *gin.Context) {
|
||||
|
|
|
|||
|
|
@ -132,7 +132,10 @@ func writeLoop(wsWorker *WSWorker) {
|
|||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := wsWorker.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
wsWorker.mu.Lock()
|
||||
err := wsWorker.conn.WriteMessage(websocket.PingMessage, nil)
|
||||
wsWorker.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -163,7 +166,9 @@ func heartbeatLoop(wsWorker *WSWorker) {
|
|||
|
||||
func sendWS(wsWorker *WSWorker, msg models.WSServerMessage) {
|
||||
data, _ := json.Marshal(msg)
|
||||
wsWorker.mu.Lock()
|
||||
wsWorker.conn.WriteMessage(websocket.TextMessage, data)
|
||||
wsWorker.mu.Unlock()
|
||||
}
|
||||
|
||||
func cleanupWorker(wsWorker *WSWorker, apiKey string) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue