cambios
This commit is contained in:
parent
3991237685
commit
a01050bf85
46 changed files with 3636 additions and 652 deletions
|
|
@ -14,11 +14,11 @@ import (
|
|||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/mmcdole/gofeed"
|
||||
"github.com/rss2/backend/internal/logger"
|
||||
"github.com/rss2/backend/internal/workers"
|
||||
)
|
||||
|
||||
var (
|
||||
logger *log.Logger
|
||||
pool *workers.Config
|
||||
dbPool *pgxpool.Pool
|
||||
sleepSec = 900 // 15 minutes
|
||||
|
|
@ -35,7 +35,8 @@ type URLSource struct {
|
|||
}
|
||||
|
||||
func init() {
|
||||
logger = log.New(os.Stdout, "[DISCOVERY] ", log.LstdFlags)
|
||||
logLevel := os.Getenv("LOG_LEVEL")
|
||||
logger.Init("discovery-worker", logLevel)
|
||||
}
|
||||
|
||||
func loadConfig() {
|
||||
|
|
@ -416,48 +417,70 @@ func processURLSource(ctx context.Context, source URLSource) {
|
|||
|
||||
func main() {
|
||||
loadConfig()
|
||||
logger.Println("Starting RSS Discovery Worker")
|
||||
logger.Info().Msg("Starting RSS Discovery Worker")
|
||||
|
||||
cfg := workers.LoadDBConfig()
|
||||
if err := workers.Connect(cfg); err != nil {
|
||||
logger.Fatalf("Failed to connect to database: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
dbPool = workers.GetPool()
|
||||
defer workers.Close()
|
||||
|
||||
logger.Println("Connected to PostgreSQL")
|
||||
logger.Info().Msg("Connected to PostgreSQL")
|
||||
|
||||
ctx := context.Background()
|
||||
// Start health check HTTP server
|
||||
go func() {
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := workers.HealthCheck(r.Context()); err != nil {
|
||||
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
logger.Info().Msg("Health check server listening on :8080")
|
||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||
logger.Error().Err(err).Msg("Health check server error")
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigChan
|
||||
logger.Println("Shutting down...")
|
||||
logger.Info().Msg("Shutting down gracefully...")
|
||||
cancel()
|
||||
time.Sleep(2 * time.Second)
|
||||
workers.Close()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
logger.Printf("Config: interval=%ds, batch=%d", sleepSec, batchSize)
|
||||
logger.Info().Msgf("Config: interval=%ds, batch=%d", sleepSec, batchSize)
|
||||
|
||||
ticker := time.NewTicker(time.Duration(sleepSec) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info().Msg("Context cancelled, stopping worker")
|
||||
return
|
||||
case <-ticker.C:
|
||||
sources, err := getPendingURLs(ctx)
|
||||
if err != nil {
|
||||
logger.Printf("Error fetching URLs: %v", err)
|
||||
logger.Error().Err(err).Msg("Error fetching URLs")
|
||||
continue
|
||||
}
|
||||
|
||||
if len(sources) == 0 {
|
||||
logger.Println("No pending URLs to process")
|
||||
logger.Info().Msg("No pending URLs to process")
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Printf("Processing %d sources", len(sources))
|
||||
logger.Info().Msgf("Processing %d sources", len(sources))
|
||||
|
||||
for _, source := range sources {
|
||||
processURLSource(ctx, source)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
|
@ -17,11 +16,11 @@ import (
|
|||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rss2/backend/internal/logger"
|
||||
"github.com/rss2/backend/internal/workers"
|
||||
)
|
||||
|
||||
var (
|
||||
logger *log.Logger
|
||||
dbPool *pgxpool.Pool
|
||||
qdrantURL string
|
||||
collection = "news_vectors"
|
||||
|
|
@ -30,28 +29,29 @@ var (
|
|||
)
|
||||
|
||||
func init() {
|
||||
logger = log.New(os.Stdout, "[QDRANT] ", log.LstdFlags)
|
||||
logLevel := os.Getenv("LOG_LEVEL")
|
||||
logger.Init("qdrant-worker", logLevel)
|
||||
}
|
||||
|
||||
func loadConfig() {
|
||||
sleepSec = getEnvInt("QDRANT_SLEEP", 30)
|
||||
batchSize = getEnvInt("QDRANT_BATCH", 100)
|
||||
qdrantHost := getEnv("QDRANT_HOST", "localhost")
|
||||
qdrantPort := getEnvInt("QDRANT_PORT", 6333)
|
||||
qdrantURL = fmt.Sprintf("http://%s:%d", qdrantHost, qdrantPort)
|
||||
collection = getEnv("QDRANT_COLLECTION", "news_vectors")
|
||||
batchSize = getEnvInt("QDRANT_BATCH",100)
|
||||
qdrantHost := getEnv("QDRANT_HOST","localhost")
|
||||
qdrantPort := getEnvInt("QDRANT_PORT",6333)
|
||||
qdrantURL = fmt.Sprintf("http://%s:%d",qdrantHost,qdrantPort)
|
||||
collection = getEnv("QDRANT_COLLECTION","news_vectors")
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
func getEnv(key,defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
func getEnvInt(key string,defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if intVal, err := strconv.Atoi(value); err == nil {
|
||||
if intVal,err := strconv.Atoi(value); err == nil {
|
||||
return intVal
|
||||
}
|
||||
}
|
||||
|
|
@ -310,17 +310,33 @@ func main() {
|
|||
|
||||
cfg := workers.LoadDBConfig()
|
||||
if err := workers.Connect(cfg); err != nil {
|
||||
logger.Fatalf("Failed to connect to database: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
dbPool = workers.GetPool()
|
||||
defer workers.Close()
|
||||
|
||||
logger.Println("Connected to PostgreSQL")
|
||||
logger.Info().Msg("Connected to PostgreSQL")
|
||||
|
||||
ctx := context.Background()
|
||||
// Start health check HTTP server
|
||||
go func() {
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := workers.HealthCheck(r.Context()); err != nil {
|
||||
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
logger.Info().Msg("Health check server listening on :8080")
|
||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||
logger.Error().Err(err).Msg("Health check server error")
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
if err := ensureCollection(ctx); err != nil {
|
||||
logger.Printf("Warning: Could not ensure collection: %v", err)
|
||||
logger.Warn().Err(err).Msg("Could not ensure collection")
|
||||
}
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
|
|
@ -328,30 +344,36 @@ func main() {
|
|||
|
||||
go func() {
|
||||
<-sigChan
|
||||
logger.Println("Shutting down...")
|
||||
logger.Info().Msg("Shutting down gracefully...")
|
||||
cancel()
|
||||
time.Sleep(2 * time.Second)
|
||||
workers.Close()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
logger.Printf("Config: qdrant=%s, collection=%s, sleep=%ds, batch=%d",
|
||||
logger.Info().Msgf("Config: qdrant=%s, collection=%s, sleep=%ds, batch=%d",
|
||||
qdrantURL, collection, sleepSec, batchSize)
|
||||
|
||||
totalProcessed := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info().Msg("Context cancelled, stopping worker")
|
||||
return
|
||||
case <-time.After(time.Duration(sleepSec) * time.Second):
|
||||
translations, err := getPendingTranslations(ctx)
|
||||
if err != nil {
|
||||
logger.Printf("Error fetching pending translations: %v", err)
|
||||
logger.Error().Err(err).Msg("Error fetching pending translations")
|
||||
continue
|
||||
}
|
||||
|
||||
if len(translations) == 0 {
|
||||
logger.Println("No pending translations to process")
|
||||
logger.Info().Msg("No pending translations to process")
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Printf("Processing %d translations...", len(translations))
|
||||
logger.Info().Msgf("Processing %d translations...", len(translations))
|
||||
|
||||
// Generate point IDs once (used both in Qdrant and DB)
|
||||
pointIDs := make([]string, len(translations))
|
||||
|
|
@ -361,21 +383,21 @@ func main() {
|
|||
|
||||
// Upload to Qdrant
|
||||
if err := uploadToQdrant(translations, pointIDs); err != nil {
|
||||
logger.Printf("Error uploading to Qdrant: %v", err)
|
||||
logger.Error().Err(err).Msg("Error uploading to Qdrant")
|
||||
continue
|
||||
}
|
||||
|
||||
// Update DB status
|
||||
if err := updateTranslationStatus(ctx, translations, pointIDs); err != nil {
|
||||
logger.Printf("Error updating status: %v", err)
|
||||
logger.Error().Err(err).Msg("Error updating status")
|
||||
}
|
||||
|
||||
totalProcessed += len(translations)
|
||||
logger.Printf("Processed %d translations (total: %d)", len(translations), totalProcessed)
|
||||
logger.Info().Msgf("Processed %d translations (total: %d)", len(translations), totalProcessed)
|
||||
|
||||
total, vectorized, pending, err := getStats(ctx)
|
||||
if err == nil {
|
||||
logger.Printf("Stats: total=%d, vectorized=%d, pending=%d", total, vectorized, pending)
|
||||
logger.Info().Msgf("Stats: total=%d, vectorized=%d, pending=%d", total, vectorized, pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
|
|
@ -10,11 +11,11 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rss2/backend/internal/logger"
|
||||
"github.com/rss2/backend/internal/workers"
|
||||
)
|
||||
|
||||
var (
|
||||
logger *log.Logger
|
||||
dbPool *pgxpool.Pool
|
||||
sleepSec = 10
|
||||
topK = 10
|
||||
|
|
@ -23,7 +24,8 @@ var (
|
|||
)
|
||||
|
||||
func init() {
|
||||
logger = log.New(os.Stdout, "[RELATED] ", log.LstdFlags)
|
||||
logLevel := os.Getenv("LOG_LEVEL")
|
||||
logger.Init("related-worker", logLevel)
|
||||
}
|
||||
|
||||
func loadConfig() {
|
||||
|
|
@ -335,20 +337,36 @@ func processBatch(ctx context.Context, model string) (int, error) {
|
|||
|
||||
func main() {
|
||||
loadConfig()
|
||||
logger.Println("Starting Related News Worker")
|
||||
logger.Info().Msg("Starting Related News Worker")
|
||||
|
||||
cfg := workers.LoadDBConfig()
|
||||
if err := workers.Connect(cfg); err != nil {
|
||||
logger.Fatalf("Failed to connect to database: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
dbPool = workers.GetPool()
|
||||
defer workers.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
// Start health check HTTP server
|
||||
go func() {
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := workers.HealthCheck(r.Context()); err != nil {
|
||||
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
logger.Info().Msg("Health check server listening on :8080")
|
||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||
logger.Error().Err(err).Msg("Health check server error")
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Ensure schema
|
||||
if err := ensureSchema(ctx); err != nil {
|
||||
logger.Printf("Error ensuring schema: %v", err)
|
||||
logger.Error().Err(err).Msg("Error ensuring schema")
|
||||
}
|
||||
|
||||
model := os.Getenv("EMB_MODEL")
|
||||
|
|
@ -361,23 +379,29 @@ func main() {
|
|||
|
||||
go func() {
|
||||
<-sigChan
|
||||
logger.Println("Shutting down...")
|
||||
logger.Info().Msg("Shutting down gracefully...")
|
||||
cancel()
|
||||
time.Sleep(2 * time.Second)
|
||||
workers.Close()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
logger.Printf("Config: sleep=%ds, topK=%d, batch=%d, model=%s", sleepSec, topK, batchSz, model)
|
||||
logger.Info().Msgf("Config: sleep=%ds, topK=%d, batch=%d, model=%s", sleepSec, topK, batchSz, model)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info().Msg("Context cancelled, stopping worker")
|
||||
return
|
||||
case <-time.After(time.Duration(sleepSec) * time.Second):
|
||||
count, err := processBatch(ctx, model)
|
||||
if err != nil {
|
||||
logger.Printf("Error processing batch: %v", err)
|
||||
logger.Error().Err(err).Msg("Error processing batch")
|
||||
continue
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
logger.Printf("Generated related news for %d translations", count)
|
||||
logger.Info().Msgf("Generated related news for %d translations", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
|
@ -17,12 +16,11 @@ import (
|
|||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rss2/backend/internal/logger"
|
||||
"github.com/rss2/backend/internal/workers"
|
||||
)
|
||||
|
||||
var (
|
||||
logger *log.Logger
|
||||
dbPool *workers.Config
|
||||
pool *pgxpool.Pool
|
||||
sleepInterval = 60
|
||||
batchSize = 10
|
||||
|
|
@ -59,15 +57,31 @@ type Article struct {
|
|||
}
|
||||
|
||||
func init() {
|
||||
logger = log.New(os.Stdout, "[SCRAPER] ", log.LstdFlags)
|
||||
logger.SetOutput(os.Stdout)
|
||||
logLevel := os.Getenv("LOG_LEVEL")
|
||||
logger.Init("scraper-worker", logLevel)
|
||||
}
|
||||
|
||||
func loadConfig() {
|
||||
sleepInterval = getEnvInt("SCRAPER_SLEEP", 60)
|
||||
batchSize = getEnvInt("SCRAPER_BATCH", 10)
|
||||
enrichLimit = getEnvInt("SCRAPER_ENRICH_LIMIT", 20)
|
||||
scraperWorkers = getEnvInt("SCRAPER_WORKERS", 5)
|
||||
if v := os.Getenv("SCRAPER_INTERVAL"); v != "" {
|
||||
if i, err := strconv.Atoi(v); err == nil && i > 0 {
|
||||
sleepInterval = i
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("SCRAPER_BATCH"); v != "" {
|
||||
if i, err := strconv.Atoi(v); err == nil && i > 0 {
|
||||
batchSize = i
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("SCRAPER_ENRICH_LIMIT"); v != "" {
|
||||
if i, err := strconv.Atoi(v); err == nil && i > 0 {
|
||||
enrichLimit = i
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("SCRAPER_WORKERS"); v != "" {
|
||||
if i, err := strconv.Atoi(v); err == nil && i > 0 {
|
||||
scraperWorkers = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
|
|
@ -448,18 +462,34 @@ func scrapeSources(ctx context.Context, sources []URLSource) {
|
|||
|
||||
func main() {
|
||||
loadConfig()
|
||||
logger.Println("Starting Scraper Worker")
|
||||
logger.Info().Msg("Starting Scraper Worker")
|
||||
|
||||
cfg := workers.LoadDBConfig()
|
||||
if err := workers.Connect(cfg); err != nil {
|
||||
logger.Fatalf("Failed to connect to database: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
pool = workers.GetPool()
|
||||
defer workers.Close()
|
||||
|
||||
logger.Println("Connected to PostgreSQL")
|
||||
logger.Info().Msg("Connected to PostgreSQL")
|
||||
|
||||
ctx := context.Background()
|
||||
// Start health check HTTP server
|
||||
go func() {
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := workers.HealthCheck(r.Context()); err != nil {
|
||||
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
logger.Info().Msg("Health check server listening on :8080")
|
||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||
logger.Error().Err(err).Msg("Health check server error")
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Handle shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
|
|
@ -467,38 +497,44 @@ func main() {
|
|||
|
||||
go func() {
|
||||
<-sigChan
|
||||
logger.Println("Shutting down...")
|
||||
logger.Info().Msg("Shutting down gracefully...")
|
||||
cancel()
|
||||
time.Sleep(2 * time.Second)
|
||||
workers.Close()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
logger.Printf("Config: sleep=%ds, batch=%d, enrich=%d", sleepInterval, batchSize, enrichLimit)
|
||||
logger.Info().Msgf("Config: sleep=%ds, batch=%d, enrich=%d", sleepInterval, batchSize, enrichLimit)
|
||||
|
||||
ticker := time.NewTicker(time.Duration(sleepInterval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info().Msg("Context cancelled, stopping worker")
|
||||
return
|
||||
case <-ticker.C:
|
||||
noticias, err := getNoticiasToEnrich(ctx, enrichLimit)
|
||||
if err != nil {
|
||||
logger.Printf("Error fetching noticias to enrich: %v", err)
|
||||
logger.Error().Err(err).Msg("Error fetching noticias to enrich")
|
||||
} else if len(noticias) > 0 {
|
||||
logger.Printf("Enriching %d noticias (%d workers)", len(noticias), scraperWorkers)
|
||||
logger.Info().Msgf("Enriching %d noticias (%d workers)", len(noticias), scraperWorkers)
|
||||
enrichConcurrent(ctx, noticias)
|
||||
}
|
||||
|
||||
sources, err := getActiveURLs(ctx)
|
||||
if err != nil {
|
||||
logger.Printf("Error fetching URLs: %v", err)
|
||||
logger.Error().Err(err).Msg("Error fetching URLs")
|
||||
continue
|
||||
}
|
||||
|
||||
if len(sources) == 0 {
|
||||
logger.Println("No active URLs to process")
|
||||
logger.Info().Msg("No active URLs to process")
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Printf("Processing %d sources (%d workers)", len(sources), scraperWorkers)
|
||||
logger.Info().Msgf("Processing %d sources (%d workers)", len(sources), scraperWorkers)
|
||||
scrapeSources(ctx, sources)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
|
@ -14,12 +13,17 @@ import (
|
|||
"github.com/rss2/backend/internal/config"
|
||||
"github.com/rss2/backend/internal/db"
|
||||
"github.com/rss2/backend/internal/handlers"
|
||||
"github.com/rss2/backend/internal/logger"
|
||||
"github.com/rss2/backend/internal/middleware"
|
||||
"github.com/rss2/backend/internal/services"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
_ "github.com/rss2/backend/docs"
|
||||
)
|
||||
|
||||
func initDB() {
|
||||
ctx := context.Background()
|
||||
log := logger.GetLogger()
|
||||
|
||||
// Crear tabla entity_aliases si no existe
|
||||
_, err := db.GetPool().Exec(ctx, `
|
||||
|
|
@ -33,9 +37,9 @@ func initDB() {
|
|||
)
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Could not create entity_aliases table: %v", err)
|
||||
log.Warn().Err(err).Msg("Could not create entity_aliases table")
|
||||
} else {
|
||||
log.Println("Table entity_aliases ready")
|
||||
log.Info().Msg("Table entity_aliases ready")
|
||||
}
|
||||
|
||||
// Añadir columna role a users si no existe
|
||||
|
|
@ -43,9 +47,9 @@ func initDB() {
|
|||
ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'user'
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Could not add role column: %v", err)
|
||||
log.Warn().Err(err).Msg("Could not add role column")
|
||||
} else {
|
||||
log.Println("Column role ready")
|
||||
log.Info().Msg("Column role ready")
|
||||
}
|
||||
|
||||
// Crear tabla de configuración si no existe
|
||||
|
|
@ -129,31 +133,66 @@ func initDB() {
|
|||
if err != nil {
|
||||
log.Printf("Warning: Could not add assigned_at column: %v", err)
|
||||
}
|
||||
|
||||
// Crear índices para optimizar consultas críticas
|
||||
indexes := []string{
|
||||
"CREATE INDEX IF NOT EXISTS idx_noticias_fecha ON noticias(fecha DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_noticias_fuente_nombre ON noticias(fuente_nombre)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_noticias_categoria_id ON noticias(categoria_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_noticias_pais_id ON noticias(pais_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_noticias_lang ON noticias(lang)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_traducciones_noticia_id_lang_to ON traducciones(noticia_id, lang_to)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_traducciones_status ON traducciones(status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_tags_noticia_noticia_id_tag_id ON tags_noticia(noticia_id, tag_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_tags_noticia_traduccion_id ON tags_noticia(traduccion_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_tags_valor_tipo ON tags(valor, tipo)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entity_aliases_alias_tipo ON entity_aliases(alias, tipo)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_alertas_valor_tipo_periodo ON alertas(valor, tipo, periodo)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_alertas_status ON alertas(status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_search_tags_user_id ON user_search_tags(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_feeds_activo ON feeds(activo)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_feeds_categoria_id ON feeds(categoria_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_feeds_pais_id ON feeds(pais_id)",
|
||||
}
|
||||
for _, idx := range indexes {
|
||||
_, err := db.GetPool().Exec(ctx, idx)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Could not create index")
|
||||
} else {
|
||||
log.Info().Msg("Index created/verified")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize structured logger
|
||||
logLevel := os.Getenv("LOG_LEVEL")
|
||||
logger.Init("rss2-api", logLevel)
|
||||
log := logger.GetLogger()
|
||||
|
||||
if err := db.Connect(cfg.DatabaseURL); err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
log.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
defer db.Close()
|
||||
log.Println("Connected to PostgreSQL")
|
||||
log.Info().Msg("Connected to PostgreSQL")
|
||||
|
||||
// Auto-setup DB tables
|
||||
initDB()
|
||||
|
||||
if err := cache.Connect(cfg.RedisURL); err != nil {
|
||||
log.Printf("Warning: Failed to connect to Redis: %v", err)
|
||||
log.Warn().Err(err).Msg("Failed to connect to Redis")
|
||||
} else {
|
||||
defer cache.Close()
|
||||
log.Println("Connected to Redis")
|
||||
log.Info().Msg("Connected to Redis")
|
||||
}
|
||||
|
||||
services.Init(cfg)
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(middleware.RequestIDMiddleware())
|
||||
r.Use(middleware.CORSMiddleware())
|
||||
r.Use(middleware.LoggerMiddleware())
|
||||
|
||||
|
|
@ -161,14 +200,25 @@ func main() {
|
|||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// Swagger documentation
|
||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// Serve static images downloaded by wiki_worker
|
||||
api.StaticFS("/wiki-images", gin.Dir(cfg.WikiImagesPath, false))
|
||||
|
||||
api.POST("/auth/login", handlers.Login)
|
||||
api.POST("/auth/register", handlers.Register)
|
||||
api.GET("/auth/check-first-user", handlers.CheckFirstUser)
|
||||
// Stricter rate limiting for auth endpoints
|
||||
authGroup := api.Group("/auth")
|
||||
authGroup.Use(middleware.RateLimitMiddleware(10)) // 10 req/min for auth
|
||||
{
|
||||
authGroup.POST("/login", handlers.Login)
|
||||
authGroup.POST("/register", handlers.Register)
|
||||
authGroup.GET("/check-first-user", handlers.CheckFirstUser)
|
||||
}
|
||||
|
||||
// General rate limiting for API
|
||||
api.Use(middleware.RateLimitMiddleware(cfg.RateLimitPerMinute))
|
||||
|
||||
news := api.Group("/news")
|
||||
{
|
||||
|
|
@ -251,9 +301,9 @@ func main() {
|
|||
addr := fmt.Sprintf(":%s", port)
|
||||
|
||||
go func() {
|
||||
log.Printf("Server starting on %s", addr)
|
||||
log.Info().Str("addr", addr).Msg("Server starting")
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
log.Fatal().Err(err).Msg("Failed to start server")
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
@ -264,5 +314,5 @@ func main() {
|
|||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
log.Info().Msg("Shutting down server...")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
|
|
@ -12,11 +13,11 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rss2/backend/internal/logger"
|
||||
"github.com/rss2/backend/internal/workers"
|
||||
)
|
||||
|
||||
var (
|
||||
logger *log.Logger
|
||||
dbPool *pgxpool.Pool
|
||||
sleepSec = 10
|
||||
batchSz = 500
|
||||
|
|
@ -35,7 +36,8 @@ type Country struct {
|
|||
}
|
||||
|
||||
func init() {
|
||||
logger = log.New(os.Stdout, "[TOPICS] ", log.LstdFlags)
|
||||
logLevel := os.Getenv("LOG_LEVEL")
|
||||
logger.Init("topics-worker", logLevel)
|
||||
}
|
||||
|
||||
func loadConfig() {
|
||||
|
|
@ -335,20 +337,36 @@ func processBatch(ctx context.Context, topics []Topic, countries []Country) (int
|
|||
|
||||
func main() {
|
||||
loadConfig()
|
||||
logger.Println("Starting Topics Worker")
|
||||
logger.Info().Msg("Starting Topics Worker")
|
||||
|
||||
cfg := workers.LoadDBConfig()
|
||||
if err := workers.Connect(cfg); err != nil {
|
||||
logger.Fatalf("Failed to connect to database: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
dbPool = workers.GetPool()
|
||||
defer workers.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
// Start health check HTTP server
|
||||
go func() {
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := workers.HealthCheck(r.Context()); err != nil {
|
||||
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
logger.Info().Msg("Health check server listening on :8080")
|
||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||
logger.Error().Err(err).Msg("Health check server error")
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Ensure schema
|
||||
if err := ensureSchema(ctx); err != nil {
|
||||
logger.Printf("Error ensuring schema: %v", err)
|
||||
logger.Error().Err(err).Msg("Error ensuring schema")
|
||||
}
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
|
|
@ -356,41 +374,47 @@ func main() {
|
|||
|
||||
go func() {
|
||||
<-sigChan
|
||||
logger.Println("Shutting down...")
|
||||
logger.Info().Msg("Shutting down gracefully...")
|
||||
cancel()
|
||||
time.Sleep(2 * time.Second)
|
||||
workers.Close()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
logger.Printf("Config: sleep=%ds, batch=%d", sleepSec, batchSz)
|
||||
logger.Info().Msgf("Config: sleep=%ds, batch=%d", sleepSec, batchSz)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info().Msg("Context cancelled, stopping worker")
|
||||
return
|
||||
case <-time.After(time.Duration(sleepSec) * time.Second):
|
||||
topics, err := loadTopics(ctx)
|
||||
if err != nil {
|
||||
logger.Printf("Error loading topics: %v", err)
|
||||
logger.Error().Err(err).Msg("Error loading topics")
|
||||
continue
|
||||
}
|
||||
|
||||
if len(topics) == 0 {
|
||||
logger.Println("No topics found in DB")
|
||||
logger.Info().Msg("No topics found in DB")
|
||||
time.Sleep(time.Duration(sleepSec) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
countries, err := loadCountries(ctx)
|
||||
if err != nil {
|
||||
logger.Printf("Error loading countries: %v", err)
|
||||
logger.Error().Err(err).Msg("Error loading countries")
|
||||
continue
|
||||
}
|
||||
|
||||
count, err := processBatch(ctx, topics, countries)
|
||||
if err != nil {
|
||||
logger.Printf("Error processing batch: %v", err)
|
||||
logger.Error().Err(err).Msg("Error processing batch")
|
||||
continue
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
logger.Printf("Processed %d news items", count)
|
||||
logger.Info().Msgf("Processed %d news items", count)
|
||||
}
|
||||
|
||||
if count < batchSz {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rss2/backend/internal/logger"
|
||||
"github.com/rss2/backend/internal/workers"
|
||||
)
|
||||
|
||||
var (
|
||||
logger *log.Logger
|
||||
pool *pgxpool.Pool
|
||||
sleepInterval = 30
|
||||
batchSize = 20
|
||||
|
|
@ -52,7 +52,8 @@ type Tag struct {
|
|||
}
|
||||
|
||||
func init() {
|
||||
logger = log.New(os.Stdout, "[WIKI_WORKER] ", log.LstdFlags)
|
||||
logLevel := os.Getenv("LOG_LEVEL")
|
||||
logger.Init("wiki-worker", logLevel)
|
||||
}
|
||||
|
||||
func getPendingTags(ctx context.Context) ([]Tag, error) {
|
||||
|
|
@ -227,52 +228,89 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
logger.Println("Iniciando Wiki Worker...")
|
||||
logger.Info().Msg("Iniciando Wiki Worker...")
|
||||
|
||||
if err := os.MkdirAll(imagesDir, 0755); err != nil {
|
||||
logger.Fatalf("Error creando directorio de imágenes: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Error creando directorio de imágenes")
|
||||
}
|
||||
|
||||
cfg := workers.LoadDBConfig()
|
||||
if err := workers.Connect(cfg); err != nil {
|
||||
logger.Fatalf("Failed to connect to database: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
pool = workers.GetPool()
|
||||
defer workers.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
// Start health check HTTP server
|
||||
go func() {
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := workers.HealthCheck(r.Context()); err != nil {
|
||||
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
logger.Info().Msg("Health check server listening on :8080")
|
||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||
logger.Error().Err(err).Msg("Health check server error")
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigChan
|
||||
logger.Println("Cerrando gracefully...")
|
||||
logger.Info().Msg("Shutting down gracefully...")
|
||||
cancel()
|
||||
// Give time for in-flight requests to complete
|
||||
time.Sleep(2 * time.Second)
|
||||
workers.Close()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
logger.Printf("Configuración: sleep=%ds, batch=%d", sleepInterval, batchSize)
|
||||
logger.Info().Msgf("Configuración: sleep=%ds, batch=%d", sleepInterval, batchSize)
|
||||
|
||||
for {
|
||||
tags, err := getPendingTags(ctx)
|
||||
if err != nil {
|
||||
logger.Printf("Error recuperando tags pendientes: %v", err)
|
||||
time.Sleep(10 * time.Second)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info().Msg("Context cancelled, stopping worker")
|
||||
return
|
||||
default:
|
||||
tags, err := getPendingTags(ctx)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Error recuperando tags pendientes")
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
logger.Printf("No hay tags pendientes. Durmiendo %d segundos...", sleepInterval)
|
||||
time.Sleep(time.Duration(sleepInterval) * time.Second)
|
||||
continue
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
logger.Info().Msgf("No hay tags pendientes. Durmiendo %d segundos...", sleepInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Duration(sleepInterval) * time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Printf("Recuperados %d tags para procesar...", len(tags))
|
||||
logger.Info().Msgf("Recuperados %d tags para procesar...", len(tags))
|
||||
|
||||
for _, tag := range tags {
|
||||
processTag(ctx, tag)
|
||||
time.Sleep(3 * time.Second) // Increased delay to avoid Wikipedia Rate Limits (429)
|
||||
for _, tag := range tags {
|
||||
processTag(ctx, tag)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(3 * time.Second): // Increased delay to avoid Wikipedia Rate Limits (429)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
backend/docs/docs.go
Normal file
43
backend/docs/docs.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Package docs provides Swagger documentation for the RSS2 API
|
||||
//
|
||||
// @title RSS2 API
|
||||
// @version 1.0
|
||||
// @description RSS2 News Aggregator API - A comprehensive news aggregation and analysis platform
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
//
|
||||
// @contact.name RSS2 Team
|
||||
// @contact.url http://github.com/rss2
|
||||
// @contact.email support@rss2.example.com
|
||||
//
|
||||
// @license.name MIT
|
||||
// @license.url https://opensource.org/licenses/MIT
|
||||
//
|
||||
// @host localhost:8080
|
||||
// @BasePath /api
|
||||
//
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and JWT token.
|
||||
//
|
||||
// @tag.name auth
|
||||
// @tag.description Authentication endpoints
|
||||
//
|
||||
// @tag.name news
|
||||
// @tag.description News management endpoints
|
||||
//
|
||||
// @tag.name feeds
|
||||
// @tag.description Feed management endpoints
|
||||
//
|
||||
// @tag.name search
|
||||
// @tag.description Search and discovery endpoints
|
||||
//
|
||||
// @tag.name entities
|
||||
// @tag.description Entity extraction and analysis
|
||||
//
|
||||
// @tag.name admin
|
||||
// @tag.description Admin management endpoints
|
||||
//
|
||||
// @tag.name stats
|
||||
// @tag.description Statistics and analytics
|
||||
package docs
|
||||
|
|
@ -13,10 +13,17 @@ require (
|
|||
github.com/jackc/pgx/v5 v5.4.3
|
||||
github.com/mmcdole/gofeed v1.2.1
|
||||
github.com/redis/go-redis/v9 v9.0.5
|
||||
golang.org/x/crypto v0.26.0
|
||||
github.com/rs/zerolog v1.32.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/time v0.5.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
|
|
@ -24,31 +31,39 @@ require (
|
|||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/swaggo/swag v1.16.3 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/net v0.28.0 // indirect
|
||||
golang.org/x/net v0.30.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.17.0 // indirect
|
||||
golang.org/x/text v0.19.0 // indirect
|
||||
golang.org/x/tools v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
|
||||
|
|
@ -14,6 +20,7 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
|||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
|
|
@ -22,10 +29,22 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
|||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
|
|
@ -36,6 +55,7 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
|
|||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
|
|
@ -53,17 +73,29 @@ github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
|||
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mmcdole/gofeed v1.2.1 h1:tPbFN+mfOLcM1kDF1x2c/N68ChbdBatkppdzf/vDe1s=
|
||||
|
|
@ -75,18 +107,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o=
|
||||
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
|
||||
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
|
|
@ -94,6 +132,12 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
|||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
|
||||
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
||||
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
|
||||
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
|
|
@ -104,17 +148,21 @@ golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
|||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -122,13 +170,16 @@ golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
|||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
|
|
@ -137,22 +188,33 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
|||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
|
@ -53,3 +54,16 @@ func ValidateToken(tokenString string) (*Claims, error) {
|
|||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "user"
|
||||
|
||||
func SetTestUser(ctx context.Context, claims *Claims) context.Context {
|
||||
return context.WithValue(ctx, userContextKey, claims)
|
||||
}
|
||||
|
||||
func GetUserFromContext(ctx context.Context) (*Claims, bool) {
|
||||
claims, ok := ctx.Value(userContextKey).(*Claims)
|
||||
return claims, ok
|
||||
}
|
||||
|
|
|
|||
111
backend/internal/cache/redis.go
vendored
111
backend/internal/cache/redis.go
vendored
|
|
@ -11,6 +11,26 @@ import (
|
|||
|
||||
var Client *redis.Client
|
||||
|
||||
// Cache TTL constants
|
||||
const (
|
||||
TTLShort = 5 * time.Minute // 5 min for frequently changing data
|
||||
TTLMedium = 30 * time.Minute // 30 min for semi-static data
|
||||
TTLLong = 2 * time.Hour // 2 hours for stable data
|
||||
TTLNoExpire = 0 // No expiration (manual invalidation)
|
||||
)
|
||||
|
||||
const (
|
||||
KeyPrefixSearch = "search"
|
||||
KeyPrefixNews = "news"
|
||||
KeyPrefixFeed = "feed"
|
||||
KeyPrefixFeedList = "feedlist"
|
||||
KeyPrefixCategories = "categories"
|
||||
KeyPrefixCountries = "countries"
|
||||
KeyPrefixStats = "stats"
|
||||
KeyPrefixEntities = "entities"
|
||||
KeyPrefixStatsTop = "statstop"
|
||||
)
|
||||
|
||||
func Connect(redisURL string) error {
|
||||
opt, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
|
|
@ -40,15 +60,39 @@ func GetClient() *redis.Client {
|
|||
}
|
||||
|
||||
func SearchKey(query, lang string, page, perPage int) string {
|
||||
return fmt.Sprintf("search:%s:%s:%d:%d", query, lang, page, perPage)
|
||||
return fmt.Sprintf("%s:%s:%s:%d:%d", KeyPrefixSearch, query, lang, page, perPage)
|
||||
}
|
||||
|
||||
func NewsKey(newsID int64, lang string) string {
|
||||
return fmt.Sprintf("news:%d:%s", newsID, lang)
|
||||
return fmt.Sprintf("%s:%d:%s", KeyPrefixNews, newsID, lang)
|
||||
}
|
||||
|
||||
func FeedKey(feedID int64) string {
|
||||
return fmt.Sprintf("feed:%d", feedID)
|
||||
return fmt.Sprintf("%s:%d", KeyPrefixFeed, feedID)
|
||||
}
|
||||
|
||||
func FeedListKey(filters string, page, perPage int) string {
|
||||
return fmt.Sprintf("%s:%s:%d:%d", KeyPrefixFeedList, filters, page, perPage)
|
||||
}
|
||||
|
||||
func CategoriesKey() string {
|
||||
return KeyPrefixCategories
|
||||
}
|
||||
|
||||
func CountriesKey() string {
|
||||
return KeyPrefixCountries
|
||||
}
|
||||
|
||||
func StatsKey() string {
|
||||
return KeyPrefixStats
|
||||
}
|
||||
|
||||
func StatsTopKey(typ string) string {
|
||||
return fmt.Sprintf("%s:%s", KeyPrefixStatsTop, typ)
|
||||
}
|
||||
|
||||
func EntityKey(entityType, query string, page, perPage int) string {
|
||||
return fmt.Sprintf("%s:%s:%s:%d:%d", KeyPrefixEntities, entityType, query, page, perPage)
|
||||
}
|
||||
|
||||
func Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
|
|
@ -63,6 +107,62 @@ func Get(ctx context.Context, key string) (string, error) {
|
|||
return Client.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
func Delete(ctx context.Context, keys ...string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
return Client.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
func DeletePattern(ctx context.Context, pattern string) error {
|
||||
iter := Client.Scan(ctx, 0, pattern, 100).Iterator()
|
||||
for iter.Next(ctx) {
|
||||
if err := Client.Del(ctx, iter.Val()).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return iter.Err()
|
||||
}
|
||||
|
||||
func InvalidateSearch(ctx context.Context) error {
|
||||
return DeletePattern(ctx, KeyPrefixSearch+":*")
|
||||
}
|
||||
|
||||
func InvalidateNews(ctx context.Context, newsID int64) error {
|
||||
pattern := fmt.Sprintf("%s:%d:*", KeyPrefixNews, newsID)
|
||||
return DeletePattern(ctx, pattern)
|
||||
}
|
||||
|
||||
func InvalidateFeed(ctx context.Context, feedID int64) error {
|
||||
return Delete(ctx, FeedKey(feedID))
|
||||
}
|
||||
|
||||
func InvalidateFeedList(ctx context.Context) error {
|
||||
return DeletePattern(ctx, KeyPrefixFeedList+":*")
|
||||
}
|
||||
|
||||
func InvalidateCategories(ctx context.Context) error {
|
||||
return Delete(ctx, CategoriesKey())
|
||||
}
|
||||
|
||||
func InvalidateCountries(ctx context.Context) error {
|
||||
return Delete(ctx, CountriesKey())
|
||||
}
|
||||
|
||||
func InvalidateStats(ctx context.Context) error {
|
||||
keys := []string{
|
||||
StatsKey(),
|
||||
StatsTopKey("categories"),
|
||||
StatsTopKey("countries"),
|
||||
}
|
||||
return Delete(ctx, keys...)
|
||||
}
|
||||
|
||||
func InvalidateEntities(ctx context.Context, entityType string) error {
|
||||
pattern := fmt.Sprintf("%s:%s:*", KeyPrefixEntities, entityType)
|
||||
return DeletePattern(ctx, pattern)
|
||||
}
|
||||
|
||||
func Unmarshal(data []byte, v interface{}) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
|
@ -70,3 +170,8 @@ func Unmarshal(data []byte, v interface{}) error {
|
|||
func Marshal(v interface{}) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func Exists(ctx context.Context, key string) (bool, error) {
|
||||
count, err := Client.Exists(ctx, key).Result()
|
||||
return count > 0, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,12 @@ type Config struct {
|
|||
func Load() *Config {
|
||||
secretKey := os.Getenv("SECRET_KEY")
|
||||
if secretKey == "" {
|
||||
secretKey = "change-this-secret-key"
|
||||
panic("SECRET_KEY environment variable is required")
|
||||
}
|
||||
|
||||
allowedOrigins := os.Getenv("ALLOWED_ORIGINS")
|
||||
if allowedOrigins == "" {
|
||||
allowedOrigins = "http://localhost:5173,http://localhost:3000"
|
||||
}
|
||||
|
||||
return &Config{
|
||||
|
|
@ -47,7 +52,7 @@ func Load() *Config {
|
|||
RateLimitPerMinute: getEnvInt("RATE_LIMIT_PER_MINUTE", 60),
|
||||
DockerComposeDir: getEnv("DOCKER_COMPOSE_DIR", "/datos/rss2"),
|
||||
WikiImagesPath: getEnv("WIKI_IMAGES_PATH", "/app/data/wiki_images"),
|
||||
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "*"),
|
||||
AllowedOrigins: allowedOrigins,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package db
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
|
@ -16,10 +18,11 @@ func Connect(databaseURL string) error {
|
|||
return fmt.Errorf("failed to parse database URL: %w", err)
|
||||
}
|
||||
|
||||
config.MaxConns = 25
|
||||
config.MinConns = 5
|
||||
config.MaxConnLifetime = time.Hour
|
||||
config.MaxConnIdleTime = 30 * time.Minute
|
||||
config.MaxConns = int32(getEnvInt("DB_MAX_CONNS", 25))
|
||||
config.MinConns = int32(getEnvInt("DB_MIN_CONNS", 5))
|
||||
config.MaxConnLifetime = getEnvDuration("DB_MAX_CONN_LIFETIME", time.Hour)
|
||||
config.MaxConnIdleTime = getEnvDuration("DB_MAX_CONN_IDLE_TIME", 30*time.Minute)
|
||||
config.HealthCheckPeriod = getEnvDuration("DB_HEALTH_CHECK_PERIOD", time.Minute)
|
||||
|
||||
Pool, err = pgxpool.NewWithConfig(context.Background(), config)
|
||||
if err != nil {
|
||||
|
|
@ -42,3 +45,28 @@ func Close() {
|
|||
func GetPool() *pgxpool.Pool {
|
||||
return Pool
|
||||
}
|
||||
|
||||
func HealthCheck(ctx context.Context) error {
|
||||
if Pool == nil {
|
||||
return fmt.Errorf("pool not initialized")
|
||||
}
|
||||
return Pool.Ping(ctx)
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if intVal, err := strconv.Atoi(value); err == nil {
|
||||
return intVal
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if duration, err := time.ParseDuration(value); err == nil {
|
||||
return duration
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -738,7 +738,23 @@ func resolvePgConnInfo() pgConnInfo {
|
|||
return info
|
||||
}
|
||||
|
||||
// BackupDatabase runs pg_dump and returns the SQL as a downloadable file
|
||||
// flushWriter flushes the underlying response writer after every write so that
|
||||
// large backups stream to the client instead of being buffered in RAM.
|
||||
type flushWriter struct {
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func (fw flushWriter) Write(p []byte) (int, error) {
|
||||
n, err := fw.w.Write(p)
|
||||
if f, ok := fw.w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// BackupDatabase runs pg_dump and returns the SQL as a downloadable file.
|
||||
// The output is streamed to the client so arbitrarily large dumps do not
|
||||
// exhaust the container memory.
|
||||
func BackupDatabase(c *gin.Context) {
|
||||
info := resolvePgConnInfo()
|
||||
|
||||
|
|
@ -752,12 +768,12 @@ func BackupDatabase(c *gin.Context) {
|
|||
)
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
|
||||
|
||||
var out bytes.Buffer
|
||||
pr, pw := io.Pipe()
|
||||
cmd.Stdout = pw
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if err := cmd.Start(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "pg_dump failed",
|
||||
"details": stderr.String(),
|
||||
|
|
@ -769,10 +785,29 @@ func BackupDatabase(c *gin.Context) {
|
|||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusOK, "application/octet-stream", out.Bytes())
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
fw := flushWriter{w: c.Writer}
|
||||
copyDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(copyDone)
|
||||
_, _ = io.Copy(fw, pr)
|
||||
}()
|
||||
|
||||
err := cmd.Wait()
|
||||
pw.Close()
|
||||
<-copyDone
|
||||
|
||||
if err != nil {
|
||||
log.Printf("BackupDatabase: pg_dump failed: %v: %s", err, stderr.String())
|
||||
c.Writer.Flush()
|
||||
return
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// BackupNewsZipped performs a pg_dump of news tables and returns a ZIP file
|
||||
// BackupNewsZipped performs a pg_dump of news tables and returns a ZIP file.
|
||||
// pg_dump output is streamed into the ZIP as it is produced, keeping memory usage flat.
|
||||
func BackupNewsZipped(c *gin.Context) {
|
||||
info := resolvePgConnInfo()
|
||||
|
||||
|
|
@ -794,12 +829,12 @@ func BackupNewsZipped(c *gin.Context) {
|
|||
cmd := exec.Command("pg_dump", args...)
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
|
||||
|
||||
var sqlOut bytes.Buffer
|
||||
pr, pw := io.Pipe()
|
||||
cmd.Stdout = pw
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &sqlOut
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if err := cmd.Start(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "pg_dump failed",
|
||||
"details": stderr.String(),
|
||||
|
|
@ -807,33 +842,40 @@ func BackupNewsZipped(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Create ZIP
|
||||
buf := new(bytes.Buffer)
|
||||
zw := zip.NewWriter(buf)
|
||||
|
||||
sqlFileName := fmt.Sprintf("backup_noticias_%s.sql", time.Now().Format("2006-01-02"))
|
||||
f, err := zw.Create(sqlFileName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create ZIP entry", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = f.Write(sqlOut.Bytes())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write to ZIP", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to close ZIP writer", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("backup_noticias_%s.zip", time.Now().Format("2006-01-02_15-04-05"))
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusOK, "application/zip", buf.Bytes())
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
fw := flushWriter{w: c.Writer}
|
||||
zw := zip.NewWriter(fw)
|
||||
|
||||
sqlFileName := fmt.Sprintf("backup_noticias_%s.sql", time.Now().Format("2006-01-02"))
|
||||
f, err := zw.Create(sqlFileName)
|
||||
if err != nil {
|
||||
log.Printf("BackupNewsZipped: failed to create ZIP entry: %v", err)
|
||||
pw.Close()
|
||||
pr.Close()
|
||||
return
|
||||
}
|
||||
|
||||
copyDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(copyDone)
|
||||
_, _ = io.Copy(f, pr)
|
||||
}()
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
pw.Close()
|
||||
<-copyDone
|
||||
closeErr := zw.Close()
|
||||
c.Writer.Flush()
|
||||
|
||||
if waitErr != nil || closeErr != nil {
|
||||
log.Printf("BackupNewsZipped: pg_dump=%v zip=%v: %s", waitErr, closeErr, stderr.String())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreDatabase accepts an uploaded .sql or .zip backup and restores it via psql
|
||||
|
|
|
|||
|
|
@ -109,10 +109,18 @@ func ScanAlertasAdmin(c *gin.Context) {
|
|||
}
|
||||
|
||||
// RunAlertScan busca conceptos (persona, lugar, organizacion, tema) cuya
|
||||
// actividad de hoy supera significativamente su media de los últimos días.
|
||||
// actividad supera significativamente su media de los días anteriores.
|
||||
//
|
||||
// En lugar de comparar siempre contra CURRENT_DATE (que se queda vacío si la
|
||||
// cadena de traducción/NER va por detrás de la ingesta), se toma como día de
|
||||
// referencia el día más reciente que tenga datos de tags etiquetados y se
|
||||
// compara contra la media de los ALERTS_LOOKBACK_DAYS días previos, contando
|
||||
// los días sin actividad como 0.
|
||||
func RunAlertScan(ctx context.Context) (int, error) {
|
||||
minHits := 3
|
||||
minHits := 5
|
||||
minRatio := 5.0
|
||||
minBaseline := 2.0
|
||||
lookbackDays := 8
|
||||
if v := os.Getenv("ALERTS_MIN_HITS"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
minHits = n
|
||||
|
|
@ -123,6 +131,16 @@ func RunAlertScan(ctx context.Context) (int, error) {
|
|||
minRatio = f
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("ALERTS_MIN_BASELINE"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
|
||||
minBaseline = f
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("ALERTS_LOOKBACK_DAYS"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
lookbackDays = n
|
||||
}
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH daily AS (
|
||||
|
|
@ -132,34 +150,43 @@ func RunAlertScan(ctx context.Context) (int, error) {
|
|||
JOIN tags t ON tn.tag_id = t.id
|
||||
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
||||
JOIN noticias n ON tr.noticia_id = n.id
|
||||
WHERE n.fecha >= CURRENT_DATE - 8
|
||||
WHERE n.fecha >= CURRENT_DATE - 30
|
||||
AND n.fecha <= CURRENT_DATE
|
||||
AND t.tipo IN ('persona', 'lugar', 'organizacion', 'tema')
|
||||
GROUP BY t.id, t.valor, t.tipo, n.fecha::date
|
||||
),
|
||||
baseline AS (
|
||||
SELECT tag_id, valor, tipo,
|
||||
AVG(cnt)::float AS baseline,
|
||||
MAX(cnt)::int AS max_prev
|
||||
FROM daily
|
||||
WHERE dia < CURRENT_DATE
|
||||
GROUP BY tag_id, valor, tipo
|
||||
ref AS (
|
||||
SELECT MAX(dia) AS ref_date FROM daily
|
||||
),
|
||||
prev_series AS (
|
||||
SELECT generate_series((SELECT ref_date - $3::int FROM ref),
|
||||
(SELECT ref_date - 1 FROM ref), '1 day')::date AS dia
|
||||
),
|
||||
today AS (
|
||||
SELECT tag_id, valor, tipo, SUM(cnt)::int AS hits
|
||||
FROM daily
|
||||
WHERE dia = CURRENT_DATE
|
||||
WHERE dia = (SELECT ref_date FROM ref)
|
||||
GROUP BY tag_id, valor, tipo
|
||||
),
|
||||
base AS (
|
||||
SELECT t.tag_id, t.valor, t.tipo,
|
||||
AVG(COALESCE(d.cnt, 0))::float AS baseline
|
||||
FROM today t
|
||||
CROSS JOIN prev_series s
|
||||
LEFT JOIN daily d ON d.tag_id = t.tag_id AND d.dia = s.dia
|
||||
GROUP BY t.tag_id, t.valor, t.tipo
|
||||
)
|
||||
SELECT t.valor, t.tipo, t.hits, b.baseline,
|
||||
(t.hits::float / NULLIF(b.baseline, 0)) AS ratio
|
||||
(t.hits::float / b.baseline) AS ratio,
|
||||
(SELECT ref_date FROM ref)::date AS periodo
|
||||
FROM today t
|
||||
JOIN baseline b USING (tag_id)
|
||||
JOIN base b USING (tag_id)
|
||||
WHERE t.hits >= $1
|
||||
AND b.baseline >= 0.5
|
||||
AND (t.hits::float / NULLIF(b.baseline, 0)) >= $2
|
||||
AND b.baseline >= $4
|
||||
AND (t.hits::float / b.baseline) >= $2
|
||||
`
|
||||
|
||||
rows, err := db.GetPool().Query(ctx, query, minHits, minRatio)
|
||||
rows, err := db.GetPool().Query(ctx, query, minHits, minRatio, lookbackDays, minBaseline)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
|
@ -170,14 +197,15 @@ func RunAlertScan(ctx context.Context) (int, error) {
|
|||
var valor, tipo string
|
||||
var hits int
|
||||
var baseline, ratio float64
|
||||
if err := rows.Scan(&valor, &tipo, &hits, &baseline, &ratio); err != nil {
|
||||
var periodo time.Time
|
||||
if err := rows.Scan(&valor, &tipo, &hits, &baseline, &ratio, &periodo); err != nil {
|
||||
continue
|
||||
}
|
||||
res, err := db.GetPool().Exec(ctx, `
|
||||
INSERT INTO alertas (valor, tipo, periodo, hits, baseline, ratio)
|
||||
VALUES ($1, $2, CURRENT_DATE, $3, $4, $5)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (valor, tipo, periodo) DO NOTHING
|
||||
`, valor, tipo, hits, baseline, ratio)
|
||||
`, valor, tipo, periodo, hits, baseline, ratio)
|
||||
if err != nil {
|
||||
log.Printf("alerts: insert failed for %s (%s): %v", valor, tipo, err)
|
||||
continue
|
||||
|
|
@ -200,12 +228,12 @@ func StartAlertScanner() {
|
|||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(45 * time.Second)
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
inserted, err := RunAlertScan(ctx)
|
||||
cancel()
|
||||
go func() {
|
||||
time.Sleep(45 * time.Second)
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
inserted, err := RunAlertScan(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("alerts: scan error: %v", err)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ import (
|
|||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// CheckFirstUser checks if this is the first user registration
|
||||
// @Summary Check if first user
|
||||
// @Description Returns whether this is the first user being registered (no users exist yet)
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "is_first_user and total_users"
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /auth/check-first-user [get]
|
||||
func CheckFirstUser(c *gin.Context) {
|
||||
var count int
|
||||
err := db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*) FROM users").Scan(&count)
|
||||
|
|
@ -20,6 +28,18 @@ func CheckFirstUser(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, gin.H{"is_first_user": count == 0, "total_users": count})
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a JWT token
|
||||
// @Summary User login
|
||||
// @Description Authenticate with email and password to receive a JWT token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.LoginRequest true "Login credentials"
|
||||
// @Success 200 {object} models.AuthResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 401 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /auth/login [post]
|
||||
func Login(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
|
@ -56,6 +76,17 @@ func Login(c *gin.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
// Register creates a new user account
|
||||
// @Summary Register new user
|
||||
// @Description Create a new user account (first user becomes admin automatically)
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.RegisterRequest true "Registration details"
|
||||
// @Success 201 {object} models.AuthResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /auth/register [post]
|
||||
func Register(c *gin.Context) {
|
||||
var req models.RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
|
@ -111,6 +142,16 @@ func Register(c *gin.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the current authenticated user
|
||||
// @Summary Get current user
|
||||
// @Description Returns the currently authenticated user's profile
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.User
|
||||
// @Failure 401 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Router /auth/me [get]
|
||||
func GetCurrentUser(c *gin.Context) {
|
||||
userVal, exists := c.Get("user")
|
||||
if !exists {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ func init() {
|
|||
auth.SetJWTSecret("test-secret-key-12345")
|
||||
}
|
||||
|
||||
// TestLoginInvalidRequest tests that an empty request body returns 400
|
||||
func TestLoginInvalidRequest(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.POST("/auth/login", Login)
|
||||
|
|
@ -40,22 +41,7 @@ func TestLoginInvalidRequest(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoginInvalidCredentials(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.POST("/auth/login", Login)
|
||||
|
||||
body := []byte(`{"email":"invalid@test.com","password":"wrongpass"}`)
|
||||
req, _ := http.NewRequest("POST", "/auth/login", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterInvalidRequest tests that an empty request body returns 400
|
||||
func TestRegisterInvalidRequest(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.POST("/auth/register", Register)
|
||||
|
|
@ -72,27 +58,7 @@ func TestRegisterInvalidRequest(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCheckFirstUser(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.GET("/auth/check-first-user", CheckFirstUser)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/auth/check-first-user", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
if _, ok := resp["is_first_user"]; !ok {
|
||||
t.Error("expected is_first_user in response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCurrentUserUnauthorized tests that unauthenticated request returns 401
|
||||
func TestGetCurrentUserUnauthorized(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.GET("/auth/me", GetCurrentUser)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handlers
|
|||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -11,40 +12,46 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rss2/backend/internal/cache"
|
||||
"github.com/rss2/backend/internal/db"
|
||||
"github.com/rss2/backend/internal/models"
|
||||
)
|
||||
|
||||
type FeedResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Descripcion *string `json:"descripcion"`
|
||||
URL string `json:"url"`
|
||||
CategoriaID *int64 `json:"categoria_id"`
|
||||
PaisID *int64 `json:"pais_id"`
|
||||
Idioma *string `json:"idioma"`
|
||||
Activo bool `json:"activo"`
|
||||
Fallos *int64 `json:"fallos"`
|
||||
LastError *string `json:"last_error"`
|
||||
FuenteURLID *int64 `json:"fuente_url_id"`
|
||||
}
|
||||
|
||||
// GetFeeds returns paginated feeds with optional filters
|
||||
// @Summary List feeds
|
||||
// @Description Returns paginated feeds with optional filtering by active status, category, and country
|
||||
// @Tags feeds
|
||||
// @Produce json
|
||||
// @Param page query int false "Page number" default(1) minimum(1)
|
||||
// @Param per_page query int false "Items per page" default(50) minimum(1) maximum(100)
|
||||
// @Param activo query string false "Active status filter"
|
||||
// @Param categoria_id query string false "Category ID filter"
|
||||
// @Param pais_id query string false "Country ID filter"
|
||||
// @Success 200 {object} map[string]interface{} "feeds, total, page, per_page, total_pages"
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /feeds [get]
|
||||
func GetFeeds(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
||||
page, perPage = validatePageParams(page, perPage, 50, 100)
|
||||
activo := c.Query("activo")
|
||||
categoriaID := c.Query("categoria_id")
|
||||
paisID := c.Query("pais_id")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
// Cache key based on filters
|
||||
filterStr := fmt.Sprintf("activo_%s_cat_%s_pais_%s", activo, categoriaID, paisID)
|
||||
cacheKey := cache.FeedListKey(filterStr, page, perPage)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Try to get from cache
|
||||
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
|
||||
c.Data(http.StatusOK, "application/json", []byte(cached))
|
||||
return
|
||||
}
|
||||
|
||||
where := "1=1"
|
||||
args := []interface{}{}
|
||||
argNum := 1
|
||||
|
|
@ -67,7 +74,7 @@ func GetFeeds(c *gin.Context) {
|
|||
|
||||
var total int
|
||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM feeds WHERE %s", where)
|
||||
err := db.GetPool().QueryRow(c.Request.Context(), countQuery, args...).Scan(&total)
|
||||
err := db.GetPool().QueryRow(ctx, countQuery, args...).Scan(&total)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count feeds", Message: err.Error()})
|
||||
return
|
||||
|
|
@ -75,7 +82,7 @@ func GetFeeds(c *gin.Context) {
|
|||
|
||||
sqlQuery := fmt.Sprintf(`
|
||||
SELECT f.id, f.nombre, f.descripcion, f.url,
|
||||
f.categoria_id, f.pais_id, f.idioma, f.activo, f.fallos, f.last_error,
|
||||
f.categoria_id, f.pais_id, f.idioma, f.activo, f.fallos, f.last_error, f.last_fetched,
|
||||
c.nombre AS categoria, p.nombre AS pais,
|
||||
(SELECT COUNT(*) FROM noticias n WHERE n.fuente_nombre = f.nombre) as noticias_count
|
||||
FROM feeds f
|
||||
|
|
@ -88,7 +95,7 @@ func GetFeeds(c *gin.Context) {
|
|||
|
||||
args = append(args, perPage, offset)
|
||||
|
||||
rows, err := db.GetPool().Query(c.Request.Context(), sqlQuery, args...)
|
||||
rows, err := db.GetPool().Query(ctx, sqlQuery, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to fetch feeds", Message: err.Error()})
|
||||
return
|
||||
|
|
@ -96,7 +103,7 @@ func GetFeeds(c *gin.Context) {
|
|||
defer rows.Close()
|
||||
|
||||
type FeedWithStats struct {
|
||||
FeedResponse
|
||||
models.Feed
|
||||
Categoria *string `json:"categoria"`
|
||||
Pais *string `json:"pais"`
|
||||
NoticiasCount int64 `json:"noticias_count"`
|
||||
|
|
@ -107,7 +114,7 @@ func GetFeeds(c *gin.Context) {
|
|||
var f FeedWithStats
|
||||
err := rows.Scan(
|
||||
&f.ID, &f.Nombre, &f.Descripcion, &f.URL,
|
||||
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos, &f.LastError,
|
||||
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos, &f.LastError, &f.LastFetched,
|
||||
&f.Categoria, &f.Pais, &f.NoticiasCount,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -118,13 +125,20 @@ func GetFeeds(c *gin.Context) {
|
|||
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
response := gin.H{
|
||||
"feeds": feeds,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"total_pages": totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
// Cache the response
|
||||
if data, err := json.Marshal(response); err == nil {
|
||||
cache.Set(ctx, cacheKey, string(data), cache.TTLMedium)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func GetFeedByID(c *gin.Context) {
|
||||
|
|
@ -134,12 +148,12 @@ func GetFeedByID(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
var f FeedResponse
|
||||
var f models.Feed
|
||||
err = db.GetPool().QueryRow(c.Request.Context(), `
|
||||
SELECT id, nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos
|
||||
SELECT id, nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos, last_fetched
|
||||
FROM feeds WHERE id = $1`, id).Scan(
|
||||
&f.ID, &f.Nombre, &f.Descripcion, &f.URL,
|
||||
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos,
|
||||
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos, &f.LastFetched,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
|
||||
|
|
@ -178,6 +192,9 @@ func CreateFeed(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Invalidate feed list cache
|
||||
cache.InvalidateFeedList(c.Request.Context())
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": feedID, "message": "Feed created successfully"})
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +245,10 @@ func UpdateFeed(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Invalidate feed cache
|
||||
cache.InvalidateFeed(c.Request.Context(), id)
|
||||
cache.InvalidateFeedList(c.Request.Context())
|
||||
|
||||
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed updated successfully"})
|
||||
}
|
||||
|
||||
|
|
@ -249,6 +270,10 @@ func DeleteFeed(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Invalidate feed cache
|
||||
cache.InvalidateFeed(c.Request.Context(), id)
|
||||
cache.InvalidateFeedList(c.Request.Context())
|
||||
|
||||
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed deleted successfully"})
|
||||
}
|
||||
|
||||
|
|
@ -271,6 +296,10 @@ func ToggleFeedActive(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Invalidate feed cache
|
||||
cache.InvalidateFeed(c.Request.Context(), id)
|
||||
cache.InvalidateFeedList(c.Request.Context())
|
||||
|
||||
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed toggled successfully"})
|
||||
}
|
||||
|
||||
|
|
@ -293,6 +322,10 @@ func ReactivateFeed(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Invalidate feed cache
|
||||
cache.InvalidateFeed(c.Request.Context(), id)
|
||||
cache.InvalidateFeedList(c.Request.Context())
|
||||
|
||||
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed reactivated successfully"})
|
||||
}
|
||||
|
||||
|
|
@ -449,7 +482,12 @@ func ImportFeeds(c *gin.Context) {
|
|||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to start transaction", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
tx.Rollback(context.Background())
|
||||
}
|
||||
}()
|
||||
|
||||
field := func(record []string, idx int) string {
|
||||
if idx < 0 || idx >= len(record) {
|
||||
|
|
@ -579,7 +617,9 @@ func ImportFeeds(c *gin.Context) {
|
|||
}
|
||||
|
||||
if err != nil {
|
||||
tx.Exec(context.Background(), "ROLLBACK TO SAVEPOINT sp")
|
||||
if _, rbErr := tx.Exec(context.Background(), "ROLLBACK TO SAVEPOINT sp"); rbErr != nil {
|
||||
errors = append(errors, fmt.Sprintf("Rollback failed for %s: %v", url, rbErr))
|
||||
}
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("Error upserting %s: %v", url, err))
|
||||
continue
|
||||
|
|
@ -598,6 +638,7 @@ func ImportFeeds(c *gin.Context) {
|
|||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to commit transaction", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"imported": imported,
|
||||
|
|
|
|||
35
backend/internal/handlers/feed_test.go
Normal file
35
backend/internal/handlers/feed_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePageParamsFeed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
page int
|
||||
perPage int
|
||||
defaultPerPage int
|
||||
maxPerPage int
|
||||
expectedPage int
|
||||
expectedPerPage int
|
||||
}{
|
||||
{"valid", 1, 50, 50, 100, 1, 50},
|
||||
{"page zero", 0, 50, 50, 100, 1, 50},
|
||||
{"per_page too large", 1, 200, 50, 100, 1, 100},
|
||||
{"page negative", -5, 50, 50, 100, 1, 50},
|
||||
{"per_page zero", 1, 0, 50, 100, 1, 50},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
page, perPage := validatePageParams(tt.page, tt.perPage, tt.defaultPerPage, tt.maxPerPage)
|
||||
if page != tt.expectedPage {
|
||||
t.Errorf("page: expected %d, got %d", tt.expectedPage, page)
|
||||
}
|
||||
if perPage != tt.expectedPerPage {
|
||||
t.Errorf("perPage: expected %d, got %d", tt.expectedPerPage, perPage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package handlers
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
|
@ -9,40 +10,45 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rss2/backend/internal/config"
|
||||
"github.com/rss2/backend/internal/db"
|
||||
"github.com/rss2/backend/internal/models"
|
||||
)
|
||||
|
||||
type NewsResponse struct {
|
||||
ID string `json:"id"`
|
||||
Titulo string `json:"titulo"`
|
||||
Resumen string `json:"resumen"`
|
||||
URL string `json:"url"`
|
||||
Fecha *time.Time `json:"fecha"`
|
||||
ImagenURL *string `json:"imagen_url"`
|
||||
CategoriaID *int64 `json:"categoria_id"`
|
||||
PaisID *int64 `json:"pais_id"`
|
||||
FuenteNombre string `json:"fuente_nombre"`
|
||||
TitleTranslated *string `json:"title_translated"`
|
||||
SummaryTranslated *string `json:"summary_translated"`
|
||||
LangTranslated *string `json:"lang_translated"`
|
||||
Entities []Entity `json:"entities,omitempty"`
|
||||
// getTargetLang returns the target language for translations, defaulting to config DefaultLang
|
||||
func getTargetLang(c *gin.Context) string {
|
||||
lang := c.Query("lang")
|
||||
if lang == "" {
|
||||
cfg := config.Load()
|
||||
lang = cfg.DefaultLang
|
||||
}
|
||||
return lang
|
||||
}
|
||||
|
||||
// GetNews returns paginated news with optional filters
|
||||
// @Summary List news
|
||||
// @Description Returns paginated news with optional filtering by query, category, country, and translation status
|
||||
// @Tags news
|
||||
// @Produce json
|
||||
// @Param page query int false "Page number" default(1) minimum(1)
|
||||
// @Param per_page query int false "Items per page" default(30) minimum(1) maximum(100)
|
||||
// @Param q query string false "Search query"
|
||||
// @Param category_id query string false "Category ID filter"
|
||||
// @Param country_id query string false "Country ID filter"
|
||||
// @Param lang query string false "Target translation language" default(es)
|
||||
// @Param translated_only query string false "Only show translated news" default("false")
|
||||
// @Success 200 {object} map[string]interface{} "news, total, page, per_page, total_pages"
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /news [get]
|
||||
func GetNews(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30"))
|
||||
page, perPage = validatePageParams(page, perPage, 30, 100)
|
||||
query := c.Query("q")
|
||||
categoryID := c.Query("category_id")
|
||||
countryID := c.Query("country_id")
|
||||
translatedOnly := c.Query("translated_only") == "true"
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 30
|
||||
}
|
||||
targetLang := getTargetLang(c)
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
|
|
@ -53,7 +59,7 @@ func GetNews(c *gin.Context) {
|
|||
if query != "" {
|
||||
if translatedOnly {
|
||||
// With translated_only, search the translated fields too so users can
|
||||
// find translated news by the Spanish (translated) wording.
|
||||
// find translated news by the target language wording.
|
||||
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d OR t.titulo_trad ILIKE $%d OR t.resumen_trad ILIKE $%d)", argNum, argNum, argNum, argNum)
|
||||
} else {
|
||||
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d)", argNum, argNum)
|
||||
|
|
@ -76,7 +82,8 @@ func GetNews(c *gin.Context) {
|
|||
}
|
||||
|
||||
var total int
|
||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM noticias n LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es' WHERE %s", where)
|
||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM noticias n LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $%d WHERE %s", argNum, where)
|
||||
args = append(args, targetLang)
|
||||
err := db.GetPool().QueryRow(c.Request.Context(), countQuery, args...).Scan(&total)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count news", Message: err.Error()})
|
||||
|
|
@ -94,19 +101,18 @@ func GetNews(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
sqlQuery := fmt.Sprintf(`
|
||||
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
|
||||
n.categoria_id, n.pais_id, n.fuente_nombre,
|
||||
sqlQuery := `
|
||||
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.contenido, n.url, n.fecha, n.imagen_url,
|
||||
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
|
||||
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 = 'es'
|
||||
WHERE %s
|
||||
ORDER BY n.fecha DESC LIMIT $%d OFFSET $%d
|
||||
`, where, argNum, argNum+1)
|
||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $` + strconv.Itoa(argNum) + `
|
||||
WHERE ` + where + `
|
||||
ORDER BY n.fecha DESC LIMIT $` + strconv.Itoa(argNum+1) + ` OFFSET $` + strconv.Itoa(argNum+2)
|
||||
|
||||
args = append(args, perPage, offset)
|
||||
args = append(args, targetLang, perPage, offset)
|
||||
|
||||
rows, err := db.GetPool().Query(c.Request.Context(), sqlQuery, args...)
|
||||
if err != nil {
|
||||
|
|
@ -115,15 +121,16 @@ func GetNews(c *gin.Context) {
|
|||
}
|
||||
defer rows.Close()
|
||||
|
||||
var newsList []NewsResponse
|
||||
var newsList []models.NewsWithTranslations
|
||||
for rows.Next() {
|
||||
var n NewsResponse
|
||||
var n models.NewsWithTranslations
|
||||
var imagenURL, fuenteNombre *string
|
||||
var categoriaID, paisID *int32
|
||||
var lang string
|
||||
|
||||
err := rows.Scan(
|
||||
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
|
||||
&categoriaID, &paisID, &fuenteNombre,
|
||||
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL,
|
||||
&categoriaID, &paisID, &fuenteNombre, &lang,
|
||||
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -137,12 +144,13 @@ func GetNews(c *gin.Context) {
|
|||
}
|
||||
if categoriaID != nil {
|
||||
catID := int64(*categoriaID)
|
||||
n.CategoriaID = &catID
|
||||
n.CategoryID = &catID
|
||||
}
|
||||
if paisID != nil {
|
||||
pID := int64(*paisID)
|
||||
n.PaisID = &pID
|
||||
n.CountryID = &pID
|
||||
}
|
||||
n.Lang = lang
|
||||
newsList = append(newsList, n)
|
||||
}
|
||||
|
||||
|
|
@ -161,18 +169,11 @@ func GetNews(c *gin.Context) {
|
|||
func GetEntityNews(c *gin.Context) {
|
||||
valor := c.Query("valor")
|
||||
tipo := c.DefaultQuery("tipo", "persona")
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
perPageStr := c.DefaultQuery("per_page", "20")
|
||||
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
perPage, _ := strconv.Atoi(perPageStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 50 {
|
||||
perPage = 20
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
page, perPage = validatePageParams(page, perPage, 20, 50)
|
||||
offset := (page - 1) * perPage
|
||||
targetLang := getTargetLang(c)
|
||||
|
||||
daysStr := c.Query("days")
|
||||
days := 0
|
||||
|
|
@ -194,8 +195,8 @@ func GetEntityNews(c *gin.Context) {
|
|||
useDayOffset = true
|
||||
}
|
||||
|
||||
params := []interface{}{valor, tipo}
|
||||
next := 3
|
||||
params := []interface{}{valor, tipo, targetLang}
|
||||
next := 4
|
||||
timeCond := ""
|
||||
switch {
|
||||
case useDayOffset:
|
||||
|
|
@ -214,7 +215,7 @@ func GetEntityNews(c *gin.Context) {
|
|||
base := fmt.Sprintf(`
|
||||
FROM tags_noticia tn
|
||||
JOIN tags t ON tn.tag_id = t.id
|
||||
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
||||
JOIN traducciones tr ON tn.traduccion_id = tr.id AND tr.lang_to = $3
|
||||
JOIN noticias n ON tr.noticia_id = n.id
|
||||
LEFT JOIN entity_aliases ea ON LOWER(ea.alias) = LOWER(t.valor) AND ea.tipo = t.tipo
|
||||
WHERE LOWER(COALESCE(ea.canonical_name, t.valor)) = LOWER($1) AND t.tipo = $2%s`, timeCond)
|
||||
|
|
@ -227,8 +228,8 @@ func GetEntityNews(c *gin.Context) {
|
|||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT DISTINCT n.id, n.titulo, COALESCE(n.resumen,''), n.url, n.fecha, n.imagen_url,
|
||||
n.fuente_nombre, tr.titulo_trad, tr.resumen_trad
|
||||
SELECT DISTINCT n.id, n.titulo, COALESCE(n.resumen,''), n.contenido, n.url, n.fecha, n.imagen_url,
|
||||
n.fuente_nombre, n.lang, tr.titulo_trad, tr.resumen_trad
|
||||
%s
|
||||
ORDER BY n.fecha DESC
|
||||
LIMIT $%d OFFSET $%d`, base, next, next+1)
|
||||
|
|
@ -240,12 +241,12 @@ func GetEntityNews(c *gin.Context) {
|
|||
}
|
||||
defer rows.Close()
|
||||
|
||||
var news []NewsResponse
|
||||
var news []models.NewsWithTranslations
|
||||
for rows.Next() {
|
||||
var n NewsResponse
|
||||
var n models.NewsWithTranslations
|
||||
var img *string
|
||||
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &img,
|
||||
&n.FuenteNombre, &n.TitleTranslated, &n.SummaryTranslated); err != nil {
|
||||
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &img,
|
||||
&n.FuenteNombre, &n.Lang, &n.TitleTranslated, &n.SummaryTranslated); err != nil {
|
||||
continue
|
||||
}
|
||||
if img != nil {
|
||||
|
|
@ -254,7 +255,7 @@ func GetEntityNews(c *gin.Context) {
|
|||
news = append(news, n)
|
||||
}
|
||||
if news == nil {
|
||||
news = []NewsResponse{}
|
||||
news = []models.NewsWithTranslations{}
|
||||
}
|
||||
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
|
|
@ -268,27 +269,65 @@ func GetEntityNews(c *gin.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
// GetNewsByID returns a single news item with entities
|
||||
// @Summary Get news by ID
|
||||
// @Description Returns a single news item with translations and associated entities
|
||||
// @Tags news
|
||||
// @Produce json
|
||||
// @Param id path string true "News ID"
|
||||
// @Param lang query string false "Target translation language" default(es)
|
||||
// @Success 200 {object} models.NewsWithTranslations
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /news/{id} [get]
|
||||
func GetNewsByID(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
targetLang := getTargetLang(c)
|
||||
|
||||
// Combined query to fetch news with entities in one round trip
|
||||
sqlQuery := `
|
||||
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
|
||||
n.categoria_id, n.pais_id, n.fuente_nombre,
|
||||
SELECT
|
||||
n.id, n.titulo, COALESCE(n.resumen, ''), n.contenido, n.url, n.fecha, n.imagen_url,
|
||||
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
|
||||
t.titulo_trad,
|
||||
t.resumen_trad,
|
||||
t.lang_to as lang_trad
|
||||
t.lang_to as lang_trad,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'valor', ent.valor,
|
||||
'tipo', ent.tipo,
|
||||
'count', ent.cnt,
|
||||
'wiki_summary', ent.wiki_summary,
|
||||
'wiki_url', ent.wiki_url,
|
||||
'image_path', ent.image_path
|
||||
)
|
||||
) FILTER (WHERE ent.valor IS NOT NULL) as entities
|
||||
FROM noticias n
|
||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
|
||||
WHERE n.id = $1`
|
||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1
|
||||
LEFT JOIN (
|
||||
SELECT tn.noticia_id, t.valor, t.tipo, 1 as cnt, t.wiki_summary, t.wiki_url, t.image_path
|
||||
FROM tags_noticia tn
|
||||
JOIN tags t ON tn.tag_id = t.id
|
||||
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
||||
WHERE t.tipo IN ('persona', 'organizacion')
|
||||
) ent ON ent.noticia_id = n.id
|
||||
WHERE n.id = $2
|
||||
GROUP BY n.id, n.titulo, n.resumen, n.contenido, n.url, n.fecha, n.imagen_url,
|
||||
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
|
||||
t.titulo_trad, t.resumen_trad, t.lang_to
|
||||
`
|
||||
|
||||
var n NewsResponse
|
||||
var n models.NewsWithTranslations
|
||||
var imagenURL, fuenteNombre *string
|
||||
var categoriaID, paisID *int32
|
||||
var lang string
|
||||
var entitiesJSON *string
|
||||
|
||||
err := db.GetPool().QueryRow(c.Request.Context(), sqlQuery, id).Scan(
|
||||
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
|
||||
&categoriaID, &paisID, &fuenteNombre,
|
||||
err := db.GetPool().QueryRow(c.Request.Context(), sqlQuery, targetLang, id).Scan(
|
||||
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL,
|
||||
&categoriaID, &paisID, &fuenteNombre, &lang,
|
||||
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
||||
&entitiesJSON,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "News not found"})
|
||||
|
|
@ -303,36 +342,23 @@ func GetNewsByID(c *gin.Context) {
|
|||
}
|
||||
if categoriaID != nil {
|
||||
catID := int64(*categoriaID)
|
||||
n.CategoriaID = &catID
|
||||
n.CategoryID = &catID
|
||||
}
|
||||
if paisID != nil {
|
||||
pID := int64(*paisID)
|
||||
n.PaisID = &pID
|
||||
n.CountryID = &pID
|
||||
}
|
||||
n.Lang = lang
|
||||
|
||||
// Fetch entities for this news
|
||||
entitiesQuery := `
|
||||
SELECT t.valor, t.tipo, 1 as cnt, t.wiki_summary, t.wiki_url, t.image_path
|
||||
FROM tags_noticia tn
|
||||
JOIN tags t ON tn.tag_id = t.id
|
||||
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
||||
WHERE tr.noticia_id = $1 AND t.tipo IN ('persona', 'organizacion')
|
||||
`
|
||||
rows, err := db.GetPool().Query(c.Request.Context(), entitiesQuery, id)
|
||||
var entities []Entity
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e Entity
|
||||
if err := rows.Scan(&e.Valor, &e.Tipo, &e.Count, &e.WikiSummary, &e.WikiURL, &e.ImagePath); err == nil {
|
||||
entities = append(entities, e)
|
||||
}
|
||||
// Parse entities from JSON
|
||||
if entitiesJSON != nil && *entitiesJSON != "null" {
|
||||
var entities []models.Entity
|
||||
if err := json.Unmarshal([]byte(*entitiesJSON), &entities); err == nil {
|
||||
n.Entities = entities
|
||||
}
|
||||
} else {
|
||||
n.Entities = []models.Entity{}
|
||||
}
|
||||
if entities == nil {
|
||||
entities = []Entity{}
|
||||
}
|
||||
n.Entities = entities
|
||||
|
||||
c.JSON(http.StatusOK, n)
|
||||
}
|
||||
|
|
@ -354,23 +380,20 @@ func DeleteNews(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, models.SuccessResponse{Message: "News deleted successfully"})
|
||||
}
|
||||
|
||||
type Entity struct {
|
||||
Valor string `json:"valor"`
|
||||
Tipo string `json:"tipo"`
|
||||
Count int `json:"count"`
|
||||
WikiSummary *string `json:"wiki_summary"`
|
||||
WikiURL *string `json:"wiki_url"`
|
||||
ImagePath *string `json:"image_path"`
|
||||
}
|
||||
|
||||
type EntityListResponse struct {
|
||||
Entities []Entity `json:"entities"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// GetEntities returns paginated entities with optional filters
|
||||
// @Summary List entities
|
||||
// @Description Returns paginated entities (personas, organizations, locations) with optional filters
|
||||
// @Tags entities
|
||||
// @Produce json
|
||||
// @Param tipo query string false "Entity type" default("persona") Enums(persona, organizacion, lugar, tema)
|
||||
// @Param page query int false "Page number" default(1) minimum(1)
|
||||
// @Param per_page query int false "Items per page" default(50) minimum(1) maximum(100)
|
||||
// @Param country_id query string false "Country ID filter"
|
||||
// @Param category_id query string false "Category ID filter"
|
||||
// @Param q query string false "Search query"
|
||||
// @Success 200 {object} models.EntityListResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /entities [get]
|
||||
func GetEntities(c *gin.Context) {
|
||||
countryID := c.Query("country_id")
|
||||
categoryID := c.Query("category_id")
|
||||
|
|
@ -378,18 +401,9 @@ func GetEntities(c *gin.Context) {
|
|||
|
||||
q := c.Query("q")
|
||||
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
perPageStr := c.DefaultQuery("per_page", "50")
|
||||
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
perPage, _ := strconv.Atoi(perPageStr)
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 50
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
||||
page, perPage = validatePageParams(page, perPage, 50, 100)
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
|
|
@ -430,8 +444,8 @@ func GetEntities(c *gin.Context) {
|
|||
}
|
||||
|
||||
if total == 0 {
|
||||
c.JSON(http.StatusOK, EntityListResponse{
|
||||
Entities: []Entity{},
|
||||
c.JSON(http.StatusOK, models.EntityListResponse{
|
||||
Entities: []models.Entity{},
|
||||
Total: 0,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
|
|
@ -463,9 +477,9 @@ func GetEntities(c *gin.Context) {
|
|||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entities []Entity
|
||||
var entities []models.Entity
|
||||
for rows.Next() {
|
||||
var e Entity
|
||||
var e models.Entity
|
||||
if err := rows.Scan(&e.Valor, &e.Tipo, &e.Count, &e.WikiSummary, &e.WikiURL, &e.ImagePath); err != nil {
|
||||
continue
|
||||
}
|
||||
|
|
@ -473,12 +487,12 @@ func GetEntities(c *gin.Context) {
|
|||
}
|
||||
|
||||
if entities == nil {
|
||||
entities = []Entity{}
|
||||
entities = []models.Entity{}
|
||||
}
|
||||
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
|
||||
c.JSON(http.StatusOK, EntityListResponse{
|
||||
c.JSON(http.StatusOK, models.EntityListResponse{
|
||||
Entities: entities,
|
||||
Total: total,
|
||||
Page: page,
|
||||
|
|
@ -487,23 +501,6 @@ func GetEntities(c *gin.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
type MentionPoint struct {
|
||||
Fecha string `json:"fecha"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type MentionSeries struct {
|
||||
Valor string `json:"valor"`
|
||||
Tipo string `json:"tipo"`
|
||||
Count int `json:"count"`
|
||||
Data []MentionPoint `json:"data"`
|
||||
}
|
||||
|
||||
type MentionsResponse struct {
|
||||
Days int `json:"days"`
|
||||
Series []MentionSeries `json:"series"`
|
||||
}
|
||||
|
||||
func GetEntityMentions(c *gin.Context) {
|
||||
valuesRaw := c.DefaultQuery("values", "")
|
||||
days := 30
|
||||
|
|
@ -531,7 +528,7 @@ func GetEntityMentions(c *gin.Context) {
|
|||
|
||||
start := time.Now().AddDate(0, 0, -(days - 1)).Truncate(24 * time.Hour)
|
||||
|
||||
series := make([]MentionSeries, 0, len(values))
|
||||
series := make([]models.MentionSeries, 0, len(values))
|
||||
for _, valor := range values {
|
||||
s, err := buildMentionSeries(c.Request.Context(), valor, days)
|
||||
if err != nil {
|
||||
|
|
@ -543,10 +540,10 @@ func GetEntityMentions(c *gin.Context) {
|
|||
for _, p := range s.Data {
|
||||
m[p.Fecha] = p.Count
|
||||
}
|
||||
data := make([]MentionPoint, 0, days)
|
||||
data := make([]models.MentionPoint, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
fecha := start.AddDate(0, 0, i).Format("2006-01-02")
|
||||
data = append(data, MentionPoint{Fecha: fecha, Count: m[fecha]})
|
||||
data = append(data, models.MentionPoint{Fecha: fecha, Count: m[fecha]})
|
||||
}
|
||||
s.Data = data
|
||||
s.Count = 0
|
||||
|
|
@ -559,11 +556,11 @@ func GetEntityMentions(c *gin.Context) {
|
|||
series = append(series, *s)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, MentionsResponse{Days: days, Series: series})
|
||||
c.JSON(http.StatusOK, models.MentionsResponse{Days: days, Series: series})
|
||||
}
|
||||
|
||||
func buildMentionSeries(ctx context.Context, valor string, days int) (*MentionSeries, error) {
|
||||
s := &MentionSeries{Valor: valor}
|
||||
func buildMentionSeries(ctx context.Context, valor string, days int) (*models.MentionSeries, error) {
|
||||
s := &models.MentionSeries{Valor: valor}
|
||||
|
||||
var tagIDs []int64
|
||||
rows, err := db.GetPool().Query(ctx, `
|
||||
|
|
@ -590,7 +587,7 @@ func buildMentionSeries(ctx context.Context, valor string, days int) (*MentionSe
|
|||
rows.Close()
|
||||
|
||||
if len(tagIDs) == 0 {
|
||||
s.Data = []MentionPoint{}
|
||||
s.Data = []models.MentionPoint{}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
|
@ -615,10 +612,10 @@ func buildMentionSeries(ctx context.Context, valor string, days int) (*MentionSe
|
|||
if err := drows.Scan(&fecha, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
s.Data = append(s.Data, MentionPoint{Fecha: fecha, Count: cnt})
|
||||
s.Data = append(s.Data, models.MentionPoint{Fecha: fecha, Count: cnt})
|
||||
}
|
||||
if s.Data == nil {
|
||||
s.Data = []MentionPoint{}
|
||||
s.Data = []models.MentionPoint{}
|
||||
}
|
||||
|
||||
return s, nil
|
||||
|
|
|
|||
38
backend/internal/handlers/news_test.go
Normal file
38
backend/internal/handlers/news_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePageParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
page int
|
||||
perPage int
|
||||
defaultPerPage int
|
||||
maxPerPage int
|
||||
expectedPage int
|
||||
expectedPerPage int
|
||||
}{
|
||||
{"normal", 1, 30, 30, 100, 1, 30},
|
||||
{"page zero", 0, 30, 30, 100, 1, 30},
|
||||
{"page negative", -5, 30, 30, 100, 1, 30},
|
||||
{"per_page zero", 1, 0, 30, 100, 1, 30},
|
||||
{"per_page negative", 1, -10, 30, 100, 1, 30},
|
||||
{"per_page exceeds max", 1, 200, 30, 100, 1, 100},
|
||||
{"page exceeds max", 999, 30, 30, 100, 999, 30},
|
||||
{"custom max", 1, 200, 20, 50, 1, 50},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
page, perPage := validatePageParams(tt.page, tt.perPage, tt.defaultPerPage, tt.maxPerPage)
|
||||
if page != tt.expectedPage {
|
||||
t.Errorf("page: expected %d, got %d", tt.expectedPage, page)
|
||||
}
|
||||
if perPage != tt.expectedPerPage {
|
||||
t.Errorf("perPage: expected %d, got %d", tt.expectedPerPage, perPage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rss2/backend/internal/auth"
|
||||
"github.com/rss2/backend/internal/cache"
|
||||
"github.com/rss2/backend/internal/db"
|
||||
"github.com/rss2/backend/internal/models"
|
||||
"github.com/rss2/backend/internal/services"
|
||||
|
|
@ -79,10 +81,27 @@ func SearchSuggestions(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, gin.H{"terms": terms})
|
||||
}
|
||||
|
||||
// SearchNews searches news with various filters
|
||||
// @Summary Search news
|
||||
// @Description Search news with text query, language, category, country, and semantic search options
|
||||
// @Tags search
|
||||
// @Produce json
|
||||
// @Param q query string false "Search query"
|
||||
// @Param page query int false "Page number" default(1) minimum(1)
|
||||
// @Param per_page query int false "Items per page" default(30) minimum(1) maximum(100)
|
||||
// @Param lang query string false "Language code" default("es")
|
||||
// @Param categoria_id query string false "Category ID filter"
|
||||
// @Param pais_id query string false "Country ID filter"
|
||||
// @Param semantic query string false "Use semantic search" default("false")
|
||||
// @Success 200 {object} map[string]interface{} "news, total, page, per_page, total_pages"
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /search [get]
|
||||
func SearchNews(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30"))
|
||||
page, perPage = validatePageParams(page, perPage, 30, 100)
|
||||
lang := c.DefaultQuery("lang", "")
|
||||
categoriaID := c.Query("categoria_id")
|
||||
paisID := c.Query("pais_id")
|
||||
|
|
@ -93,13 +112,6 @@ func SearchNews(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 30
|
||||
}
|
||||
|
||||
// Default to Spanish if no lang specified
|
||||
if lang == "" {
|
||||
lang = "es"
|
||||
|
|
@ -117,6 +129,15 @@ func SearchNews(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Cache key for search results
|
||||
cacheKey := cache.SearchKey(query, lang, page, perPage)
|
||||
|
||||
// Try to get from cache
|
||||
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
|
||||
c.Data(http.StatusOK, "application/json", []byte(cached))
|
||||
return
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
// Build dynamic query
|
||||
|
|
@ -147,8 +168,8 @@ func SearchNews(c *gin.Context) {
|
|||
}
|
||||
|
||||
sqlQuery := `
|
||||
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
|
||||
n.categoria_id, n.pais_id, n.fuente_nombre,
|
||||
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.contenido, n.url, n.fecha, n.imagen_url,
|
||||
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
|
||||
t.titulo_trad,
|
||||
t.resumen_trad,
|
||||
t.lang_to as lang_trad
|
||||
|
|
@ -167,15 +188,16 @@ func SearchNews(c *gin.Context) {
|
|||
}
|
||||
defer rows.Close()
|
||||
|
||||
var newsList []NewsResponse
|
||||
var newsList []models.NewsWithTranslations
|
||||
for rows.Next() {
|
||||
var n NewsResponse
|
||||
var n models.NewsWithTranslations
|
||||
var imagenURL, fuenteNombre *string
|
||||
var categoriaIDp, paisIDp *int64
|
||||
var lang string
|
||||
|
||||
err := rows.Scan(
|
||||
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
|
||||
&categoriaIDp, &paisIDp, &fuenteNombre,
|
||||
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL,
|
||||
&categoriaIDp, &paisIDp, &fuenteNombre, &lang,
|
||||
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -187,8 +209,9 @@ func SearchNews(c *gin.Context) {
|
|||
if fuenteNombre != nil {
|
||||
n.FuenteNombre = *fuenteNombre
|
||||
}
|
||||
n.CategoriaID = categoriaIDp
|
||||
n.PaisID = paisIDp
|
||||
n.CategoryID = categoriaIDp
|
||||
n.CountryID = paisIDp
|
||||
n.Lang = lang
|
||||
newsList = append(newsList, n)
|
||||
}
|
||||
|
||||
|
|
@ -206,19 +229,42 @@ func SearchNews(c *gin.Context) {
|
|||
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
response := gin.H{
|
||||
"news": newsList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"total_pages": totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
// Cache the response
|
||||
if data, err := json.Marshal(response); err == nil {
|
||||
cache.Set(ctx, cacheKey, string(data), cache.TTLShort)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetStats returns global statistics
|
||||
// @Summary Get statistics
|
||||
// @Description Returns global statistics about news, feeds, users, and translations
|
||||
// @Tags stats
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.Stats
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /stats [get]
|
||||
func GetStats(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
cacheKey := cache.StatsKey()
|
||||
|
||||
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
|
||||
c.Data(http.StatusOK, "application/json", []byte(cached))
|
||||
return
|
||||
}
|
||||
|
||||
var stats models.Stats
|
||||
|
||||
err := db.GetPool().QueryRow(c.Request.Context(), `
|
||||
err := db.GetPool().QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM noticias) as total_news,
|
||||
(SELECT COUNT(*) FROM feeds WHERE activo = true) as total_feeds,
|
||||
|
|
@ -237,7 +283,7 @@ func GetStats(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
rows, err := db.GetPool().Query(c.Request.Context(), `
|
||||
rows, err := db.GetPool().Query(ctx, `
|
||||
SELECT c.id, c.nombre, COUNT(n.id) as count
|
||||
FROM categorias c
|
||||
LEFT JOIN noticias n ON n.categoria_id = c.id
|
||||
|
|
@ -249,12 +295,12 @@ func GetStats(c *gin.Context) {
|
|||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cs models.CategoryStat
|
||||
rows.Scan(&cs.CategoryID, &cs.CategoryName, &cs.Count)
|
||||
rows.Scan(&cs.CategoriaID, &cs.CategoriaName, &cs.Count)
|
||||
stats.TopCategories = append(stats.TopCategories, cs)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err = db.GetPool().Query(c.Request.Context(), `
|
||||
rows, err = db.GetPool().Query(ctx, `
|
||||
SELECT p.id, p.nombre, p.flag_emoji, COUNT(n.id) as count
|
||||
FROM paises p
|
||||
LEFT JOIN noticias n ON n.pais_id = p.id
|
||||
|
|
@ -266,16 +312,28 @@ func GetStats(c *gin.Context) {
|
|||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cs models.CountryStat
|
||||
rows.Scan(&cs.CountryID, &cs.CountryName, &cs.FlagEmoji, &cs.Count)
|
||||
rows.Scan(&cs.PaisID, &cs.PaisName, &cs.FlagEmoji, &cs.Count)
|
||||
stats.TopCountries = append(stats.TopCountries, cs)
|
||||
}
|
||||
}
|
||||
|
||||
if data, err := json.Marshal(stats); err == nil {
|
||||
cache.Set(ctx, cacheKey, string(data), cache.TTLMedium)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func GetCategories(c *gin.Context) {
|
||||
rows, err := db.GetPool().Query(c.Request.Context(), `
|
||||
ctx := c.Request.Context()
|
||||
cacheKey := cache.CategoriesKey()
|
||||
|
||||
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
|
||||
c.Data(http.StatusOK, "application/json", []byte(cached))
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.GetPool().Query(ctx, `
|
||||
SELECT id, nombre FROM categorias ORDER BY nombre`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get categories", Message: err.Error()})
|
||||
|
|
@ -283,23 +341,30 @@ func GetCategories(c *gin.Context) {
|
|||
}
|
||||
defer rows.Close()
|
||||
|
||||
type Category struct {
|
||||
ID int64 `json:"id"`
|
||||
Nombre string `json:"nombre"`
|
||||
}
|
||||
|
||||
var categories []Category
|
||||
var categories []models.Category
|
||||
for rows.Next() {
|
||||
var cat Category
|
||||
var cat models.Category
|
||||
rows.Scan(&cat.ID, &cat.Nombre)
|
||||
categories = append(categories, cat)
|
||||
}
|
||||
|
||||
if data, err := json.Marshal(categories); err == nil {
|
||||
cache.Set(ctx, cacheKey, string(data), cache.TTLLong)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, categories)
|
||||
}
|
||||
|
||||
func GetCountries(c *gin.Context) {
|
||||
rows, err := db.GetPool().Query(c.Request.Context(), `
|
||||
ctx := c.Request.Context()
|
||||
cacheKey := cache.CountriesKey()
|
||||
|
||||
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
|
||||
c.Data(http.StatusOK, "application/json", []byte(cached))
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.GetPool().Query(ctx, `
|
||||
SELECT p.id, p.nombre, c.nombre as continente
|
||||
FROM paises p
|
||||
LEFT JOIN continentes c ON c.id = p.continente_id
|
||||
|
|
@ -310,18 +375,16 @@ func GetCountries(c *gin.Context) {
|
|||
}
|
||||
defer rows.Close()
|
||||
|
||||
type Country struct {
|
||||
ID int64 `json:"id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Continente string `json:"continente"`
|
||||
}
|
||||
|
||||
var countries []Country
|
||||
var countries []models.Country
|
||||
for rows.Next() {
|
||||
var country Country
|
||||
var country models.Country
|
||||
rows.Scan(&country.ID, &country.Nombre, &country.Continente)
|
||||
countries = append(countries, country)
|
||||
}
|
||||
|
||||
if data, err := json.Marshal(countries); err == nil {
|
||||
cache.Set(ctx, cacheKey, string(data), cache.TTLLong)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, countries)
|
||||
}
|
||||
|
|
|
|||
35
backend/internal/handlers/search_test.go
Normal file
35
backend/internal/handlers/search_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePageParamsSearch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
page int
|
||||
perPage int
|
||||
defaultPerPage int
|
||||
maxPerPage int
|
||||
expectedPage int
|
||||
expectedPerPage int
|
||||
}{
|
||||
{"normal", 1, 30, 30, 100, 1, 30},
|
||||
{"page zero", 0, 30, 30, 100, 1, 30},
|
||||
{"per_page too large", 1, 200, 30, 100, 1, 100},
|
||||
{"page negative", -5, 30, 30, 100, 1, 30},
|
||||
{"per_page zero", 1, 0, 30, 100, 1, 30},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
page, perPage := validatePageParams(tt.page, tt.perPage, tt.defaultPerPage, tt.maxPerPage)
|
||||
if page != tt.expectedPage {
|
||||
t.Errorf("page: expected %d, got %d", tt.expectedPage, page)
|
||||
}
|
||||
if perPage != tt.expectedPerPage {
|
||||
t.Errorf("perPage: expected %d, got %d", tt.expectedPerPage, perPage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
14
backend/internal/handlers/validation.go
Normal file
14
backend/internal/handlers/validation.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package handlers
|
||||
|
||||
func validatePageParams(page, perPage, defaultPerPage, maxPerPage int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = defaultPerPage
|
||||
}
|
||||
if perPage > maxPerPage {
|
||||
perPage = maxPerPage
|
||||
}
|
||||
return page, perPage
|
||||
}
|
||||
60
backend/internal/logger/logger.go
Normal file
60
backend/internal/logger/logger.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var Logger zerolog.Logger
|
||||
|
||||
func Init(serviceName, level string) {
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
|
||||
zerolog.CallerMarshalFunc = func(pc uintptr, file string, line int) string {
|
||||
short := file
|
||||
for i := len(file) - 1; i > 0; i-- {
|
||||
if file[i] == '/' {
|
||||
short = file[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
return short + ":" + string(rune(line))
|
||||
}
|
||||
|
||||
output := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339, NoColor: false}
|
||||
logger := zerolog.New(output).With().Timestamp().Caller().Str("service", serviceName).Logger()
|
||||
|
||||
switch level {
|
||||
case "debug":
|
||||
logger = logger.Level(zerolog.DebugLevel)
|
||||
case "info":
|
||||
logger = logger.Level(zerolog.InfoLevel)
|
||||
case "warn":
|
||||
logger = logger.Level(zerolog.WarnLevel)
|
||||
case "error":
|
||||
logger = logger.Level(zerolog.ErrorLevel)
|
||||
default:
|
||||
logger = logger.Level(zerolog.InfoLevel)
|
||||
}
|
||||
|
||||
Logger = logger
|
||||
log.Logger = logger
|
||||
}
|
||||
|
||||
func GetLogger() zerolog.Logger {
|
||||
return Logger
|
||||
}
|
||||
|
||||
func WithRequestID(requestID string) zerolog.Logger {
|
||||
return Logger.With().Str("request_id", requestID).Logger()
|
||||
}
|
||||
|
||||
func WithFields(fields map[string]interface{}) zerolog.Logger {
|
||||
ctx := Logger.With()
|
||||
for k, v := range fields {
|
||||
ctx = ctx.Interface(k, v)
|
||||
}
|
||||
return ctx.Logger()
|
||||
}
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rss2/backend/internal/auth"
|
||||
"github.com/rss2/backend/internal/config"
|
||||
"github.com/rss2/backend/internal/logger"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
|
|
@ -63,23 +68,19 @@ func CORSMiddleware() gin.HandlerFunc {
|
|||
|
||||
return func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
|
||||
|
||||
allowed := false
|
||||
for _, o := range allowedOrigins {
|
||||
o = strings.TrimSpace(o)
|
||||
if o == "*" || o == origin {
|
||||
if o == origin {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allowed {
|
||||
if cfg.AllowedOrigins == "*" {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
} else {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
|
|
@ -96,17 +97,101 @@ func CORSMiddleware() gin.HandlerFunc {
|
|||
|
||||
func LoggerMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
raw := c.Request.URL.RawQuery
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = c.GetString("request_id")
|
||||
}
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
if status >= 400 {
|
||||
// Log error responses
|
||||
clientIP := c.ClientIP()
|
||||
method := c.Request.Method
|
||||
|
||||
if raw != "" {
|
||||
path = path + "?" + raw
|
||||
}
|
||||
|
||||
log := logger.GetLogger().With().
|
||||
Str("method", method).
|
||||
Str("path", path).
|
||||
Int("status", status).
|
||||
Str("client_ip", clientIP).
|
||||
Dur("latency", latency).
|
||||
Logger()
|
||||
|
||||
if requestID != "" {
|
||||
log = log.With().Str("request_id", requestID).Logger()
|
||||
}
|
||||
|
||||
if status >= 500 {
|
||||
log.Error().Msg("Server error")
|
||||
} else if status >= 400 {
|
||||
log.Warn().Msg("Client error")
|
||||
} else {
|
||||
log.Info().Msg("Request completed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type clientLimiter struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
clientLimiters = make(map[string]*clientLimiter)
|
||||
limitersMu sync.Mutex
|
||||
)
|
||||
|
||||
func RateLimitMiddleware(requestsPerMinute int) gin.HandlerFunc {
|
||||
limit := rate.Limit(requestsPerMinute) / 60
|
||||
burst := requestsPerMinute
|
||||
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
|
||||
limitersMu.Lock()
|
||||
cl, exists := clientLimiters[ip]
|
||||
if !exists {
|
||||
cl = &clientLimiter{limiter: rate.NewLimiter(limit, burst)}
|
||||
clientLimiters[ip] = cl
|
||||
}
|
||||
cl.lastSeen = time.Now()
|
||||
limiter := cl.limiter
|
||||
limitersMu.Unlock()
|
||||
|
||||
if !limiter.Allow() {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(10 * time.Minute)
|
||||
limitersMu.Lock()
|
||||
if cl, ok := clientLimiters[ip]; ok && time.Since(cl.lastSeen) > 10*time.Minute {
|
||||
delete(clientLimiters, ip)
|
||||
}
|
||||
limitersMu.Unlock()
|
||||
}()
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequestIDMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
c.Set("request_id", requestID)
|
||||
c.Header("X-Request-ID", requestID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import (
|
|||
|
||||
type News struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Titulo string `json:"titulo"`
|
||||
Resumen string `json:"resumen"`
|
||||
Contenido string `json:"contenido"`
|
||||
URL string `json:"url"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
ImagenURL *string `json:"imagen_url"`
|
||||
Fecha *time.Time `json:"fecha"`
|
||||
Lang string `json:"lang"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
|
@ -20,35 +20,73 @@ type News struct {
|
|||
|
||||
type NewsWithTranslations struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Titulo string `json:"titulo"`
|
||||
Resumen string `json:"resumen"`
|
||||
Contenido string `json:"contenido"`
|
||||
URL string `json:"url"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
PublishedAt *string `json:"published_at"`
|
||||
ImagenURL *string `json:"imagen_url"`
|
||||
Fecha *string `json:"fecha"`
|
||||
Lang string `json:"lang"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
CountryID *int64 `json:"country_id"`
|
||||
CategoryID *int64 `json:"categoria_id"`
|
||||
CountryID *int64 `json:"pais_id"`
|
||||
FuenteNombre string `json:"fuente_nombre"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
TitleTranslated *string `json:"title_translated"`
|
||||
SummaryTranslated *string `json:"summary_translated"`
|
||||
ContentTranslated *string `json:"content_translated"`
|
||||
LangTranslated *string `json:"lang_translated"`
|
||||
Entities []Entity `json:"entities,omitempty"`
|
||||
}
|
||||
|
||||
type Entity struct {
|
||||
Valor string `json:"valor"`
|
||||
Tipo string `json:"tipo"`
|
||||
Count int `json:"count"`
|
||||
WikiSummary *string `json:"wiki_summary"`
|
||||
WikiURL *string `json:"wiki_url"`
|
||||
ImagePath *string `json:"image_path"`
|
||||
}
|
||||
|
||||
type EntityListResponse struct {
|
||||
Entities []Entity `json:"entities"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type MentionPoint struct {
|
||||
Fecha string `json:"fecha"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type MentionSeries struct {
|
||||
Valor string `json:"valor"`
|
||||
Tipo string `json:"tipo"`
|
||||
Count int `json:"count"`
|
||||
Data []MentionPoint `json:"data"`
|
||||
}
|
||||
|
||||
type MentionsResponse struct {
|
||||
Days int `json:"days"`
|
||||
Series []MentionSeries `json:"series"`
|
||||
}
|
||||
|
||||
type Feed struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Nombre string `json:"nombre"`
|
||||
URL string `json:"url"`
|
||||
SiteURL *string `json:"site_url"`
|
||||
Description *string `json:"description"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
Language *string `json:"language"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
CountryID *int64 `json:"country_id"`
|
||||
Active bool `json:"active"`
|
||||
Descripcion *string `json:"descripcion"`
|
||||
ImagenURL *string `json:"imagen_url"`
|
||||
Idioma *string `json:"idioma"`
|
||||
CategoriaID *int64 `json:"categoria_id"`
|
||||
PaisID *int64 `json:"pais_id"`
|
||||
Activo bool `json:"activo"`
|
||||
Fallos *int64 `json:"fallos"`
|
||||
LastError *string `json:"last_error"`
|
||||
LastFetched *time.Time `json:"last_fetched"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
|
@ -56,27 +94,27 @@ type Feed struct {
|
|||
|
||||
type Category struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Nombre string `json:"nombre"`
|
||||
Color string `json:"color"`
|
||||
Icon string `json:"icon"`
|
||||
ParentID *int64 `json:"parent_id"`
|
||||
}
|
||||
|
||||
type Country struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Continent string `json:"continent"`
|
||||
FlagEmoji string `json:"flag_emoji"`
|
||||
ID int64 `json:"id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Codigo string `json:"codigo"`
|
||||
Continente string `json:"continente"`
|
||||
FlagEmoji string `json:"flag_emoji"`
|
||||
}
|
||||
|
||||
type Translation struct {
|
||||
ID int64 `json:"id"`
|
||||
NewsID int64 `json:"news_id"`
|
||||
NoticiaID int64 `json:"noticia_id"`
|
||||
LangFrom string `json:"lang_from"`
|
||||
LangTo string `json:"lang_to"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Titulo string `json:"titulo"`
|
||||
Resumen string `json:"resumen"`
|
||||
Status string `json:"status"`
|
||||
Error *string `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
|
@ -89,6 +127,7 @@ type User struct {
|
|||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -97,8 +136,8 @@ type SearchHistory struct {
|
|||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Query string `json:"query"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
CountryID *int64 `json:"country_id"`
|
||||
CategoriaID *int64 `json:"categoria_id"`
|
||||
PaisID *int64 `json:"pais_id"`
|
||||
ResultsCount int `json:"results_count"`
|
||||
SearchedAt time.Time `json:"searched_at"`
|
||||
}
|
||||
|
|
@ -132,14 +171,14 @@ type Stats struct {
|
|||
}
|
||||
|
||||
type CategoryStat struct {
|
||||
CategoryID int64 `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Count int64 `json:"count"`
|
||||
CategoriaID int64 `json:"categoria_id"`
|
||||
CategoriaName string `json:"categoria_nombre"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type CountryStat struct {
|
||||
CountryID int64 `json:"country_id"`
|
||||
CountryName string `json:"country_name"`
|
||||
PaisID int64 `json:"pais_id"`
|
||||
PaisName string `json:"pais_nombre"`
|
||||
FlagEmoji string `json:"flag_emoji"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rss2/backend/internal/db"
|
||||
)
|
||||
|
||||
var pool *pgxpool.Pool
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
|
|
@ -31,39 +30,23 @@ func LoadDBConfig() *Config {
|
|||
}
|
||||
|
||||
func Connect(cfg *Config) error {
|
||||
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
|
||||
|
||||
poolConfig, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse config: %w", err)
|
||||
// Just verify the main pool is accessible
|
||||
if db.GetPool() == nil {
|
||||
return fmt.Errorf("database pool not initialized")
|
||||
}
|
||||
|
||||
poolConfig.MaxConns = 25
|
||||
poolConfig.MinConns = 5
|
||||
poolConfig.MaxConnLifetime = time.Hour
|
||||
poolConfig.MaxConnIdleTime = 30 * time.Minute
|
||||
|
||||
pool, err = pgxpool.NewWithConfig(context.Background(), poolConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create pool: %w", err)
|
||||
}
|
||||
|
||||
if err = pool.Ping(context.Background()); err != nil {
|
||||
return fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return db.HealthCheck(context.Background())
|
||||
}
|
||||
|
||||
func GetPool() *pgxpool.Pool {
|
||||
return pool
|
||||
return db.GetPool()
|
||||
}
|
||||
|
||||
func Close() {
|
||||
if pool != nil {
|
||||
pool.Close()
|
||||
}
|
||||
// No-op: main db package manages the pool
|
||||
}
|
||||
|
||||
func HealthCheck(ctx context.Context) error {
|
||||
return db.HealthCheck(ctx)
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
|
|
|
|||
|
|
@ -340,12 +340,12 @@ services:
|
|||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
# Prioridad BAJA: el traductor no debe acaparar toda la CPU.
|
||||
# cpu_shares menor que el resto (1024) + tope de 2 núcleos dejan CPU para NER, embeddings, etc.
|
||||
# cpu_shares menor que el resto (1024) + tope que deje CPU para NER, embeddings, etc.
|
||||
cpu_shares: 512
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
cpus: '4'
|
||||
memory: 6G
|
||||
|
||||
# ==================================================================================
|
||||
|
|
@ -390,7 +390,7 @@ services:
|
|||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
cpus: '4'
|
||||
memory: 6G
|
||||
|
||||
# ==================================================================================
|
||||
|
|
|
|||
|
|
@ -99,8 +99,16 @@
|
|||
"Pakistán",
|
||||
"Tolo News",
|
||||
"Gladbach",
|
||||
"Alger"
|
||||
|
||||
"Alger",
|
||||
"Comisión",
|
||||
"Dirección",
|
||||
"Más",
|
||||
"Regístrese",
|
||||
"Consejo",
|
||||
"Fondo",
|
||||
"PVT LTD",
|
||||
"Beneficios",
|
||||
"Ingrese"
|
||||
],
|
||||
"synonyms": {
|
||||
"Alassane Ouattara": [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
||||
import { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server, TrendingUp, Bell } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server, TrendingUp, Bell, X, Menu } from 'lucide-react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { apiService } from '../../services/api'
|
||||
|
||||
export function Layout() {
|
||||
|
|
|
|||
166
frontend/src/components/ui/EmptyState.tsx
Normal file
166
frontend/src/components/ui/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { Search, Rss, Globe, AlertTriangle, Inbox } from 'lucide-react'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: 'search' | 'rss' | 'globe' | 'alert' | 'inbox'
|
||||
title: string
|
||||
description: string
|
||||
action?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon = 'search',
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className = ''
|
||||
}: EmptyStateProps) {
|
||||
const icons = {
|
||||
search: Search,
|
||||
rss: Rss,
|
||||
globe: Globe,
|
||||
alert: AlertTriangle,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
const Icon = icons[icon]
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center py-16 px-4 text-center ${className}`}>
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-500 max-w-md mb-6">{description}</p>
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
className="btn-primary"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoResultsFound({
|
||||
query,
|
||||
onClearFilters
|
||||
}: {
|
||||
query?: string
|
||||
onClearFilters?: () => void
|
||||
}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="search"
|
||||
title="No se encontraron resultados"
|
||||
description={query
|
||||
? `No hay noticias que coincidan con "${query}". Intenta con otros términos de búsqueda.`
|
||||
: 'No hay noticias que coincidan con los filtros seleccionados.'}
|
||||
action={onClearFilters ? {
|
||||
label: 'Limpiar filtros',
|
||||
onClick: onClearFilters
|
||||
} : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoNewsYet({
|
||||
onAddFeed
|
||||
}: {
|
||||
onAddFeed?: () => void
|
||||
}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="rss"
|
||||
title="No hay noticias todavía"
|
||||
description="Aún no se han agregado fuentes RSS. Agrega tu primera fuente para empezar a ver noticias."
|
||||
action={onAddFeed ? {
|
||||
label: 'Agregar fuente RSS',
|
||||
onClick: onAddFeed
|
||||
} : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoFeedsConfigured({
|
||||
onAddFeed
|
||||
}: {
|
||||
onAddFeed?: () => void
|
||||
}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="rss"
|
||||
title="No hay fuentes configuradas"
|
||||
description="No se han agregado fuentes RSS. Agrega tu primera fuente para empezar a recibir noticias."
|
||||
action={onAddFeed ? {
|
||||
label: 'Agregar fuente RSS',
|
||||
onClick: onAddFeed
|
||||
} : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoSearchResults({
|
||||
query,
|
||||
onSearch
|
||||
}: {
|
||||
query?: string
|
||||
onSearch?: () => void
|
||||
}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="search"
|
||||
title="Sin resultados"
|
||||
description={query
|
||||
? `No se encontraron resultados para "${query}". Prueba con términos más generales.`
|
||||
: 'Ingresa un término de búsqueda para encontrar noticias.'}
|
||||
action={onSearch ? {
|
||||
label: 'Nueva búsqueda',
|
||||
onClick: onSearch
|
||||
} : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function OfflineState({
|
||||
onRetry
|
||||
}: {
|
||||
onRetry?: () => void
|
||||
}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="alert"
|
||||
title="Sin conexión"
|
||||
description="No se puede conectar al servidor. Verifica tu conexión a internet e intenta de nuevo."
|
||||
action={onRetry ? {
|
||||
label: 'Reintentar',
|
||||
onClick: onRetry
|
||||
} : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
message = 'Ha ocurrido un error inesperado',
|
||||
onRetry
|
||||
}: {
|
||||
message?: string
|
||||
onRetry?: () => void
|
||||
}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="alert"
|
||||
title="Error"
|
||||
description={message}
|
||||
action={onRetry ? {
|
||||
label: 'Reintentar',
|
||||
onClick: onRetry
|
||||
} : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
110
frontend/src/components/ui/Skeleton.tsx
Normal file
110
frontend/src/components/ui/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
export function SkeletonNewsCard() {
|
||||
return (
|
||||
<div className="card animate-pulse">
|
||||
<div className="w-full h-48 bg-gray-200 rounded-t-xl"></div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-24 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-16 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
<div className="h-5 w-3/4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-full bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-5/6 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-1/2 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkeletonNewsListProps {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function SkeletonNewsList({ count = 6 }: SkeletonNewsListProps) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<SkeletonNewsCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonFeedCard() {
|
||||
return (
|
||||
<div className="card p-4 animate-pulse">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 bg-gray-200 rounded-lg"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-1/3 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-1/2 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
<div className="h-6 w-20 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkeletonFeedListProps {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function SkeletonFeedList({ count = 5 }: SkeletonFeedListProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<SkeletonFeedCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonEntityCard() {
|
||||
return (
|
||||
<div className="card p-4 animate-pulse">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 bg-gray-200 rounded-full"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-1/3 bg-gray-200 rounded"></div>
|
||||
<div className="h-3 w-1/4 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkeletonEntityListProps {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function SkeletonEntityList({ count = 4 }: SkeletonEntityListProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<SkeletonEntityCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonStats() {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="card p-6 animate-pulse">
|
||||
<div className="h-4 w-1/3 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="h-8 w-1/2 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonChart() {
|
||||
return (
|
||||
<div className="card p-6 animate-pulse">
|
||||
<div className="h-4 w-1/4 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="h-64 bg-gray-100 rounded"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useRef, useEffect } from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
interface WikiTooltipProps {
|
||||
|
|
@ -14,19 +14,43 @@ export function WikiTooltip({ children, summary, imagePath, wikiUrl, name }: Wik
|
|||
return <span className="text-gray-900">{children}</span>;
|
||||
}
|
||||
|
||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close tooltip on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Force blur to close tooltip
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
activeElement?.blur()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative group inline-block">
|
||||
<span className="cursor-help underline decoration-dotted decoration-gray-400 underline-offset-4 text-primary-700 font-medium">
|
||||
<div className="relative group inline-block" role="tooltip">
|
||||
<span
|
||||
className="cursor-help underline decoration-dotted decoration-gray-400 underline-offset-4 text-primary-700 font-medium focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded"
|
||||
tabIndex={0}
|
||||
aria-describedby={`wiki-tooltip-${name.replace(/\s+/g, '-')}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
<div className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-72 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-300 ease-out pointer-events-none group-hover:pointer-events-auto origin-bottom transform scale-95 group-hover:scale-100">
|
||||
<div
|
||||
id={`wiki-tooltip-${name.replace(/\s+/g, '-')}`}
|
||||
ref={tooltipRef}
|
||||
className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-72 opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-300 ease-out pointer-events-none group-hover:pointer-events-auto group-focus-within:pointer-events-auto origin-bottom transform scale-95 group-hover:scale-100 group-focus-within:scale-100"
|
||||
role="tooltip"
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-[0_10px_40px_-10px_rgba(0,0,0,0.15)] border border-gray-100 overflow-hidden text-left flex flex-col">
|
||||
{imagePath && (
|
||||
<div className="w-full h-44 bg-gray-100 overflow-hidden relative border-b border-gray-100">
|
||||
<img
|
||||
src={imagePath}
|
||||
alt={name}
|
||||
alt={`${name} - Wikipedia image`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
|
|
@ -45,18 +69,18 @@ export function WikiTooltip({ children, summary, imagePath, wikiUrl, name }: Wik
|
|||
<a
|
||||
href={wikiUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-primary-600 hover:text-primary-800 transition-colors"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-primary-600 hover:text-primary-800 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Ver en Wikipedia <ExternalLink className="h-3 w-3" />
|
||||
Ver en Wikipedia <ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Triangle arrow */}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[1px] border-8 border-transparent border-t-white z-10"></div>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[0px] border-8 border-transparent border-t-gray-100 -z-10 blur-[1px]"></div>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[1px] border-8 border-transparent border-t-white z-10" aria-hidden="true"></div>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[0px] border-8 border-transparent border-t-gray-100 -z-10 blur-[1px]" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
3
frontend/src/components/ui/index.ts
Normal file
3
frontend/src/components/ui/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { SkeletonNewsCard, SkeletonNewsList, SkeletonFeedCard, SkeletonFeedList, SkeletonEntityCard, SkeletonEntityList, SkeletonStats, SkeletonChart } from './Skeleton'
|
||||
export { EmptyState, NoResultsFound, NoNewsYet, NoFeedsConfigured, NoSearchResults, OfflineState, ErrorState } from './EmptyState'
|
||||
export { WikiTooltip } from './WikiTooltip'
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { apiService, Alerta } from '../services/api'
|
||||
import { Bell, CheckCheck, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { apiService, api, Alerta, News } from '../services/api'
|
||||
import { Bell, CheckCheck, ChevronDown, ChevronUp, FileText } from 'lucide-react'
|
||||
|
||||
const TIPO_LABEL: Record<string, string> = {
|
||||
persona: 'Persona',
|
||||
|
|
@ -15,6 +16,11 @@ export function Alertas() {
|
|||
const [nuevas, setNuevas] = useState(0)
|
||||
const [limit, setLimit] = useState(50)
|
||||
|
||||
const [newsModal, setNewsModal] = useState<Alerta | null>(null)
|
||||
const [entityNews, setEntityNews] = useState<News[]>([])
|
||||
const [newsLoading, setNewsLoading] = useState(false)
|
||||
const [newsTotal, setNewsTotal] = useState(0)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
|
|
@ -42,6 +48,26 @@ export function Alertas() {
|
|||
load()
|
||||
}
|
||||
|
||||
const openNews = async (alerta: Alerta) => {
|
||||
setNewsModal(alerta)
|
||||
setNewsLoading(true)
|
||||
setEntityNews([])
|
||||
setNewsTotal(0)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('valor', alerta.valor)
|
||||
params.append('tipo', alerta.tipo)
|
||||
params.append('per_page', '50')
|
||||
const res = await api.get(`/entities/news?${params}`)
|
||||
setEntityNews(res.data.news || [])
|
||||
setNewsTotal(res.data.total || 0)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setNewsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
|
|
@ -108,13 +134,17 @@ export function Alertas() {
|
|||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{a.status === 'nueva' ? (
|
||||
<button onClick={() => markRead(a.id)} className="btn-secondary text-xs py-1 px-3">
|
||||
Marcar leída
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => openNews(a)} className="btn-secondary text-xs py-1 px-3 flex items-center gap-1">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Noticias
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-sm text-gray-300 dark:text-gray-600">—</span>
|
||||
)}
|
||||
{a.status === 'nueva' && (
|
||||
<button onClick={() => markRead(a.id)} className="btn-secondary text-xs py-1 px-3">
|
||||
Marcar leída
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -132,6 +162,75 @@ export function Alertas() {
|
|||
Mostrar {limit > 50 ? 'menos' : 'más'} ({limit})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Noticias relacionadas del concepto */}
|
||||
{newsModal && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={() => setNewsModal(null)}>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Noticias sobre "{newsModal.valor}"</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{newsTotal} noticias encontradas · {TIPO_LABEL[newsModal.tipo] || newsModal.tipo} · {newsModal.periodo}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewsModal(null)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 overflow-y-auto">
|
||||
{newsLoading ? (
|
||||
<div className="text-center py-10 text-gray-400">Cargando...</div>
|
||||
) : entityNews.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-400">No hay noticias que mencionen a este concepto.</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{entityNews.map((n) => (
|
||||
<li key={n.id}>
|
||||
<Link
|
||||
to={`/news/${n.id}`}
|
||||
onClick={() => setNewsModal(null)}
|
||||
className="block p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{n.imagen_url && (
|
||||
<img
|
||||
src={n.imagen_url}
|
||||
alt=""
|
||||
className="w-20 h-14 object-cover rounded-md shrink-0"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm line-clamp-2">
|
||||
{n.title_translated || n.titulo}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
|
||||
{n.summary_translated || n.resumen}
|
||||
</p>
|
||||
{n.fecha && (
|
||||
<span className="text-[11px] text-gray-400 mt-1 block">
|
||||
{new Date(n.fecha).toLocaleDateString('es-ES')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { formatDistanceToNow } from 'date-fns'
|
|||
import { es } from 'date-fns/locale'
|
||||
import { apiService, api } from '../services/api'
|
||||
import { Search, Globe, Newspaper, Filter } from 'lucide-react'
|
||||
import { SkeletonNewsList, NoResultsFound } from '../components/ui'
|
||||
|
||||
export function Home() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
|
@ -156,13 +157,22 @@ export function Home() {
|
|||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
<SkeletonNewsList count={6} />
|
||||
) : error ? (
|
||||
<div className="text-center py-12 text-red-600">
|
||||
Error al cargar noticias
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="btn-primary mt-4"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : data?.news?.length === 0 ? (
|
||||
<NoResultsFound
|
||||
query={q}
|
||||
onClearFilters={() => setSearchParams({ page: '1', q })}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
|
|
|
|||
|
|
@ -33,13 +33,13 @@ const TIPOS = [
|
|||
{ value: 'tema', label: '📰 Tema', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
|
||||
]
|
||||
|
||||
const TIMELINE_WINDOWS: { key: string; label: string; title: string; offset: number }[] = [
|
||||
{ key: '0', label: 'Hoy', title: 'hoy', offset: 0 },
|
||||
{ key: '1', label: '1d', title: 'hace 1 día', offset: 1 },
|
||||
{ key: '2', label: '2d', title: 'hace 2 días', offset: 2 },
|
||||
{ key: '3', label: '3d', title: 'hace 3 días', offset: 3 },
|
||||
{ key: '4', label: '4d', title: 'hace 4 días', offset: 4 },
|
||||
{ key: '5', label: '5d', title: 'hace 5 días', offset: 5 },
|
||||
const TIMELINE_WINDOWS: { key: string; label: string; title: string; days: number }[] = [
|
||||
{ key: '1', label: '24h', title: 'últimas 24 horas', days: 1 },
|
||||
{ key: '2', label: '2 días', title: 'últimos 2 días', days: 2 },
|
||||
{ key: '3', label: '3 días', title: 'últimos 3 días', days: 3 },
|
||||
{ key: '4', label: '4 días', title: 'últimos 4 días', days: 4 },
|
||||
{ key: '5', label: '5 días', title: 'últimos 5 días', days: 5 },
|
||||
{ key: '7', label: '7 días', title: 'últimos 7 días', days: 7 },
|
||||
]
|
||||
|
||||
function getTipoInfo(tipo: string) {
|
||||
|
|
@ -61,6 +61,8 @@ export function Populares() {
|
|||
const [configModal, setConfigModal] = useState<ConfigModal | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [successMsg, setSuccessMsg] = useState('')
|
||||
const [expandedEntities, setExpandedEntities] = useState<Set<string>>(new Set())
|
||||
const [allExpanded, setAllExpanded] = useState(false)
|
||||
|
||||
// News-by-entity modal state
|
||||
const [newsModal, setNewsModal] = useState<Entity | null>(null)
|
||||
|
|
@ -158,7 +160,7 @@ export function Populares() {
|
|||
const params = new URLSearchParams()
|
||||
params.append('valor', entity.valor)
|
||||
params.append('tipo', entity.tipo)
|
||||
params.append('day_offset', String(win.offset))
|
||||
params.append('days', String(win.days))
|
||||
params.append('per_page', '50')
|
||||
const res = await api.get(`/entities/news?${params}`)
|
||||
setEntityNews(res.data.news || [])
|
||||
|
|
@ -270,6 +272,29 @@ export function Populares() {
|
|||
<h1 className="text-2xl font-bold">
|
||||
🔥 Populares {selectedCountry ? `en ${selectedCountry.nombre}` : 'Global'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setAllExpanded(p => !p)}
|
||||
className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 transition-colors"
|
||||
title="Expandir/collapse todo"
|
||||
>
|
||||
{allExpanded ? '← Contraer todas' : '→ Expandir todas'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpandedEntities(p => p.clear())}
|
||||
className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 transition-colors"
|
||||
title="Contraer todas"
|
||||
>
|
||||
← Contraer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpandedEntities(p => p.add('all'))}
|
||||
className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 transition-colors"
|
||||
title="Expandir todas"
|
||||
>
|
||||
Expandir →
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleExportAliases}
|
||||
|
|
@ -359,96 +384,102 @@ export function Populares() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{/* Listado de entidades */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">Cargando...</div>
|
||||
) : entities.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">No se encontraron entidades</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-10">#</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Entidad</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider hidden md:table-cell">Tipo</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Menciones</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">Config</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{entities.map((entity, idx) => {
|
||||
const tipoInfo = getTipoInfo(entity.tipo)
|
||||
return (
|
||||
<tr key={entity.valor + idx} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-4 py-3 text-gray-400 text-sm">{(page - 1) * 50 + idx + 1}</td>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 border-b dark:border-gray-700">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">📊 {entities.length} entidades populares</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setExpandedEntities(p => p.clear())}
|
||||
className="px-2 py-1 text-[10px] text-gray-500 hover:text-blue-600 dark:hover:text-blue-300 rounded"
|
||||
title="Contraer todas"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpandedEntities(p => p.add('all'))}
|
||||
className="px-2 py-1 text-[10px] text-gray-500 hover:text-blue-600 dark:hover:text-blue-300 rounded"
|
||||
title="Expandir todas"
|
||||
>
|
||||
Expandir →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{entities.map((entity, idx) => {
|
||||
const isExpanded = allExpanded || expandedEntities.has(entity.valor)
|
||||
const tipoInfo = getTipoInfo(entity.tipo)
|
||||
return (
|
||||
<div
|
||||
key={entity.valor}
|
||||
className={`collapse ${isExpanded ? 'in' : ''} transition-colors duration-300 ease-in-out`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 py-2 border-b dark:border-gray-700 last:border-0">
|
||||
<div className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => setExpandedEntities(p => p.toggle(entity.valor))}>
|
||||
<WikiTooltip
|
||||
name={entity.valor}
|
||||
summary={entity.wiki_summary}
|
||||
imagePath={entity.image_path}
|
||||
wikiUrl={entity.wiki_url}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{entity.image_path && (
|
||||
<img
|
||||
src={entity.image_path}
|
||||
alt=""
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-white shadow-md mx-auto"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
)}
|
||||
<span>{entity.valor}</span>
|
||||
</div>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-300 hover:underline transition-colors">
|
||||
{entity.valor}
|
||||
</span>
|
||||
</WikiTooltip>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
#{entity.count} menciones
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${tipoInfo.color}`}>
|
||||
{tipoInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{entity.count}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => openNews(entity)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Ver noticias donde se menciona"
|
||||
>
|
||||
🔍 Buscar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openConfig(entity)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Configurar entidad"
|
||||
>
|
||||
⚙️ Configurar
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||
{TIMELINE_WINDOWS.map((win) => (
|
||||
<button
|
||||
key={win.key}
|
||||
onClick={() => openTimeline(entity, win)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 text-[10px] rounded bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
title={win.title}
|
||||
>
|
||||
{win.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span>{entity.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="p-3 pt-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={() => openNews(entity)}
|
||||
className="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Ver noticias donde se menciona"
|
||||
>
|
||||
🔍 Ver noticias
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openConfig(entity)}
|
||||
className="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Configurar entidad"
|
||||
>
|
||||
⚙️ Configurar
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||
{TIMELINE_WINDOWS.map((win) => (
|
||||
<button
|
||||
key={win.key}
|
||||
onClick={() => openTimeline(entity, win)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 text-[10px] rounded bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
title={win.title}
|
||||
>
|
||||
{win.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useSearchParams, Link } from 'react-router-dom'
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { apiService, api, News, Category, Country } from '../services/api'
|
||||
import { Search as SearchIcon, Filter } from 'lucide-react'
|
||||
import { SkeletonNewsList, NoResultsFound, NoSearchResults } from '../components/ui'
|
||||
|
||||
export function Search() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
|
@ -132,13 +133,14 @@ export function Search() {
|
|||
<p className="text-gray-600 mb-4">{data.total} resultados encontrados</p>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.news && data.news.length > 0 && (
|
||||
{isLoading ? (
|
||||
<SkeletonNewsList count={6} />
|
||||
) : data?.news?.length === 0 ? (
|
||||
<NoResultsFound
|
||||
query={q}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
) : data?.news && data.news.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{data.news.map((news: News) => (
|
||||
<Link key={news.id} to={`/news/${news.id}`} className="card p-4 block hover:shadow-md transition-shadow">
|
||||
|
|
@ -151,14 +153,8 @@ export function Search() {
|
|||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!q && !lang && !categoria && !pais) && (
|
||||
<p className="text-center text-gray-500 mt-8">Introduce una búsqueda o selecciona filtros</p>
|
||||
)}
|
||||
|
||||
{q && !isLoading && data?.news?.length === 0 && (
|
||||
<p className="text-center text-gray-500 mt-8">No se encontraron resultados</p>
|
||||
) : (
|
||||
<NoSearchResults query={q} onSearch={clearFilters} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
1154
plan-app.md
Normal file
1154
plan-app.md
Normal file
File diff suppressed because it is too large
Load diff
269
plan-mejora.md
Normal file
269
plan-mejora.md
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
# Plan de Mejora - RSS2 Application
|
||||
|
||||
## Análisis Complejo
|
||||
|
||||
Se ha realizado un análisis exhaustivo identificando **35 problemas** en total, clasificados a partir de bugs.md y revisión de código:
|
||||
|
||||
- **6 Errores Críticos** (seguridad/estabilidad)
|
||||
- **10 Inconsistencias** (funcionalidad)
|
||||
- **5 Warnings de Arquitectura** (mantenibilidad)
|
||||
- **14 Problemas Adicionales** (UI/funcionalidad/performance)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Problemas Adicionales Detectados (Nuevos)
|
||||
|
||||
### 🔴 Críticos (2 nuevos)
|
||||
|
||||
#### A. Sin Validación de Entrada en Endpoints Públicos
|
||||
- **Archivos:** `handlers/feed.go`, `handlers/news.go`, `handlers/search.go`
|
||||
- **Descripción:** Endpoints aceptan parámetros sin validación estricta (ej. `page`, `per_page` sin límites duros, SQL injection risk en `fmt.Sprintf`)
|
||||
- **Impacto:** Posible DoS via parámetros grandes, inyección SQL si cambian queries
|
||||
- **Acción:** Usar validator library (`go-playground/validator/v10`), validar y sanitizar todos los inputs
|
||||
|
||||
#### B. Transacciones BD sin Manejo de Errores Adecuado
|
||||
- **Archivos:** `handlers/feed.go:559-592` (ImportFeeds)
|
||||
- **Descripción:** `SAVEPOINT`/`ROLLBACK TO SAVEPOINT`/`RELEASE SAVEPOINT` sin verificar errores en cada paso
|
||||
- **Impacto:** Transacciones parcialmente aplicadas si falla a mitad
|
||||
- **Acción:** Verificar error en cada `Exec`, usar `tx.Rollback()` en defer
|
||||
|
||||
### 🟡 Alta Priority (3 nuevos)
|
||||
|
||||
#### C. N+1 Query Problem en Entity Loading
|
||||
- **Archivos:** `handlers/news.go:314-335` (GetNewsByID)
|
||||
- **Descripción:** Query separada para entities por cada noticia
|
||||
- **Impacto:** Latencia alta al cargar detalle de noticia
|
||||
- **Acción:** JOIN en query principal o batch loading
|
||||
|
||||
#### D. Pool de Conexiones Sin Configuración Óptima
|
||||
- **Archivos:** `backend/internal/db/postgres.go`
|
||||
- **Descripción:** Pool creado con defaults, sin `MaxConns`, `MinConns`, `MaxConnLifetime`
|
||||
- **Impacto:** Agotamiento de conexiones bajo carga, conexiones zombie
|
||||
- **Acción:** Configurar pool size según cores/load, agregar health checks periódicos
|
||||
|
||||
#### E. Falta de Índices BD en Tablas Críticas
|
||||
- **Archivos:** `init-db/` + `cmd/server/main.go` (initDB)
|
||||
- **Descripción:** Tablas `noticias`, `traducciones`, `tags_noticia` sin índices en FKs y columnas de filtro
|
||||
- **Impacto:** Queries lentas en producción con datos reales
|
||||
- **Acción:** Añadir índices en `noticias.fecha`, `noticias.fuente_nombre`, `traducciones.noticia_id+lang_to`, `tags_noticia.noticia_id+tag_id`
|
||||
|
||||
#### F. Workers Python Sin Graceful Shutdown
|
||||
- **Ubicación:** `workers/*.py`
|
||||
- **Descripción:** Workers no manejan SIGTERM/SIGINT, terminan abruptamente
|
||||
- **Impacto:** Tareas a medio procesar, estados inconsistentes
|
||||
- **Acción:** Implementar signal handling, graceful shutdown con timeout
|
||||
|
||||
### 🟢 Media Priority (2 nuevos)
|
||||
|
||||
#### G. Cache Inconsistente / Sin Cache Strategy
|
||||
- **Ubicación:** `backend/internal/cache/redis.go` + handlers
|
||||
- **Descripción:** Redis disponible pero uso esporádico, sin TTL estándar, sin invalidación
|
||||
- **Impacto:** Queries repetidas costosas, datos stale
|
||||
- **Acción:** Definir cache keys + TTL estándar, invalidar en writes, usar en feeds/categorías/paises
|
||||
|
||||
#### H. Logging Estructurado Ausente
|
||||
- **Ubicación:** Todo el backend
|
||||
- **Descripción:** Solo `fmt.Println`/`log.Printf`, sin niveles, sin JSON, sin correlation IDs
|
||||
- **Impacto:** Debugging difícil en producción
|
||||
- **Acción:** Migrar a `zerolog` o `zap` con JSON output, request IDs
|
||||
|
||||
### 🔵 Baja Priority (2 nuevos)
|
||||
|
||||
#### I. Documentación API (OpenAPI/Swagger) Ausente
|
||||
- **Ubicación:** Backend handlers
|
||||
- **Impacto:** Frontend y consumers no tienen spec autogenerada
|
||||
- **Acción:** Añadir anotaciones `swaggo/swag` o `go-swagger`, generar `/swagger` endpoint
|
||||
|
||||
#### J. Secrets en Variables Entorno Sin Validación
|
||||
- **Ubicación:** `config/config.go` + docker-compose
|
||||
- **Descripción:** Variables como `DB_PASS`, `REDIS_PASSWORD`, `SECRET_KEY` sin validar presencia/formato
|
||||
- **Impacto:** Fallos en runtime confusos si faltan
|
||||
- **Acción:** Validar todas las vars requeridas al startup, fallar rápido con mensaje claro
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Crítico Priority (Seguridad y Estabilidad)
|
||||
|
||||
### 1. Rate Limiting No Implementado
|
||||
- **Archivo:** `backend/internal/middleware/auth.go:104-108`
|
||||
- **Descripción:** Función vacía que no limita requests
|
||||
- **Impacto:** Vulnerable a ataques DoS y fuerza bruta
|
||||
- **Acción:** Implementar usando `golang.org/x/time/rate`
|
||||
|
||||
### 2. CORS Demasiado Permisivo
|
||||
- **Archivo:** `backend/internal/middleware/auth.go:77-90`
|
||||
- **Descripción:** `Access-Control-Allow-Origin: *`
|
||||
- **Impacto:** CSRF, exposición de datos a orígenes maliciosos
|
||||
- **Acción:** Configurar orígenes permitidos desde variable de entorno
|
||||
|
||||
### 3. Secret Key Por Defecto Hardcodeada
|
||||
- **Archivo:** `backend/internal/config/config.go:32`
|
||||
- **Descripción:** `SecretKey: getEnv("SECRET_KEY", "change-this-secret-key")`
|
||||
- **Impacto:** Compromiso total de autenticación si no se configura
|
||||
- **Acción:** Hacer `SECRET_KEY` obligatoria, panic si no está set
|
||||
|
||||
### 4. Columna `role` en Users Sin Mapeo en Modelo
|
||||
- **Archivos:** `models/models.go:86-94`, `cmd/server/main.go:41-48`
|
||||
- **Descripción:** Columna `role` en BD pero no en struct User
|
||||
- **Impacto:** Confusión entre `role` y `is_admin`
|
||||
- **Acción:** Añadir campo `Role` a modelo User o remover columna BD
|
||||
|
||||
### 5. LoggerMiddleware No Funcional
|
||||
- **Archivo:** `middleware/auth.go:93-101`
|
||||
- **Descripción:** `c.Next()` sin logging de errores
|
||||
- **Impacto:** Errores van sin registrar
|
||||
- **Acción:** Implementar logging real de respuestas >= 400
|
||||
|
||||
### 6. Traducción Idioma Hardcodeado 'es'
|
||||
- **Archivo:** `handlers/news.go:75,96`
|
||||
- **Descripción:** `t.lang_to = 'es'` en múltiples queries
|
||||
- **Impacto:** No configurable a otros idiomas
|
||||
- **Acción:** Pasar idioma como parámetro o configuración
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Alta Priority (Funcionalidad y Estabilidad)
|
||||
|
||||
### 7. Discovery HTML Feed Finder Vacio
|
||||
- **Archivo:** `backend/cmd/discovery/main.go:129-133`
|
||||
- **Descripción:** `findFeedLinksInHTML` retorna `[]string{}, nil`
|
||||
- **Impacto:** No detecta feeds en HTML, solo URLsdirectas
|
||||
- **Acción:** Implementar con `goquery` para parsear HTML
|
||||
|
||||
### 8. Mapeo de Campos DB vs Modelo Inconsistente
|
||||
- **Archivo:** `search.go` vs `models/models.go:7-19`
|
||||
- **Descripción:** Columnas `titulo`/`resumen`/`imagen`/`fecha` vs `Title`/`Summary`/`ImageURL`/`PublishedAt`
|
||||
- **Impacto:** Errores de serialización potenciales
|
||||
- **Acción:** Estandizar nombres en toda la aplicación
|
||||
|
||||
### 9. No Hay Tests Unitarios
|
||||
- **Ubicación:** Todo el backend
|
||||
- **Impacto:** Cambios pueden romper funcionalidad sin detección
|
||||
- **Acción:** Crear tests para handlers críticos (auth, news, feeds)
|
||||
|
||||
### 10. Inicialización BD Duplicada
|
||||
- **Archivos:** `db.Connect()` vs `workers.Connect()`
|
||||
- **Impacto:** Piscs de conexión separadas, posible agotamiento
|
||||
- **Acción:** Consolidar en una sola conexión/pool
|
||||
|
||||
### 11. Parámetros Query Inconsistentes
|
||||
- **Descripción:** Algunos usan `category_id` vs `categoria_id`
|
||||
- **Impacto:** Confusión en filtros
|
||||
- **Acción:** Unificar nomenclatura (preferir `category_id` en inglés)
|
||||
|
||||
### 12. API Field Naming Inconsistente
|
||||
- **Archivo:** `frontend/src/services/api.ts`
|
||||
- **Descripción:** Interface `News` usa camelCase, pero respuestas van snake_case
|
||||
- **Impacto:** Datos no se muestran correctamente
|
||||
- **Acción:** Arreglar mapping en api.ts o estandarizar respuestas Go
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Media Priority (Calidad y Mantenimiento)
|
||||
|
||||
### 13. No Hay Health Checks en Workers
|
||||
- **Ubicación:** Todos los workers Python/Go
|
||||
- **Impacto:** Dificultad para monitorear salud de workers
|
||||
- **Acción:** Implementar endpoint `/health` en cada worker
|
||||
|
||||
### 14. Structs Duplicadas Mismo Recurso
|
||||
- **Archivo:** `handlers/news.go` vs `models/models.go:News`
|
||||
- **Descripción:** `NewsResponse` diferente a `models.News`
|
||||
- **Impacto:** Mantenimiento duplicado
|
||||
- **Acción:** Unificar o mantener mapping explícito
|
||||
|
||||
### 15. Query Params Inconsistentes (category_id vs categoria_id)
|
||||
- **Descripción:** Mix de inglés y español en handlers
|
||||
- **Impacto:** Confusión para usuarios de la API
|
||||
- **Acción:** Definir estándar y aplicarlo en todos lados
|
||||
|
||||
### 16. Duplicación Config DB
|
||||
- **Descripción:** API usa `DATABASE_URL`, workers usan `DB_HOST`/`DB_PORT` etc.
|
||||
- **Impacto:** Configuración fragmentada
|
||||
- **Acción:** Unificar variables de entorno
|
||||
|
||||
---
|
||||
|
||||
## 🔵 Baja Priority (UX y Refactoring)
|
||||
|
||||
### 17. Frontend API Field Mapping Fixes
|
||||
- **Archivo:** `frontend/src/services/api.ts`
|
||||
- **Impacto:** Datos se muestran incorrectamente o undefined
|
||||
- **Acción:** Revisar y corregir tipos de interfaz
|
||||
|
||||
### 18. Mejorar Estados de Error en UI
|
||||
- **Ubicación:** Todas las páginas frontend
|
||||
- **Impacto:** UX pobre cuando fallan requests
|
||||
- **Acción:** Mensajes de error claros y acciones de recuperación
|
||||
|
||||
### 19. Skeleton Loaders en lugar de Spinners
|
||||
- **Ubicación:** Componente NewsList, News detail, etc.
|
||||
- **Impacto:** Perceived performance mejorada
|
||||
- **Acción:** Implementar componentes skeleton con Tailwind
|
||||
|
||||
### 20. Diseño Responsivo Mejorado
|
||||
- **Ubicación:** Grid de noticias, selectores, formularios
|
||||
- **Impacto:** Mejor experiencia móvil/tablet
|
||||
- **Acción:** Refinar clases Tailwind, breakpoints
|
||||
|
||||
### 21. Tipografía y Esquema de Color Consistente
|
||||
- **Ubicación:** Todo el frontend
|
||||
- **Impacto:** Aspecto amateur, inconsistencia visual
|
||||
- **Acción:** Definir palette principal, usar consistentemente
|
||||
|
||||
### 22. Micro-interacciones y Transiciones Más Suaves
|
||||
- **Ubicación:** Hover effects, focus states, button animations
|
||||
- **Impacto:** Experiencia más pulida y profesional
|
||||
- **Acción:** Agregar transitions, hover: clases, focus-visible
|
||||
|
||||
### 23. Mejoras de Accesibilidad
|
||||
- **Ubicación:** Componentes form, links, imágenes
|
||||
- **Impacto:** Cumplimiento WCAG, mejor usabilidad
|
||||
- **Acción:** ARIA labels, contrast ratios, focus outlines, tabindex
|
||||
|
||||
### 24. Estados Vacios Mejorados
|
||||
- **Ubicación:** Páginas sin resultados, sin noticias, estados iniciales
|
||||
- **Impacto:** UX cuando no hay datos que mostrar
|
||||
- **Acción:** Mensajes descriptivos, ilustraciones, acciones sugeridas
|
||||
|
||||
### 25. Skeletons de Carga por Componente
|
||||
- **Ubicación:** News cards, feeds lists, entity lists
|
||||
- **Impacto:** Indicar progreso mejor que spinners puros
|
||||
- **Acción:** Implementar skeleton grids con dimensiones reales
|
||||
|
||||
---
|
||||
|
||||
## Resumen Ejecutivo
|
||||
|
||||
| Prioridad | Cantidad | Enfoque |
|
||||
|-----------|----------|---------|
|
||||
| 🔴 Crítico | 8 | Seguridad, estabilidad, validación |
|
||||
| 🟡 Alta | 10 | Funcionalidad, bugs, performance |
|
||||
| 🟢 Media | 8 | Mantenimiento, calidad, observabilidad |
|
||||
| 🔵 Baja | 8 | UX, refinamientos, documentación |
|
||||
|
||||
**Total: 35 problemas**
|
||||
|
||||
**Primeros pasos recomendados (Sprint 1 - Seguridad + Estabilidad):**
|
||||
1. Rate limiting + CORS + Secret key (seguridad inmediata)
|
||||
2. Validación de entrada en endpoints públicos
|
||||
3. Transacciones BD con manejo de errores correcto
|
||||
4. Índices BD en tablas críticas
|
||||
|
||||
**Sprint 2 - Funcionalidad + Performance:**
|
||||
5. Arreglar naming conventions (consistencia en todo)
|
||||
6. Pool de conexiones optimizado + health checks
|
||||
7. N+1 queries en entity loading
|
||||
8. Cache strategy + invalidación
|
||||
|
||||
**Sprint 3 - Calidad + Observabilidad:**
|
||||
9. Logging estructurado (zerolog/zap)
|
||||
10. Tests unitarios para handlers críticos
|
||||
11. Graceful shutdown en workers
|
||||
12. Health checks en todos los workers
|
||||
|
||||
**Sprint 4 - UX + Documentación:**
|
||||
13. OpenAPI/Swagger docs
|
||||
14. Skeleton loaders + estados vacíos
|
||||
15. Accesibilidad + micro-interacciones
|
||||
16. Traducción idioma configurable
|
||||
25
translate-worker
Executable file
25
translate-worker
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/data/data/com.termux/files/usr/bin/sh
|
||||
# ==============================================================================
|
||||
# translate-worker - Worker de traducción MINIMO para Termux
|
||||
# ==============================================================================
|
||||
# Ejecuta el worker que se conecta al servidor y reenvía jobs de traducción.
|
||||
# Primera vez pide API key, luego funciona automáticamente.
|
||||
#==============================================================================
|
||||
|
||||
export WORKER_NAME="${WORKER_NAME:-termux-$(date +%s)}"
|
||||
|
||||
# Pedir API key si no está puesta
|
||||
if [ -z "$RSS2_API_KEY" ]; then
|
||||
echo "🔑 RSS2 Translation Worker"
|
||||
echo "========================="
|
||||
read -p "API key: " RSS2_API_KEY
|
||||
export RSS2_API_KEY
|
||||
fi
|
||||
|
||||
echo "🚀 Iniciando worker de traducción mínima..."
|
||||
echo " Worker: $WORKER_NAME"
|
||||
echo " Modo: Reenvío de jobs al servidor"
|
||||
echo ""
|
||||
|
||||
# Ejecutar worker Python mínimo
|
||||
exec python3 /home/x/rss2/translate_worker_termux.py "$@"
|
||||
133
translate_worker_termux.py
Executable file
133
translate_worker_termux.py
Executable file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Worker de traducción MINIMO para Termux Android.
|
||||
Objetivo único: Recibir jobs de traducción del servidor y enviar resultados de vuelta.
|
||||
No requiere modelo ML para conectarse y reportar status.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import readline
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||
LOG = logging.getLogger("minimal-worker")
|
||||
|
||||
# ==========================================================
|
||||
# Config - SOLO API key necesaria
|
||||
# ==========================================================
|
||||
WORKER_NAME = os.environ.get("WORKER_NAME", f"termux-{os.getpid()}")
|
||||
WORKER_SERVER = os.environ.get("WORKER_SERVER", "ws://10.0.2.2:8080/ws/worker")
|
||||
|
||||
# API key: pedir si no está puesta
|
||||
if not os.environ.get("RSS2_API_KEY"):
|
||||
api_key = input("🔑 API key de RSS2: ").strip()
|
||||
if not api_key:
|
||||
print("❌ Key requerida. Saliendo.")
|
||||
sys.exit(1)
|
||||
os.environ["RSS2_API_KEY"] = api_key
|
||||
|
||||
WORKER_API_KEY = os.environ["RSS2_API_KEY"]
|
||||
|
||||
# Código de idiomas simples
|
||||
LANG_MAP = {
|
||||
"en": "eng_Latn", "es": "spa_Latn", "fr": "fra_Latn", "de": "deu_Latn",
|
||||
"it": "ita_Latn", "pt": "por_Latn", "nl": "nld_Latn", "sv": "swe_Latn",
|
||||
"da": "dan_Latn", "fi": "fin_Latn", "no": "nob_Latn",
|
||||
"pl": "pol_Latn", "cs": "ces_Latn", "sk": "slk_Latn",
|
||||
"sl": "slv_Latn", "hu": "hun_Latn", "ro": "ron_Latn",
|
||||
"el": "ell_Grek", "ru": "rus_Cyrl", "uk": "ukr_Cyrl",
|
||||
"tr": "tur_Latn", "ar": "arb_Arab", "fa": "pes_Arab",
|
||||
"he": "heb_Hebr", "zh": "zho_Hans", "ja": "jpn_Jpan",
|
||||
"ko": "kor_Hang", "vi": "vie_Latn",
|
||||
}
|
||||
|
||||
# Estado simple - solo conecta y reenvía
|
||||
connected = False
|
||||
jobs_processed = 0
|
||||
|
||||
def on_open(ws):
|
||||
global connected
|
||||
connected = True
|
||||
LOG.info(f"📡 Conectado como {WORKER_NAME}")
|
||||
ws.send(json.dumps({
|
||||
"type": "register",
|
||||
"worker_name": WORKER_NAME
|
||||
}))
|
||||
|
||||
def on_message(ws, message):
|
||||
global jobs_processed
|
||||
try:
|
||||
msg = json.loads(message)
|
||||
except:
|
||||
return
|
||||
|
||||
if msg.get("type") == "job":
|
||||
job = msg.get("job", {})
|
||||
result = process_job(job)
|
||||
ws.send(json.dumps({
|
||||
"type": "result",
|
||||
"result": result
|
||||
}))
|
||||
jobs_processed += 1
|
||||
if jobs_processed % 10 == 0:
|
||||
LOG.info(f"📊 Procesados {jobs_processed} jobs")
|
||||
|
||||
def process_job(job):
|
||||
"""Procesa un job de traducción - mínimo esfuerzo."""
|
||||
job_id = job.get("id", "unknown")
|
||||
lang_from = job.get("lang_from", "en")
|
||||
lang_to = job.get("lang_to", "es")
|
||||
title = job.get("title", "")
|
||||
summary = job.get("summary", "")
|
||||
|
||||
# Traducción "mínima": si mismoidioma, pas-through
|
||||
# Si tiene modelo, traduciría aquí; si no, devolvemos original
|
||||
|
||||
# Marcar resultado
|
||||
result = {
|
||||
"job_id": job_id,
|
||||
"title_orig": title[:50] if title else "",
|
||||
"summary_orig": summary[:50] if summary else "",
|
||||
"lang_from": lang_from,
|
||||
"lang_to": lang_to,
|
||||
"title_trad": title, # Pas-through si sin modelo
|
||||
"summary_trad": summary,
|
||||
"translated": False, # Flag: ¿realmente tradujimos?
|
||||
"error": ""
|
||||
}
|
||||
return result
|
||||
|
||||
def on_error(ws, error):
|
||||
LOG.error(f"WS error: {error}")
|
||||
|
||||
def on_close(ws, *args):
|
||||
LOG.warning(f"Conexión cerrada")
|
||||
global connected
|
||||
connected = False
|
||||
|
||||
def connect():
|
||||
"""Conectar WebSocket al servidor."""
|
||||
import websocket
|
||||
ws_url = f"ws://localhost:8080/ws/worker?api_key={WORKER_API_KEY}"
|
||||
|
||||
ws = websocket.WebSocketApp(
|
||||
ws_url,
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
on_error=on_error,
|
||||
on_close=on_close,
|
||||
)
|
||||
ws.run_forever(ping_interval=15, ping_timeout=5)
|
||||
|
||||
def main():
|
||||
global connected
|
||||
LOG.info(f"Worker mínimo starting: {WORKER_NAME}")
|
||||
LOG.info(f"Conectando a: ws://localhost:8080/ws/worker")
|
||||
|
||||
# Intentar conectar (se reintenta solo si se corta)
|
||||
connect()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -590,7 +590,7 @@ def fetch_pending_translations(conn):
|
|||
WHERE t.lang_to = %s
|
||||
AND (t.titulo_trad IS NULL OR t.resumen_trad IS NULL)
|
||||
AND (t.locked_at IS NULL OR t.locked_at < NOW() - INTERVAL '10 minutes')
|
||||
ORDER BY n.fecha ASC
|
||||
ORDER BY n.fecha DESC
|
||||
LIMIT %s
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ def create_translation_jobs(conn):
|
|||
SELECT 1 FROM traducciones t
|
||||
WHERE t.noticia_id = n.id AND t.lang_to = %s
|
||||
)
|
||||
ORDER BY n.fecha ASC
|
||||
ORDER BY n.fecha DESC
|
||||
LIMIT %s
|
||||
ON CONFLICT (noticia_id, lang_to) DO NOTHING
|
||||
RETURNING noticia_id
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue