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/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/mmcdole/gofeed"
|
"github.com/mmcdole/gofeed"
|
||||||
|
"github.com/rss2/backend/internal/logger"
|
||||||
"github.com/rss2/backend/internal/workers"
|
"github.com/rss2/backend/internal/workers"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logger *log.Logger
|
|
||||||
pool *workers.Config
|
pool *workers.Config
|
||||||
dbPool *pgxpool.Pool
|
dbPool *pgxpool.Pool
|
||||||
sleepSec = 900 // 15 minutes
|
sleepSec = 900 // 15 minutes
|
||||||
|
|
@ -35,7 +35,8 @@ type URLSource struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logger = log.New(os.Stdout, "[DISCOVERY] ", log.LstdFlags)
|
logLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
logger.Init("discovery-worker", logLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() {
|
func loadConfig() {
|
||||||
|
|
@ -416,48 +417,70 @@ func processURLSource(ctx context.Context, source URLSource) {
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
logger.Println("Starting RSS Discovery Worker")
|
logger.Info().Msg("Starting RSS Discovery Worker")
|
||||||
|
|
||||||
cfg := workers.LoadDBConfig()
|
cfg := workers.LoadDBConfig()
|
||||||
if err := workers.Connect(cfg); err != nil {
|
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()
|
dbPool = workers.GetPool()
|
||||||
defer workers.Close()
|
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)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigChan
|
<-sigChan
|
||||||
logger.Println("Shutting down...")
|
logger.Info().Msg("Shutting down gracefully...")
|
||||||
|
cancel()
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
workers.Close()
|
||||||
os.Exit(0)
|
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)
|
ticker := time.NewTicker(time.Duration(sleepSec) * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Info().Msg("Context cancelled, stopping worker")
|
||||||
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
sources, err := getPendingURLs(ctx)
|
sources, err := getPendingURLs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("Error fetching URLs: %v", err)
|
logger.Error().Err(err).Msg("Error fetching URLs")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
logger.Println("No pending URLs to process")
|
logger.Info().Msg("No pending URLs to process")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Printf("Processing %d sources", len(sources))
|
logger.Info().Msgf("Processing %d sources", len(sources))
|
||||||
|
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
processURLSource(ctx, source)
|
processURLSource(ctx, source)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
|
@ -17,11 +16,11 @@ import (
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/rss2/backend/internal/logger"
|
||||||
"github.com/rss2/backend/internal/workers"
|
"github.com/rss2/backend/internal/workers"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logger *log.Logger
|
|
||||||
dbPool *pgxpool.Pool
|
dbPool *pgxpool.Pool
|
||||||
qdrantURL string
|
qdrantURL string
|
||||||
collection = "news_vectors"
|
collection = "news_vectors"
|
||||||
|
|
@ -30,28 +29,29 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logger = log.New(os.Stdout, "[QDRANT] ", log.LstdFlags)
|
logLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
logger.Init("qdrant-worker", logLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() {
|
func loadConfig() {
|
||||||
sleepSec = getEnvInt("QDRANT_SLEEP", 30)
|
sleepSec = getEnvInt("QDRANT_SLEEP", 30)
|
||||||
batchSize = getEnvInt("QDRANT_BATCH", 100)
|
batchSize = getEnvInt("QDRANT_BATCH",100)
|
||||||
qdrantHost := getEnv("QDRANT_HOST", "localhost")
|
qdrantHost := getEnv("QDRANT_HOST","localhost")
|
||||||
qdrantPort := getEnvInt("QDRANT_PORT", 6333)
|
qdrantPort := getEnvInt("QDRANT_PORT",6333)
|
||||||
qdrantURL = fmt.Sprintf("http://%s:%d", qdrantHost, qdrantPort)
|
qdrantURL = fmt.Sprintf("http://%s:%d",qdrantHost,qdrantPort)
|
||||||
collection = getEnv("QDRANT_COLLECTION", "news_vectors")
|
collection = getEnv("QDRANT_COLLECTION","news_vectors")
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
func getEnv(key,defaultValue string) string {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnvInt(key string, defaultValue int) int {
|
func getEnvInt(key string,defaultValue int) int {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
if intVal, err := strconv.Atoi(value); err == nil {
|
if intVal,err := strconv.Atoi(value); err == nil {
|
||||||
return intVal
|
return intVal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -310,17 +310,33 @@ func main() {
|
||||||
|
|
||||||
cfg := workers.LoadDBConfig()
|
cfg := workers.LoadDBConfig()
|
||||||
if err := workers.Connect(cfg); err != nil {
|
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()
|
dbPool = workers.GetPool()
|
||||||
defer workers.Close()
|
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 {
|
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)
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
|
@ -328,30 +344,36 @@ func main() {
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigChan
|
<-sigChan
|
||||||
logger.Println("Shutting down...")
|
logger.Info().Msg("Shutting down gracefully...")
|
||||||
|
cancel()
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
workers.Close()
|
||||||
os.Exit(0)
|
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)
|
qdrantURL, collection, sleepSec, batchSize)
|
||||||
|
|
||||||
totalProcessed := 0
|
totalProcessed := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Info().Msg("Context cancelled, stopping worker")
|
||||||
|
return
|
||||||
case <-time.After(time.Duration(sleepSec) * time.Second):
|
case <-time.After(time.Duration(sleepSec) * time.Second):
|
||||||
translations, err := getPendingTranslations(ctx)
|
translations, err := getPendingTranslations(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("Error fetching pending translations: %v", err)
|
logger.Error().Err(err).Msg("Error fetching pending translations")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(translations) == 0 {
|
if len(translations) == 0 {
|
||||||
logger.Println("No pending translations to process")
|
logger.Info().Msg("No pending translations to process")
|
||||||
continue
|
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)
|
// Generate point IDs once (used both in Qdrant and DB)
|
||||||
pointIDs := make([]string, len(translations))
|
pointIDs := make([]string, len(translations))
|
||||||
|
|
@ -361,21 +383,21 @@ func main() {
|
||||||
|
|
||||||
// Upload to Qdrant
|
// Upload to Qdrant
|
||||||
if err := uploadToQdrant(translations, pointIDs); err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update DB status
|
// Update DB status
|
||||||
if err := updateTranslationStatus(ctx, translations, pointIDs); err != nil {
|
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)
|
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)
|
total, vectorized, pending, err := getStats(ctx)
|
||||||
if err == nil {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -10,11 +11,11 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/rss2/backend/internal/logger"
|
||||||
"github.com/rss2/backend/internal/workers"
|
"github.com/rss2/backend/internal/workers"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logger *log.Logger
|
|
||||||
dbPool *pgxpool.Pool
|
dbPool *pgxpool.Pool
|
||||||
sleepSec = 10
|
sleepSec = 10
|
||||||
topK = 10
|
topK = 10
|
||||||
|
|
@ -23,7 +24,8 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logger = log.New(os.Stdout, "[RELATED] ", log.LstdFlags)
|
logLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
logger.Init("related-worker", logLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() {
|
func loadConfig() {
|
||||||
|
|
@ -335,20 +337,36 @@ func processBatch(ctx context.Context, model string) (int, error) {
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
logger.Println("Starting Related News Worker")
|
logger.Info().Msg("Starting Related News Worker")
|
||||||
|
|
||||||
cfg := workers.LoadDBConfig()
|
cfg := workers.LoadDBConfig()
|
||||||
if err := workers.Connect(cfg); err != nil {
|
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()
|
dbPool = workers.GetPool()
|
||||||
defer workers.Close()
|
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
|
// Ensure schema
|
||||||
if err := ensureSchema(ctx); err != nil {
|
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")
|
model := os.Getenv("EMB_MODEL")
|
||||||
|
|
@ -361,23 +379,29 @@ func main() {
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigChan
|
<-sigChan
|
||||||
logger.Println("Shutting down...")
|
logger.Info().Msg("Shutting down gracefully...")
|
||||||
|
cancel()
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
workers.Close()
|
||||||
os.Exit(0)
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Info().Msg("Context cancelled, stopping worker")
|
||||||
|
return
|
||||||
case <-time.After(time.Duration(sleepSec) * time.Second):
|
case <-time.After(time.Duration(sleepSec) * time.Second):
|
||||||
count, err := processBatch(ctx, model)
|
count, err := processBatch(ctx, model)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("Error processing batch: %v", err)
|
logger.Error().Err(err).Msg("Error processing batch")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
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"
|
"crypto/md5"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
|
@ -17,12 +16,11 @@ import (
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/rss2/backend/internal/logger"
|
||||||
"github.com/rss2/backend/internal/workers"
|
"github.com/rss2/backend/internal/workers"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logger *log.Logger
|
|
||||||
dbPool *workers.Config
|
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
sleepInterval = 60
|
sleepInterval = 60
|
||||||
batchSize = 10
|
batchSize = 10
|
||||||
|
|
@ -59,15 +57,31 @@ type Article struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logger = log.New(os.Stdout, "[SCRAPER] ", log.LstdFlags)
|
logLevel := os.Getenv("LOG_LEVEL")
|
||||||
logger.SetOutput(os.Stdout)
|
logger.Init("scraper-worker", logLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() {
|
func loadConfig() {
|
||||||
sleepInterval = getEnvInt("SCRAPER_SLEEP", 60)
|
if v := os.Getenv("SCRAPER_INTERVAL"); v != "" {
|
||||||
batchSize = getEnvInt("SCRAPER_BATCH", 10)
|
if i, err := strconv.Atoi(v); err == nil && i > 0 {
|
||||||
enrichLimit = getEnvInt("SCRAPER_ENRICH_LIMIT", 20)
|
sleepInterval = i
|
||||||
scraperWorkers = getEnvInt("SCRAPER_WORKERS", 5)
|
}
|
||||||
|
}
|
||||||
|
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 {
|
func getEnvInt(key string, defaultValue int) int {
|
||||||
|
|
@ -448,18 +462,34 @@ func scrapeSources(ctx context.Context, sources []URLSource) {
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
logger.Println("Starting Scraper Worker")
|
logger.Info().Msg("Starting Scraper Worker")
|
||||||
|
|
||||||
cfg := workers.LoadDBConfig()
|
cfg := workers.LoadDBConfig()
|
||||||
if err := workers.Connect(cfg); err != nil {
|
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()
|
pool = workers.GetPool()
|
||||||
defer workers.Close()
|
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
|
// Handle shutdown
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
|
@ -467,38 +497,44 @@ func main() {
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigChan
|
<-sigChan
|
||||||
logger.Println("Shutting down...")
|
logger.Info().Msg("Shutting down gracefully...")
|
||||||
|
cancel()
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
workers.Close()
|
||||||
os.Exit(0)
|
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)
|
ticker := time.NewTicker(time.Duration(sleepInterval) * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Info().Msg("Context cancelled, stopping worker")
|
||||||
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
noticias, err := getNoticiasToEnrich(ctx, enrichLimit)
|
noticias, err := getNoticiasToEnrich(ctx, enrichLimit)
|
||||||
if err != nil {
|
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 {
|
} 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)
|
enrichConcurrent(ctx, noticias)
|
||||||
}
|
}
|
||||||
|
|
||||||
sources, err := getActiveURLs(ctx)
|
sources, err := getActiveURLs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("Error fetching URLs: %v", err)
|
logger.Error().Err(err).Msg("Error fetching URLs")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
logger.Println("No active URLs to process")
|
logger.Info().Msg("No active URLs to process")
|
||||||
continue
|
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)
|
scrapeSources(ctx, sources)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
@ -14,12 +13,17 @@ import (
|
||||||
"github.com/rss2/backend/internal/config"
|
"github.com/rss2/backend/internal/config"
|
||||||
"github.com/rss2/backend/internal/db"
|
"github.com/rss2/backend/internal/db"
|
||||||
"github.com/rss2/backend/internal/handlers"
|
"github.com/rss2/backend/internal/handlers"
|
||||||
|
"github.com/rss2/backend/internal/logger"
|
||||||
"github.com/rss2/backend/internal/middleware"
|
"github.com/rss2/backend/internal/middleware"
|
||||||
"github.com/rss2/backend/internal/services"
|
"github.com/rss2/backend/internal/services"
|
||||||
|
swaggerFiles "github.com/swaggo/files"
|
||||||
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
|
_ "github.com/rss2/backend/docs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func initDB() {
|
func initDB() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
log := logger.GetLogger()
|
||||||
|
|
||||||
// Crear tabla entity_aliases si no existe
|
// Crear tabla entity_aliases si no existe
|
||||||
_, err := db.GetPool().Exec(ctx, `
|
_, err := db.GetPool().Exec(ctx, `
|
||||||
|
|
@ -33,9 +37,9 @@ func initDB() {
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
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 {
|
} else {
|
||||||
log.Println("Table entity_aliases ready")
|
log.Info().Msg("Table entity_aliases ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Añadir columna role a users si no existe
|
// 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'
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'user'
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Could not add role column: %v", err)
|
log.Warn().Err(err).Msg("Could not add role column")
|
||||||
} else {
|
} else {
|
||||||
log.Println("Column role ready")
|
log.Info().Msg("Column role ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear tabla de configuración si no existe
|
// Crear tabla de configuración si no existe
|
||||||
|
|
@ -129,31 +133,66 @@ func initDB() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Could not add assigned_at column: %v", err)
|
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() {
|
func main() {
|
||||||
cfg := config.Load()
|
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 {
|
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()
|
defer db.Close()
|
||||||
log.Println("Connected to PostgreSQL")
|
log.Info().Msg("Connected to PostgreSQL")
|
||||||
|
|
||||||
// Auto-setup DB tables
|
// Auto-setup DB tables
|
||||||
initDB()
|
initDB()
|
||||||
|
|
||||||
if err := cache.Connect(cfg.RedisURL); err != nil {
|
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 {
|
} else {
|
||||||
defer cache.Close()
|
defer cache.Close()
|
||||||
log.Println("Connected to Redis")
|
log.Info().Msg("Connected to Redis")
|
||||||
}
|
}
|
||||||
|
|
||||||
services.Init(cfg)
|
services.Init(cfg)
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
r.Use(middleware.RequestIDMiddleware())
|
||||||
r.Use(middleware.CORSMiddleware())
|
r.Use(middleware.CORSMiddleware())
|
||||||
r.Use(middleware.LoggerMiddleware())
|
r.Use(middleware.LoggerMiddleware())
|
||||||
|
|
||||||
|
|
@ -161,14 +200,25 @@ func main() {
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Swagger documentation
|
||||||
|
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
// Serve static images downloaded by wiki_worker
|
// Serve static images downloaded by wiki_worker
|
||||||
api.StaticFS("/wiki-images", gin.Dir(cfg.WikiImagesPath, false))
|
api.StaticFS("/wiki-images", gin.Dir(cfg.WikiImagesPath, false))
|
||||||
|
|
||||||
api.POST("/auth/login", handlers.Login)
|
// Stricter rate limiting for auth endpoints
|
||||||
api.POST("/auth/register", handlers.Register)
|
authGroup := api.Group("/auth")
|
||||||
api.GET("/auth/check-first-user", handlers.CheckFirstUser)
|
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")
|
news := api.Group("/news")
|
||||||
{
|
{
|
||||||
|
|
@ -251,9 +301,9 @@ func main() {
|
||||||
addr := fmt.Sprintf(":%s", port)
|
addr := fmt.Sprintf(":%s", port)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("Server starting on %s", addr)
|
log.Info().Str("addr", addr).Msg("Server starting")
|
||||||
if err := r.Run(addr); err != nil {
|
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)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-quit
|
<-quit
|
||||||
|
|
||||||
log.Println("Shutting down server...")
|
log.Info().Msg("Shutting down server...")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -12,11 +13,11 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/rss2/backend/internal/logger"
|
||||||
"github.com/rss2/backend/internal/workers"
|
"github.com/rss2/backend/internal/workers"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logger *log.Logger
|
|
||||||
dbPool *pgxpool.Pool
|
dbPool *pgxpool.Pool
|
||||||
sleepSec = 10
|
sleepSec = 10
|
||||||
batchSz = 500
|
batchSz = 500
|
||||||
|
|
@ -35,7 +36,8 @@ type Country struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logger = log.New(os.Stdout, "[TOPICS] ", log.LstdFlags)
|
logLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
logger.Init("topics-worker", logLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() {
|
func loadConfig() {
|
||||||
|
|
@ -335,20 +337,36 @@ func processBatch(ctx context.Context, topics []Topic, countries []Country) (int
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
logger.Println("Starting Topics Worker")
|
logger.Info().Msg("Starting Topics Worker")
|
||||||
|
|
||||||
cfg := workers.LoadDBConfig()
|
cfg := workers.LoadDBConfig()
|
||||||
if err := workers.Connect(cfg); err != nil {
|
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()
|
dbPool = workers.GetPool()
|
||||||
defer workers.Close()
|
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
|
// Ensure schema
|
||||||
if err := ensureSchema(ctx); err != nil {
|
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)
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
|
@ -356,41 +374,47 @@ func main() {
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigChan
|
<-sigChan
|
||||||
logger.Println("Shutting down...")
|
logger.Info().Msg("Shutting down gracefully...")
|
||||||
|
cancel()
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
workers.Close()
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logger.Printf("Config: sleep=%ds, batch=%d", sleepSec, batchSz)
|
logger.Info().Msgf("Config: sleep=%ds, batch=%d", sleepSec, batchSz)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Info().Msg("Context cancelled, stopping worker")
|
||||||
|
return
|
||||||
case <-time.After(time.Duration(sleepSec) * time.Second):
|
case <-time.After(time.Duration(sleepSec) * time.Second):
|
||||||
topics, err := loadTopics(ctx)
|
topics, err := loadTopics(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("Error loading topics: %v", err)
|
logger.Error().Err(err).Msg("Error loading topics")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(topics) == 0 {
|
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)
|
time.Sleep(time.Duration(sleepSec) * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
countries, err := loadCountries(ctx)
|
countries, err := loadCountries(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("Error loading countries: %v", err)
|
logger.Error().Err(err).Msg("Error loading countries")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
count, err := processBatch(ctx, topics, countries)
|
count, err := processBatch(ctx, topics, countries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("Error processing batch: %v", err)
|
logger.Error().Err(err).Msg("Error processing batch")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
logger.Printf("Processed %d news items", count)
|
logger.Info().Msgf("Processed %d news items", count)
|
||||||
}
|
}
|
||||||
|
|
||||||
if count < batchSz {
|
if count < batchSz {
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,11 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/rss2/backend/internal/logger"
|
||||||
"github.com/rss2/backend/internal/workers"
|
"github.com/rss2/backend/internal/workers"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logger *log.Logger
|
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
sleepInterval = 30
|
sleepInterval = 30
|
||||||
batchSize = 20
|
batchSize = 20
|
||||||
|
|
@ -52,7 +52,8 @@ type Tag struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
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) {
|
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 {
|
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()
|
cfg := workers.LoadDBConfig()
|
||||||
if err := workers.Connect(cfg); err != nil {
|
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()
|
pool = workers.GetPool()
|
||||||
defer workers.Close()
|
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)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigChan
|
<-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()
|
workers.Close()
|
||||||
os.Exit(0)
|
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 {
|
for {
|
||||||
tags, err := getPendingTags(ctx)
|
select {
|
||||||
if err != nil {
|
case <-ctx.Done():
|
||||||
logger.Printf("Error recuperando tags pendientes: %v", err)
|
logger.Info().Msg("Context cancelled, stopping worker")
|
||||||
time.Sleep(10 * time.Second)
|
return
|
||||||
continue
|
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 {
|
if len(tags) == 0 {
|
||||||
logger.Printf("No hay tags pendientes. Durmiendo %d segundos...", sleepInterval)
|
logger.Info().Msgf("No hay tags pendientes. Durmiendo %d segundos...", sleepInterval)
|
||||||
time.Sleep(time.Duration(sleepInterval) * time.Second)
|
select {
|
||||||
continue
|
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 {
|
for _, tag := range tags {
|
||||||
processTag(ctx, tag)
|
processTag(ctx, tag)
|
||||||
time.Sleep(3 * time.Second) // Increased delay to avoid Wikipedia Rate Limits (429)
|
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/jackc/pgx/v5 v5.4.3
|
||||||
github.com/mmcdole/gofeed v1.2.1
|
github.com/mmcdole/gofeed v1.2.1
|
||||||
github.com/redis/go-redis/v9 v9.0.5
|
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 (
|
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/andybalholm/cascadia v1.3.2 // indirect
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // 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/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // 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/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/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/mmcdole/goxpp v1.1.0 // indirect
|
github.com/mmcdole/goxpp v1.1.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
golang.org/x/arch v0.3.0 // 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/sync v0.8.0 // indirect
|
||||||
golang.org/x/sys v0.26.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
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
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 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||||
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
|
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-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 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/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 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
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 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
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 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
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=
|
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/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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
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 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
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/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 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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.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 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
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 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
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 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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=
|
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/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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o=
|
||||||
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
|
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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.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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
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/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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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=
|
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/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-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.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.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
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.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.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-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-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.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.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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/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/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-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-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-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-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-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-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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.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 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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=
|
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/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.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.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.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.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.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.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
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-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.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.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.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=
|
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 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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-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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
@ -53,3 +54,16 @@ func ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
|
||||||
return claims, nil
|
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
|
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 {
|
func Connect(redisURL string) error {
|
||||||
opt, err := redis.ParseURL(redisURL)
|
opt, err := redis.ParseURL(redisURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -40,15 +60,39 @@ func GetClient() *redis.Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
func SearchKey(query, lang string, page, perPage int) string {
|
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 {
|
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 {
|
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 {
|
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()
|
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 {
|
func Unmarshal(data []byte, v interface{}) error {
|
||||||
return json.Unmarshal(data, v)
|
return json.Unmarshal(data, v)
|
||||||
}
|
}
|
||||||
|
|
@ -70,3 +170,8 @@ func Unmarshal(data []byte, v interface{}) error {
|
||||||
func Marshal(v interface{}) ([]byte, error) {
|
func Marshal(v interface{}) ([]byte, error) {
|
||||||
return json.Marshal(v)
|
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 {
|
func Load() *Config {
|
||||||
secretKey := os.Getenv("SECRET_KEY")
|
secretKey := os.Getenv("SECRET_KEY")
|
||||||
if secretKey == "" {
|
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{
|
return &Config{
|
||||||
|
|
@ -47,7 +52,7 @@ func Load() *Config {
|
||||||
RateLimitPerMinute: getEnvInt("RATE_LIMIT_PER_MINUTE", 60),
|
RateLimitPerMinute: getEnvInt("RATE_LIMIT_PER_MINUTE", 60),
|
||||||
DockerComposeDir: getEnv("DOCKER_COMPOSE_DIR", "/datos/rss2"),
|
DockerComposeDir: getEnv("DOCKER_COMPOSE_DIR", "/datos/rss2"),
|
||||||
WikiImagesPath: getEnv("WIKI_IMAGES_PATH", "/app/data/wiki_images"),
|
WikiImagesPath: getEnv("WIKI_IMAGES_PATH", "/app/data/wiki_images"),
|
||||||
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "*"),
|
AllowedOrigins: allowedOrigins,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package db
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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)
|
return fmt.Errorf("failed to parse database URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
config.MaxConns = 25
|
config.MaxConns = int32(getEnvInt("DB_MAX_CONNS", 25))
|
||||||
config.MinConns = 5
|
config.MinConns = int32(getEnvInt("DB_MIN_CONNS", 5))
|
||||||
config.MaxConnLifetime = time.Hour
|
config.MaxConnLifetime = getEnvDuration("DB_MAX_CONN_LIFETIME", time.Hour)
|
||||||
config.MaxConnIdleTime = 30 * time.Minute
|
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)
|
Pool, err = pgxpool.NewWithConfig(context.Background(), config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -42,3 +45,28 @@ func Close() {
|
||||||
func GetPool() *pgxpool.Pool {
|
func GetPool() *pgxpool.Pool {
|
||||||
return 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
|
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) {
|
func BackupDatabase(c *gin.Context) {
|
||||||
info := resolvePgConnInfo()
|
info := resolvePgConnInfo()
|
||||||
|
|
||||||
|
|
@ -752,12 +768,12 @@ func BackupDatabase(c *gin.Context) {
|
||||||
)
|
)
|
||||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
|
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
|
var stderr bytes.Buffer
|
||||||
cmd.Stdout = &out
|
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "pg_dump failed",
|
"error": "pg_dump failed",
|
||||||
"details": stderr.String(),
|
"details": stderr.String(),
|
||||||
|
|
@ -769,10 +785,29 @@ func BackupDatabase(c *gin.Context) {
|
||||||
c.Header("Content-Type", "application/octet-stream")
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
c.Header("Cache-Control", "no-cache")
|
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) {
|
func BackupNewsZipped(c *gin.Context) {
|
||||||
info := resolvePgConnInfo()
|
info := resolvePgConnInfo()
|
||||||
|
|
||||||
|
|
@ -794,12 +829,12 @@ func BackupNewsZipped(c *gin.Context) {
|
||||||
cmd := exec.Command("pg_dump", args...)
|
cmd := exec.Command("pg_dump", args...)
|
||||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
|
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
|
var stderr bytes.Buffer
|
||||||
cmd.Stdout = &sqlOut
|
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "pg_dump failed",
|
"error": "pg_dump failed",
|
||||||
"details": stderr.String(),
|
"details": stderr.String(),
|
||||||
|
|
@ -807,33 +842,40 @@ func BackupNewsZipped(c *gin.Context) {
|
||||||
return
|
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"))
|
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-Type", "application/zip")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
c.Header("Cache-Control", "no-cache")
|
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
|
// 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
|
// 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) {
|
func RunAlertScan(ctx context.Context) (int, error) {
|
||||||
minHits := 3
|
minHits := 5
|
||||||
minRatio := 5.0
|
minRatio := 5.0
|
||||||
|
minBaseline := 2.0
|
||||||
|
lookbackDays := 8
|
||||||
if v := os.Getenv("ALERTS_MIN_HITS"); v != "" {
|
if v := os.Getenv("ALERTS_MIN_HITS"); v != "" {
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
minHits = n
|
minHits = n
|
||||||
|
|
@ -123,6 +131,16 @@ func RunAlertScan(ctx context.Context) (int, error) {
|
||||||
minRatio = f
|
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 := `
|
query := `
|
||||||
WITH daily AS (
|
WITH daily AS (
|
||||||
|
|
@ -132,34 +150,43 @@ func RunAlertScan(ctx context.Context) (int, error) {
|
||||||
JOIN tags t ON tn.tag_id = t.id
|
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
|
||||||
JOIN noticias n ON tr.noticia_id = n.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')
|
AND t.tipo IN ('persona', 'lugar', 'organizacion', 'tema')
|
||||||
GROUP BY t.id, t.valor, t.tipo, n.fecha::date
|
GROUP BY t.id, t.valor, t.tipo, n.fecha::date
|
||||||
),
|
),
|
||||||
baseline AS (
|
ref AS (
|
||||||
SELECT tag_id, valor, tipo,
|
SELECT MAX(dia) AS ref_date FROM daily
|
||||||
AVG(cnt)::float AS baseline,
|
),
|
||||||
MAX(cnt)::int AS max_prev
|
prev_series AS (
|
||||||
FROM daily
|
SELECT generate_series((SELECT ref_date - $3::int FROM ref),
|
||||||
WHERE dia < CURRENT_DATE
|
(SELECT ref_date - 1 FROM ref), '1 day')::date AS dia
|
||||||
GROUP BY tag_id, valor, tipo
|
|
||||||
),
|
),
|
||||||
today AS (
|
today AS (
|
||||||
SELECT tag_id, valor, tipo, SUM(cnt)::int AS hits
|
SELECT tag_id, valor, tipo, SUM(cnt)::int AS hits
|
||||||
FROM daily
|
FROM daily
|
||||||
WHERE dia = CURRENT_DATE
|
WHERE dia = (SELECT ref_date FROM ref)
|
||||||
GROUP BY tag_id, valor, tipo
|
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,
|
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
|
FROM today t
|
||||||
JOIN baseline b USING (tag_id)
|
JOIN base b USING (tag_id)
|
||||||
WHERE t.hits >= $1
|
WHERE t.hits >= $1
|
||||||
AND b.baseline >= 0.5
|
AND b.baseline >= $4
|
||||||
AND (t.hits::float / NULLIF(b.baseline, 0)) >= $2
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
@ -170,14 +197,15 @@ func RunAlertScan(ctx context.Context) (int, error) {
|
||||||
var valor, tipo string
|
var valor, tipo string
|
||||||
var hits int
|
var hits int
|
||||||
var baseline, ratio float64
|
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
|
continue
|
||||||
}
|
}
|
||||||
res, err := db.GetPool().Exec(ctx, `
|
res, err := db.GetPool().Exec(ctx, `
|
||||||
INSERT INTO alertas (valor, tipo, periodo, hits, baseline, ratio)
|
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
|
ON CONFLICT (valor, tipo, periodo) DO NOTHING
|
||||||
`, valor, tipo, hits, baseline, ratio)
|
`, valor, tipo, periodo, hits, baseline, ratio)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("alerts: insert failed for %s (%s): %v", valor, tipo, err)
|
log.Printf("alerts: insert failed for %s (%s): %v", valor, tipo, err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -200,12 +228,12 @@ func StartAlertScanner() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(45 * time.Second)
|
time.Sleep(45 * time.Second)
|
||||||
for {
|
for {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
inserted, err := RunAlertScan(ctx)
|
inserted, err := RunAlertScan(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("alerts: scan error: %v", err)
|
log.Printf("alerts: scan error: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
"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) {
|
func CheckFirstUser(c *gin.Context) {
|
||||||
var count int
|
var count int
|
||||||
err := db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*) FROM users").Scan(&count)
|
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})
|
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) {
|
func Login(c *gin.Context) {
|
||||||
var req models.LoginRequest
|
var req models.LoginRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
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) {
|
func Register(c *gin.Context) {
|
||||||
var req models.RegisterRequest
|
var req models.RegisterRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
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) {
|
func GetCurrentUser(c *gin.Context) {
|
||||||
userVal, exists := c.Get("user")
|
userVal, exists := c.Get("user")
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ func init() {
|
||||||
auth.SetJWTSecret("test-secret-key-12345")
|
auth.SetJWTSecret("test-secret-key-12345")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoginInvalidRequest tests that an empty request body returns 400
|
||||||
func TestLoginInvalidRequest(t *testing.T) {
|
func TestLoginInvalidRequest(t *testing.T) {
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.POST("/auth/login", Login)
|
router.POST("/auth/login", Login)
|
||||||
|
|
@ -40,22 +41,7 @@ func TestLoginInvalidRequest(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginInvalidCredentials(t *testing.T) {
|
// TestRegisterInvalidRequest tests that an empty request body returns 400
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterInvalidRequest(t *testing.T) {
|
func TestRegisterInvalidRequest(t *testing.T) {
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.POST("/auth/register", Register)
|
router.POST("/auth/register", Register)
|
||||||
|
|
@ -72,27 +58,7 @@ func TestRegisterInvalidRequest(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckFirstUser(t *testing.T) {
|
// TestGetCurrentUserUnauthorized tests that unauthenticated request returns 401
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetCurrentUserUnauthorized(t *testing.T) {
|
func TestGetCurrentUserUnauthorized(t *testing.T) {
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.GET("/auth/me", GetCurrentUser)
|
router.GET("/auth/me", GetCurrentUser)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package handlers
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -11,40 +12,46 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/rss2/backend/internal/cache"
|
||||||
"github.com/rss2/backend/internal/db"
|
"github.com/rss2/backend/internal/db"
|
||||||
"github.com/rss2/backend/internal/models"
|
"github.com/rss2/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FeedResponse struct {
|
// GetFeeds returns paginated feeds with optional filters
|
||||||
ID int64 `json:"id"`
|
// @Summary List feeds
|
||||||
Nombre string `json:"nombre"`
|
// @Description Returns paginated feeds with optional filtering by active status, category, and country
|
||||||
Descripcion *string `json:"descripcion"`
|
// @Tags feeds
|
||||||
URL string `json:"url"`
|
// @Produce json
|
||||||
CategoriaID *int64 `json:"categoria_id"`
|
// @Param page query int false "Page number" default(1) minimum(1)
|
||||||
PaisID *int64 `json:"pais_id"`
|
// @Param per_page query int false "Items per page" default(50) minimum(1) maximum(100)
|
||||||
Idioma *string `json:"idioma"`
|
// @Param activo query string false "Active status filter"
|
||||||
Activo bool `json:"activo"`
|
// @Param categoria_id query string false "Category ID filter"
|
||||||
Fallos *int64 `json:"fallos"`
|
// @Param pais_id query string false "Country ID filter"
|
||||||
LastError *string `json:"last_error"`
|
// @Success 200 {object} map[string]interface{} "feeds, total, page, per_page, total_pages"
|
||||||
FuenteURLID *int64 `json:"fuente_url_id"`
|
// @Failure 500 {object} models.ErrorResponse
|
||||||
}
|
// @Router /feeds [get]
|
||||||
|
|
||||||
func GetFeeds(c *gin.Context) {
|
func GetFeeds(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
||||||
|
page, perPage = validatePageParams(page, perPage, 50, 100)
|
||||||
activo := c.Query("activo")
|
activo := c.Query("activo")
|
||||||
categoriaID := c.Query("categoria_id")
|
categoriaID := c.Query("categoria_id")
|
||||||
paisID := c.Query("pais_id")
|
paisID := c.Query("pais_id")
|
||||||
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 || perPage > 100 {
|
|
||||||
perPage = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := (page - 1) * perPage
|
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"
|
where := "1=1"
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
argNum := 1
|
argNum := 1
|
||||||
|
|
@ -67,7 +74,7 @@ func GetFeeds(c *gin.Context) {
|
||||||
|
|
||||||
var total int
|
var total int
|
||||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM feeds WHERE %s", where)
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count feeds", Message: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count feeds", Message: err.Error()})
|
||||||
return
|
return
|
||||||
|
|
@ -75,7 +82,7 @@ func GetFeeds(c *gin.Context) {
|
||||||
|
|
||||||
sqlQuery := fmt.Sprintf(`
|
sqlQuery := fmt.Sprintf(`
|
||||||
SELECT f.id, f.nombre, f.descripcion, f.url,
|
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,
|
c.nombre AS categoria, p.nombre AS pais,
|
||||||
(SELECT COUNT(*) FROM noticias n WHERE n.fuente_nombre = f.nombre) as noticias_count
|
(SELECT COUNT(*) FROM noticias n WHERE n.fuente_nombre = f.nombre) as noticias_count
|
||||||
FROM feeds f
|
FROM feeds f
|
||||||
|
|
@ -88,7 +95,7 @@ func GetFeeds(c *gin.Context) {
|
||||||
|
|
||||||
args = append(args, perPage, offset)
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to fetch feeds", Message: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to fetch feeds", Message: err.Error()})
|
||||||
return
|
return
|
||||||
|
|
@ -96,7 +103,7 @@ func GetFeeds(c *gin.Context) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
type FeedWithStats struct {
|
type FeedWithStats struct {
|
||||||
FeedResponse
|
models.Feed
|
||||||
Categoria *string `json:"categoria"`
|
Categoria *string `json:"categoria"`
|
||||||
Pais *string `json:"pais"`
|
Pais *string `json:"pais"`
|
||||||
NoticiasCount int64 `json:"noticias_count"`
|
NoticiasCount int64 `json:"noticias_count"`
|
||||||
|
|
@ -107,7 +114,7 @@ func GetFeeds(c *gin.Context) {
|
||||||
var f FeedWithStats
|
var f FeedWithStats
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&f.ID, &f.Nombre, &f.Descripcion, &f.URL,
|
&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,
|
&f.Categoria, &f.Pais, &f.NoticiasCount,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -118,13 +125,20 @@ func GetFeeds(c *gin.Context) {
|
||||||
|
|
||||||
totalPages := (total + perPage - 1) / perPage
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
response := gin.H{
|
||||||
"feeds": feeds,
|
"feeds": feeds,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
"total_pages": totalPages,
|
"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) {
|
func GetFeedByID(c *gin.Context) {
|
||||||
|
|
@ -134,12 +148,12 @@ func GetFeedByID(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var f FeedResponse
|
var f models.Feed
|
||||||
err = db.GetPool().QueryRow(c.Request.Context(), `
|
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(
|
FROM feeds WHERE id = $1`, id).Scan(
|
||||||
&f.ID, &f.Nombre, &f.Descripcion, &f.URL,
|
&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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
|
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
|
||||||
|
|
@ -178,6 +192,9 @@ func CreateFeed(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate feed list cache
|
||||||
|
cache.InvalidateFeedList(c.Request.Context())
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"id": feedID, "message": "Feed created successfully"})
|
c.JSON(http.StatusCreated, gin.H{"id": feedID, "message": "Feed created successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +245,10 @@ func UpdateFeed(c *gin.Context) {
|
||||||
return
|
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"})
|
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed updated successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,6 +270,10 @@ func DeleteFeed(c *gin.Context) {
|
||||||
return
|
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"})
|
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,6 +296,10 @@ func ToggleFeedActive(c *gin.Context) {
|
||||||
return
|
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"})
|
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed toggled successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,6 +322,10 @@ func ReactivateFeed(c *gin.Context) {
|
||||||
return
|
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"})
|
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()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to start transaction", Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback(context.Background())
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
tx.Rollback(context.Background())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
field := func(record []string, idx int) string {
|
field := func(record []string, idx int) string {
|
||||||
if idx < 0 || idx >= len(record) {
|
if idx < 0 || idx >= len(record) {
|
||||||
|
|
@ -579,7 +617,9 @@ func ImportFeeds(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
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++
|
failed++
|
||||||
errors = append(errors, fmt.Sprintf("Error upserting %s: %v", url, err))
|
errors = append(errors, fmt.Sprintf("Error upserting %s: %v", url, err))
|
||||||
continue
|
continue
|
||||||
|
|
@ -598,6 +638,7 @@ func ImportFeeds(c *gin.Context) {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to commit transaction", Message: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to commit transaction", Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
committed = true
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"imported": imported,
|
"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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -9,40 +10,45 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/rss2/backend/internal/config"
|
||||||
"github.com/rss2/backend/internal/db"
|
"github.com/rss2/backend/internal/db"
|
||||||
"github.com/rss2/backend/internal/models"
|
"github.com/rss2/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NewsResponse struct {
|
// getTargetLang returns the target language for translations, defaulting to config DefaultLang
|
||||||
ID string `json:"id"`
|
func getTargetLang(c *gin.Context) string {
|
||||||
Titulo string `json:"titulo"`
|
lang := c.Query("lang")
|
||||||
Resumen string `json:"resumen"`
|
if lang == "" {
|
||||||
URL string `json:"url"`
|
cfg := config.Load()
|
||||||
Fecha *time.Time `json:"fecha"`
|
lang = cfg.DefaultLang
|
||||||
ImagenURL *string `json:"imagen_url"`
|
}
|
||||||
CategoriaID *int64 `json:"categoria_id"`
|
return lang
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func GetNews(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30"))
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30"))
|
||||||
|
page, perPage = validatePageParams(page, perPage, 30, 100)
|
||||||
query := c.Query("q")
|
query := c.Query("q")
|
||||||
categoryID := c.Query("category_id")
|
categoryID := c.Query("category_id")
|
||||||
countryID := c.Query("country_id")
|
countryID := c.Query("country_id")
|
||||||
translatedOnly := c.Query("translated_only") == "true"
|
translatedOnly := c.Query("translated_only") == "true"
|
||||||
|
targetLang := getTargetLang(c)
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 || perPage > 100 {
|
|
||||||
perPage = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := (page - 1) * perPage
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
|
@ -53,7 +59,7 @@ func GetNews(c *gin.Context) {
|
||||||
if query != "" {
|
if query != "" {
|
||||||
if translatedOnly {
|
if translatedOnly {
|
||||||
// With translated_only, search the translated fields too so users can
|
// 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)
|
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 {
|
} else {
|
||||||
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d)", argNum, argNum)
|
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
|
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)
|
err := db.GetPool().QueryRow(c.Request.Context(), countQuery, args...).Scan(&total)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count news", Message: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count news", Message: err.Error()})
|
||||||
|
|
@ -94,19 +101,18 @@ func GetNews(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlQuery := fmt.Sprintf(`
|
sqlQuery := `
|
||||||
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
|
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.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
|
||||||
t.titulo_trad,
|
t.titulo_trad,
|
||||||
t.resumen_trad,
|
t.resumen_trad,
|
||||||
t.lang_to as lang_trad
|
t.lang_to as lang_trad
|
||||||
FROM noticias n
|
FROM noticias n
|
||||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $` + strconv.Itoa(argNum) + `
|
||||||
WHERE %s
|
WHERE ` + where + `
|
||||||
ORDER BY n.fecha DESC LIMIT $%d OFFSET $%d
|
ORDER BY n.fecha DESC LIMIT $` + strconv.Itoa(argNum+1) + ` OFFSET $` + strconv.Itoa(argNum+2)
|
||||||
`, where, argNum, argNum+1)
|
|
||||||
|
|
||||||
args = append(args, perPage, offset)
|
args = append(args, targetLang, perPage, offset)
|
||||||
|
|
||||||
rows, err := db.GetPool().Query(c.Request.Context(), sqlQuery, args...)
|
rows, err := db.GetPool().Query(c.Request.Context(), sqlQuery, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -115,15 +121,16 @@ func GetNews(c *gin.Context) {
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var newsList []NewsResponse
|
var newsList []models.NewsWithTranslations
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n NewsResponse
|
var n models.NewsWithTranslations
|
||||||
var imagenURL, fuenteNombre *string
|
var imagenURL, fuenteNombre *string
|
||||||
var categoriaID, paisID *int32
|
var categoriaID, paisID *int32
|
||||||
|
var lang string
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
|
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL,
|
||||||
&categoriaID, &paisID, &fuenteNombre,
|
&categoriaID, &paisID, &fuenteNombre, &lang,
|
||||||
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -137,12 +144,13 @@ func GetNews(c *gin.Context) {
|
||||||
}
|
}
|
||||||
if categoriaID != nil {
|
if categoriaID != nil {
|
||||||
catID := int64(*categoriaID)
|
catID := int64(*categoriaID)
|
||||||
n.CategoriaID = &catID
|
n.CategoryID = &catID
|
||||||
}
|
}
|
||||||
if paisID != nil {
|
if paisID != nil {
|
||||||
pID := int64(*paisID)
|
pID := int64(*paisID)
|
||||||
n.PaisID = &pID
|
n.CountryID = &pID
|
||||||
}
|
}
|
||||||
|
n.Lang = lang
|
||||||
newsList = append(newsList, n)
|
newsList = append(newsList, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,18 +169,11 @@ func GetNews(c *gin.Context) {
|
||||||
func GetEntityNews(c *gin.Context) {
|
func GetEntityNews(c *gin.Context) {
|
||||||
valor := c.Query("valor")
|
valor := c.Query("valor")
|
||||||
tipo := c.DefaultQuery("tipo", "persona")
|
tipo := c.DefaultQuery("tipo", "persona")
|
||||||
pageStr := c.DefaultQuery("page", "1")
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
perPageStr := c.DefaultQuery("per_page", "20")
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
|
page, perPage = validatePageParams(page, perPage, 20, 50)
|
||||||
page, _ := strconv.Atoi(pageStr)
|
|
||||||
perPage, _ := strconv.Atoi(perPageStr)
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 || perPage > 50 {
|
|
||||||
perPage = 20
|
|
||||||
}
|
|
||||||
offset := (page - 1) * perPage
|
offset := (page - 1) * perPage
|
||||||
|
targetLang := getTargetLang(c)
|
||||||
|
|
||||||
daysStr := c.Query("days")
|
daysStr := c.Query("days")
|
||||||
days := 0
|
days := 0
|
||||||
|
|
@ -194,8 +195,8 @@ func GetEntityNews(c *gin.Context) {
|
||||||
useDayOffset = true
|
useDayOffset = true
|
||||||
}
|
}
|
||||||
|
|
||||||
params := []interface{}{valor, tipo}
|
params := []interface{}{valor, tipo, targetLang}
|
||||||
next := 3
|
next := 4
|
||||||
timeCond := ""
|
timeCond := ""
|
||||||
switch {
|
switch {
|
||||||
case useDayOffset:
|
case useDayOffset:
|
||||||
|
|
@ -214,7 +215,7 @@ func GetEntityNews(c *gin.Context) {
|
||||||
base := fmt.Sprintf(`
|
base := fmt.Sprintf(`
|
||||||
FROM tags_noticia tn
|
FROM tags_noticia tn
|
||||||
JOIN tags t ON tn.tag_id = t.id
|
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
|
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
|
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)
|
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(`
|
query := fmt.Sprintf(`
|
||||||
SELECT DISTINCT n.id, n.titulo, COALESCE(n.resumen,''), n.url, n.fecha, n.imagen_url,
|
SELECT DISTINCT n.id, n.titulo, COALESCE(n.resumen,''), n.contenido, n.url, n.fecha, n.imagen_url,
|
||||||
n.fuente_nombre, tr.titulo_trad, tr.resumen_trad
|
n.fuente_nombre, n.lang, tr.titulo_trad, tr.resumen_trad
|
||||||
%s
|
%s
|
||||||
ORDER BY n.fecha DESC
|
ORDER BY n.fecha DESC
|
||||||
LIMIT $%d OFFSET $%d`, base, next, next+1)
|
LIMIT $%d OFFSET $%d`, base, next, next+1)
|
||||||
|
|
@ -240,12 +241,12 @@ func GetEntityNews(c *gin.Context) {
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var news []NewsResponse
|
var news []models.NewsWithTranslations
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n NewsResponse
|
var n models.NewsWithTranslations
|
||||||
var img *string
|
var img *string
|
||||||
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &img,
|
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &img,
|
||||||
&n.FuenteNombre, &n.TitleTranslated, &n.SummaryTranslated); err != nil {
|
&n.FuenteNombre, &n.Lang, &n.TitleTranslated, &n.SummaryTranslated); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if img != nil {
|
if img != nil {
|
||||||
|
|
@ -254,7 +255,7 @@ func GetEntityNews(c *gin.Context) {
|
||||||
news = append(news, n)
|
news = append(news, n)
|
||||||
}
|
}
|
||||||
if news == nil {
|
if news == nil {
|
||||||
news = []NewsResponse{}
|
news = []models.NewsWithTranslations{}
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPages := (total + perPage - 1) / perPage
|
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) {
|
func GetNewsByID(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
targetLang := getTargetLang(c)
|
||||||
|
|
||||||
|
// Combined query to fetch news with entities in one round trip
|
||||||
sqlQuery := `
|
sqlQuery := `
|
||||||
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
|
SELECT
|
||||||
n.categoria_id, n.pais_id, n.fuente_nombre,
|
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.titulo_trad,
|
||||||
t.resumen_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
|
FROM noticias n
|
||||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1
|
||||||
WHERE n.id = $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 imagenURL, fuenteNombre *string
|
||||||
var categoriaID, paisID *int32
|
var categoriaID, paisID *int32
|
||||||
|
var lang string
|
||||||
|
var entitiesJSON *string
|
||||||
|
|
||||||
err := db.GetPool().QueryRow(c.Request.Context(), sqlQuery, id).Scan(
|
err := db.GetPool().QueryRow(c.Request.Context(), sqlQuery, targetLang, id).Scan(
|
||||||
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
|
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL,
|
||||||
&categoriaID, &paisID, &fuenteNombre,
|
&categoriaID, &paisID, &fuenteNombre, &lang,
|
||||||
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
||||||
|
&entitiesJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "News not found"})
|
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "News not found"})
|
||||||
|
|
@ -303,36 +342,23 @@ func GetNewsByID(c *gin.Context) {
|
||||||
}
|
}
|
||||||
if categoriaID != nil {
|
if categoriaID != nil {
|
||||||
catID := int64(*categoriaID)
|
catID := int64(*categoriaID)
|
||||||
n.CategoriaID = &catID
|
n.CategoryID = &catID
|
||||||
}
|
}
|
||||||
if paisID != nil {
|
if paisID != nil {
|
||||||
pID := int64(*paisID)
|
pID := int64(*paisID)
|
||||||
n.PaisID = &pID
|
n.CountryID = &pID
|
||||||
}
|
}
|
||||||
|
n.Lang = lang
|
||||||
|
|
||||||
// Fetch entities for this news
|
// Parse entities from JSON
|
||||||
entitiesQuery := `
|
if entitiesJSON != nil && *entitiesJSON != "null" {
|
||||||
SELECT t.valor, t.tipo, 1 as cnt, t.wiki_summary, t.wiki_url, t.image_path
|
var entities []models.Entity
|
||||||
FROM tags_noticia tn
|
if err := json.Unmarshal([]byte(*entitiesJSON), &entities); err == nil {
|
||||||
JOIN tags t ON tn.tag_id = t.id
|
n.Entities = entities
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
n.Entities = []models.Entity{}
|
||||||
}
|
}
|
||||||
if entities == nil {
|
|
||||||
entities = []Entity{}
|
|
||||||
}
|
|
||||||
n.Entities = entities
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, n)
|
c.JSON(http.StatusOK, n)
|
||||||
}
|
}
|
||||||
|
|
@ -354,23 +380,20 @@ func DeleteNews(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, models.SuccessResponse{Message: "News deleted successfully"})
|
c.JSON(http.StatusOK, models.SuccessResponse{Message: "News deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
type Entity struct {
|
// GetEntities returns paginated entities with optional filters
|
||||||
Valor string `json:"valor"`
|
// @Summary List entities
|
||||||
Tipo string `json:"tipo"`
|
// @Description Returns paginated entities (personas, organizations, locations) with optional filters
|
||||||
Count int `json:"count"`
|
// @Tags entities
|
||||||
WikiSummary *string `json:"wiki_summary"`
|
// @Produce json
|
||||||
WikiURL *string `json:"wiki_url"`
|
// @Param tipo query string false "Entity type" default("persona") Enums(persona, organizacion, lugar, tema)
|
||||||
ImagePath *string `json:"image_path"`
|
// @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"
|
||||||
type EntityListResponse struct {
|
// @Param category_id query string false "Category ID filter"
|
||||||
Entities []Entity `json:"entities"`
|
// @Param q query string false "Search query"
|
||||||
Total int `json:"total"`
|
// @Success 200 {object} models.EntityListResponse
|
||||||
Page int `json:"page"`
|
// @Failure 500 {object} models.ErrorResponse
|
||||||
PerPage int `json:"per_page"`
|
// @Router /entities [get]
|
||||||
TotalPages int `json:"total_pages"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetEntities(c *gin.Context) {
|
func GetEntities(c *gin.Context) {
|
||||||
countryID := c.Query("country_id")
|
countryID := c.Query("country_id")
|
||||||
categoryID := c.Query("category_id")
|
categoryID := c.Query("category_id")
|
||||||
|
|
@ -378,18 +401,9 @@ func GetEntities(c *gin.Context) {
|
||||||
|
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
|
|
||||||
pageStr := c.DefaultQuery("page", "1")
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
perPageStr := c.DefaultQuery("per_page", "50")
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
||||||
|
page, perPage = validatePageParams(page, perPage, 50, 100)
|
||||||
page, _ := strconv.Atoi(pageStr)
|
|
||||||
perPage, _ := strconv.Atoi(perPageStr)
|
|
||||||
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 || perPage > 100 {
|
|
||||||
perPage = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := (page - 1) * perPage
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
|
@ -430,8 +444,8 @@ func GetEntities(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
c.JSON(http.StatusOK, EntityListResponse{
|
c.JSON(http.StatusOK, models.EntityListResponse{
|
||||||
Entities: []Entity{},
|
Entities: []models.Entity{},
|
||||||
Total: 0,
|
Total: 0,
|
||||||
Page: page,
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
|
|
@ -463,9 +477,9 @@ func GetEntities(c *gin.Context) {
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var entities []Entity
|
var entities []models.Entity
|
||||||
for rows.Next() {
|
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 {
|
if err := rows.Scan(&e.Valor, &e.Tipo, &e.Count, &e.WikiSummary, &e.WikiURL, &e.ImagePath); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -473,12 +487,12 @@ func GetEntities(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if entities == nil {
|
if entities == nil {
|
||||||
entities = []Entity{}
|
entities = []models.Entity{}
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPages := (total + perPage - 1) / perPage
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
|
||||||
c.JSON(http.StatusOK, EntityListResponse{
|
c.JSON(http.StatusOK, models.EntityListResponse{
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
Total: total,
|
Total: total,
|
||||||
Page: page,
|
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) {
|
func GetEntityMentions(c *gin.Context) {
|
||||||
valuesRaw := c.DefaultQuery("values", "")
|
valuesRaw := c.DefaultQuery("values", "")
|
||||||
days := 30
|
days := 30
|
||||||
|
|
@ -531,7 +528,7 @@ func GetEntityMentions(c *gin.Context) {
|
||||||
|
|
||||||
start := time.Now().AddDate(0, 0, -(days - 1)).Truncate(24 * time.Hour)
|
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 {
|
for _, valor := range values {
|
||||||
s, err := buildMentionSeries(c.Request.Context(), valor, days)
|
s, err := buildMentionSeries(c.Request.Context(), valor, days)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -543,10 +540,10 @@ func GetEntityMentions(c *gin.Context) {
|
||||||
for _, p := range s.Data {
|
for _, p := range s.Data {
|
||||||
m[p.Fecha] = p.Count
|
m[p.Fecha] = p.Count
|
||||||
}
|
}
|
||||||
data := make([]MentionPoint, 0, days)
|
data := make([]models.MentionPoint, 0, days)
|
||||||
for i := 0; i < days; i++ {
|
for i := 0; i < days; i++ {
|
||||||
fecha := start.AddDate(0, 0, i).Format("2006-01-02")
|
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.Data = data
|
||||||
s.Count = 0
|
s.Count = 0
|
||||||
|
|
@ -559,11 +556,11 @@ func GetEntityMentions(c *gin.Context) {
|
||||||
series = append(series, *s)
|
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) {
|
func buildMentionSeries(ctx context.Context, valor string, days int) (*models.MentionSeries, error) {
|
||||||
s := &MentionSeries{Valor: valor}
|
s := &models.MentionSeries{Valor: valor}
|
||||||
|
|
||||||
var tagIDs []int64
|
var tagIDs []int64
|
||||||
rows, err := db.GetPool().Query(ctx, `
|
rows, err := db.GetPool().Query(ctx, `
|
||||||
|
|
@ -590,7 +587,7 @@ func buildMentionSeries(ctx context.Context, valor string, days int) (*MentionSe
|
||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
||||||
if len(tagIDs) == 0 {
|
if len(tagIDs) == 0 {
|
||||||
s.Data = []MentionPoint{}
|
s.Data = []models.MentionPoint{}
|
||||||
return s, nil
|
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 {
|
if err := drows.Scan(&fecha, &cnt); err != nil {
|
||||||
continue
|
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 {
|
if s.Data == nil {
|
||||||
s.Data = []MentionPoint{}
|
s.Data = []models.MentionPoint{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s, nil
|
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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/rss2/backend/internal/auth"
|
"github.com/rss2/backend/internal/auth"
|
||||||
|
"github.com/rss2/backend/internal/cache"
|
||||||
"github.com/rss2/backend/internal/db"
|
"github.com/rss2/backend/internal/db"
|
||||||
"github.com/rss2/backend/internal/models"
|
"github.com/rss2/backend/internal/models"
|
||||||
"github.com/rss2/backend/internal/services"
|
"github.com/rss2/backend/internal/services"
|
||||||
|
|
@ -79,10 +81,27 @@ func SearchSuggestions(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"terms": terms})
|
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) {
|
func SearchNews(c *gin.Context) {
|
||||||
query := c.Query("q")
|
query := c.Query("q")
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30"))
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30"))
|
||||||
|
page, perPage = validatePageParams(page, perPage, 30, 100)
|
||||||
lang := c.DefaultQuery("lang", "")
|
lang := c.DefaultQuery("lang", "")
|
||||||
categoriaID := c.Query("categoria_id")
|
categoriaID := c.Query("categoria_id")
|
||||||
paisID := c.Query("pais_id")
|
paisID := c.Query("pais_id")
|
||||||
|
|
@ -93,13 +112,6 @@ func SearchNews(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 || perPage > 100 {
|
|
||||||
perPage = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to Spanish if no lang specified
|
// Default to Spanish if no lang specified
|
||||||
if lang == "" {
|
if lang == "" {
|
||||||
lang = "es"
|
lang = "es"
|
||||||
|
|
@ -117,6 +129,15 @@ func SearchNews(c *gin.Context) {
|
||||||
return
|
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
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
// Build dynamic query
|
// Build dynamic query
|
||||||
|
|
@ -147,8 +168,8 @@ func SearchNews(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlQuery := `
|
sqlQuery := `
|
||||||
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
|
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.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
|
||||||
t.titulo_trad,
|
t.titulo_trad,
|
||||||
t.resumen_trad,
|
t.resumen_trad,
|
||||||
t.lang_to as lang_trad
|
t.lang_to as lang_trad
|
||||||
|
|
@ -167,15 +188,16 @@ func SearchNews(c *gin.Context) {
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var newsList []NewsResponse
|
var newsList []models.NewsWithTranslations
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n NewsResponse
|
var n models.NewsWithTranslations
|
||||||
var imagenURL, fuenteNombre *string
|
var imagenURL, fuenteNombre *string
|
||||||
var categoriaIDp, paisIDp *int64
|
var categoriaIDp, paisIDp *int64
|
||||||
|
var lang string
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
|
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL,
|
||||||
&categoriaIDp, &paisIDp, &fuenteNombre,
|
&categoriaIDp, &paisIDp, &fuenteNombre, &lang,
|
||||||
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -187,8 +209,9 @@ func SearchNews(c *gin.Context) {
|
||||||
if fuenteNombre != nil {
|
if fuenteNombre != nil {
|
||||||
n.FuenteNombre = *fuenteNombre
|
n.FuenteNombre = *fuenteNombre
|
||||||
}
|
}
|
||||||
n.CategoriaID = categoriaIDp
|
n.CategoryID = categoriaIDp
|
||||||
n.PaisID = paisIDp
|
n.CountryID = paisIDp
|
||||||
|
n.Lang = lang
|
||||||
newsList = append(newsList, n)
|
newsList = append(newsList, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,19 +229,42 @@ func SearchNews(c *gin.Context) {
|
||||||
|
|
||||||
totalPages := (total + perPage - 1) / perPage
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
response := gin.H{
|
||||||
"news": newsList,
|
"news": newsList,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
"total_pages": totalPages,
|
"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) {
|
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
|
var stats models.Stats
|
||||||
|
|
||||||
err := db.GetPool().QueryRow(c.Request.Context(), `
|
err := db.GetPool().QueryRow(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT COUNT(*) FROM noticias) as total_news,
|
(SELECT COUNT(*) FROM noticias) as total_news,
|
||||||
(SELECT COUNT(*) FROM feeds WHERE activo = true) as total_feeds,
|
(SELECT COUNT(*) FROM feeds WHERE activo = true) as total_feeds,
|
||||||
|
|
@ -237,7 +283,7 @@ func GetStats(c *gin.Context) {
|
||||||
return
|
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
|
SELECT c.id, c.nombre, COUNT(n.id) as count
|
||||||
FROM categorias c
|
FROM categorias c
|
||||||
LEFT JOIN noticias n ON n.categoria_id = c.id
|
LEFT JOIN noticias n ON n.categoria_id = c.id
|
||||||
|
|
@ -249,12 +295,12 @@ func GetStats(c *gin.Context) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var cs models.CategoryStat
|
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)
|
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
|
SELECT p.id, p.nombre, p.flag_emoji, COUNT(n.id) as count
|
||||||
FROM paises p
|
FROM paises p
|
||||||
LEFT JOIN noticias n ON n.pais_id = p.id
|
LEFT JOIN noticias n ON n.pais_id = p.id
|
||||||
|
|
@ -266,16 +312,28 @@ func GetStats(c *gin.Context) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var cs models.CountryStat
|
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)
|
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)
|
c.JSON(http.StatusOK, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCategories(c *gin.Context) {
|
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`)
|
SELECT id, nombre FROM categorias ORDER BY nombre`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get categories", Message: err.Error()})
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
type Category struct {
|
var categories []models.Category
|
||||||
ID int64 `json:"id"`
|
|
||||||
Nombre string `json:"nombre"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var categories []Category
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var cat Category
|
var cat models.Category
|
||||||
rows.Scan(&cat.ID, &cat.Nombre)
|
rows.Scan(&cat.ID, &cat.Nombre)
|
||||||
categories = append(categories, cat)
|
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)
|
c.JSON(http.StatusOK, categories)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCountries(c *gin.Context) {
|
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
|
SELECT p.id, p.nombre, c.nombre as continente
|
||||||
FROM paises p
|
FROM paises p
|
||||||
LEFT JOIN continentes c ON c.id = p.continente_id
|
LEFT JOIN continentes c ON c.id = p.continente_id
|
||||||
|
|
@ -310,18 +375,16 @@ func GetCountries(c *gin.Context) {
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
type Country struct {
|
var countries []models.Country
|
||||||
ID int64 `json:"id"`
|
|
||||||
Nombre string `json:"nombre"`
|
|
||||||
Continente string `json:"continente"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var countries []Country
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var country Country
|
var country models.Country
|
||||||
rows.Scan(&country.ID, &country.Nombre, &country.Continente)
|
rows.Scan(&country.ID, &country.Nombre, &country.Continente)
|
||||||
countries = append(countries, country)
|
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)
|
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
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/rss2/backend/internal/auth"
|
"github.com/rss2/backend/internal/auth"
|
||||||
"github.com/rss2/backend/internal/config"
|
"github.com/rss2/backend/internal/config"
|
||||||
|
"github.com/rss2/backend/internal/logger"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AuthRequired() gin.HandlerFunc {
|
func AuthRequired() gin.HandlerFunc {
|
||||||
|
|
@ -63,23 +68,19 @@ func CORSMiddleware() gin.HandlerFunc {
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
origin := c.Request.Header.Get("Origin")
|
origin := c.Request.Header.Get("Origin")
|
||||||
|
|
||||||
allowed := false
|
allowed := false
|
||||||
for _, o := range allowedOrigins {
|
for _, o := range allowedOrigins {
|
||||||
o = strings.TrimSpace(o)
|
o = strings.TrimSpace(o)
|
||||||
if o == "*" || o == origin {
|
if o == origin {
|
||||||
allowed = true
|
allowed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if allowed {
|
if allowed {
|
||||||
if cfg.AllowedOrigins == "*" {
|
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
} 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-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
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 {
|
func LoggerMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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()
|
c.Next()
|
||||||
|
|
||||||
|
latency := time.Since(start)
|
||||||
status := c.Writer.Status()
|
status := c.Writer.Status()
|
||||||
if status >= 400 {
|
clientIP := c.ClientIP()
|
||||||
// Log error responses
|
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 {
|
func RateLimitMiddleware(requestsPerMinute int) gin.HandlerFunc {
|
||||||
|
limit := rate.Limit(requestsPerMinute) / 60
|
||||||
|
burst := requestsPerMinute
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
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()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import (
|
||||||
|
|
||||||
type News struct {
|
type News struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Titulo string `json:"titulo"`
|
||||||
Summary string `json:"summary"`
|
Resumen string `json:"resumen"`
|
||||||
Content string `json:"content"`
|
Contenido string `json:"contenido"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
ImageURL *string `json:"image_url"`
|
ImagenURL *string `json:"imagen_url"`
|
||||||
PublishedAt *time.Time `json:"published_at"`
|
Fecha *time.Time `json:"fecha"`
|
||||||
Lang string `json:"lang"`
|
Lang string `json:"lang"`
|
||||||
FeedID int64 `json:"feed_id"`
|
FeedID int64 `json:"feed_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
@ -20,35 +20,73 @@ type News struct {
|
||||||
|
|
||||||
type NewsWithTranslations struct {
|
type NewsWithTranslations struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Titulo string `json:"titulo"`
|
||||||
Summary string `json:"summary"`
|
Resumen string `json:"resumen"`
|
||||||
Content string `json:"content"`
|
Contenido string `json:"contenido"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
ImageURL *string `json:"image_url"`
|
ImagenURL *string `json:"imagen_url"`
|
||||||
PublishedAt *string `json:"published_at"`
|
Fecha *string `json:"fecha"`
|
||||||
Lang string `json:"lang"`
|
Lang string `json:"lang"`
|
||||||
FeedID int64 `json:"feed_id"`
|
FeedID int64 `json:"feed_id"`
|
||||||
CategoryID *int64 `json:"category_id"`
|
CategoryID *int64 `json:"categoria_id"`
|
||||||
CountryID *int64 `json:"country_id"`
|
CountryID *int64 `json:"pais_id"`
|
||||||
|
FuenteNombre string `json:"fuente_nombre"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
TitleTranslated *string `json:"title_translated"`
|
TitleTranslated *string `json:"title_translated"`
|
||||||
SummaryTranslated *string `json:"summary_translated"`
|
SummaryTranslated *string `json:"summary_translated"`
|
||||||
ContentTranslated *string `json:"content_translated"`
|
ContentTranslated *string `json:"content_translated"`
|
||||||
LangTranslated *string `json:"lang_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 {
|
type Feed struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Nombre string `json:"nombre"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
SiteURL *string `json:"site_url"`
|
SiteURL *string `json:"site_url"`
|
||||||
Description *string `json:"description"`
|
Descripcion *string `json:"descripcion"`
|
||||||
ImageURL *string `json:"image_url"`
|
ImagenURL *string `json:"imagen_url"`
|
||||||
Language *string `json:"language"`
|
Idioma *string `json:"idioma"`
|
||||||
CategoryID *int64 `json:"category_id"`
|
CategoriaID *int64 `json:"categoria_id"`
|
||||||
CountryID *int64 `json:"country_id"`
|
PaisID *int64 `json:"pais_id"`
|
||||||
Active bool `json:"active"`
|
Activo bool `json:"activo"`
|
||||||
|
Fallos *int64 `json:"fallos"`
|
||||||
|
LastError *string `json:"last_error"`
|
||||||
LastFetched *time.Time `json:"last_fetched"`
|
LastFetched *time.Time `json:"last_fetched"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
@ -56,27 +94,27 @@ type Feed struct {
|
||||||
|
|
||||||
type Category struct {
|
type Category struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Nombre string `json:"nombre"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
ParentID *int64 `json:"parent_id"`
|
ParentID *int64 `json:"parent_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Country struct {
|
type Country struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Nombre string `json:"nombre"`
|
||||||
Code string `json:"code"`
|
Codigo string `json:"codigo"`
|
||||||
Continent string `json:"continent"`
|
Continente string `json:"continente"`
|
||||||
FlagEmoji string `json:"flag_emoji"`
|
FlagEmoji string `json:"flag_emoji"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Translation struct {
|
type Translation struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
NewsID int64 `json:"news_id"`
|
NoticiaID int64 `json:"noticia_id"`
|
||||||
LangFrom string `json:"lang_from"`
|
LangFrom string `json:"lang_from"`
|
||||||
LangTo string `json:"lang_to"`
|
LangTo string `json:"lang_to"`
|
||||||
Title string `json:"title"`
|
Titulo string `json:"titulo"`
|
||||||
Summary string `json:"summary"`
|
Resumen string `json:"resumen"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Error *string `json:"error"`
|
Error *string `json:"error"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
@ -89,6 +127,7 @@ type User struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
Role string `json:"role"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
@ -97,8 +136,8 @@ type SearchHistory struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
CategoryID *int64 `json:"category_id"`
|
CategoriaID *int64 `json:"categoria_id"`
|
||||||
CountryID *int64 `json:"country_id"`
|
PaisID *int64 `json:"pais_id"`
|
||||||
ResultsCount int `json:"results_count"`
|
ResultsCount int `json:"results_count"`
|
||||||
SearchedAt time.Time `json:"searched_at"`
|
SearchedAt time.Time `json:"searched_at"`
|
||||||
}
|
}
|
||||||
|
|
@ -132,14 +171,14 @@ type Stats struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryStat struct {
|
type CategoryStat struct {
|
||||||
CategoryID int64 `json:"category_id"`
|
CategoriaID int64 `json:"categoria_id"`
|
||||||
CategoryName string `json:"category_name"`
|
CategoriaName string `json:"categoria_nombre"`
|
||||||
Count int64 `json:"count"`
|
Count int64 `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CountryStat struct {
|
type CountryStat struct {
|
||||||
CountryID int64 `json:"country_id"`
|
PaisID int64 `json:"pais_id"`
|
||||||
CountryName string `json:"country_name"`
|
PaisName string `json:"pais_nombre"`
|
||||||
FlagEmoji string `json:"flag_emoji"`
|
FlagEmoji string `json:"flag_emoji"`
|
||||||
Count int64 `json:"count"`
|
Count int64 `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,9 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/rss2/backend/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
var pool *pgxpool.Pool
|
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
|
|
@ -31,39 +30,23 @@ func LoadDBConfig() *Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
func Connect(cfg *Config) error {
|
func Connect(cfg *Config) error {
|
||||||
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
// Just verify the main pool is accessible
|
||||||
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
|
if db.GetPool() == nil {
|
||||||
|
return fmt.Errorf("database pool not initialized")
|
||||||
poolConfig, err := pgxpool.ParseConfig(dsn)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse config: %w", err)
|
|
||||||
}
|
}
|
||||||
|
return db.HealthCheck(context.Background())
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetPool() *pgxpool.Pool {
|
func GetPool() *pgxpool.Pool {
|
||||||
return pool
|
return db.GetPool()
|
||||||
}
|
}
|
||||||
|
|
||||||
func Close() {
|
func Close() {
|
||||||
if pool != nil {
|
// No-op: main db package manages the pool
|
||||||
pool.Close()
|
}
|
||||||
}
|
|
||||||
|
func HealthCheck(ctx context.Context) error {
|
||||||
|
return db.HealthCheck(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
func getEnv(key, defaultValue string) string {
|
||||||
|
|
|
||||||
|
|
@ -340,12 +340,12 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Prioridad BAJA: el traductor no debe acaparar toda la CPU.
|
# 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
|
cpu_shares: 512
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: '2'
|
cpus: '4'
|
||||||
memory: 6G
|
memory: 6G
|
||||||
|
|
||||||
# ==================================================================================
|
# ==================================================================================
|
||||||
|
|
@ -390,7 +390,7 @@ services:
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: '2'
|
cpus: '4'
|
||||||
memory: 6G
|
memory: 6G
|
||||||
|
|
||||||
# ==================================================================================
|
# ==================================================================================
|
||||||
|
|
|
||||||
|
|
@ -99,8 +99,16 @@
|
||||||
"Pakistán",
|
"Pakistán",
|
||||||
"Tolo News",
|
"Tolo News",
|
||||||
"Gladbach",
|
"Gladbach",
|
||||||
"Alger"
|
"Alger",
|
||||||
|
"Comisión",
|
||||||
|
"Dirección",
|
||||||
|
"Más",
|
||||||
|
"Regístrese",
|
||||||
|
"Consejo",
|
||||||
|
"Fondo",
|
||||||
|
"PVT LTD",
|
||||||
|
"Beneficios",
|
||||||
|
"Ingrese"
|
||||||
],
|
],
|
||||||
"synonyms": {
|
"synonyms": {
|
||||||
"Alassane Ouattara": [
|
"Alassane Ouattara": [
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
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 { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server, TrendingUp, Bell, X, Menu } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { apiService } from '../../services/api'
|
import { apiService } from '../../services/api'
|
||||||
|
|
||||||
export function Layout() {
|
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';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
interface WikiTooltipProps {
|
interface WikiTooltipProps {
|
||||||
|
|
@ -14,19 +14,43 @@ export function WikiTooltip({ children, summary, imagePath, wikiUrl, name }: Wik
|
||||||
return <span className="text-gray-900">{children}</span>;
|
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 (
|
return (
|
||||||
<div className="relative group inline-block">
|
<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">
|
<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}
|
{children}
|
||||||
</span>
|
</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">
|
<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 && (
|
{imagePath && (
|
||||||
<div className="w-full h-44 bg-gray-100 overflow-hidden relative border-b border-gray-100">
|
<div className="w-full h-44 bg-gray-100 overflow-hidden relative border-b border-gray-100">
|
||||||
<img
|
<img
|
||||||
src={imagePath}
|
src={imagePath}
|
||||||
alt={name}
|
alt={`${name} - Wikipedia image`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
|
@ -45,18 +69,18 @@ export function WikiTooltip({ children, summary, imagePath, wikiUrl, name }: Wik
|
||||||
<a
|
<a
|
||||||
href={wikiUrl}
|
href={wikiUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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"
|
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()}
|
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>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Triangle arrow */}
|
{/* 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-[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]"></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>
|
||||||
</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 { useEffect, useState } from 'react'
|
||||||
import { apiService, Alerta } from '../services/api'
|
import { Link } from 'react-router-dom'
|
||||||
import { Bell, CheckCheck, ChevronDown, ChevronUp } from 'lucide-react'
|
import { apiService, api, Alerta, News } from '../services/api'
|
||||||
|
import { Bell, CheckCheck, ChevronDown, ChevronUp, FileText } from 'lucide-react'
|
||||||
|
|
||||||
const TIPO_LABEL: Record<string, string> = {
|
const TIPO_LABEL: Record<string, string> = {
|
||||||
persona: 'Persona',
|
persona: 'Persona',
|
||||||
|
|
@ -15,6 +16,11 @@ export function Alertas() {
|
||||||
const [nuevas, setNuevas] = useState(0)
|
const [nuevas, setNuevas] = useState(0)
|
||||||
const [limit, setLimit] = useState(50)
|
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 () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -42,6 +48,26 @@ export function Alertas() {
|
||||||
load()
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
|
@ -108,13 +134,17 @@ export function Alertas() {
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{a.status === 'nueva' ? (
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => markRead(a.id)} className="btn-secondary text-xs py-1 px-3">
|
<button onClick={() => openNews(a)} className="btn-secondary text-xs py-1 px-3 flex items-center gap-1">
|
||||||
Marcar leída
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
Noticias
|
||||||
</button>
|
</button>
|
||||||
) : (
|
{a.status === 'nueva' && (
|
||||||
<span className="text-sm text-gray-300 dark:text-gray-600">—</span>
|
<button onClick={() => markRead(a.id)} className="btn-secondary text-xs py-1 px-3">
|
||||||
)}
|
Marcar leída
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -132,6 +162,75 @@ export function Alertas() {
|
||||||
Mostrar {limit > 50 ? 'menos' : 'más'} ({limit})
|
Mostrar {limit > 50 ? 'menos' : 'más'} ({limit})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { formatDistanceToNow } from 'date-fns'
|
||||||
import { es } from 'date-fns/locale'
|
import { es } from 'date-fns/locale'
|
||||||
import { apiService, api } from '../services/api'
|
import { apiService, api } from '../services/api'
|
||||||
import { Search, Globe, Newspaper, Filter } from 'lucide-react'
|
import { Search, Globe, Newspaper, Filter } from 'lucide-react'
|
||||||
|
import { SkeletonNewsList, NoResultsFound } from '../components/ui'
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
@ -156,13 +157,22 @@ export function Home() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-12">
|
<SkeletonNewsList count={6} />
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-center py-12 text-red-600">
|
<div className="text-center py-12 text-red-600">
|
||||||
Error al cargar noticias
|
Error al cargar noticias
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="btn-primary mt-4"
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : data?.news?.length === 0 ? (
|
||||||
|
<NoResultsFound
|
||||||
|
query={q}
|
||||||
|
onClearFilters={() => setSearchParams({ page: '1', q })}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 text-sm text-gray-600">
|
<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' },
|
{ 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 }[] = [
|
const TIMELINE_WINDOWS: { key: string; label: string; title: string; days: number }[] = [
|
||||||
{ key: '0', label: 'Hoy', title: 'hoy', offset: 0 },
|
{ key: '1', label: '24h', title: 'últimas 24 horas', days: 1 },
|
||||||
{ key: '1', label: '1d', title: 'hace 1 día', offset: 1 },
|
{ key: '2', label: '2 días', title: 'últimos 2 días', days: 2 },
|
||||||
{ key: '2', label: '2d', title: 'hace 2 días', offset: 2 },
|
{ key: '3', label: '3 días', title: 'últimos 3 días', days: 3 },
|
||||||
{ key: '3', label: '3d', title: 'hace 3 días', offset: 3 },
|
{ key: '4', label: '4 días', title: 'últimos 4 días', days: 4 },
|
||||||
{ key: '4', label: '4d', title: 'hace 4 días', offset: 4 },
|
{ key: '5', label: '5 días', title: 'últimos 5 días', days: 5 },
|
||||||
{ key: '5', label: '5d', title: 'hace 5 días', offset: 5 },
|
{ key: '7', label: '7 días', title: 'últimos 7 días', days: 7 },
|
||||||
]
|
]
|
||||||
|
|
||||||
function getTipoInfo(tipo: string) {
|
function getTipoInfo(tipo: string) {
|
||||||
|
|
@ -61,6 +61,8 @@ export function Populares() {
|
||||||
const [configModal, setConfigModal] = useState<ConfigModal | null>(null)
|
const [configModal, setConfigModal] = useState<ConfigModal | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [successMsg, setSuccessMsg] = useState('')
|
const [successMsg, setSuccessMsg] = useState('')
|
||||||
|
const [expandedEntities, setExpandedEntities] = useState<Set<string>>(new Set())
|
||||||
|
const [allExpanded, setAllExpanded] = useState(false)
|
||||||
|
|
||||||
// News-by-entity modal state
|
// News-by-entity modal state
|
||||||
const [newsModal, setNewsModal] = useState<Entity | null>(null)
|
const [newsModal, setNewsModal] = useState<Entity | null>(null)
|
||||||
|
|
@ -158,7 +160,7 @@ export function Populares() {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.append('valor', entity.valor)
|
params.append('valor', entity.valor)
|
||||||
params.append('tipo', entity.tipo)
|
params.append('tipo', entity.tipo)
|
||||||
params.append('day_offset', String(win.offset))
|
params.append('days', String(win.days))
|
||||||
params.append('per_page', '50')
|
params.append('per_page', '50')
|
||||||
const res = await api.get(`/entities/news?${params}`)
|
const res = await api.get(`/entities/news?${params}`)
|
||||||
setEntityNews(res.data.news || [])
|
setEntityNews(res.data.news || [])
|
||||||
|
|
@ -270,6 +272,29 @@ export function Populares() {
|
||||||
<h1 className="text-2xl font-bold">
|
<h1 className="text-2xl font-bold">
|
||||||
🔥 Populares {selectedCountry ? `en ${selectedCountry.nombre}` : 'Global'}
|
🔥 Populares {selectedCountry ? `en ${selectedCountry.nombre}` : 'Global'}
|
||||||
</h1>
|
</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">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleExportAliases}
|
onClick={handleExportAliases}
|
||||||
|
|
@ -359,96 +384,102 @@ export function Populares() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Listado de entidades */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12 text-gray-400">Cargando...</div>
|
<div className="text-center py-12 text-gray-400">Cargando...</div>
|
||||||
) : entities.length === 0 ? (
|
) : entities.length === 0 ? (
|
||||||
<div className="text-center py-12 text-gray-400">No se encontraron entidades</div>
|
<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">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||||
<table className="w-full">
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 border-b dark:border-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||||
<tr>
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">📊 {entities.length} entidades populares</span>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-10">#</th>
|
<div className="flex gap-1">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Entidad</th>
|
<button
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider hidden md:table-cell">Tipo</th>
|
onClick={() => setExpandedEntities(p => p.clear())}
|
||||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Menciones</th>
|
className="px-2 py-1 text-[10px] text-gray-500 hover:text-blue-600 dark:hover:text-blue-300 rounded"
|
||||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">Config</th>
|
title="Contraer todas"
|
||||||
</tr>
|
>
|
||||||
</thead>
|
←
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
</button>
|
||||||
{entities.map((entity, idx) => {
|
<button
|
||||||
const tipoInfo = getTipoInfo(entity.tipo)
|
onClick={() => setExpandedEntities(p => p.add('all'))}
|
||||||
return (
|
className="px-2 py-1 text-[10px] text-gray-500 hover:text-blue-600 dark:hover:text-blue-300 rounded"
|
||||||
<tr key={entity.valor + idx} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
title="Expandir todas"
|
||||||
<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">
|
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
|
<WikiTooltip
|
||||||
name={entity.valor}
|
name={entity.valor}
|
||||||
summary={entity.wiki_summary}
|
summary={entity.wiki_summary}
|
||||||
imagePath={entity.image_path}
|
imagePath={entity.image_path}
|
||||||
wikiUrl={entity.wiki_url}
|
wikiUrl={entity.wiki_url}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<span className="font-medium text-blue-600 dark:text-blue-300 hover:underline transition-colors">
|
||||||
{entity.image_path && (
|
{entity.valor}
|
||||||
<img
|
</span>
|
||||||
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>
|
|
||||||
</WikiTooltip>
|
</WikiTooltip>
|
||||||
</td>
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
<td className="px-4 py-3 hidden md:table-cell">
|
#{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}`}>
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${tipoInfo.color}`}>
|
||||||
{tipoInfo.label}
|
{tipoInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
<span>{entity.count}</span>
|
||||||
<td className="px-4 py-3 text-right">
|
</div>
|
||||||
<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">
|
</div>
|
||||||
{entity.count}
|
{isExpanded && (
|
||||||
</span>
|
<div className="p-3 pt-0">
|
||||||
</td>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<td className="px-4 py-3 text-center">
|
<button
|
||||||
<div className="flex flex-col items-center gap-1">
|
onClick={() => openNews(entity)}
|
||||||
<div className="flex items-center justify-center gap-1">
|
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"
|
||||||
<button
|
title="Ver noticias donde se menciona"
|
||||||
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"
|
🔍 Ver noticias
|
||||||
title="Ver noticias donde se menciona"
|
</button>
|
||||||
>
|
<button
|
||||||
🔍 Buscar
|
onClick={() => openConfig(entity)}
|
||||||
</button>
|
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"
|
||||||
<button
|
title="Configurar entidad"
|
||||||
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"
|
⚙️ Configurar
|
||||||
title="Configurar entidad"
|
</button>
|
||||||
>
|
|
||||||
⚙️ 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>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||||
</tr>
|
{TIMELINE_WINDOWS.map((win) => (
|
||||||
)
|
<button
|
||||||
})}
|
key={win.key}
|
||||||
</tbody>
|
onClick={() => openTimeline(entity, win)}
|
||||||
</table>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useSearchParams, Link } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { apiService, api, News, Category, Country } from '../services/api'
|
import { apiService, api, News, Category, Country } from '../services/api'
|
||||||
import { Search as SearchIcon, Filter } from 'lucide-react'
|
import { Search as SearchIcon, Filter } from 'lucide-react'
|
||||||
|
import { SkeletonNewsList, NoResultsFound, NoSearchResults } from '../components/ui'
|
||||||
|
|
||||||
export function Search() {
|
export function Search() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
@ -132,13 +133,14 @@ export function Search() {
|
||||||
<p className="text-gray-600 mb-4">{data.total} resultados encontrados</p>
|
<p className="text-gray-600 mb-4">{data.total} resultados encontrados</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-12">
|
<SkeletonNewsList count={6} />
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
) : data?.news?.length === 0 ? (
|
||||||
</div>
|
<NoResultsFound
|
||||||
)}
|
query={q}
|
||||||
|
onClearFilters={clearFilters}
|
||||||
{data?.news && data.news.length > 0 && (
|
/>
|
||||||
|
) : data?.news && data.news.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.news.map((news: News) => (
|
{data.news.map((news: News) => (
|
||||||
<Link key={news.id} to={`/news/${news.id}`} className="card p-4 block hover:shadow-md transition-shadow">
|
<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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<NoSearchResults query={q} onSearch={clearFilters} />
|
||||||
{(!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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
WHERE t.lang_to = %s
|
||||||
AND (t.titulo_trad IS NULL OR t.resumen_trad IS NULL)
|
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')
|
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
|
LIMIT %s
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
""",
|
""",
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ def create_translation_jobs(conn):
|
||||||
SELECT 1 FROM traducciones t
|
SELECT 1 FROM traducciones t
|
||||||
WHERE t.noticia_id = n.id AND t.lang_to = %s
|
WHERE t.noticia_id = n.id AND t.lang_to = %s
|
||||||
)
|
)
|
||||||
ORDER BY n.fecha ASC
|
ORDER BY n.fecha DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
ON CONFLICT (noticia_id, lang_to) DO NOTHING
|
ON CONFLICT (noticia_id, lang_to) DO NOTHING
|
||||||
RETURNING noticia_id
|
RETURNING noticia_id
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue