Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9ea78b8a7 | ||
|
|
10f0555c46 | ||
|
|
b3bf3d7a7f | ||
|
|
ec839b5b54 | ||
|
|
e3c682a36f | ||
|
|
ab3b0b53c5 | ||
|
|
00c0254e6c | ||
|
|
10d51c3c52 |
173 changed files with 4376 additions and 21789 deletions
|
|
@ -1,39 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Hook para docker compose - Se ejecuta automáticamente
|
|
||||||
# Copia este archivo al inicio de tu .bashrc o .bash_profile
|
|
||||||
|
|
||||||
# Función de validación automática
|
|
||||||
validate_credentials() {
|
|
||||||
# Verificar .env
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "🔑 Generando credenciales..."
|
|
||||||
./generate_secure_credentials.sh --force
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verificar contraseñas vacías
|
|
||||||
for var in POSTGRES_PASSWORD REDIS_PASSWORD DB_PASS; do
|
|
||||||
val=$(grep "^${var}=" .env | cut -d'=' -f2 | tr -d ' ')
|
|
||||||
if [ -z "$val" ] || [ "$val" = "change_*" ]; then
|
|
||||||
echo "🔑 Generando credenciales para $var..."
|
|
||||||
./generate_secure_credentials.sh --force
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# Alias para docker compose con validación automática
|
|
||||||
alias docker="docker"
|
|
||||||
alias "docker compose"="docker compose --env-file .env"
|
|
||||||
|
|
||||||
# Función helper: docker compose up con validación
|
|
||||||
docker_compose_up() {
|
|
||||||
echo "🔐 Validando credenciales..."
|
|
||||||
validate_credentials
|
|
||||||
|
|
||||||
echo "🚀 Iniciando servicios..."
|
|
||||||
docker compose up -d
|
|
||||||
}
|
|
||||||
|
|
||||||
# Exportar alias
|
|
||||||
alias docker_compose_up="docker_compose_up"
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Validador automático de credenciales para docker compose
|
|
||||||
# Se ejecuta automáticamente antes de iniciar los servicios
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Colores
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
echo -e "${BLUE}🔐 Validando credenciales antes de iniciar...${NC}"
|
|
||||||
|
|
||||||
# Verificar archivo .env
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo -e "${RED}❌ ERROR: Archivo .env no encontrado${NC}"
|
|
||||||
echo -e "${YELLOW}💡 Generando credenciales...${NC}"
|
|
||||||
./generate_secure_credentials.sh --force
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verificar credenciales CRÍTICAS
|
|
||||||
for var in POSTGRES_PASSWORD REDIS_PASSWORD DB_PASS; do
|
|
||||||
value=$(grep "^${var}=" .env 2>/dev/null | cut -d'=' -f2 | tr -d ' ')
|
|
||||||
|
|
||||||
if [ -z "$value" ] || [ "$value" = "change_this_to_a_long_random_string" ] || [ "$value" = "change_this_password" ]; then
|
|
||||||
echo -e "${RED}❌ ERROR: $var está vacía o tiene valor por defecto${NC}"
|
|
||||||
echo -e "${YELLOW}💡 Generando credenciales seguras...${NC}"
|
|
||||||
./generate_secure_credentials.sh --force
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo -e "${GREEN}✅ Todas las credenciales están definidas${NC}"
|
|
||||||
exit 0
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
.git
|
|
||||||
pgdata
|
|
||||||
pgdata-replica
|
|
||||||
pgdata-replica.old.*
|
|
||||||
pgdata.failed_restore
|
|
||||||
redis-data
|
|
||||||
hf_cache
|
|
||||||
qdrant_storage
|
|
||||||
|
|
||||||
venv
|
|
||||||
__pycache__
|
|
||||||
*.pyc
|
|
||||||
*.log
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
POSTGRES_PASSWORD=test_pass_123456
|
|
||||||
REDIS_PASSWORD=test_redis_123456
|
|
||||||
DB_PASS=test_pass_123456
|
|
||||||
SECRET_KEY=test_secret_key
|
|
||||||
GRAFANA_PASSWORD=test_grafana_123456
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
POSTGRES_PASSWORD=test_pass_123456
|
|
||||||
REDIS_PASSWORD=test_redis_123456
|
|
||||||
DB_PASS=test_pass_123456
|
|
||||||
SECRET_KEY=test_secret_key
|
|
||||||
GRAFANA_PASSWORD=test_grafana_123456
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
# ==================================================================================
|
|
||||||
# CONFIGURACIÓN SEGURA - Generado automáticamente
|
|
||||||
# Fecha: 2026-04-03 23:21:15
|
|
||||||
# ==================================================================================
|
|
||||||
#
|
|
||||||
# IMPORTANTE:
|
|
||||||
# - NO compartas este archivo
|
|
||||||
# - Guarda las credenciales en un gestor de contraseñas
|
|
||||||
# - Añade .env al .gitignore
|
|
||||||
#
|
|
||||||
# ==================================================================================
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# DATABASE CONFIGURATION - PostgreSQL
|
|
||||||
# ==================================================================================
|
|
||||||
POSTGRES_DB=rss
|
|
||||||
POSTGRES_USER=rss
|
|
||||||
POSTGRES_PASSWORD=npti6eFCYsAv71LcJKasnpEAhtHgoD4a
|
|
||||||
|
|
||||||
DB_NAME=rss
|
|
||||||
DB_USER=rss
|
|
||||||
DB_PASS=npti6eFCYsAv71LcJKasnpEAhtHgoD4a
|
|
||||||
DB_HOST=db
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_WRITE_HOST=db
|
|
||||||
DB_READ_HOST=db-replica
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# REDIS CONFIGURATION - Con autenticación
|
|
||||||
# ==================================================================================
|
|
||||||
REDIS_HOST=redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=pmBJtSBMiYGptknv5pXa163kUR946V25
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# APPLICATION SECRETS
|
|
||||||
# ==================================================================================
|
|
||||||
SECRET_KEY=4a60ebda58f0716b846de1758e63e209e33070d0dbf8ac3c4ecd0c216e6bb4aa
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# MONITORING - Grafana
|
|
||||||
# ==================================================================================
|
|
||||||
GRAFANA_PASSWORD=oTkxxwKCNB25r0unvtNi8AQt
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# EXTERNAL SERVICES
|
|
||||||
# ==================================================================================
|
|
||||||
ALLTALK_URL=http://host.docker.internal:7851
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# AI MODELS & WORKERS
|
|
||||||
# ==================================================================================
|
|
||||||
RSS_MAX_WORKERS=3
|
|
||||||
TARGET_LANGS=es
|
|
||||||
TRANSLATOR_BATCH=128
|
|
||||||
ENQUEUE=300
|
|
||||||
|
|
||||||
# RSS Ingestor Configuration
|
|
||||||
RSS_POKE_INTERVAL_MIN=15
|
|
||||||
RSS_MAX_FAILURES=10
|
|
||||||
RSS_FEED_TIMEOUT=60
|
|
||||||
|
|
||||||
# URL Feed Discovery Worker
|
|
||||||
URL_DISCOVERY_INTERVAL_MIN=15
|
|
||||||
URL_DISCOVERY_BATCH_SIZE=10
|
|
||||||
MAX_FEEDS_PER_URL=5
|
|
||||||
|
|
||||||
# CTranslate2 / AI Model Paths
|
|
||||||
CT2_MODEL_PATH=/app/models/nllb-ct2
|
|
||||||
CT2_DEVICE=cuda
|
|
||||||
CT2_COMPUTE_TYPE=int8_float16
|
|
||||||
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
|
|
||||||
|
|
||||||
# Embeddings
|
|
||||||
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
EMB_BATCH=64
|
|
||||||
EMB_DEVICE=cuda
|
|
||||||
|
|
||||||
# NER
|
|
||||||
NER_LANG=es
|
|
||||||
NER_BATCH=64
|
|
||||||
|
|
||||||
# Flask / Gunicorn
|
|
||||||
GUNICORN_WORKERS=8
|
|
||||||
FLASK_DEBUG=0
|
|
||||||
|
|
||||||
# Qdrant Configuration
|
|
||||||
QDRANT_HOST=qdrant
|
|
||||||
QDRANT_PORT=6333
|
|
||||||
QDRANT_COLLECTION_NAME=news_vectors
|
|
||||||
QDRANT_BATCH_SIZE=100
|
|
||||||
QDRANT_SLEEP_IDLE=30
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
# ==================================================================================
|
|
||||||
# CONFIGURACIÓN SEGURA - Generado automáticamente
|
|
||||||
# Fecha: 2026-04-03 23:21:15
|
|
||||||
# ==================================================================================
|
|
||||||
#
|
|
||||||
# IMPORTANTE:
|
|
||||||
# - NO compartas este archivo
|
|
||||||
# - Guarda las credenciales en un gestor de contraseñas
|
|
||||||
# - Añade .env al .gitignore
|
|
||||||
#
|
|
||||||
# ==================================================================================
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# DATABASE CONFIGURATION - PostgreSQL
|
|
||||||
# ==================================================================================
|
|
||||||
POSTGRES_DB=rss
|
|
||||||
POSTGRES_USER=rss
|
|
||||||
POSTGRES_PASSWORD=npti6eFCYsAv71LcJKasnpEAhtHgoD4a
|
|
||||||
|
|
||||||
DB_NAME=rss
|
|
||||||
DB_USER=rss
|
|
||||||
DB_PASS=npti6eFCYsAv71LcJKasnpEAhtHgoD4a
|
|
||||||
DB_HOST=db
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_WRITE_HOST=db
|
|
||||||
DB_READ_HOST=db-replica
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# REDIS CONFIGURATION - Con autenticación
|
|
||||||
# ==================================================================================
|
|
||||||
REDIS_HOST=redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=pmBJtSBMiYGptknv5pXa163kUR946V25
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# APPLICATION SECRETS
|
|
||||||
# ==================================================================================
|
|
||||||
SECRET_KEY=4a60ebda58f0716b846de1758e63e209e33070d0dbf8ac3c4ecd0c216e6bb4aa
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# MONITORING - Grafana
|
|
||||||
# ==================================================================================
|
|
||||||
GRAFANA_PASSWORD=oTkxxwKCNB25r0unvtNi8AQt
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# EXTERNAL SERVICES
|
|
||||||
# ==================================================================================
|
|
||||||
ALLTALK_URL=http://host.docker.internal:7851
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# AI MODELS & WORKERS
|
|
||||||
# ==================================================================================
|
|
||||||
RSS_MAX_WORKERS=3
|
|
||||||
TARGET_LANGS=es
|
|
||||||
TRANSLATOR_BATCH=128
|
|
||||||
ENQUEUE=300
|
|
||||||
|
|
||||||
# RSS Ingestor Configuration
|
|
||||||
RSS_POKE_INTERVAL_MIN=15
|
|
||||||
RSS_MAX_FAILURES=10
|
|
||||||
RSS_FEED_TIMEOUT=60
|
|
||||||
|
|
||||||
# URL Feed Discovery Worker
|
|
||||||
URL_DISCOVERY_INTERVAL_MIN=15
|
|
||||||
URL_DISCOVERY_BATCH_SIZE=10
|
|
||||||
MAX_FEEDS_PER_URL=5
|
|
||||||
|
|
||||||
# CTranslate2 / AI Model Paths
|
|
||||||
CT2_MODEL_PATH=/app/models/nllb-ct2
|
|
||||||
CT2_DEVICE=cuda
|
|
||||||
CT2_COMPUTE_TYPE=int8_float16
|
|
||||||
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
|
|
||||||
|
|
||||||
# Embeddings
|
|
||||||
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
EMB_BATCH=64
|
|
||||||
EMB_DEVICE=cuda
|
|
||||||
|
|
||||||
# NER
|
|
||||||
NER_LANG=es
|
|
||||||
NER_BATCH=64
|
|
||||||
|
|
||||||
# Flask / Gunicorn
|
|
||||||
GUNICORN_WORKERS=8
|
|
||||||
FLASK_DEBUG=0
|
|
||||||
|
|
||||||
# Qdrant Configuration
|
|
||||||
QDRANT_HOST=qdrant
|
|
||||||
QDRANT_PORT=6333
|
|
||||||
QDRANT_COLLECTION_NAME=news_vectors
|
|
||||||
QDRANT_BATCH_SIZE=100
|
|
||||||
QDRANT_SLEEP_IDLE=30
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
# ==================================================================================
|
|
||||||
# CONFIGURACIÓN SEGURA - Generado automáticamente
|
|
||||||
# Fecha: 2026-04-03 23:30:04
|
|
||||||
# ==================================================================================
|
|
||||||
#
|
|
||||||
# IMPORTANTE:
|
|
||||||
# - NO compartas este archivo
|
|
||||||
# - Guarda las credenciales en un gestor de contraseñas
|
|
||||||
# - Añade .env al .gitignore
|
|
||||||
#
|
|
||||||
# ==================================================================================
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# DATABASE CONFIGURATION - PostgreSQL
|
|
||||||
# ==================================================================================
|
|
||||||
POSTGRES_DB=rss
|
|
||||||
POSTGRES_USER=rss
|
|
||||||
POSTGRES_PASSWORD=SI84OnqRT40FtdAeQro3eSJULKzFJ06F
|
|
||||||
|
|
||||||
DB_NAME=rss
|
|
||||||
DB_USER=rss
|
|
||||||
DB_PASS=SI84OnqRT40FtdAeQro3eSJULKzFJ06F
|
|
||||||
DB_HOST=db
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_WRITE_HOST=db
|
|
||||||
DB_READ_HOST=db-replica
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# REDIS CONFIGURATION - Con autenticación
|
|
||||||
# ==================================================================================
|
|
||||||
REDIS_HOST=redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=MvdWZlO1YCZ3QxE761bbvEd2qx44dO9j
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# APPLICATION SECRETS
|
|
||||||
# ==================================================================================
|
|
||||||
SECRET_KEY=68d5b0297d44e88f5ab83720e0bdf8eeef2ceeccd72b8e46b7f1b47531571780
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# MONITORING - Grafana
|
|
||||||
# ==================================================================================
|
|
||||||
GRAFANA_PASSWORD=BVWprtQiW2KDmaDR8zZ2Br83
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# EXTERNAL SERVICES
|
|
||||||
# ==================================================================================
|
|
||||||
ALLTALK_URL=http://host.docker.internal:7851
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# AI MODELS & WORKERS
|
|
||||||
# ==================================================================================
|
|
||||||
RSS_MAX_WORKERS=3
|
|
||||||
TARGET_LANGS=es
|
|
||||||
TRANSLATOR_BATCH=128
|
|
||||||
ENQUEUE=300
|
|
||||||
|
|
||||||
# RSS Ingestor Configuration
|
|
||||||
RSS_POKE_INTERVAL_MIN=15
|
|
||||||
RSS_MAX_FAILURES=10
|
|
||||||
RSS_FEED_TIMEOUT=60
|
|
||||||
|
|
||||||
# URL Feed Discovery Worker
|
|
||||||
URL_DISCOVERY_INTERVAL_MIN=15
|
|
||||||
URL_DISCOVERY_BATCH_SIZE=10
|
|
||||||
MAX_FEEDS_PER_URL=5
|
|
||||||
|
|
||||||
# CTranslate2 / AI Model Paths
|
|
||||||
CT2_MODEL_PATH=/app/models/nllb-ct2
|
|
||||||
CT2_DEVICE=cuda
|
|
||||||
CT2_COMPUTE_TYPE=int8_float16
|
|
||||||
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
|
|
||||||
|
|
||||||
# Embeddings
|
|
||||||
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
EMB_BATCH=64
|
|
||||||
EMB_DEVICE=cuda
|
|
||||||
|
|
||||||
# NER
|
|
||||||
NER_LANG=es
|
|
||||||
NER_BATCH=64
|
|
||||||
|
|
||||||
# Flask / Gunicorn
|
|
||||||
GUNICORN_WORKERS=8
|
|
||||||
FLASK_DEBUG=0
|
|
||||||
|
|
||||||
# Qdrant Configuration
|
|
||||||
QDRANT_HOST=qdrant
|
|
||||||
QDRANT_PORT=6333
|
|
||||||
QDRANT_COLLECTION_NAME=news_vectors
|
|
||||||
QDRANT_BATCH_SIZE=100
|
|
||||||
QDRANT_SLEEP_IDLE=30
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
# ==================================================================================
|
|
||||||
# CONFIGURACIÓN SEGURA - Generado automáticamente
|
|
||||||
# Fecha: 2026-04-03 23:31:40
|
|
||||||
# ==================================================================================
|
|
||||||
#
|
|
||||||
# IMPORTANTE:
|
|
||||||
# - NO compartas este archivo
|
|
||||||
# - Guarda las credenciales en un gestor de contraseñas
|
|
||||||
# - Añade .env al .gitignore
|
|
||||||
#
|
|
||||||
# ==================================================================================
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# DATABASE CONFIGURATION - PostgreSQL
|
|
||||||
# ==================================================================================
|
|
||||||
POSTGRES_DB=rss
|
|
||||||
POSTGRES_USER=rss
|
|
||||||
POSTGRES_PASSWORD=S5xwsI9HKjdaBWLpCkqp0mA0DJYZerpD
|
|
||||||
|
|
||||||
DB_NAME=rss
|
|
||||||
DB_USER=rss
|
|
||||||
DB_PASS=S5xwsI9HKjdaBWLpCkqp0mA0DJYZerpD
|
|
||||||
DB_HOST=db
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_WRITE_HOST=db
|
|
||||||
DB_READ_HOST=db-replica
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# REDIS CONFIGURATION - Con autenticación
|
|
||||||
# ==================================================================================
|
|
||||||
REDIS_HOST=redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=cVF79QuJulPO5auEhA2nNqv7mqAruzYh
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# APPLICATION SECRETS
|
|
||||||
# ==================================================================================
|
|
||||||
SECRET_KEY=2eb7c221245bb8d896f83255188de614e59fae6b8dfbf16eee8a23c60dde4ffa
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# MONITORING - Grafana
|
|
||||||
# ==================================================================================
|
|
||||||
GRAFANA_PASSWORD=R4J61V6OGLTCesrITwEvUPTm
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# EXTERNAL SERVICES
|
|
||||||
# ==================================================================================
|
|
||||||
ALLTALK_URL=http://host.docker.internal:7851
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# AI MODELS & WORKERS
|
|
||||||
# ==================================================================================
|
|
||||||
RSS_MAX_WORKERS=3
|
|
||||||
TARGET_LANGS=es
|
|
||||||
TRANSLATOR_BATCH=128
|
|
||||||
ENQUEUE=300
|
|
||||||
|
|
||||||
# RSS Ingestor Configuration
|
|
||||||
RSS_POKE_INTERVAL_MIN=15
|
|
||||||
RSS_MAX_FAILURES=10
|
|
||||||
RSS_FEED_TIMEOUT=60
|
|
||||||
|
|
||||||
# URL Feed Discovery Worker
|
|
||||||
URL_DISCOVERY_INTERVAL_MIN=15
|
|
||||||
URL_DISCOVERY_BATCH_SIZE=10
|
|
||||||
MAX_FEEDS_PER_URL=5
|
|
||||||
|
|
||||||
# CTranslate2 / AI Model Paths
|
|
||||||
CT2_MODEL_PATH=/app/models/nllb-ct2
|
|
||||||
CT2_DEVICE=cuda
|
|
||||||
CT2_COMPUTE_TYPE=int8_float16
|
|
||||||
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
|
|
||||||
|
|
||||||
# Embeddings
|
|
||||||
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
EMB_BATCH=64
|
|
||||||
EMB_DEVICE=cuda
|
|
||||||
|
|
||||||
# NER
|
|
||||||
NER_LANG=es
|
|
||||||
NER_BATCH=64
|
|
||||||
|
|
||||||
# Flask / Gunicorn
|
|
||||||
GUNICORN_WORKERS=8
|
|
||||||
FLASK_DEBUG=0
|
|
||||||
|
|
||||||
# Qdrant Configuration
|
|
||||||
QDRANT_HOST=qdrant
|
|
||||||
QDRANT_PORT=6333
|
|
||||||
QDRANT_COLLECTION_NAME=news_vectors
|
|
||||||
QDRANT_BATCH_SIZE=100
|
|
||||||
QDRANT_SLEEP_IDLE=30
|
|
||||||
67
.env.example
67
.env.example
|
|
@ -1,67 +0,0 @@
|
||||||
# Database Configuration
|
|
||||||
POSTGRES_DB=rss
|
|
||||||
POSTGRES_USER=rss
|
|
||||||
POSTGRES_PASSWORD=change_this_password
|
|
||||||
DB_NAME=rss
|
|
||||||
DB_USER=rss
|
|
||||||
DB_PASS=change_this_password
|
|
||||||
DB_HOST=db
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_WRITE_HOST=db
|
|
||||||
DB_READ_HOST=db-replica
|
|
||||||
|
|
||||||
# Redis Configuration
|
|
||||||
REDIS_HOST=redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
# Application Secrets
|
|
||||||
SECRET_KEY=change_this_to_a_long_random_string
|
|
||||||
|
|
||||||
# External Services
|
|
||||||
ALLTALK_URL=http://host.docker.internal:7851
|
|
||||||
|
|
||||||
# AI Models & Workers
|
|
||||||
RSS_MAX_WORKERS=3
|
|
||||||
# Translation Pipeline
|
|
||||||
TARGET_LANGS=es
|
|
||||||
TRANSLATOR_BATCH=16
|
|
||||||
SCHEDULER_BATCH=2000
|
|
||||||
SCHEDULER_SLEEP=30
|
|
||||||
LANG_DETECT_BATCH=1000
|
|
||||||
LANG_DETECT_SLEEP=60
|
|
||||||
|
|
||||||
# RSS Ingestor Configuration
|
|
||||||
RSS_POKE_INTERVAL_MIN=15
|
|
||||||
RSS_MAX_FAILURES=10
|
|
||||||
RSS_FEED_TIMEOUT=60
|
|
||||||
|
|
||||||
# URL Feed Discovery Worker
|
|
||||||
URL_DISCOVERY_INTERVAL_MIN=15
|
|
||||||
URL_DISCOVERY_BATCH_SIZE=10
|
|
||||||
MAX_FEEDS_PER_URL=5
|
|
||||||
|
|
||||||
# CTranslate2 / AI Model Paths
|
|
||||||
CT2_MODEL_PATH=/app/models/nllb-ct2
|
|
||||||
CT2_DEVICE=cuda
|
|
||||||
CT2_COMPUTE_TYPE=int8_float16
|
|
||||||
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
|
|
||||||
|
|
||||||
# Embeddings
|
|
||||||
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
EMB_BATCH=64
|
|
||||||
EMB_DEVICE=cuda
|
|
||||||
|
|
||||||
# NER
|
|
||||||
NER_LANG=es
|
|
||||||
NER_BATCH=64
|
|
||||||
|
|
||||||
# Flask / Gunicorn
|
|
||||||
GUNICORN_WORKERS=8
|
|
||||||
FLASK_DEBUG=0
|
|
||||||
|
|
||||||
# Qdrant Configuration
|
|
||||||
QDRANT_HOST=qdrant
|
|
||||||
QDRANT_PORT=6333
|
|
||||||
QDRANT_COLLECTION_NAME=news_vectors
|
|
||||||
QDRANT_BATCH_SIZE=100
|
|
||||||
QDRANT_SLEEP_IDLE=30
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
# ==================================================================================
|
|
||||||
# SEGURIDAD: CONFIGURACIÓN DE PRODUCCIÓN
|
|
||||||
# ==================================================================================
|
|
||||||
#
|
|
||||||
# IMPORTANTE:
|
|
||||||
# 1. Copia este archivo a .env
|
|
||||||
# 2. Cambia TODOS los valores de contraseñas y secrets
|
|
||||||
# 3. NO compartas este archivo en repositorios públicos
|
|
||||||
# 4. Añade .env al .gitignore
|
|
||||||
#
|
|
||||||
# ==================================================================================
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# DATABASE CONFIGURATION - PostgreSQL
|
|
||||||
# ==================================================================================
|
|
||||||
POSTGRES_DB=rss
|
|
||||||
POSTGRES_USER=rss
|
|
||||||
# CRÍTICO: Genera una contraseña fuerte (mínimo 32 caracteres aleatorios)
|
|
||||||
# Ejemplo para generar: openssl rand -base64 32
|
|
||||||
POSTGRES_PASSWORD=CAMBIAR_ESTO_POR_UNA_CONTRASEÑA_FUERTE_DE_32_CARACTERES
|
|
||||||
|
|
||||||
DB_NAME=rss
|
|
||||||
DB_USER=rss
|
|
||||||
DB_PASS=CAMBIAR_ESTO_POR_UNA_CONTRASEÑA_FUERTE_DE_32_CARACTERES
|
|
||||||
DB_HOST=db
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_WRITE_HOST=db
|
|
||||||
DB_READ_HOST=db-replica
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# REDIS CONFIGURATION - Autenticación habilitada
|
|
||||||
# ==================================================================================
|
|
||||||
REDIS_HOST=redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
# CRÍTICO: Genera una contraseña fuerte para Redis
|
|
||||||
# Ejemplo: openssl rand -base64 32
|
|
||||||
REDIS_PASSWORD=CAMBIAR_ESTO_POR_UNA_CONTRASEÑA_FUERTE_REDIS
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# APPLICATION SECRETS
|
|
||||||
# ==================================================================================
|
|
||||||
# CRÍTICO: Secret key para Flask - debe ser único y secreto
|
|
||||||
# Genera con: python -c "import secrets; print(secrets.token_hex(32))"
|
|
||||||
SECRET_KEY=CAMBIAR_ESTO_POR_UN_TOKEN_HEX_DE_64_CARACTERES
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# MONITORING - Grafana
|
|
||||||
# ==================================================================================
|
|
||||||
# IMPORTANTE: Cambia el password de admin de Grafana
|
|
||||||
GRAFANA_PASSWORD=CAMBIAR_ESTO_POR_UNA_CONTRASEÑA_FUERTE_GRAFANA
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# EXTERNAL SERVICES
|
|
||||||
# ==================================================================================
|
|
||||||
ALLTALK_URL=http://host.docker.internal:7851
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# AI MODELS & WORKERS
|
|
||||||
# ==================================================================================
|
|
||||||
RSS_MAX_WORKERS=3
|
|
||||||
TARGET_LANGS=es
|
|
||||||
TRANSLATOR_BATCH=128
|
|
||||||
ENQUEUE=300
|
|
||||||
|
|
||||||
# RSS Ingestor Configuration
|
|
||||||
RSS_POKE_INTERVAL_MIN=15
|
|
||||||
RSS_MAX_FAILURES=10
|
|
||||||
RSS_FEED_TIMEOUT=60
|
|
||||||
|
|
||||||
# URL Feed Discovery Worker
|
|
||||||
URL_DISCOVERY_INTERVAL_MIN=15
|
|
||||||
URL_DISCOVERY_BATCH_SIZE=10
|
|
||||||
MAX_FEEDS_PER_URL=5
|
|
||||||
|
|
||||||
# CTranslate2 / AI Model Paths
|
|
||||||
CT2_MODEL_PATH=/app/models/nllb-ct2
|
|
||||||
CT2_DEVICE=cuda
|
|
||||||
CT2_COMPUTE_TYPE=int8_float16
|
|
||||||
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
|
|
||||||
|
|
||||||
# Embeddings
|
|
||||||
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
EMB_BATCH=64
|
|
||||||
EMB_DEVICE=cuda
|
|
||||||
|
|
||||||
# NER
|
|
||||||
NER_LANG=es
|
|
||||||
NER_BATCH=64
|
|
||||||
|
|
||||||
# Flask / Gunicorn
|
|
||||||
GUNICORN_WORKERS=8
|
|
||||||
FLASK_DEBUG=0
|
|
||||||
|
|
||||||
# Qdrant Configuration
|
|
||||||
QDRANT_HOST=qdrant
|
|
||||||
QDRANT_PORT=6333
|
|
||||||
QDRANT_COLLECTION_NAME=news_vectors
|
|
||||||
QDRANT_BATCH_SIZE=100
|
|
||||||
QDRANT_SLEEP_IDLE=30
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# COMANDOS ÚTILES PARA GENERAR CONTRASEÑAS SEGURAS
|
|
||||||
# ==================================================================================
|
|
||||||
#
|
|
||||||
# PostgreSQL Password (32 caracteres):
|
|
||||||
# openssl rand -base64 32
|
|
||||||
#
|
|
||||||
# Redis Password (32 caracteres):
|
|
||||||
# openssl rand -base64 32
|
|
||||||
#
|
|
||||||
# Flask Secret Key (64 hex chars):
|
|
||||||
# python -c "import secrets; print(secrets.token_hex(32))"
|
|
||||||
#
|
|
||||||
# Grafana Password (fuerte):
|
|
||||||
# openssl rand -base64 24
|
|
||||||
#
|
|
||||||
# ==================================================================================
|
|
||||||
140
.gitignore
vendored
140
.gitignore
vendored
|
|
@ -1,120 +1,80 @@
|
||||||
# ==================================================================================
|
|
||||||
# Python
|
|
||||||
# ==================================================================================
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyc
|
||||||
*$py.class
|
*.pyo
|
||||||
*.so
|
*.pyd
|
||||||
.Python
|
.Python
|
||||||
build/
|
*.so
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
# Virtual environments
|
dist/
|
||||||
|
build/
|
||||||
venv/
|
venv/
|
||||||
env/
|
.env
|
||||||
ENV/
|
|
||||||
.venv
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# IDEs
|
|
||||||
# ==================================================================================
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# SECURITY - NEVER COMMIT
|
|
||||||
# ==================================================================================
|
|
||||||
.env
|
|
||||||
.env.backup
|
|
||||||
.env.backup2
|
|
||||||
.env.generated
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Credentials
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
*.crt
|
|
||||||
credentials.json
|
|
||||||
service-account.json
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# RSS2 Data & Models (downloaded at runtime)
|
|
||||||
# ==================================================================================
|
|
||||||
# ML Models - downloaded/converted by workers
|
|
||||||
models/
|
|
||||||
models/nllb-ct2/
|
|
||||||
models/nllb-ct2-1.3b/
|
|
||||||
|
|
||||||
# Wikipedia images - downloaded by wiki_worker
|
|
||||||
data/wiki_images/
|
|
||||||
data/backups/
|
|
||||||
backups/
|
|
||||||
|
|
||||||
# Database storage (PostgreSQL)
|
|
||||||
data/pgdata/
|
|
||||||
pgdata/
|
pgdata/
|
||||||
pgdata-replica/
|
pgdata-replica/
|
||||||
pgdata.failed_restore/
|
pgdata.failed_restore/
|
||||||
pgdata-replica.old.*/
|
pgdata-replica.old.*/
|
||||||
|
|
||||||
# Redis data
|
|
||||||
data/redis-data/
|
|
||||||
redis-data/
|
redis-data/
|
||||||
|
|
||||||
# Qdrant vector storage
|
|
||||||
data/qdrant_storage/
|
|
||||||
qdrant_storage/
|
|
||||||
|
|
||||||
# HuggingFace cache (downloaded models)
|
|
||||||
hf_cache/
|
hf_cache/
|
||||||
|
models/nllb-ct2/
|
||||||
|
qdrant_storage/
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
data/
|
||||||
|
*.mp4
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
*.srt
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
*.bak
|
||||||
|
*.old
|
||||||
|
|
||||||
# Monitoring data
|
|
||||||
monitoring/data/
|
|
||||||
|
|
||||||
# ==================================================================================
|
# ==================================================================================
|
||||||
# Backup files
|
# SECURITY FILES - NEVER COMMIT THESE
|
||||||
# ==================================================================================
|
# ==================================================================================
|
||||||
|
# Environment files with credentials
|
||||||
|
.env
|
||||||
|
.env.backup*
|
||||||
|
.env.generated
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Database backups
|
||||||
*.sql
|
*.sql
|
||||||
!init-db/*.sql
|
!init-db/*.sql
|
||||||
!migrations/*.sql
|
!migrations/*.sql
|
||||||
backup_*.sql
|
backup_*.sql
|
||||||
|
|
||||||
|
# Redis backups
|
||||||
*.rdb
|
*.rdb
|
||||||
redis_backup_*.rdb
|
redis_backup_*.rdb
|
||||||
|
|
||||||
|
# Qdrant backups
|
||||||
qdrant_backup_*.tar.gz
|
qdrant_backup_*.tar.gz
|
||||||
|
|
||||||
# ==================================================================================
|
# Docker compose with real credentials (if you create variations)
|
||||||
# Temporary files
|
|
||||||
# ==================================================================================
|
|
||||||
tmp/
|
|
||||||
temp/
|
|
||||||
*.tmp
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# Docker
|
|
||||||
# ==================================================================================
|
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
# ==================================================================================
|
# Large Language Models
|
||||||
# Keep directory structure (optional - uncomment if needed)
|
models/llm/
|
||||||
# models/.gitkeep
|
|
||||||
# data/.gitkeep
|
# User Uploads
|
||||||
|
static/uploads/
|
||||||
|
|
||||||
|
# Celery/Workers
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# System/IDE
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
|
||||||
110
AGENT.md
110
AGENT.md
|
|
@ -1,110 +0,0 @@
|
||||||
# RSS2 - Agente de Mejora de Software
|
|
||||||
|
|
||||||
## Visión General del Proyecto
|
|
||||||
|
|
||||||
Aplicación de agregación y procesamiento de noticias RSS con capacidades de IA.
|
|
||||||
|
|
||||||
### Stack Tecnológico
|
|
||||||
|
|
||||||
| Capa | Tecnología |
|
|
||||||
|------|-------------|
|
|
||||||
| Frontend | React 18 + TypeScript + Vite + TailwindCSS + React Query + React Router |
|
|
||||||
| Backend API | Go 1.25 + Gin |
|
|
||||||
| Workers Backend | Go 1.25, Python 3.x |
|
|
||||||
| Base de datos | PostgreSQL 18 |
|
|
||||||
| Caché | Redis 7 |
|
|
||||||
| Búsqueda vectorial | Qdrant |
|
|
||||||
| IA/LLM | Ollama, HuggingFace (NLLB, BERT, sentence-transformers) |
|
|
||||||
| Orquestación | Docker Compose |
|
|
||||||
| Monitoreo | Prometheus + Grafana + cAdvisor |
|
|
||||||
|
|
||||||
### Arquitectura de Workers
|
|
||||||
|
|
||||||
- **rss-ingestor-go**: Ingestión de feeds RSS
|
|
||||||
- **scraper**: Extracción de artículos de URLs
|
|
||||||
- **discovery**: Descubrimiento de nuevos feeds
|
|
||||||
- **topics**: Matching de temas y países
|
|
||||||
- **related**: Noticias relacionadas
|
|
||||||
- **langdetect**: Detección de idioma
|
|
||||||
- **translator**: Traducción con NLLB (CPU/GPU)
|
|
||||||
- **embeddings**: Generación de vectores semánticos
|
|
||||||
- **ner**: Extracción de entidades nombradas
|
|
||||||
- **cluster**: Agrupación de noticias
|
|
||||||
- **llm-categorizer**: Categorización con Ollama
|
|
||||||
- **wiki-worker**: Wikipedia info y thumbnails
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recomendaciones de Mejora
|
|
||||||
|
|
||||||
### 1. Infraestructura y DevOps
|
|
||||||
|
|
||||||
- **Añadir CI/CD**: Pipeline con GitHub Actions para testing y deploy automático
|
|
||||||
- **Health checks**: Implementar health checks en todos los workers (no solo en db/redis)
|
|
||||||
- **Logging centralizado**: Integrar Loki o ELK stack para agregación de logs
|
|
||||||
- **Variables de entorno**: Mover configuración hardcodeada (ej: `translator_type: cpu`) a variables与环境
|
|
||||||
|
|
||||||
### 2. Backend Go
|
|
||||||
|
|
||||||
- **Estructura**: El proyecto no tiene estructura clara de packages (cmd/ internal/)
|
|
||||||
- **Testing**: No se observan tests unitarios en el backend
|
|
||||||
- **Config**: Usar Viper o library dedicada para configuración
|
|
||||||
- **Graceful shutdown**: Implementar en todos los workers
|
|
||||||
- **Migraciones**: Usar herramientas como golang-migrate en lugar de crear tablas en initDB()
|
|
||||||
|
|
||||||
### 3. Frontend
|
|
||||||
|
|
||||||
- **E2E Testing**: Añadir Playwright o Cypress
|
|
||||||
- **State Management**: Considerar Zustand o Jotai para estado global
|
|
||||||
- **Componentes**: Crear library de componentes reutilizables
|
|
||||||
- **i18n**: Preparar para multiidioma
|
|
||||||
- **PWA**: Añadir Service Worker para offline
|
|
||||||
|
|
||||||
### 4. Base de Datos
|
|
||||||
|
|
||||||
- **ORM/Query Builder**: Considerar GORM o sqlc para mejor mantenimiento
|
|
||||||
- **Índices**: Verificar que todos los campos de búsqueda tengan índices
|
|
||||||
- **Migrations**: Sistema formal de migraciones
|
|
||||||
- **Connection Pooling**: Configurar apropiadamente
|
|
||||||
|
|
||||||
### 5. Workers y Procesamiento
|
|
||||||
|
|
||||||
- **Colas**: Implementar RabbitMQ o Redis Streams para jobs asíncronos
|
|
||||||
- **Retry Logic**: Sistema robusto de reintentos con backoff exponencial
|
|
||||||
- **Dead Letter Queue**: Manejo de trabajos fallidos
|
|
||||||
- **Métricas por worker**: Prometheus metrics específicas por worker
|
|
||||||
- **Rate Limiting**: Protecciones contra rate limits de APIs externas
|
|
||||||
|
|
||||||
### 6. Seguridad
|
|
||||||
|
|
||||||
- **Rate limiting API**: Implementar en endpoints sensibles
|
|
||||||
- **Input Validation**: Usar biblioteca como go-playground/validator
|
|
||||||
- **CSRF Protection**: Añadir tokens CSRF
|
|
||||||
- **Security Headers**: Implementar todos los headers de seguridad
|
|
||||||
- **Audit Logs**: Logging de acciones administrativas
|
|
||||||
|
|
||||||
### 7. Rendimiento
|
|
||||||
|
|
||||||
- **Caching**: Cachear más endpoints con Redis (ej: estadísticas)
|
|
||||||
- **Pagination**: Implementar cursor-based pagination para grandes datasets
|
|
||||||
- **Database Connection Pooling**: Configurar límites apropiados
|
|
||||||
- **Image Optimization**: Comprimir imágenes antes de servir
|
|
||||||
|
|
||||||
### 8. Documentación
|
|
||||||
|
|
||||||
- **API Docs**: OpenAPI/Swagger para la REST API
|
|
||||||
- **Arquitecture Decision Records (ADRs)**: Documentar decisiones técnicas
|
|
||||||
- **Runbooks**: Documentación de operaciones
|
|
||||||
|
|
||||||
### 9. Monitorización y Alertas
|
|
||||||
|
|
||||||
- **Alertas**: Configurar alertas en Grafana para workers caídos
|
|
||||||
- **Dashboards**: Dashboard específico por worker
|
|
||||||
- **Tracing**: Añadir OpenTelemetry para distributed tracing
|
|
||||||
|
|
||||||
### 10. User Experience
|
|
||||||
|
|
||||||
- **Dark Mode**: Soporte completo
|
|
||||||
- **Keyboard Shortcuts**: Mejorar navegación
|
|
||||||
- **Infinite Scroll**: Para feeds grandes
|
|
||||||
- **Real-time Updates**: WebSockets para nuevas noticias
|
|
||||||
408
AGENTS.md
408
AGENTS.md
|
|
@ -1,408 +0,0 @@
|
||||||
# AGENTS.md - RSS2 Development Guide
|
|
||||||
|
|
||||||
## 🛠️ Build & Test Commands
|
|
||||||
|
|
||||||
### Backend (Go)
|
|
||||||
```bash
|
|
||||||
cd backend && go mod tidy
|
|
||||||
cd backend && go build -o ../bin/server ./cmd/server
|
|
||||||
cd rss-ingestor-go && go build -o ../bin/rss-ingestor .
|
|
||||||
|
|
||||||
# Single test
|
|
||||||
cd backend && go test ./internal/handlers -v -run TestLogin
|
|
||||||
cd backend && go test ./internal/auth -v -run TestGenerateToken
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
```bash
|
|
||||||
cd frontend && npm install
|
|
||||||
cd frontend && npm run dev # Development
|
|
||||||
cd frontend && npm run build # Production build
|
|
||||||
cd frontend && npm test # Run tests
|
|
||||||
cd frontend && npm run test:ui # UI mode
|
|
||||||
```
|
|
||||||
|
|
||||||
### Makefile
|
|
||||||
```bash
|
|
||||||
make build # Build all binaries
|
|
||||||
make clean # Remove binaries
|
|
||||||
make docker-build # Build Docker images
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
|
||||||
```
|
|
||||||
rss2/
|
|
||||||
├── backend/ # Go API server + Workers
|
|
||||||
│ ├── cmd/
|
|
||||||
│ │ ├── server/main.go # API REST principal (Gin)
|
|
||||||
│ │ ├── wiki_worker/main.go # Wikipedia integration
|
|
||||||
│ │ ├── qdrant/main.go # Vector indexing worker
|
|
||||||
│ │ ├── related/main.go # Related news worker
|
|
||||||
│ │ ├── topics/main.go # Country/topic matcher
|
|
||||||
│ │ ├── scraper/main.go # Deep scraping worker
|
|
||||||
│ │ ├── discovery/main.go # RSS feed discovery
|
|
||||||
│ │ └── topics/main.go # Topic matching
|
|
||||||
│ └── internal/
|
|
||||||
│ ├── handlers/ # HTTP endpoints
|
|
||||||
│ ├── models/ # Data models
|
|
||||||
│ ├── auth/ # JWT authentication
|
|
||||||
│ ├── middleware/ # CORS, Auth middleware
|
|
||||||
│ ├── services/ # ML services (Translate, Embeddings, NER, Semantic Search)
|
|
||||||
│ ├── db/ # PostgreSQL connection
|
|
||||||
│ ├── cache/ # Redis connection
|
|
||||||
│ └── config/ # Configuration loading
|
|
||||||
├── frontend/ # React + TypeScript + Vite
|
|
||||||
│ └── src/
|
|
||||||
│ ├── pages/ # Home, News, Search, Admin, Feeds, Stats
|
|
||||||
│ ├── components/ # Layout, UI components
|
|
||||||
│ └── services/ # API client
|
|
||||||
├── workers/ # Python workers
|
|
||||||
│ ├── ctranslator_worker.py # NLLB-200 translation (CTranslate2)
|
|
||||||
│ ├── ner_worker.py # Spacy NER + Topic extraction
|
|
||||||
│ ├── embeddings_worker.py # Sentence transformers embeddings
|
|
||||||
│ ├── cluster_worker.py # News clustering
|
|
||||||
│ ├── langdetect_worker.py # Language detection
|
|
||||||
│ ├── llm_categorizer_worker.py # Ollama LLM categorization
|
|
||||||
│ ├── simple_categorizer_worker.py
|
|
||||||
│ ├── simple_translator.py
|
|
||||||
│ ├── simple_translator_worker.py
|
|
||||||
│ ├── translation_worker.py
|
|
||||||
│ ├── translation_scheduler.py
|
|
||||||
│ └── remote_translator_worker.py # Remote GPU worker via WebSocket
|
|
||||||
├── data/ # PostgreSQL, Redis, Qdrant data
|
|
||||||
├── models/ # ML models (nllb-ct2)
|
|
||||||
├── hf_cache/ # HuggingFace cache
|
|
||||||
├── init-db/ # SQL migrations
|
|
||||||
├── monitoring/ # Prometheus + Grafana config
|
|
||||||
├── rss-ingestor-go/ # RSS crawler (Go)
|
|
||||||
└── docker-compose.yml # Full stack orchestration
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Servicios del Sistema
|
|
||||||
|
|
||||||
### Capa de Acceso y API (Puerto 8888)
|
|
||||||
| Servicio | Tecnología | Descripción |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| **nginx** | Nginx Alpine | Gateway y Proxy Inverso |
|
|
||||||
| **rss2_frontend** | React + Vite | Interfaz web responsiva |
|
|
||||||
| **backend-go** | Go + Gin | API REST principal |
|
|
||||||
|
|
||||||
### Ingesta y Descubrimiento (Go)
|
|
||||||
| Servicio | Tecnología | Descripción |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| **rss-ingestor-go** | Go | Crawler RSS de alto rendimiento |
|
|
||||||
| **scraper** | Go | Scraper profundo con sanitización HTML |
|
|
||||||
| **discovery** | Go | Agente de descubrimiento de feeds RSS |
|
|
||||||
|
|
||||||
### Procesamiento de Datos e IA
|
|
||||||
| Servicio | Tecnología | Descripción |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| **translator** | NLLB-200 (CPU) | Traducción neuronal CTranslate2 |
|
|
||||||
| **translator-gpu** | NLLB-200 (GPU) | Traducción acelerada CUDA |
|
|
||||||
| **remote-translator** | WebSocket | Worker GPU remoto |
|
|
||||||
| **embeddings** | S-Transformers | Generación de vectores semánticos |
|
|
||||||
| **ner** | Spacy + BERT | Reconocimiento de entidades (PER, ORG, LOC) |
|
|
||||||
| **llm-categorizer** | Ollama/Mistral | Clasificación con modelos de lenguaje |
|
|
||||||
| **wiki-worker** | Go | Integración Wikipedia + thumbnails |
|
|
||||||
| **topics** | Go | Matcher de países y temas |
|
|
||||||
| **related** | Go | Detección de noticias relacionadas |
|
|
||||||
| **qdrant-worker** | Go | Vectorización + búsqueda semántica |
|
|
||||||
| **cluster** | Python | Agrupación de noticias |
|
|
||||||
| **langdetect** | Python | Detección de idioma |
|
|
||||||
| **translation-scheduler** | Python | Creador de tareas de traducción |
|
|
||||||
|
|
||||||
### Capa de Almacenamiento
|
|
||||||
| Servicio | Tecnología | Descripción |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| **db** | PostgreSQL 18 | Base de datos relacional |
|
|
||||||
| **qdrant** | Qdrant | Base de datos vectorial |
|
|
||||||
| **redis** | Redis 7 | Cache y colas de mensajes |
|
|
||||||
|
|
||||||
### Monitoreo
|
|
||||||
| Servicio | Tecnología | Descripción |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| **prometheus** | Prometheus | Métricas del sistema |
|
|
||||||
| **grafana** | Grafana | Dashboard (puerto 3001) |
|
|
||||||
| **cadvisor** | cAdvisor | Monitoreo Docker |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Code Style Guidelines
|
|
||||||
|
|
||||||
### Backend (Go)
|
|
||||||
|
|
||||||
#### Imports
|
|
||||||
```go
|
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/rss2/backend/internal/auth"
|
|
||||||
"github.com/rss2/backend/internal/models"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
**Order:** Standard → Third-party → Local packages
|
|
||||||
**Use:** `goimports` to auto-format
|
|
||||||
|
|
||||||
#### Naming
|
|
||||||
- Packages: lowercase (`handlers`, `services`)
|
|
||||||
- Functions/Methods: camelCase (`GetNews`, `CreateUser`)
|
|
||||||
- Variables: camelCase (`userId`, `newsList`)
|
|
||||||
- Constants: UPPER_SNAKE_CASE (`MaxPageSize`)
|
|
||||||
- Types: PascalCase (`NewsResponse`, `User`)
|
|
||||||
|
|
||||||
#### Error Handling
|
|
||||||
```go
|
|
||||||
func CreateResource(c *gin.Context) {
|
|
||||||
var req CreateRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
|
||||||
Error: "Invalid request",
|
|
||||||
Message: err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Always return errors** | **Use `models.ErrorResponse`**
|
|
||||||
|
|
||||||
#### Database
|
|
||||||
```go
|
|
||||||
err := db.GetPool().QueryRow(ctx, "SELECT * FROM users WHERE id = $1", id).Scan(&user)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "User not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Use context** | **Named parameters ($1, $2)**
|
|
||||||
|
|
||||||
#### Tests
|
|
||||||
```go
|
|
||||||
func TestLoginInvalidRequest(t *testing.T) {
|
|
||||||
router := gin.New()
|
|
||||||
router.POST("/auth/login", Login)
|
|
||||||
body := []byte(`{}`)
|
|
||||||
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.StatusBadRequest {
|
|
||||||
t.Errorf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Use `gin.TestMode`** | **Test error paths**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Frontend (TypeScript + React)
|
|
||||||
|
|
||||||
#### Imports
|
|
||||||
```tsx
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { Routes, Route } from 'react-router-dom'
|
|
||||||
import { Layout } from './components/layout/Layout'
|
|
||||||
import { api } from './services/api'
|
|
||||||
```
|
|
||||||
**Order:** React → Router → Components → Services → Utils
|
|
||||||
|
|
||||||
#### Components
|
|
||||||
```tsx
|
|
||||||
function NewsList() {
|
|
||||||
const [news, setNews] = useState<News[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => { fetchNews(); }, [])
|
|
||||||
|
|
||||||
const fetchNews = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get('/news')
|
|
||||||
setNews(res.data)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="news-list">
|
|
||||||
{loading && <Spinner />}
|
|
||||||
{error && <ErrorBanner message={error} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Types
|
|
||||||
```tsx
|
|
||||||
interface News {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
summary: string
|
|
||||||
url: string
|
|
||||||
publishedAt: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Styling
|
|
||||||
- Use Tailwind CSS utility classes
|
|
||||||
- Avoid inline styles
|
|
||||||
- Use `clsx` for conditional classes
|
|
||||||
|
|
||||||
#### TypeScript Rules
|
|
||||||
- Use `strict: true` mode
|
|
||||||
- Avoid `any` - define interfaces
|
|
||||||
- Use type guards for narrowing
|
|
||||||
- Prefer optional chaining `?.`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔒 Security Guidelines
|
|
||||||
|
|
||||||
1. **Never commit secrets** - Use `.env` in `.gitignore`
|
|
||||||
2. **Validate all inputs** - Use `go-playground/validator`
|
|
||||||
3. **Prepared statements** - Prevent SQL injection
|
|
||||||
4. **Rate limiting** - On sensitive endpoints
|
|
||||||
5. **HTTPS only** - Enforce in production
|
|
||||||
|
|
||||||
### Variables de Entorno Críticas
|
|
||||||
```bash
|
|
||||||
POSTGRES_PASSWORD # Contraseña PostgreSQL
|
|
||||||
REDIS_PASSWORD # Contraseña Redis
|
|
||||||
DB_PASS # Contraseña para workers
|
|
||||||
SECRET_KEY # Key JWT
|
|
||||||
GRAFANA_PASSWORD # Dashboard password
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing Best Practices
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- Test error cases and edge cases
|
|
||||||
- Use `gin.TestMode` for HTTP tests
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- Test component rendering
|
|
||||||
- Test API integration
|
|
||||||
- Test error states
|
|
||||||
- Use vitest
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generar credenciales seguras
|
|
||||||
./pre-deploy.sh --generate
|
|
||||||
|
|
||||||
# Validar y desplegar
|
|
||||||
./pre-deploy.sh
|
|
||||||
|
|
||||||
# O manualmente
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Escalar workers de traducción
|
|
||||||
docker compose up -d --scale translator-gpu=4
|
|
||||||
```
|
|
||||||
|
|
||||||
### Escalado de Workers GPU
|
|
||||||
```bash
|
|
||||||
# 1 worker GPU (8GB+ VRAM)
|
|
||||||
docker compose up -d --scale translator-gpu=1
|
|
||||||
|
|
||||||
# 2 workers GPU (16GB+ VRAM)
|
|
||||||
docker compose up -d --scale translator-gpu=2
|
|
||||||
|
|
||||||
# 4 workers GPU (32GB+ VRAM)
|
|
||||||
docker compose up -d --scale translator-gpu=4
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Configuración de Workers
|
|
||||||
|
|
||||||
### Environment Variables Principales
|
|
||||||
|
|
||||||
| Variable | Descripción | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `DB_HOST` | Host PostgreSQL | localhost |
|
|
||||||
| `DB_PORT` | Puerto PostgreSQL | 5432 |
|
|
||||||
| `DB_NAME` | Nombre base de datos | rss |
|
|
||||||
| `DB_USER` | Usuario PostgreSQL | rss |
|
|
||||||
| `DB_PASS` | Contraseña PostgreSQL | - |
|
|
||||||
| `TARGET_LANGS` | Idiomas destino | es |
|
|
||||||
| `TRANSLATOR_BATCH` | Tamaño de batch | 32 |
|
|
||||||
| `CT2_DEVICE` | Dispositivo (cpu/cuda) | cpu |
|
|
||||||
| `CT2_COMPUTE_TYPE` | Tipo (int8/float16) | int8 |
|
|
||||||
| `NER_BATCH` | Batch NER | 64 |
|
|
||||||
| `EMB_BATCH` | Batch embeddings | 64 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Endpoints de API Principales
|
|
||||||
|
|
||||||
### News
|
|
||||||
- `GET /api/news` - Listar noticias (paginado, filtros)
|
|
||||||
- `GET /api/news/:id` - Ver noticia con entidades
|
|
||||||
- `DELETE /api/news/:id` - Eliminar noticia (admin)
|
|
||||||
|
|
||||||
### Feeds
|
|
||||||
- `GET /api/feeds` - Listar feeds
|
|
||||||
- `POST /api/feeds` - Crear feed (auth)
|
|
||||||
- `PUT /api/feeds/:id` - Actualizar feed (auth)
|
|
||||||
- `DELETE /api/feeds/:id` - Eliminar feed (auth)
|
|
||||||
|
|
||||||
### Search
|
|
||||||
- `GET /api/search?q=...` - Búsqueda texto
|
|
||||||
- `GET /api/search?q=...&semantic=true` - Búsqueda semántica
|
|
||||||
|
|
||||||
### Entities
|
|
||||||
- `GET /api/entities?tipo=persona` - Listar entidades (PER, ORG, LOC)
|
|
||||||
|
|
||||||
### Admin
|
|
||||||
- `GET /api/admin/backup` - Backup SQL completo
|
|
||||||
- `GET /api/admin/backup/news` - Backup noticias (ZIP)
|
|
||||||
- `GET /api/admin/users` - Listar usuarios
|
|
||||||
- `POST /api/admin/workers/start` - Iniciar workers traducción
|
|
||||||
- `POST /api/admin/workers/stop` - Detener workers traducción
|
|
||||||
- `GET /api/admin/workers/status` - Estado de workers
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
- `POST /api/auth/login` - Iniciar sesión
|
|
||||||
- `POST /api/auth/register` - Registrarse
|
|
||||||
- `GET /api/auth/me` - Usuario actual (auth)
|
|
||||||
|
|
||||||
### Stats
|
|
||||||
- `GET /api/stats` - Estadísticas globales
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Capabilities del Sistema
|
|
||||||
|
|
||||||
1. **Enriquecimiento Wikipedia**: Detecta personas/orgs, descarga biografías e imágenes
|
|
||||||
2. **Categorización LLM**: Clasificación con Mistral-7B vía Ollama
|
|
||||||
3. **Búsqueda Semántica**: Qdrant vector search con mxbai-embed-large
|
|
||||||
4. **Traducción Neuronal**: NLLB-200 (600M-1.3B params) CPU/GPU
|
|
||||||
5. **NER**: Spacy es_core_news_lg para entidades nombradas
|
|
||||||
6. **Noticias Relacionadas**: Similitud coseno entre embeddings
|
|
||||||
7. **Detección de Idioma**: langdetect
|
|
||||||
8. **Clustering**: Agrupación automática de noticias
|
|
||||||
9. **WebSocket Workers**: Workers GPU remotos conectados por WS
|
|
||||||
10. **Backup Automático**: pg_dump con compresión ZIP
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Documentación Adicional
|
|
||||||
|
|
||||||
- [README.md](../README.md) - Guía de despliegue completo
|
|
||||||
- [DEPLOY.md](./DEPLOY.md) - Instrucciones de producción
|
|
||||||
- [SECURITY_GUIDE.md](./SECURITY_GUIDE.md) - Guía de seguridad
|
|
||||||
- [QUICKSTART_LLM.md](./QUICKSTART_LLM.md) - Configuración LLM
|
|
||||||
- [remote-worker.md](./remote-worker.md) - Workers remotos
|
|
||||||
353
DEPLOY.md
353
DEPLOY.md
|
|
@ -1,353 +0,0 @@
|
||||||
# 🚀 Guía de Despliegue RSS2
|
|
||||||
|
|
||||||
## ⚡ Despliegue Rápido (5 minutos)
|
|
||||||
|
|
||||||
### Paso 1: Clonar el Proyecto
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/tu-usuario/rss2.git
|
|
||||||
cd rss2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 2: Generar Credenciales Seguras
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./pre-deploy.sh --generate
|
|
||||||
```
|
|
||||||
|
|
||||||
**Salida esperada:**
|
|
||||||
```
|
|
||||||
🔑 Generando credenciales seguras...
|
|
||||||
|
|
||||||
⚠️ IMPORTANTE: Guarda estas credenciales en un lugar seguro
|
|
||||||
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
POSTGRES_PASSWORD: S5xwsI9HKjdaBWLpCkqp0mA0DJYZerpD
|
|
||||||
REDIS_PASSWORD: cVF79QuJulPO5auEhA2nNqv7mqAruzYh
|
|
||||||
SECRET_KEY: 2eb7c221245bb8d896f83255188de614e59fae6b8dfbf16eee8a23c60dde4ffa
|
|
||||||
GRAFANA_PASSWORD: R4J61V6OGLTCesrITwEvUPTm
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
```
|
|
||||||
|
|
||||||
**⚠️ IMPORTANTE:** Copia estas contraseñas y guárdalas en un gestor de contraseñas. ¡Nunca se volverán a mostrar!
|
|
||||||
|
|
||||||
### Paso 3: Validar y Desplegar
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./pre-deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**El script te preguntará:**
|
|
||||||
```
|
|
||||||
¿Deseas desplegar ahora?
|
|
||||||
1) Desplegar automáticamente (recomendado)
|
|
||||||
2) Solo validar y salir (modo manual)
|
|
||||||
Elige una opción:
|
|
||||||
```
|
|
||||||
|
|
||||||
**Opción A: Despliegue automático (Opción 1)**
|
|
||||||
- Elige `1` para que el script haga todo automáticamente
|
|
||||||
- Espera a que termine la construcción de las imágenes
|
|
||||||
- Espera a que inicien los servicios
|
|
||||||
|
|
||||||
**Opción B: Despliegue manual (Opción 2)**
|
|
||||||
- Elige `2` para validar solo
|
|
||||||
- Luego ejecuta manualmente:
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 4: Verificar Despliegue
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verificar estado de los servicios
|
|
||||||
docker compose ps
|
|
||||||
|
|
||||||
# Deberías ver algo como:
|
|
||||||
# Name Status
|
|
||||||
# rss2_db Up (healthy)
|
|
||||||
# rss2_redis Up (healthy)
|
|
||||||
# rss2_backend_go Up (running)
|
|
||||||
# ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paso 5: Acceder a la Aplicación
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Aplicación web
|
|
||||||
http://localhost:8888
|
|
||||||
|
|
||||||
# API (prueba con curl)
|
|
||||||
curl http://localhost:8888/api/feeds
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Seguridad
|
|
||||||
|
|
||||||
### ¿Por qué es importante?
|
|
||||||
|
|
||||||
**Sin seguridad (❌ MAL):**
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
WARN: The "DB_PASS" variable is not set. Defaulting to a blank string.
|
|
||||||
# Las contraseñas están vacías ¡PELIGRO!
|
|
||||||
```
|
|
||||||
|
|
||||||
**Con seguridad (✅ BIEN):**
|
|
||||||
```bash
|
|
||||||
./pre-deploy.sh --generate
|
|
||||||
./pre-deploy.sh
|
|
||||||
docker compose up -d
|
|
||||||
# ✅ Todo funciona correctamente
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables Críticas
|
|
||||||
|
|
||||||
| Variable | Qué es | Por qué es importante |
|
|
||||||
|----------|--------|----------------------|
|
|
||||||
| `POSTGRES_PASSWORD` | Contraseña de PostgreSQL | Base de datos principal |
|
|
||||||
| `REDIS_PASSWORD` | Contraseña de Redis | Cache y colas |
|
|
||||||
| `DB_PASS` | Contraseña para workers | Conexiones de workers |
|
|
||||||
| `SECRET_KEY` | Key secreta de la app | JWT, encriptación |
|
|
||||||
| `GRAFANA_PASSWORD` | Contraseña de Grafana | Dashboard de monitoring |
|
|
||||||
|
|
||||||
### Guardar tus Credenciales
|
|
||||||
|
|
||||||
**Opción 1: Gestor de contraseñas (Recomendado)**
|
|
||||||
```bash
|
|
||||||
# Copia las credenciales y guárdalas en:
|
|
||||||
# - LastPass
|
|
||||||
# - 1Password
|
|
||||||
# - KeePass
|
|
||||||
# - Bitwarden
|
|
||||||
```
|
|
||||||
|
|
||||||
**Opción 2: Archivo seguro local**
|
|
||||||
```bash
|
|
||||||
# Crea un archivo seguro fuera del proyecto
|
|
||||||
echo "POSTGRES_PASSWORD=..." > ~/rss2-credentials.txt
|
|
||||||
echo "REDIS_PASSWORD=..." >> ~/rss2-credentials.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Comandos Útiles
|
|
||||||
|
|
||||||
### Verificar Credenciales
|
|
||||||
```bash
|
|
||||||
# Ver que las contraseñas están definidas
|
|
||||||
grep -E "^(POSTGRES_PASSWORD|REDIS_PASSWORD|DB_PASS)=" .env
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reiniciar Servicios
|
|
||||||
```bash
|
|
||||||
# Reiniciar todos los servicios
|
|
||||||
docker compose restart
|
|
||||||
|
|
||||||
# Reiniciar solo base de datos
|
|
||||||
docker compose restart db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Escalar Workers
|
|
||||||
```bash
|
|
||||||
# Añadir más traductores (CPU)
|
|
||||||
docker compose up -d --scale translator=3
|
|
||||||
|
|
||||||
# Añadir más traductores (GPU)
|
|
||||||
docker compose up -d --scale translator-gpu=2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ver Logs
|
|
||||||
```bash
|
|
||||||
# Ver logs en tiempo real
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# Ver logs específicos
|
|
||||||
docker compose logs -f db
|
|
||||||
docker compose logs -f redis
|
|
||||||
docker compose logs -f backend-go
|
|
||||||
```
|
|
||||||
|
|
||||||
### Limpiar y Reiniciar
|
|
||||||
```bash
|
|
||||||
# Detener y eliminar todo (incluye datos)
|
|
||||||
docker compose down -v
|
|
||||||
|
|
||||||
# Eliminar solo contenedores (mantiene datos)
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# Eliminar volumen específico
|
|
||||||
docker volume rm rss2_db
|
|
||||||
docker volume rm rss2_redis
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Solución de Problemas
|
|
||||||
|
|
||||||
### Problema: WARN sobre variables no definidas
|
|
||||||
|
|
||||||
```
|
|
||||||
WARN: The "DB_PASS" variable is not set. Defaulting to a blank string.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solución:**
|
|
||||||
```bash
|
|
||||||
# Ejecutar pre-deploy para generar credenciales
|
|
||||||
./pre-deploy.sh --generate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problema: Contraseña vacía en healthcheck
|
|
||||||
|
|
||||||
```
|
|
||||||
redis-cli: Authentication failed
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solución:**
|
|
||||||
```bash
|
|
||||||
# Verificar REDIS_PASSWORD
|
|
||||||
grep REDIS_PASSWORD .env
|
|
||||||
|
|
||||||
# Si está vacío, regenerar
|
|
||||||
./pre-deploy.sh --generate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problema: Port 8888 en uso
|
|
||||||
|
|
||||||
```
|
|
||||||
ERROR: failed to start service: port 8888 already in use
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solución:**
|
|
||||||
```bash
|
|
||||||
# Cambiar puerto en docker-compose.yml
|
|
||||||
# O matar el proceso que usa el puerto
|
|
||||||
lsof -ti:8888 | xargs kill
|
|
||||||
|
|
||||||
# O usar otro puerto
|
|
||||||
docker compose up -d --scale nginx=1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problema: Base de datos no inicia
|
|
||||||
|
|
||||||
```
|
|
||||||
postgres: cannot connect to database
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solución:**
|
|
||||||
```bash
|
|
||||||
# Ver logs de la base de datos
|
|
||||||
docker compose logs db
|
|
||||||
|
|
||||||
# Reiniciar base de datos
|
|
||||||
docker compose restart db
|
|
||||||
|
|
||||||
# Si persiste, eliminar y recrear (¡PERDERÁS DATOS!)
|
|
||||||
docker compose down -v
|
|
||||||
docker compose up -d db
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Estado de los Servicios
|
|
||||||
|
|
||||||
### Servicios Críticos
|
|
||||||
|
|
||||||
| Servicio | Puerto | Descripción |
|
|
||||||
|----------|--------|-------------|
|
|
||||||
| `nginx` | 8888 | Aplicación web |
|
|
||||||
| `backend-go` | 8080 | API interna |
|
|
||||||
| `db` | 5432 | PostgreSQL |
|
|
||||||
| `redis` | 6379 | Redis cache |
|
|
||||||
| `qdrant` | 6333 | Vector database |
|
|
||||||
|
|
||||||
### Dashboard de Monitorización
|
|
||||||
|
|
||||||
| Herramienta | Puerto | Acceso |
|
|
||||||
|-------------|--------|--------|
|
|
||||||
| Grafana | 3001 | http://127.0.0.1:3001 |
|
|
||||||
| Prometheus | 9090 | http://127.0.0.1:9090 |
|
|
||||||
|
|
||||||
**Nota:** Accede a Grafana desde `127.0.0.1` (localhost) por seguridad.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Actualización (Upgrade)
|
|
||||||
|
|
||||||
### Actualizar a Nueva Versión
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Pull las nuevas imágenes
|
|
||||||
docker compose pull
|
|
||||||
|
|
||||||
# 2. Backup de la base de datos
|
|
||||||
docker compose exec db pg_dump -U rss rss > backup.sql
|
|
||||||
|
|
||||||
# 3. Parar servicios
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# 4. Actualizar código
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# 5. Reiniciar
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 6. Verificar
|
|
||||||
curl http://localhost:8888/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
### Migrar Contraseñas
|
|
||||||
|
|
||||||
Si tienes credenciales existentes y quieres mantenerlas:
|
|
||||||
```bash
|
|
||||||
# 1. Verificar .env existente
|
|
||||||
grep POSTGRES_PASSWORD .env
|
|
||||||
|
|
||||||
# 2. Si quieres nuevas credenciales
|
|
||||||
./pre-deploy.sh --generate
|
|
||||||
|
|
||||||
# 3. Si quieres mantener las existentes
|
|
||||||
# No hagas nada, las credenciales seguirán funcionando
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Archivos Importantes
|
|
||||||
|
|
||||||
| Archivo | Propósito |
|
|
||||||
|---------|-----------|
|
|
||||||
| `.env` | Contraseñas y configuración (NO subir a Git) |
|
|
||||||
| `.env.example` | Plantilla (SÍ subir a Git) |
|
|
||||||
| `pre-deploy.sh` | Script de validación (SÍ subir a Git) |
|
|
||||||
| `generate_secure_credentials.sh` | Script de generación (SÍ subir a Git) |
|
|
||||||
| `docker-compose.yml` | Configuración de Docker (SÍ subir a Git) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Checklist Final
|
|
||||||
|
|
||||||
Antes de considerar tu despliegue como "listo", verifica:
|
|
||||||
|
|
||||||
- [ ] `.env` tiene credenciales generadas (no por defecto)
|
|
||||||
- [ ] `.env` está en `.gitignore`
|
|
||||||
- [ ] Puedes acceder a http://localhost:8888
|
|
||||||
- [ ] `docker compose ps` muestra todos los servicios "Up"
|
|
||||||
- [ ] La base de datos muestra "healthy"
|
|
||||||
- [ ] Los logs de la base de datos no tienen errores
|
|
||||||
- [ ] Redis responde a ping
|
|
||||||
- [ ] Qdrant está funcionando
|
|
||||||
|
|
||||||
**¡Listo! Tu RSS2 está desplegado y funcionando.** 🎉
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Documentación Adicional
|
|
||||||
|
|
||||||
- [README.md](./README.md) - Documentación general
|
|
||||||
- [SECURITY_GUIDE.md](./SECURITY_GUIDE.md) - Guía de seguridad
|
|
||||||
- [DOCKER.md](./DOCKER.md) - Configuración avanzada de Docker
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**RSS2** - *Transformando noticias en inteligencia con IA.*
|
|
||||||
50
Dockerfile
50
Dockerfile
|
|
@ -1,50 +0,0 @@
|
||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
libpq-dev gcc git curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
|
||||||
TOKENIZERS_PARALLELISM=false \
|
|
||||||
HF_HOME=/root/.cache/huggingface
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu121
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir \
|
|
||||||
ctranslate2 \
|
|
||||||
sentencepiece \
|
|
||||||
transformers==4.44.0 \
|
|
||||||
protobuf==3.20.3 \
|
|
||||||
"numpy<2" \
|
|
||||||
psycopg2-binary \
|
|
||||||
redis \
|
|
||||||
requests \
|
|
||||||
beautifulsoup4 \
|
|
||||||
lxml \
|
|
||||||
langdetect \
|
|
||||||
nltk \
|
|
||||||
scikit-learn \
|
|
||||||
pandas \
|
|
||||||
sentence-transformers \
|
|
||||||
spacy
|
|
||||||
|
|
||||||
RUN python -m spacy download es_core_news_lg
|
|
||||||
|
|
||||||
COPY workers/ ./workers/
|
|
||||||
COPY init-db/ ./init-db/
|
|
||||||
COPY migrations/ ./migrations/
|
|
||||||
COPY entity_config.json .
|
|
||||||
|
|
||||||
ENV DB_HOST=db
|
|
||||||
ENV DB_PORT=5432
|
|
||||||
ENV DB_NAME=rss
|
|
||||||
ENV DB_USER=rss
|
|
||||||
ENV DB_PASS=x
|
|
||||||
|
|
||||||
CMD ["python", "-m", "workers.embeddings_worker"]
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
FROM golang:1.22-alpine AS builder
|
|
||||||
|
|
||||||
ENV GOTOOLCHAIN=auto
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY backend/go.mod ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN go mod tidy
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/discovery ./cmd/discovery
|
|
||||||
|
|
||||||
FROM alpine:3.19
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
|
||||||
|
|
||||||
COPY --from=builder /bin/discovery /bin/discovery
|
|
||||||
|
|
||||||
ENV DB_HOST=db \
|
|
||||||
DB_PORT=5432 \
|
|
||||||
DB_NAME=rss \
|
|
||||||
DB_USER=rss \
|
|
||||||
DB_PASS=rss \
|
|
||||||
DISCOVERY_INTERVAL=900 \
|
|
||||||
DISCOVERY_BATCH=10 \
|
|
||||||
MAX_FEEDS_PER_URL=5
|
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/discovery"]
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
FROM golang:1.22-alpine AS builder
|
|
||||||
|
|
||||||
ENV GOTOOLCHAIN=auto
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY backend/go.mod ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN go mod tidy
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/qdrant-worker ./cmd/qdrant
|
|
||||||
|
|
||||||
FROM alpine:3.19
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
|
||||||
|
|
||||||
COPY --from=builder /bin/qdrant-worker /bin/qdrant-worker
|
|
||||||
|
|
||||||
ENV DB_HOST=db \
|
|
||||||
DB_PORT=5432 \
|
|
||||||
DB_NAME=rss \
|
|
||||||
DB_USER=rss \
|
|
||||||
DB_PASS=rss \
|
|
||||||
QDRANT_HOST=qdrant \
|
|
||||||
QDRANT_PORT=6333 \
|
|
||||||
QDRANT_COLLECTION=news_vectors \
|
|
||||||
OLLAMA_URL=http://ollama:11434 \
|
|
||||||
QDRANT_SLEEP=30 \
|
|
||||||
QDRANT_BATCH=100
|
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/qdrant-worker"]
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
FROM golang:1.22-alpine AS builder
|
|
||||||
|
|
||||||
ENV GOTOOLCHAIN=auto
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY backend/go.mod ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN go mod tidy
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/related ./cmd/related
|
|
||||||
|
|
||||||
FROM alpine:3.19
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
|
||||||
|
|
||||||
COPY --from=builder /bin/related /bin/related
|
|
||||||
|
|
||||||
ENV DB_HOST=db \
|
|
||||||
DB_PORT=5432 \
|
|
||||||
DB_NAME=rss \
|
|
||||||
DB_USER=rss \
|
|
||||||
DB_PASS=rss \
|
|
||||||
RELATED_SLEEP=10 \
|
|
||||||
RELATED_BATCH=200 \
|
|
||||||
RELATED_TOPK=10 \
|
|
||||||
EMB_MODEL=mxbai-embed-large
|
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/related"]
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
FROM python:3.11-slim-bookworm
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
patchelf libpq-dev gcc git curl wget \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
|
||||||
TOKENIZERS_PARALLELISM=false \
|
|
||||||
HF_HOME=/root/.cache/huggingface
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu118
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir \
|
|
||||||
ctranslate2==3.24.0 \
|
|
||||||
sentencepiece \
|
|
||||||
transformers==4.36.0 \
|
|
||||||
protobuf==3.20.3 \
|
|
||||||
"numpy<2" \
|
|
||||||
psycopg2-binary \
|
|
||||||
langdetect \
|
|
||||||
websocket-client
|
|
||||||
|
|
||||||
RUN find /usr/local/lib/python3.11/site-packages/ctranslate2* \
|
|
||||||
-name "libctranslate2-*.so.*" -o -name "libctranslate2.so*" | \
|
|
||||||
xargs -I {} patchelf --clear-execstack {} || true
|
|
||||||
|
|
||||||
COPY workers/remote_translator_worker.py /app/worker.py
|
|
||||||
|
|
||||||
ENV CT2_DEVICE=cuda
|
|
||||||
ENV CT2_COMPUTE_TYPE=float16
|
|
||||||
ENV UNIVERSAL_MODEL=facebook/nllb-200-1.3B
|
|
||||||
|
|
||||||
CMD ["python", "/app/worker.py"]
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
libpq-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip
|
|
||||||
RUN pip install --no-cache-dir psycopg2-binary langdetect
|
|
||||||
|
|
||||||
COPY workers/translation_scheduler.py ./workers/
|
|
||||||
|
|
||||||
ENV DB_HOST=db
|
|
||||||
ENV DB_PORT=5432
|
|
||||||
ENV DB_NAME=rss
|
|
||||||
ENV DB_USER=rss
|
|
||||||
ENV DB_PASS=x
|
|
||||||
|
|
||||||
CMD ["python", "workers/translation_scheduler.py"]
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
FROM golang:1.22-alpine AS builder
|
|
||||||
|
|
||||||
ENV GOTOOLCHAIN=auto
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY backend/go.mod ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN go mod tidy
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN go mod tidy
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/scraper ./cmd/scraper
|
|
||||||
|
|
||||||
FROM alpine:3.19
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
|
||||||
|
|
||||||
COPY --from=builder /bin/scraper /bin/scraper
|
|
||||||
|
|
||||||
ENV DB_HOST=db \
|
|
||||||
DB_PORT=5432 \
|
|
||||||
DB_NAME=rss \
|
|
||||||
DB_USER=rss \
|
|
||||||
DB_PASS=rss \
|
|
||||||
SCRAPER_SLEEP=60 \
|
|
||||||
SCRAPER_BATCH=10 \
|
|
||||||
SCRAPER_ENRICH_LIMIT=20
|
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/scraper"]
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
FROM golang:1.22-alpine AS builder
|
|
||||||
|
|
||||||
ENV GOTOOLCHAIN=auto
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY backend/go.mod ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN go mod tidy
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/topics ./cmd/topics
|
|
||||||
|
|
||||||
FROM alpine:3.19
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
|
||||||
|
|
||||||
COPY --from=builder /bin/topics /bin/topics
|
|
||||||
|
|
||||||
ENV DB_HOST=db \
|
|
||||||
DB_PORT=5432 \
|
|
||||||
DB_NAME=rss \
|
|
||||||
DB_USER=rss \
|
|
||||||
DB_PASS=rss \
|
|
||||||
TOPICS_SLEEP=10 \
|
|
||||||
TOPICS_BATCH=500
|
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/topics"]
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
FROM python:3.11-slim-bookworm
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
patchelf libpq-dev gcc git curl wget \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
|
||||||
TOKENIZERS_PARALLELISM=false \
|
|
||||||
HF_HOME=/root/.cache/huggingface
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cpu
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir \
|
|
||||||
ctranslate2==3.24.0 \
|
|
||||||
sentencepiece \
|
|
||||||
transformers==4.36.0 \
|
|
||||||
protobuf==3.20.3 \
|
|
||||||
"numpy<2" \
|
|
||||||
psycopg2-binary \
|
|
||||||
langdetect \
|
|
||||||
redis
|
|
||||||
|
|
||||||
# === ARREGLAR EL EXECUTABLE STACK ===
|
|
||||||
RUN find /usr/local/lib/python3.11/site-packages/ctranslate2* \
|
|
||||||
-name "libctranslate2-*.so.*" -o -name "libctranslate2.so*" | \
|
|
||||||
xargs -I {} patchelf --clear-execstack {} || true
|
|
||||||
|
|
||||||
COPY workers/ ./workers/
|
|
||||||
COPY init-db/ ./init-db/
|
|
||||||
COPY migrations/ ./migrations/
|
|
||||||
|
|
||||||
ENV DB_HOST=db
|
|
||||||
ENV DB_PORT=5432
|
|
||||||
ENV DB_NAME=rss
|
|
||||||
ENV DB_USER=rss
|
|
||||||
ENV DB_PASS=x
|
|
||||||
|
|
||||||
CMD ["python", "-m", "workers.ctranslator_worker"]
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
FROM python:3.11-slim-bookworm
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
patchelf libpq-dev gcc git curl wget \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
|
||||||
TOKENIZERS_PARALLELISM=false \
|
|
||||||
HF_HOME=/root/.cache/huggingface
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip
|
|
||||||
|
|
||||||
# Install PyTorch with CUDA support (cu118 for broader compatibility)
|
|
||||||
RUN pip install --no-cache-dir torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu118
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir \
|
|
||||||
ctranslate2==3.24.0 \
|
|
||||||
sentencepiece \
|
|
||||||
transformers==4.36.0 \
|
|
||||||
protobuf==3.20.3 \
|
|
||||||
"numpy<2" \
|
|
||||||
psycopg2-binary \
|
|
||||||
langdetect
|
|
||||||
|
|
||||||
# Fix executable stack
|
|
||||||
RUN find /usr/local/lib/python3.11/site-packages/ctranslate2* \
|
|
||||||
-name "libctranslate2-*.so.*" -o -name "libctranslate2.so*" | \
|
|
||||||
xargs -I {} patchelf --clear-execstack {} || true
|
|
||||||
|
|
||||||
COPY workers/ ./workers/
|
|
||||||
COPY init-db/ ./init-db/
|
|
||||||
COPY migrations/ ./migrations/
|
|
||||||
|
|
||||||
ENV DB_HOST=db
|
|
||||||
ENV DB_PORT=5432
|
|
||||||
ENV DB_NAME=rss
|
|
||||||
ENV DB_USER=rss
|
|
||||||
ENV DB_PASS=x
|
|
||||||
|
|
||||||
# GPU Configuration - Override with: docker run --gpus all
|
|
||||||
ENV CT2_DEVICE=cuda
|
|
||||||
ENV CT2_COMPUTE_TYPE=float16
|
|
||||||
|
|
||||||
CMD ["python", "-m", "workers.ctranslator_worker"]
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
FROM golang:alpine AS builder
|
|
||||||
|
|
||||||
ENV GOTOOLCHAIN=auto
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY backend/go.mod ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN go mod tidy
|
|
||||||
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
RUN go mod tidy
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/wiki_worker ./cmd/wiki_worker
|
|
||||||
|
|
||||||
FROM alpine:3.19
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
|
||||||
|
|
||||||
COPY --from=builder /bin/wiki_worker /bin/wiki_worker
|
|
||||||
|
|
||||||
ENV DB_HOST=db \
|
|
||||||
DB_PORT=5432 \
|
|
||||||
DB_NAME=rss \
|
|
||||||
DB_USER=rss \
|
|
||||||
DB_PASS=rss \
|
|
||||||
WIKI_SLEEP=10
|
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/wiki_worker"]
|
|
||||||
33
Makefile
33
Makefile
|
|
@ -1,6 +1,6 @@
|
||||||
# RSS2 Workers Makefile
|
# RSS2 Workers Makefile
|
||||||
|
|
||||||
.PHONY: all build clean deps ingestor scraper discovery topics related qdrant server
|
.PHONY: all build clean deps ingestor scraper discovery topics related qdrant server install rebuild check poc
|
||||||
|
|
||||||
# Binary output directory
|
# Binary output directory
|
||||||
BIN_DIR := bin
|
BIN_DIR := bin
|
||||||
|
|
@ -69,21 +69,16 @@ run-qdrant:
|
||||||
DB_HOST=localhost DB_PORT=5432 DB_NAME=rss DB_USER=rss DB_PASS=rss \
|
DB_HOST=localhost DB_PORT=5432 DB_NAME=rss DB_USER=rss DB_PASS=rss \
|
||||||
QDRANT_HOST=localhost QDRANT_PORT=6333 OLLAMA_URL=http://localhost:11434 $(QDRANT)
|
QDRANT_HOST=localhost QDRANT_PORT=6333 OLLAMA_URL=http://localhost:11434 $(QDRANT)
|
||||||
|
|
||||||
# Docker builds
|
# Despliegue en Debian (sin Docker)
|
||||||
docker-build:
|
install:
|
||||||
docker build -t rss2-ingestor -f rss-ingestor-go/Dockerfile ./rss-ingestor-go
|
sudo bash deploy/debian/prerequisites.sh
|
||||||
docker build -t rss2-server -f backend/Dockerfile ./backend
|
sudo bash deploy/debian/install.sh
|
||||||
docker build -t rss2-scraper -f Dockerfile.scraper ./backend
|
|
||||||
docker build -t rss2-discovery -f Dockerfile.discovery ./backend
|
rebuild:
|
||||||
docker build -t rss2-topics -f Dockerfile.topics ./backend
|
sudo bash deploy/debian/build.sh
|
||||||
docker build -t rss2-related -f Dockerfile.related ./backend
|
|
||||||
docker build -t rss2-qdrant -f Dockerfile.qdrant ./backend
|
check:
|
||||||
docker build -t rss2-langdetect -f Dockerfile .
|
bash deploy/debian/check.sh
|
||||||
docker build -t rss2-scheduler -f Dockerfile.scheduler .
|
|
||||||
docker build -t rss2-translator -f Dockerfile.translator .
|
poc:
|
||||||
docker build -t rss2-translator-gpu -f Dockerfile.translator-gpu .
|
bash poc/poc.sh
|
||||||
docker build -t rss2-embeddings -f Dockerfile.embeddings_worker .
|
|
||||||
docker build -t rss2-ner -f Dockerfile .
|
|
||||||
docker build -t rss2-llm-categorizer -f Dockerfile.llm_worker .
|
|
||||||
docker build -t rss2-frontend -f frontend/Dockerfile ./frontend
|
|
||||||
docker build -t rss2-nginx -f Dockerfile.nginx .
|
|
||||||
|
|
|
||||||
663
README.md
663
README.md
|
|
@ -1,549 +1,202 @@
|
||||||
# RSS2 - AI-Powered News Intelligence Platform
|
# COCONEWS
|
||||||
|
|
||||||
RSS2 es una plataforma avanzada de agregación, traducción, análisis y vectorización de noticias, diseñada para transformar flujos masivos de información en inteligencia accionable. Utiliza una arquitectura híbrida de microservicios (Go + Python) integrada con modelos de inteligencia artificial de última generación para ofrecer búsqueda semántica, clasificación inteligente y automatización de contenidos.
|
Plataforma de inteligencia de noticias. Agrega feeds RSS de cualquier idioma, los traduce automáticamente al español, extrae entidades, los agrupa por eventos y los hace buscables semánticamente.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Capacidades Principales
|
## Qué hace
|
||||||
|
|
||||||
* **Enriquecimiento con Wikipedia**: Sistema automatizado que detecta personas y organizaciones, descarga sus biografías e imágenes oficiales de Wikipedia para mostrarlas en tooltips interactivos con avatares circulares.
|
- **Ingesta** feeds RSS/Atom de cualquier idioma de forma continua
|
||||||
* **Categorización Inteligente (LLM)**: Clasificación de noticias mediante una instancia local de Mistral-7B / Llama-3 (vía Ollama), procesando contenido en tiempo real.
|
- **Traduce** al español con NLLB-200 (200 idiomas soportados)
|
||||||
* **Búsqueda Semántica**: Motor vectorial Qdrant para descubrir noticias por contexto y significado, yendo más allá de las palabras clave tradicionales.
|
- **Extrae entidades** (personas, organizaciones, lugares) con spaCy y las enriquece con Wikipedia
|
||||||
* **Traducción Neuronal de Alta Calidad**: Integración de NLLB-200 (vía CTranslate2) para traducir noticias de múltiples idiomas al español con precisión profesional.
|
- **Genera embeddings** para búsqueda por significado, no solo por palabras clave
|
||||||
* **Inteligencia de Entidades (NER)**: Extracción y normalización automática de Personas, Organizaciones y Lugares para análisis de tendencias y mapeo de relaciones.
|
- **Agrupa noticias** del mismo evento automáticamente
|
||||||
* **Búsqueda de Noticias Relacionadas**: Algoritmos de similitud que agrupan noticias sobre el mismo tema automáticamente.
|
- **Categoriza** contenido con reglas y modelos de lenguaje
|
||||||
* **Detección de Idioma**: Identificación automática del idioma de origen para routing correcto a traducción.
|
- Interfaz web React con búsqueda semántica, filtros y tooltips de Wikipedia
|
||||||
* **Clustering de Noticias**: Agrupación automática de noticias relacionadas en eventos mediante embeddings.
|
|
||||||
* **Remote Workers GPU**: Workers de traducción GPUremotos conectados vía WebSocket para procesamiento distribuido.
|
|
||||||
* **Backup Automatizado**: Exportación completa de la base de datos en SQL o ZIP.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Arquitectura de Servicios (Docker)
|
## Arquitectura
|
||||||
|
|
||||||
El sistema se orquestra mediante Docker Compose y se divide en capas especializadas:
|
```
|
||||||
|
Internet (RSS/Atom)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
rss-ingestor-go ──→ PostgreSQL ──→ langdetect
|
||||||
|
│ │ │
|
||||||
|
scraper/discovery │ translator (NLLB-200)
|
||||||
|
│ │
|
||||||
|
│ embeddings (MiniLM)
|
||||||
|
│ │
|
||||||
|
│ ner (spaCy)
|
||||||
|
│ │
|
||||||
|
│ cluster / related
|
||||||
|
│ │
|
||||||
|
│ qdrant-worker ──→ Qdrant
|
||||||
|
│
|
||||||
|
backend (API REST :8080)
|
||||||
|
│
|
||||||
|
nginx (:8001)
|
||||||
|
│
|
||||||
|
Frontend React
|
||||||
|
```
|
||||||
|
|
||||||
### Capa de Acceso y API
|
**Stack:**
|
||||||
| Servicio | Tecnología | Descripción |
|
- Go 1.25 — API REST (Gin), ingestor RSS, scraper, workers
|
||||||
|---------|------------|-------------|
|
- Python 3 — Workers ML (NLLB-200, MiniLM, spaCy, CTranslate2)
|
||||||
| **`nginx`** | Nginx Alpine | Gateway y Proxy Inverso (Puerto **8888**). |
|
- PostgreSQL 16 — datos relacionales + full-text search
|
||||||
| **`rss2_frontend`** | React + Vite | Interfaz web de usuario moderna y responsiva. |
|
- Qdrant — búsqueda vectorial semántica
|
||||||
| **`backend-go`** | Go + Gin | API REST principal y gestión de lógica de negocio. |
|
- Redis 7 — caché de consultas
|
||||||
|
- React 18 + TypeScript + Tailwind — frontend
|
||||||
### Ingesta y Descubrimiento (Go)
|
- nginx — proxy inverso + archivos estáticos
|
||||||
| Servicio | Tecnología | Descripción |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| **`rss-ingestor-go`** | Go | Crawler de alto rendimiento para feeds RSS (100 workers). |
|
|
||||||
| **`scraper`** | Go | Scraper profundo con sanitización de HTML y extracción de texto. |
|
|
||||||
| **`discovery`** | Go | Agente autónomo para descubrir nuevos feeds a partir de URLs. |
|
|
||||||
|
|
||||||
### Procesamiento de Datos e IA (Go & Python)
|
|
||||||
| Servicio | Tecnología | Descripción |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| **`translator`** | NLLB-200 (CPU) | Traducción neuronal optimizada con CTranslate2. |
|
|
||||||
| **`translator-gpu`** | NLLB-200 (GPU) | Traducción acelerada por hardware (CUDA). |
|
|
||||||
| **`remote-translator`** | WebSocket | Worker GPU remoto (conexión vía ws://backend-go:8080/ws/worker). |
|
|
||||||
| **`wiki-worker`** | Go | Integración con Wikipedia y gestión de imágenes locales. |
|
|
||||||
| **`embeddings`** | S-Transformers | Generación de vectores para búsqueda semántica (paraphrase-multilingual-MiniLM-L12-v2). |
|
|
||||||
| **`ner`** | Spacy / es_core_news_lg | Reconocimiento de entidades nombradas (NER). |
|
|
||||||
| **`llm-categorizer`** | Ollama / Mistral | Clasificación avanzada mediante modelos de lenguaje. |
|
|
||||||
| **`topics`** | Go | Matcher automático de países y temas predefinidos. |
|
|
||||||
| **`related`** | Go | Motor de detección de noticias relacionadas (similitud coseno). |
|
|
||||||
| **`qdrant-worker`** | Go | Vectorización y búsqueda semántica con Qdrant + Ollama. |
|
|
||||||
| **`cluster`** | Python | Agrupación de noticias por eventos. |
|
|
||||||
| **`langdetect`** | Python | Detección de idioma para routing de traducciones. |
|
|
||||||
| **`translation-scheduler`** | Python | Creador de tareas de traducción. |
|
|
||||||
|
|
||||||
### Capa de Almacenamiento
|
|
||||||
| Servicio | Tecnología | Descripción |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| **`db`** | PostgreSQL 18 | Base de datos relacional principal. |
|
|
||||||
| **`qdrant`** | Qdrant | Base de datos vectorial para búsqueda por similitud. |
|
|
||||||
| **`redis`** | Redis 7 | Colas de mensajes y caché de alto desempeño. |
|
|
||||||
|
|
||||||
### Monitoreo
|
|
||||||
| Servicio | Tecnología | Descripción |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| **`prometheus`** | Prometheus | Métricas del sistema. |
|
|
||||||
| **`grafana`** | Grafana | Dashboard (puerto **3001**). |
|
|
||||||
| **`cadvisor`** | cAdvisor | Monitoreo Docker. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Endpoints de API
|
## Inicio rápido (POC local)
|
||||||
|
|
||||||
### News
|
Prueba COCONEWS en tu máquina en ~2 minutos con datos de muestra, sin instalar los modelos ML.
|
||||||
- `GET /api/news` - Listar noticias (paginado, filtros: q, category_id, country_id, translated_only)
|
|
||||||
- `GET /api/news/:id` - Ver noticia con entidades (incluye Wikipedia enrichment)
|
|
||||||
- `DELETE /api/news/:id` - Eliminar noticia (admin)
|
|
||||||
|
|
||||||
### Feeds
|
**Requisitos mínimos para el POC:**
|
||||||
- `GET /api/feeds` - Listar feeds
|
- Go 1.25+
|
||||||
- `POST /api/feeds` - Crear feed (auth requerido)
|
- Node.js 18+
|
||||||
- `PUT /api/feeds/:id` - Actualizar feed (auth requerido)
|
- PostgreSQL (corriendo)
|
||||||
- `DELETE /api/feeds/:id` - Eliminar feed (auth requerido)
|
- Redis (corriendo)
|
||||||
- `POST /api/feeds/:id/toggle` - Activar/desactivar feed
|
|
||||||
- `POST /api/feeds/:id/reactivate` - Reactivar feed
|
|
||||||
|
|
||||||
### Search
|
|
||||||
- `GET /api/search?q=...` - Búsqueda texto (filtros: lang, categoria_id, pais_id)
|
|
||||||
- `GET /api/search?q=...&semantic=true` - Búsqueda semántica con Qdrant
|
|
||||||
|
|
||||||
### Entities
|
|
||||||
- `GET /api/entities?tipo=persona|organizacion|lugar` - Listar entidades (con aliasing)
|
|
||||||
|
|
||||||
### Admin
|
|
||||||
- `GET /api/admin/backup` - Backup SQL completo (descargable)
|
|
||||||
- `GET /api/admin/backup/news` - Backup noticias (ZIP)
|
|
||||||
- `GET /api/admin/users` - Listar usuarios
|
|
||||||
- `POST /api/admin/users/:id/promote` - Promover a admin
|
|
||||||
- `POST /api/admin/users/:id/demote` - Quitar admin
|
|
||||||
- `POST /api/admin/reset-db` - Resetear base de datos
|
|
||||||
- `POST /api/admin/aliases` - Crear alias de entidad
|
|
||||||
- `GET /api/admin/aliases/export` - Exportar aliases (CSV)
|
|
||||||
- `POST /api/admin/aliases/import` - Importar aliases (CSV)
|
|
||||||
- `POST /api/admin/entities/retype` - Cambiar tipo de entidad
|
|
||||||
- `POST /api/admin/workers/config` - Configurar workers (type, workers)
|
|
||||||
- `POST /api/admin/workers/start` - Iniciar workers traducción
|
|
||||||
- `POST /api/admin/workers/stop` - Detener workers traducción
|
|
||||||
- `GET /api/admin/workers/status` - Estado de workers
|
|
||||||
- `GET /api/admin/workers/remote` - Listar remote workers
|
|
||||||
- `POST /api/admin/workers/remote` - Crear remote worker
|
|
||||||
- `DELETE /api/admin/workers/remote/:id` - Eliminar remote worker
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
- `POST /api/auth/login` - Iniciar sesión
|
|
||||||
- `POST /api/auth/register` - Registrarse
|
|
||||||
- `GET /api/auth/me` - Usuario actual (auth requerido)
|
|
||||||
- `GET /api/auth/check-first-user` - Verificar si existe primer usuario
|
|
||||||
|
|
||||||
### Stats
|
|
||||||
- `GET /api/stats` - Estadísticas globales (total news, feeds, users, hoy/semana/mes)
|
|
||||||
- `GET /api/categories` - Listar categorías
|
|
||||||
- `GET /api/countries` - Listar países con continentes
|
|
||||||
|
|
||||||
### WebSocket
|
|
||||||
- `GET /ws/worker` - Conexión de remote workers (protocolo de autenticación con API key)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗄️ Esquema de Base de Datos (Tablas Principales)
|
|
||||||
|
|
||||||
- **noticias**: Artículos RSS (titulo, resumen, url, feed_id, categoria_id, pais_id, lang)
|
|
||||||
- **feeds**: Fuentes RSS (nombre, url, activo, ultimo_fetch)
|
|
||||||
- **traducciones**: Traducciones (noticia_id, lang_from, lang_to, titulo_trad, resumen_trad, status, vectorized)
|
|
||||||
- **tags**: Entidades (valor, tipo: persona/organizacion/lugar/tema, wiki_summary, wiki_url, image_path)
|
|
||||||
- **tags_noticia**: Relación noticia-tag (traduccion_id, noticia_id, tag_id)
|
|
||||||
- **entity_aliases**: Alias de entidades (canonical_name, alias, tipo)
|
|
||||||
- **categorias**: Categorías de noticias
|
|
||||||
- **paises**: Países con continentes
|
|
||||||
- **related_noticias**: Noticias relacionadas (traduccion_id, related_traduccion_id, score)
|
|
||||||
- **traduccion_embeddings**: Embeddings en BD (traduccion_id, model, embedding)
|
|
||||||
- **users**: Usuarios (email, username, password_hash, is_admin)
|
|
||||||
- **config**: Configuración del sistema (translator_type, translator_workers, translator_status)
|
|
||||||
- **remote_workers**: Workers remotos (name, api_key, capabilities, status, last_seen)
|
|
||||||
- **favoritos**: Noticias favoritas de usuarios
|
|
||||||
- **search_history**: Historial de búsquedas
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Guía de Configuración
|
|
||||||
|
|
||||||
### 1. Requisitos de Hardware
|
|
||||||
* **Modo Básico (CPU)**: 4+ Cores CPU, 8GB RAM.
|
|
||||||
* **Modo Avanzado (IA)**: NVIDIA GPU con 8GB+ VRAM (mínimo recomendado para LLM y Traducción GPU).
|
|
||||||
|
|
||||||
### 2. Instalación Rápida con Seguridad Automática
|
|
||||||
|
|
||||||
#### Opción A: Despliegue con Validación Automática (RECOMENDADO)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Paso 1: Clonar el proyecto
|
git clone https://gitea.laenre.net/pietre/rss2.git coconews
|
||||||
git clone <repo_url>
|
cd coconews
|
||||||
cd rss2
|
git checkout coconews
|
||||||
|
|
||||||
# Paso 2: Generar credenciales seguras automáticamente
|
bash poc/poc.sh
|
||||||
./pre-deploy.sh --generate
|
|
||||||
|
|
||||||
# Paso 3: Validar y desplegar
|
|
||||||
./pre-deploy.sh
|
|
||||||
|
|
||||||
# Paso 4: Si eliges opción 2 en el prompt, despliega manualmente
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ventajas:**
|
Abre `http://127.0.0.1:18001` en el navegador.
|
||||||
- ✅ Genera credenciales seguras de 32 caracteres
|
Login: **admin** / **admin123** (creado automáticamente si tienes `pip3 install bcrypt`).
|
||||||
- ✅ Valida que no haya contraseñas por defecto
|
|
||||||
- ✅ Muestra resumen de credenciales en consola
|
|
||||||
- ✅ Automático y seguro para producción
|
|
||||||
|
|
||||||
#### Opción B: Despliegue Manual (Para desarrolladores)
|
Guía detallada para explorar la demo: [docs/POC_GUIDE.md](docs/POC_GUIDE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instalación en servidor Debian
|
||||||
|
|
||||||
|
Para un despliegue completo con todos los workers ML en producción:
|
||||||
|
|
||||||
|
### 1. Instalar prerequisites
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo_url>
|
sudo bash deploy/debian/prerequisites.sh
|
||||||
cd rss2
|
```
|
||||||
|
|
||||||
# Generar credenciales manualmente
|
Instala: PostgreSQL 16, Redis, nginx, Go 1.25, Node.js 20, Qdrant, Python 3 venv.
|
||||||
./generate_secure_credentials.sh
|
Pregunta si instalar los modelos ML pesados ahora o después.
|
||||||
|
|
||||||
# Copiar credenciales a .env
|
### 2. Configurar entorno
|
||||||
cp .env.generated .env
|
|
||||||
|
|
||||||
# Desplegar
|
```bash
|
||||||
docker compose up -d
|
cp deploy/debian/env.example /opt/rss2/.env
|
||||||
|
nano /opt/rss2/.env # edita contraseñas y SECRET_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Instalar y arrancar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deploy/debian/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Compila los binarios Go, el frontend React, crea los servicios systemd y arranca todo.
|
||||||
|
|
||||||
|
### Acceder
|
||||||
|
|
||||||
|
```
|
||||||
|
http://IP_DEL_SERVIDOR:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
Guía completa: [docs/DEPLOY_DEBIAN.md](docs/DEPLOY_DEBIAN.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gestión de servicios
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Estado general
|
||||||
|
systemctl status rss2-backend rss2-ingestor rss2-translator
|
||||||
|
|
||||||
|
# Logs en tiempo real
|
||||||
|
journalctl -u rss2-backend -f
|
||||||
|
journalctl -u rss2-translator -f
|
||||||
|
|
||||||
|
# Reiniciar tras actualizar código
|
||||||
|
git pull
|
||||||
|
sudo bash deploy/debian/build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔐 Despliegue con Seguridad Automática (RECOMENDADO)
|
## Actualizar el código
|
||||||
|
|
||||||
### 🛡️ Sistema de Validación Pre-Despliegue
|
|
||||||
|
|
||||||
RSS2 incluye `pre-deploy.sh`, un script inteligente que se ejecuta **antes** de `docker compose` para:
|
|
||||||
|
|
||||||
1. ✅ **Validar credenciales**: Asegura que `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, `DB_PASS` estén definidas
|
|
||||||
2. ✅ **Generar automáticamente**: Crea credenciales seguras de 32 caracteres si faltan
|
|
||||||
3. ✅ **Prevenir errores**: Evita que los WARN de Docker por variables vacías
|
|
||||||
4. ✅ **Mostrar resumen**: Te muestra todas las credenciales en consola
|
|
||||||
|
|
||||||
### 🚀 Despliegue Rápido y Seguro (3 Comandos)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Generar credenciales seguras automáticamente
|
cd /ruta/al/repo
|
||||||
./pre-deploy.sh --generate
|
git pull
|
||||||
|
sudo bash deploy/debian/build.sh
|
||||||
# 2. Validar y desplegar (elige opción 2 para despliegue manual)
|
|
||||||
./pre-deploy.sh
|
|
||||||
|
|
||||||
# 3. Alternativa: Despliegue manual después de validar
|
|
||||||
./pre-deploy.sh # ← Elige "2) Solo validar y salir"
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Flujo recomendado:**
|
`build.sh` recompila los binarios Go, el frontend y sincroniza los workers Python, y reinicia los servicios automáticamente.
|
||||||
```bash
|
|
||||||
./pre-deploy.sh --generate # Genera .env con credenciales seguras
|
|
||||||
./pre-deploy.sh # Valida y te pregunta si quieres desplegar
|
|
||||||
# Si eliges "2): Solo validar", luego:
|
|
||||||
docker compose up -d # Despliega manualmente
|
|
||||||
```
|
|
||||||
|
|
||||||
### 📋 Opciones del Script `pre-deploy.sh`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Validar credenciales existentes
|
|
||||||
./pre-deploy.sh
|
|
||||||
|
|
||||||
# Generar credenciales seguras automáticamente
|
|
||||||
./pre-deploy.sh --generate
|
|
||||||
|
|
||||||
# Saltar validación (NO RECOMENDADO para producción)
|
|
||||||
./pre-deploy.sh --skip
|
|
||||||
|
|
||||||
# Ver ayuda
|
|
||||||
./pre-deploy.sh --help
|
|
||||||
```
|
|
||||||
|
|
||||||
**Salida típica del script:**
|
|
||||||
```
|
|
||||||
╔═══════════════════════════════════════════════════════════╗
|
|
||||||
║ 🔐 Pre-Deploy Security Check - RSS2 Platform ║
|
|
||||||
╚═══════════════════════════════════════════════════════════╝
|
|
||||||
|
|
||||||
✅ Archivo .env encontrado
|
|
||||||
✅ DEFINIDO: POSTGRES_PASSWORD=8x7f2k9m4p1q3w5e
|
|
||||||
✅ DEFINIDO: REDIS_PASSWORD=a9b8c7d6e5f4g3h2
|
|
||||||
✅ DEFINIDO: DB_PASS=8x7f2k9m4p1q3w5e
|
|
||||||
|
|
||||||
📋 Resumen de Credenciales Activas
|
|
||||||
POSTGRES_PASSWORD: ✓
|
|
||||||
REDIS_PASSWORD: ✓
|
|
||||||
DB_PASS: ✓
|
|
||||||
|
|
||||||
✅ VALIDACIÓN COMPLETADA - LISTO PARA DESPLEGAR
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔒 Variables Críticas que Requieren Contraseñas
|
|
||||||
|
|
||||||
| Variable | Descripción | Uso |
|
|
||||||
|----------|-------------|-----|
|
|
||||||
| `POSTGRES_PASSWORD` | Contraseña de PostgreSQL | Base de datos |
|
|
||||||
| `REDIS_PASSWORD` | Contraseña de Redis | Cache y colas |
|
|
||||||
| `DB_PASS` | Contraseña para workers | Conexiones de workers |
|
|
||||||
| `SECRET_KEY` | Key secreta de la aplicación | JWT, encriptación |
|
|
||||||
| `GRAFANA_PASSWORD` | Contraseña de Grafana | Dashboard de monitoring |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. Escalado de Workers GPU (¡Importante!)
|
## Requisitos de hardware
|
||||||
|
|
||||||
#### 🚀 Workers de Traducción GPU Multi-Instancia
|
| Modo | CPU | RAM | Disco |
|
||||||
|
|------|-----|-----|-------|
|
||||||
RSS2 soporta múltiples workers de traducción ejecutándose en paralelo utilizando GPUs, lo que acelera significativamente el proceso de traducción masiva.
|
| POC local | 2 cores | 4 GB | 10 GB |
|
||||||
|
| Producción CPU | 4+ cores | 8 GB | 40 GB |
|
||||||
**Arquitectura Multi-Worker:**
|
| Producción recomendado | 8 cores | 16 GB | 80 GB |
|
||||||
- Cada worker es una instancia independiente del servicio `ctranslator_worker`
|
|
||||||
- Los workers compiten automáticamente por las traducciones pendientes usando `SELECT ... FOR UPDATE SKIP LOCKED`
|
|
||||||
- Cada worker procesa su propio lote (batch) de traducciones sin bloquearse mutuamente
|
|
||||||
- Soporta hasta 8 workers simultáneos (configurable vía panel admin)
|
|
||||||
|
|
||||||
**Requisitos GPU:**
|
|
||||||
```bash
|
|
||||||
# Verificar GPU disponibles
|
|
||||||
nvidia-smi
|
|
||||||
|
|
||||||
# Instalar drivers NVIDIA (si no los tienes)
|
|
||||||
# Ubuntu/Debian:
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y nvidia-driver-535
|
|
||||||
```
|
|
||||||
|
|
||||||
**Despliegue con Múltiples Workers GPU:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Opción 1: Desplegar 1 worker GPU (mínimo recomendado)
|
|
||||||
docker compose up -d --scale translator-gpu=1
|
|
||||||
|
|
||||||
# Opción 2: Desplegar 2 workers GPU (recomendado: 16GB+ VRAM total)
|
|
||||||
docker compose up -d --scale translator-gpu=2
|
|
||||||
|
|
||||||
# Opción 3: Desplegar 4 workers GPU (requiere: 32GB+ VRAM total)
|
|
||||||
docker compose up -d --scale translator-gpu=4
|
|
||||||
|
|
||||||
# Opción 4: Combinar CPU y GPU
|
|
||||||
# 2 workers CPU + 2 workers GPU
|
|
||||||
docker compose up -d --scale translator=2 --scale translator-gpu=2
|
|
||||||
```
|
|
||||||
|
|
||||||
**Panel de Administración:**
|
|
||||||
Desde `/admin/settings`, puedes:
|
|
||||||
- Activar modo GPU (CUDA) o CPU
|
|
||||||
- Configurar número de workers (1-8)
|
|
||||||
- Iniciar/detener workers automáticamente
|
|
||||||
|
|
||||||
**Configuración de GPU por Worker:**
|
|
||||||
Cada worker GPU está configurado con:
|
|
||||||
- `CT2_DEVICE=cuda` - Usa dispositivos CUDA
|
|
||||||
- `CT2_COMPUTE_TYPE=float16` - Precisión mixta para velocidad
|
|
||||||
- `TRANSLATOR_BATCH=128` - Tamaño de lote optimizado
|
|
||||||
- `PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:512` - Gestión de memoria PyTorch
|
|
||||||
- `NCCL_DEBUG=INFO` - Debug de comunicación multi-GPU
|
|
||||||
|
|
||||||
**Monitorización:**
|
|
||||||
```bash
|
|
||||||
# Ver estado de workers
|
|
||||||
docker compose --profile gpu ps translator-gpu
|
|
||||||
|
|
||||||
# Ver uso de GPU
|
|
||||||
watch -n 1 nvidia-smi
|
|
||||||
|
|
||||||
# Ver logs en tiempo real
|
|
||||||
docker compose logs -f translator-gpu
|
|
||||||
|
|
||||||
# Métricas en Grafana
|
|
||||||
# Dashboard: Translation Workers
|
|
||||||
# Métricas: translator_*_transitions_per_minute
|
|
||||||
```
|
|
||||||
|
|
||||||
**Performance Esperada:**
|
|
||||||
| Workers GPU | VRAM Total | Tasa de Traducción |
|
|
||||||
|-------------|------------|-------------------|
|
|
||||||
| 1 worker | 8GB+ | ~500 traducciones/min |
|
|
||||||
| 2 workers | 16GB+ | ~1000 traducciones/min |
|
|
||||||
| 4 workers | 32GB+ | ~2000 traducciones/min |
|
|
||||||
| 8 workers | 64GB+ | ~4000 traducciones/min |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛡️ Administración y Mantenimiento
|
## Estructura del repositorio
|
||||||
|
|
||||||
### Copias de Seguridad (Backups)
|
|
||||||
|
|
||||||
Desde el panel de Administración (`/admin/settings`), puedes realizar:
|
|
||||||
|
|
||||||
**Backup Completo (SQL):**
|
|
||||||
- Descarga un archivo `.sql` con todo el contenido de la base de datos
|
|
||||||
- Incluye: noticias, feeds, traducciones, usuarios, etiquetas, favoritos, videos, eventos, historial de búsqueda
|
|
||||||
- Formato: PostgreSQL custom format con datos y estructura
|
|
||||||
- Tamaño típico: 10-100MB dependiendo del volumen de datos
|
|
||||||
|
|
||||||
**Backup de Noticias (ZIP):**
|
|
||||||
- Genera un archivo comprimido `.zip` con:
|
|
||||||
- Tabla `noticias` (artículos completos)
|
|
||||||
- Tabla `traducciones` (todas las traducciones)
|
|
||||||
- Tablas `tags` y `tags_noticia` (metadatos y relaciones)
|
|
||||||
- Ideal para: Migraciones de contenido, exportación de datos, backup ligero
|
|
||||||
- Tamaño típico: 5-50MB (solo contenido de noticias)
|
|
||||||
|
|
||||||
### Variables de Entorno Clave (`.env`)
|
|
||||||
| Variable | Descripción |
|
|
||||||
|----------|-------------|
|
|
||||||
| `WIKI_SLEEP` | Tiempo de espera entre peticiones a Wikipedia (evita bloqueos). |
|
|
||||||
| `SCHEDULER_BATCH`| Cantidad de noticias a enviar a traducir por ciclo. |
|
|
||||||
| `TARGET_LANGS` | Idiomas destino (ej: `es`). |
|
|
||||||
| `OLLAMA_URL` | URL del servidor Ollama para categorización. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Descripción de Workers
|
|
||||||
|
|
||||||
### rss-ingestor-go
|
|
||||||
Crawler RSS de alto rendimiento escrito en Go. Gestiona hasta 100 workers paralelos para procesar múltiples feeds simultáneamente. Detecta nuevos artículos, extrae metadatos básicos y los inserta en la base de datos.
|
|
||||||
|
|
||||||
### scraper (Go)
|
|
||||||
Scraper profundo que:
|
|
||||||
- Descarga el contenido completo de URLs de artículos
|
|
||||||
- Sanitiza HTML removiendo scripts y estilos
|
|
||||||
- Extrae texto limpio del body
|
|
||||||
- Limita el contenido a 20 noticias por ciclo para evitar sobrecarga
|
|
||||||
|
|
||||||
### discovery (Go)
|
|
||||||
Agente de descubrimiento de feeds que:
|
|
||||||
- Analiza URLs proporcionadas para detectar feeds RSS/Atom
|
|
||||||
- Soporta hasta 5 feeds por URL
|
|
||||||
- Ejecuta cada 900 segundos (15 minutos)
|
|
||||||
|
|
||||||
### translator (Python/CTranslate2)
|
|
||||||
Worker de traducción neuronal:
|
|
||||||
- Modelo: facebook/nllb-200-distilled-600M convertido a CTranslate2
|
|
||||||
- Dispositivo: CPU (int8)
|
|
||||||
- Batch: 32 traducciones por ciclo
|
|
||||||
- Locking: `FOR UPDATE SKIP LOCKED` para evitar procesamiento duplicado
|
|
||||||
- Soporta múltiples idiomas fuente
|
|
||||||
|
|
||||||
### translator-gpu (Python/CTranslate2)
|
|
||||||
Versión acelerada por GPU del translator:
|
|
||||||
- Dispositivo: CUDA
|
|
||||||
- Compute type: float16
|
|
||||||
- Batch: 128 traducciones por ciclo
|
|
||||||
- Ideal para procesamiento de alto volumen
|
|
||||||
|
|
||||||
### remote-translator (Python)
|
|
||||||
Worker remoto conectado vía WebSocket:
|
|
||||||
- Autenticación con API key
|
|
||||||
- Conexión a `ws://backend-go:8080/ws/worker`
|
|
||||||
- Modelo: facebook/nllb-200-1.3B (más grande)
|
|
||||||
- GPU requerida
|
|
||||||
|
|
||||||
### langdetect (Python)
|
|
||||||
Detector de idioma:
|
|
||||||
- Usa biblioteca `langdetect`
|
|
||||||
- Proceso: 1000 noticias por ciclo
|
|
||||||
- Establece lang_from para correcto routing a traducción
|
|
||||||
|
|
||||||
### ner (Python/spaCy)
|
|
||||||
Extracción de entidades nombradas:
|
|
||||||
- Modelo: es_core_news_lg
|
|
||||||
- Tipos: PERSON (persona), ORG (organizacion), LOC/GPE (lugar)
|
|
||||||
- También extrae topics (noun-chunks)
|
|
||||||
- Configurable via entity_config.json (blacklist, synonyms)
|
|
||||||
|
|
||||||
### embeddings (Python)
|
|
||||||
Generador de embeddings para búsqueda semántica:
|
|
||||||
- Modelo: paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
- Dispositivo: GPU (cuda)
|
|
||||||
- Genera vectores de 384 dimensiones
|
|
||||||
- Alimenta Qdrant
|
|
||||||
|
|
||||||
### qdrant-worker (Go)
|
|
||||||
Worker de vectorización Qdrant:
|
|
||||||
- Conecta a Qdrant (puerto 6333)
|
|
||||||
- Genera embeddings via Ollama (mxbai-embed-large)
|
|
||||||
- Sube puntos a collection "news_vectors"
|
|
||||||
- Actualiza estado en BD (vectorized=true)
|
|
||||||
|
|
||||||
### related (Go)
|
|
||||||
Detector de noticias relacionadas:
|
|
||||||
- Usa similitud coseno entre embeddings
|
|
||||||
- Almacena top-k relaciones en tabla related_noticias
|
|
||||||
- Soporta configuración de threshold mínimo
|
|
||||||
|
|
||||||
### wiki-worker (Go)
|
|
||||||
Enriquecedor de Wikipedia:
|
|
||||||
- Descarga summaries de Wikipedia API
|
|
||||||
- Descarga thumbnails de imágenes
|
|
||||||
- Almacena en `./data/wiki_images`
|
|
||||||
- Evita rate limiting con WIKI_SLEEP
|
|
||||||
|
|
||||||
### llm-categorizer (Python)
|
|
||||||
Categorizador con LLM:
|
|
||||||
- Conexión a Ollama
|
|
||||||
- Usa modelo Mistral para clasificación
|
|
||||||
- Procesa 10 noticias por batch
|
|
||||||
|
|
||||||
### cluster (Python)
|
|
||||||
Agrupador de noticias por eventos:
|
|
||||||
- Usa embeddings paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
- Threshold: 0.35 de distancia
|
|
||||||
- Agrupa noticias similares
|
|
||||||
|
|
||||||
### translation-scheduler (Python)
|
|
||||||
Creador de tareas de traducción:
|
|
||||||
- Query: noticias sin traducción al español
|
|
||||||
- Crea entradas en tabla traducciones
|
|
||||||
- Batch: 1000 por ciclo
|
|
||||||
- Ciclo: 30 segundos
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Documentación de la API (Campos Wikipedia)
|
|
||||||
|
|
||||||
Las respuestas de noticias ahora incluyen el objeto `entities` enriquecido:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 67449,
|
|
||||||
"titulo": "...",
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"valor": "Apple",
|
|
||||||
"tipo": "organizacion",
|
|
||||||
"wiki_summary": "Apple Inc. es una empresa estadounidense...",
|
|
||||||
"wiki_url": "https://es.wikipedia.org/wiki/Apple",
|
|
||||||
"image_path": "/api/wiki-images/wiki_5723.png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Backend (Go)
|
|
||||||
```bash
|
|
||||||
cd backend && go test ./internal/handlers -v -run TestLogin
|
|
||||||
cd backend && go test ./internal/auth -v -run TestGenerateToken
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
```bash
|
|
||||||
cd frontend && npm run test:ui
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 Estructura de Archivos del Proyecto
|
|
||||||
|
|
||||||
```
|
```
|
||||||
rss2/
|
├── README.md
|
||||||
├── backend/ # API REST Go (Gin)
|
├── requirements.txt Dependencias Python para workers ML
|
||||||
├── frontend/ # Interfaz React + TypeScript
|
├── Makefile Compilación local de binarios Go
|
||||||
├── workers/ # Workers Python
|
│
|
||||||
├── rss-ingestor-go/ # Crawler RSS Go
|
├── backend/ Go — API REST + workers (scraper, discovery, wiki, topics, related, qdrant)
|
||||||
├── docker-compose.yml # Orquestación
|
├── rss-ingestor-go/ Go — Ingestor de feeds RSS
|
||||||
├── nginx.conf # Config Nginx
|
├── frontend/ React + TypeScript + Tailwind
|
||||||
├── monitoring/ # Prometheus + Grafana
|
├── workers/ Python — Workers ML (traducción, embeddings, NER, cluster, categorización)
|
||||||
├── init-db/ # Migraciones SQL
|
│
|
||||||
├── data/ # Datos persistentes
|
├── init-db/ SQL — Schema completo y datos iniciales
|
||||||
├── models/ # Modelos ML
|
├── migrations/ SQL — Migraciones incrementales
|
||||||
├── hf_cache/ # Cache HuggingFace
|
│
|
||||||
├── pre-deploy.sh # Script seguridad
|
├── config/
|
||||||
├── generate_secure_credentials.sh
|
│ └── entity_config.json Aliases y blacklist para normalización de entidades NER
|
||||||
└── AGENTS.md # Guía desarrollo
|
│
|
||||||
|
├── data/
|
||||||
|
│ └── feeds.csv Feeds RSS precargados para importar desde el admin
|
||||||
|
│
|
||||||
|
├── docs/
|
||||||
|
│ ├── DEPLOY_DEBIAN.md Guía detallada de despliegue en Debian
|
||||||
|
│ ├── SECURITY_GUIDE.md Guía de seguridad
|
||||||
|
│ ├── SECURITY_AUDIT.md Resultado del audit de seguridad
|
||||||
|
│ ├── QDRANT_SETUP.md Configuración de Qdrant
|
||||||
|
│ └── ... Resto de documentación técnica
|
||||||
|
│
|
||||||
|
├── scripts/
|
||||||
|
│ ├── generate_secure_credentials.sh
|
||||||
|
│ ├── migrate_to_secure.sh
|
||||||
|
│ └── verify_security.sh
|
||||||
|
│
|
||||||
|
├── deploy/debian/ Despliegue nativo en Debian (sin Docker)
|
||||||
|
│ ├── prerequisites.sh Instala todas las dependencias del sistema
|
||||||
|
│ ├── install.sh Instalación completa
|
||||||
|
│ ├── build.sh Recompila y reinicia tras actualizar código
|
||||||
|
│ ├── check.sh Diagnóstico del sistema
|
||||||
|
│ ├── env.example Plantilla de variables de entorno
|
||||||
|
│ ├── nginx.conf Configuración nginx
|
||||||
|
│ └── systemd/ 16 ficheros de servicio systemd
|
||||||
|
│
|
||||||
|
└── poc/
|
||||||
|
├── poc.sh POC local en 2 minutos (sin Docker, sin ML)
|
||||||
|
└── seed.sql 10 noticias de muestra en español
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**RSS2** - *Transformando noticias en inteligencia con IA localizada.*
|
|
||||||
220
README_DEPLOY.md
220
README_DEPLOY.md
|
|
@ -1,220 +0,0 @@
|
||||||
# 🚀 Despliegue RSS2 - 3 Comandos Simples
|
|
||||||
|
|
||||||
## ⚡ Flujo Rápido (3 pasos)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Generar credenciales seguras
|
|
||||||
./generate_secure_credentials.sh --force
|
|
||||||
|
|
||||||
# 2. Desplegar
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 3. Verificar
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
**¡Listo!** La aplicación estará disponible en `http://localhost:8888`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 ¿Por qué 3 pasos?
|
|
||||||
|
|
||||||
**Problema original:**
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
WARN: DB_PASS no definida → Contraseña vacía → ¡Inseguro!
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solución:**
|
|
||||||
1. `./generate_secure_credentials.sh --force` → Crea `.env` con contraseñas de 32 caracteres
|
|
||||||
2. `docker compose up -d` → Docker carga `.env` automáticamente
|
|
||||||
3. `docker compose ps` → Verifica que todo esté bien
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Qué hace cada comando
|
|
||||||
|
|
||||||
### 1. `./generate_secure_credentials.sh --force`
|
|
||||||
|
|
||||||
- Crea contraseñas seguras de 32 caracteres aleatorios
|
|
||||||
- Genera archivo `.env` con todas las variables necesarias
|
|
||||||
- Guarda backup del `.env` anterior
|
|
||||||
- **Tiempo: 5 segundos**
|
|
||||||
|
|
||||||
**Salida:**
|
|
||||||
```
|
|
||||||
POSTGRES_PASSWORD: S5xwsI9HKjdaBWLpCkqp0mA0DJYZerpD
|
|
||||||
REDIS_PASSWORD: cVF79QuJulPO5auEhA2nNqv7mqAruzYh
|
|
||||||
SECRET_KEY: 2eb7c221245bb8d896f83255188de614e59fae6b8dfbf16eee8a23c60dde4ffa
|
|
||||||
GRAFANA_PASSWORD: R4J61V6OGLTCesrITwEvUPTm
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `docker compose up -d`
|
|
||||||
|
|
||||||
- Lee `.env` automáticamente (está configurado en `docker-compose.yml`)
|
|
||||||
- Inicia todos los servicios en modo detached (background)
|
|
||||||
- Espera a que estén listos
|
|
||||||
- **Tiempo: 2-5 minutos** (primera vez que descarga imágenes)
|
|
||||||
|
|
||||||
### 3. `docker compose ps`
|
|
||||||
|
|
||||||
- Muestra estado de todos los servicios
|
|
||||||
- Deberías ver `Up` para todos
|
|
||||||
- **Tiempo: 1 segundo**
|
|
||||||
|
|
||||||
**Salida esperada:**
|
|
||||||
```
|
|
||||||
Name Status
|
|
||||||
rss2_db Up (healthy)
|
|
||||||
rss2_redis Up (healthy)
|
|
||||||
rss2_backend_go Up (running)
|
|
||||||
rss2_nginx Up (running)
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Comandos Útiles
|
|
||||||
|
|
||||||
### Verificar credenciales
|
|
||||||
```bash
|
|
||||||
grep -E "^(POSTGRES_PASSWORD|REDIS_PASSWORD|DB_PASS)=" .env
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ver logs en tiempo real
|
|
||||||
```bash
|
|
||||||
docker compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reiniciar servicios
|
|
||||||
```bash
|
|
||||||
# Todos
|
|
||||||
docker compose restart
|
|
||||||
|
|
||||||
# Solo base de datos
|
|
||||||
docker compose restart db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verificar estado
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
### Limpiar todo (PERDERÁS DATOS)
|
|
||||||
```bash
|
|
||||||
docker compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Escalar workers
|
|
||||||
```bash
|
|
||||||
# Más traductores CPU
|
|
||||||
docker compose up -d --scale translator=3
|
|
||||||
|
|
||||||
# Más traductores GPU
|
|
||||||
docker compose up -d --scale translator-gpu=2
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔒 Seguridad
|
|
||||||
|
|
||||||
### Contraseñas por defecto = PELIGRO ❌
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# MAL - Contraseñas vacías
|
|
||||||
docker compose up -d
|
|
||||||
WARN: DB_PASS no set
|
|
||||||
# Las contraseñas están vacías
|
|
||||||
```
|
|
||||||
|
|
||||||
### Contraseñas generadas = SEGURO ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# BIEN
|
|
||||||
./generate_secure_credentials.sh --force
|
|
||||||
docker compose up -d
|
|
||||||
# Contraseñas de 32 caracteres aleatorios
|
|
||||||
```
|
|
||||||
|
|
||||||
### Guardar tus credenciales
|
|
||||||
|
|
||||||
**Opción 1: Gestor de contraseñas (Recomendado)**
|
|
||||||
```bash
|
|
||||||
# Copia las contraseñas que muestra el script
|
|
||||||
# Guárdalas en: LastPass, 1Password, KeePass, Bitwarden
|
|
||||||
```
|
|
||||||
|
|
||||||
**Opción 2: Archivo seguro local**
|
|
||||||
```bash
|
|
||||||
# Crea un archivo fuera del proyecto
|
|
||||||
echo "POSTGRES_PASSWORD=..." > ~/rss2-credentials.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Estado de los Servicios
|
|
||||||
|
|
||||||
| Servicio | Puerto | Descripción |
|
|
||||||
|----------|--------|-------------|
|
|
||||||
| `nginx` | 8888 | Web app |
|
|
||||||
| `backend-go` | 8080 | API |
|
|
||||||
| `db` | 5432 | PostgreSQL |
|
|
||||||
| `redis` | 6379 | Cache |
|
|
||||||
| `qdrant` | 6333 | Vector DB |
|
|
||||||
|
|
||||||
**DASHBOARDS:**
|
|
||||||
- Grafana: http://127.0.0.1:3001
|
|
||||||
- Prometheus: http://127.0.0.1:9090
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Solución de Problemas
|
|
||||||
|
|
||||||
### WARN sobre contraseñas vacías
|
|
||||||
```bash
|
|
||||||
# Solución: Generar credenciales
|
|
||||||
./generate_secure_credentials.sh --force
|
|
||||||
```
|
|
||||||
|
|
||||||
### Port 8888 en uso
|
|
||||||
```bash
|
|
||||||
# Cambiar puerto en nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
### Base de datos no inicia
|
|
||||||
```bash
|
|
||||||
# Ver logs
|
|
||||||
docker compose logs db
|
|
||||||
|
|
||||||
# Reiniciar
|
|
||||||
docker compose restart db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Redis autenticación fallida
|
|
||||||
```bash
|
|
||||||
# Ver REDIS_PASSWORD
|
|
||||||
grep REDIS_PASSWORD .env
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Checklist Final
|
|
||||||
|
|
||||||
Antes de considerar tu despliegue listo:
|
|
||||||
|
|
||||||
- [ ] `.env` tiene contraseñas generadas (no por defecto)
|
|
||||||
- [ ] `docker compose ps` muestra todos "Up"
|
|
||||||
- [ ] `http://localhost:8888` abre la app
|
|
||||||
- [ ] `curl http://localhost:8888/api/health` responde
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Documentación
|
|
||||||
|
|
||||||
- [README.md](./README.md) - Documentación completa
|
|
||||||
- [DEPLOY.md](./DEPLOY.md) - Guía detallada
|
|
||||||
- [SECURITY_GUIDE.md](./SECURITY_GUIDE.md) - Seguridad
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**RSS2** - *Transformando noticias en inteligencia con IA* 🚀
|
|
||||||
128
alias.sh
128
alias.sh
|
|
@ -1,128 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Aliases y funciones para despliegue automático de RSS2
|
|
||||||
# Copia este archivo y ejecútalo: source alias.sh
|
|
||||||
|
|
||||||
# ========================================
|
|
||||||
# FUNCIONES DE VALIDACIÓN AUTOMÁTICA
|
|
||||||
# ========================================
|
|
||||||
|
|
||||||
# Validar credenciales críticas
|
|
||||||
validate_creds() {
|
|
||||||
# Verificar si .env existe
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "🔑 Generando credenciales seguras..."
|
|
||||||
./generate_secure_credentials.sh --force
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verificar contraseñas vacías
|
|
||||||
for var in POSTGRES_PASSWORD REDIS_PASSWORD DB_PASS; do
|
|
||||||
val=$(grep "^${var}=" .env 2>/dev/null | cut -d'=' -f2 | tr -d ' ')
|
|
||||||
if [ -z "$val" ] || [ "$val" = "change_*" ] || [ "$val" = "" ]; then
|
|
||||||
echo "🔑 $var está vacía, generando..."
|
|
||||||
./generate_secure_credentials.sh --force
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "✅ Credenciales OK"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========================================
|
|
||||||
# ALIASES PRINCIPALES
|
|
||||||
# ========================================
|
|
||||||
|
|
||||||
# Alias principal: docker compose con validación automática
|
|
||||||
alias dc="docker compose"
|
|
||||||
alias dcu="docker_compose_up" # docker compose up
|
|
||||||
alias dcd="docker_compose_down" # docker compose down
|
|
||||||
|
|
||||||
# Alias con validación automática
|
|
||||||
docker_compose_up() {
|
|
||||||
echo "🔐 Validando credenciales..."
|
|
||||||
validate_creds
|
|
||||||
|
|
||||||
echo "🚀 docker compose up -d"
|
|
||||||
dc up -d
|
|
||||||
}
|
|
||||||
|
|
||||||
docker_compose_down() {
|
|
||||||
echo "📦 docker compose down -v"
|
|
||||||
dc down -v
|
|
||||||
}
|
|
||||||
|
|
||||||
# Alias para despliegue completo con validación
|
|
||||||
deploy() {
|
|
||||||
echo "================================"
|
|
||||||
echo "🚀 DESPLIEGUE AUTOMÁTICO RSS2"
|
|
||||||
echo "================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "1. 🔐 Validando credenciales..."
|
|
||||||
validate_creds
|
|
||||||
|
|
||||||
echo "2. 📦 Deteniendo y eliminando..."
|
|
||||||
dc down -v 2>/dev/null || true
|
|
||||||
|
|
||||||
echo "3. 🚀 Iniciando servicios..."
|
|
||||||
dc up -d --build
|
|
||||||
|
|
||||||
echo "4. ✅ Verificando estado..."
|
|
||||||
dc ps
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "================================"
|
|
||||||
echo "✅ ¡Despliegue completado!"
|
|
||||||
echo "🌐 App: http://localhost:8888"
|
|
||||||
echo "================================"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Alias para despliegue rápido sin rebuild
|
|
||||||
deploy-quick() {
|
|
||||||
echo "🚀 Despliegue rápido (sin rebuild)..."
|
|
||||||
dc up -d
|
|
||||||
|
|
||||||
echo "📊 Estado:"
|
|
||||||
dc ps
|
|
||||||
}
|
|
||||||
|
|
||||||
# Alias para ver logs
|
|
||||||
tail-logs() {
|
|
||||||
echo "📊 Logs en tiempo real..."
|
|
||||||
dc logs -f
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========================================
|
|
||||||
# FUNCIONES DE UTILIDAD
|
|
||||||
# ========================================
|
|
||||||
|
|
||||||
# Generar credenciales
|
|
||||||
generate-secrets() {
|
|
||||||
echo "🔑 Generando credenciales seguras..."
|
|
||||||
./generate_secure_credentials.sh --force
|
|
||||||
}
|
|
||||||
|
|
||||||
# Backup
|
|
||||||
backup() {
|
|
||||||
echo "📦 Creando backup..."
|
|
||||||
dc exec db pg_dump -U rss rss > backup_$(date +%Y%m%d).sql
|
|
||||||
echo "✅ Backup guardado en ./backup_$(date +%Y%m%d).sql"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========================================
|
|
||||||
# MENSAJE DE BIENVENIDA
|
|
||||||
# ========================================
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
echo "🎉 RSS2 Deployment Aliases cargadas"
|
|
||||||
echo ""
|
|
||||||
echo "Comandos disponibles:"
|
|
||||||
echo " dc docker compose"
|
|
||||||
echo " dcu docker compose up (con validación)"
|
|
||||||
echo " dcd docker compose down"
|
|
||||||
echo " deploy Despliegue completo con validación"
|
|
||||||
echo " deploy-quick Despliegue rápido"
|
|
||||||
echo " tail-logs Ver logs en tiempo real"
|
|
||||||
echo " generate-secrets Generar credenciales"
|
|
||||||
echo " backup Crear backup"
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
FROM golang:1.23 AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y gcc musl-dev git
|
|
||||||
|
|
||||||
COPY go.mod ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN go mod tidy
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o /server ./cmd/server
|
|
||||||
|
|
||||||
FROM alpine:3.23
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata postgresql-client
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=builder /server .
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
CMD ["./server"]
|
|
||||||
|
|
@ -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,8 +35,7 @@ type URLSource struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logLevel := os.Getenv("LOG_LEVEL")
|
logger = log.New(os.Stdout, "[DISCOVERY] ", log.LstdFlags)
|
||||||
logger.Init("discovery-worker", logLevel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() {
|
func loadConfig() {
|
||||||
|
|
@ -417,70 +416,48 @@ func processURLSource(ctx context.Context, source URLSource) {
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
logger.Info().Msg("Starting RSS Discovery Worker")
|
logger.Println("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.Fatal().Err(err).Msg("Failed to connect to database")
|
logger.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
dbPool = workers.GetPool()
|
dbPool = workers.GetPool()
|
||||||
defer workers.Close()
|
defer workers.Close()
|
||||||
|
|
||||||
logger.Info().Msg("Connected to PostgreSQL")
|
logger.Println("Connected to PostgreSQL")
|
||||||
|
|
||||||
// Start health check HTTP server
|
ctx := context.Background()
|
||||||
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.Info().Msg("Shutting down gracefully...")
|
logger.Println("Shutting down...")
|
||||||
cancel()
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
workers.Close()
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logger.Info().Msgf("Config: interval=%ds, batch=%d", sleepSec, batchSize)
|
logger.Printf("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.Error().Err(err).Msg("Error fetching URLs")
|
logger.Printf("Error fetching URLs: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
logger.Info().Msg("No pending URLs to process")
|
logger.Println("No pending URLs to process")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info().Msgf("Processing %d sources", len(sources))
|
logger.Printf("Processing %d sources", len(sources))
|
||||||
|
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
processURLSource(ctx, source)
|
processURLSource(ctx, source)
|
||||||
|
|
|
||||||
|
|
@ -6,31 +6,31 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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
|
||||||
|
ollamaURL string
|
||||||
collection = "news_vectors"
|
collection = "news_vectors"
|
||||||
sleepSec = 30
|
sleepSec = 30
|
||||||
batchSize = 100
|
batchSize = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logLevel := os.Getenv("LOG_LEVEL")
|
logger = log.New(os.Stdout, "[QDRANT] ", log.LstdFlags)
|
||||||
logger.Init("qdrant-worker", logLevel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() {
|
func loadConfig() {
|
||||||
|
|
@ -39,6 +39,7 @@ func loadConfig() {
|
||||||
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)
|
||||||
|
ollamaURL = getEnv("OLLAMA_URL", "http://ollama:11434")
|
||||||
collection = getEnv("QDRANT_COLLECTION", "news_vectors")
|
collection = getEnv("QDRANT_COLLECTION", "news_vectors")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,7 +61,7 @@ func getEnvInt(key string,defaultValue int) int {
|
||||||
|
|
||||||
type Translation struct {
|
type Translation struct {
|
||||||
ID int64
|
ID int64
|
||||||
NoticiaID string
|
NoticiaID int64
|
||||||
Lang string
|
Lang string
|
||||||
Titulo string
|
Titulo string
|
||||||
Resumen string
|
Resumen string
|
||||||
|
|
@ -69,7 +70,6 @@ type Translation struct {
|
||||||
FuenteNombre string
|
FuenteNombre string
|
||||||
CategoriaID *int64
|
CategoriaID *int64
|
||||||
PaisID *int64
|
PaisID *int64
|
||||||
Embedding []float64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPendingTranslations(ctx context.Context) ([]Translation, error) {
|
func getPendingTranslations(ctx context.Context) ([]Translation, error) {
|
||||||
|
|
@ -84,11 +84,9 @@ func getPendingTranslations(ctx context.Context) ([]Translation, error) {
|
||||||
n.fecha,
|
n.fecha,
|
||||||
n.fuente_nombre,
|
n.fuente_nombre,
|
||||||
n.categoria_id,
|
n.categoria_id,
|
||||||
n.pais_id,
|
n.pais_id
|
||||||
te.embedding::text
|
|
||||||
FROM traducciones t
|
FROM traducciones t
|
||||||
INNER JOIN noticias n ON t.noticia_id = n.id
|
INNER JOIN noticias n ON t.noticia_id = n.id
|
||||||
INNER JOIN traduccion_embeddings te ON te.traduccion_id = t.id
|
|
||||||
WHERE t.vectorized = FALSE
|
WHERE t.vectorized = FALSE
|
||||||
AND t.status = 'done'
|
AND t.status = 'done'
|
||||||
ORDER BY t.created_at ASC
|
ORDER BY t.created_at ASC
|
||||||
|
|
@ -102,41 +100,54 @@ func getPendingTranslations(ctx context.Context) ([]Translation, error) {
|
||||||
var translations []Translation
|
var translations []Translation
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t Translation
|
var t Translation
|
||||||
var embText string
|
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&t.ID, &t.NoticiaID, &t.Lang, &t.Titulo, &t.Resumen,
|
&t.ID, &t.NoticiaID, &t.Lang, &t.Titulo, &t.Resumen,
|
||||||
&t.URL, &t.Fecha, &t.FuenteNombre, &t.CategoriaID, &t.PaisID,
|
&t.URL, &t.Fecha, &t.FuenteNombre, &t.CategoriaID, &t.PaisID,
|
||||||
&embText,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Printf("Error scanning translation row: %v", err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
emb, err := parseEmbedding(embText)
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("Error parsing embedding for translation %d: %v", t.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
t.Embedding = emb
|
|
||||||
translations = append(translations, t)
|
translations = append(translations, t)
|
||||||
}
|
}
|
||||||
return translations, nil
|
return translations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseEmbedding(s string) ([]float64, error) {
|
type EmbeddingRequest struct {
|
||||||
s = strings.Trim(strings.TrimSpace(s), "{}")
|
Model string `json:"model"`
|
||||||
if s == "" {
|
Input string `json:"input"`
|
||||||
return nil, fmt.Errorf("empty embedding")
|
|
||||||
}
|
}
|
||||||
parts := strings.Split(s, ",")
|
|
||||||
emb := make([]float64, len(parts))
|
type EmbeddingResponse struct {
|
||||||
for i, p := range parts {
|
Embedding []float64 `json:"embedding"`
|
||||||
v, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
|
}
|
||||||
|
|
||||||
|
func generateEmbedding(text string) ([]float64, error) {
|
||||||
|
reqBody := EmbeddingRequest{
|
||||||
|
Model: "mxbai-embed-large",
|
||||||
|
Input: text,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
emb[i] = v
|
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
resp, err := client.Post(ollamaURL+"/api/embeddings", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return emb, nil
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Ollama returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result EmbeddingResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Embedding, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type QdrantPoint struct {
|
type QdrantPoint struct {
|
||||||
|
|
@ -149,7 +160,7 @@ type QdrantUpsertRequest struct {
|
||||||
Points []QdrantPoint `json:"points"`
|
Points []QdrantPoint `json:"points"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureCollection(ctx context.Context) error {
|
func ensureCollection() error {
|
||||||
req, err := http.NewRequest("GET", qdrantURL+"/collections/"+collection, nil)
|
req, err := http.NewRequest("GET", qdrantURL+"/collections/"+collection, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -166,15 +177,16 @@ func ensureCollection(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get embedding dimension from stored embeddings
|
// Get embedding dimension
|
||||||
var dimension int
|
emb, err := generateEmbedding("test")
|
||||||
err = dbPool.QueryRow(ctx, `SELECT dim FROM traduccion_embeddings LIMIT 1`).Scan(&dimension)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get embedding dimension: %w", err)
|
return fmt.Errorf("failed to get embedding dimension: %w", err)
|
||||||
}
|
}
|
||||||
|
dimension := len(emb)
|
||||||
|
|
||||||
// Create collection
|
// Create collection
|
||||||
createReq := map[string]interface{}{
|
createReq := map[string]interface{}{
|
||||||
|
"name": collection,
|
||||||
"vectors": map[string]interface{}{
|
"vectors": map[string]interface{}{
|
||||||
"size": dimension,
|
"size": dimension,
|
||||||
"distance": "Cosine",
|
"distance": "Cosine",
|
||||||
|
|
@ -182,35 +194,26 @@ func ensureCollection(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := json.Marshal(createReq)
|
body, _ := json.Marshal(createReq)
|
||||||
createURL := qdrantURL + "/collections/" + collection
|
resp2, err := http.Post(qdrantURL+"/collections", "application/json", bytes.NewReader(body))
|
||||||
req2, err := http.NewRequest(http.MethodPut, createURL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req2.Header.Set("Content-Type", "application/json")
|
|
||||||
resp2, err := http.DefaultClient.Do(req2)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp2.Body.Close()
|
defer resp2.Body.Close()
|
||||||
|
|
||||||
if resp2.StatusCode != http.StatusOK {
|
|
||||||
respBody, _ := io.ReadAll(resp2.Body)
|
|
||||||
return fmt.Errorf("Qdrant create collection returned %d: %s", resp2.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Printf("Created collection %s with dimension %d", collection, dimension)
|
logger.Printf("Created collection %s with dimension %d", collection, dimension)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadToQdrant(translations []Translation, pointIDs []string) error {
|
func uploadToQdrant(translations []Translation, embeddings [][]float64) error {
|
||||||
points := make([]QdrantPoint, 0, len(translations))
|
points := make([]QdrantPoint, 0, len(translations))
|
||||||
|
|
||||||
for i, t := range translations {
|
for i, t := range translations {
|
||||||
if t.Embedding == nil || len(t.Embedding) == 0 {
|
if embeddings[i] == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pointID := uuid.New().String()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"news_id": t.NoticiaID,
|
"news_id": t.NoticiaID,
|
||||||
"traduccion_id": t.ID,
|
"traduccion_id": t.ID,
|
||||||
|
|
@ -232,8 +235,8 @@ func uploadToQdrant(translations []Translation, pointIDs []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
points = append(points, QdrantPoint{
|
points = append(points, QdrantPoint{
|
||||||
ID: pointIDs[i],
|
ID: pointID,
|
||||||
Vector: t.Embedding,
|
Vector: embeddings[i],
|
||||||
Payload: payload,
|
Payload: payload,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -249,12 +252,7 @@ func uploadToQdrant(translations []Translation, pointIDs []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/collections/%s/points", qdrantURL, collection)
|
url := fmt.Sprintf("%s/collections/%s/points", qdrantURL, collection)
|
||||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(body))
|
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -310,33 +308,17 @@ func main() {
|
||||||
|
|
||||||
cfg := workers.LoadDBConfig()
|
cfg := workers.LoadDBConfig()
|
||||||
if err := workers.Connect(cfg); err != nil {
|
if err := workers.Connect(cfg); err != nil {
|
||||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
logger.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
dbPool = workers.GetPool()
|
dbPool = workers.GetPool()
|
||||||
defer workers.Close()
|
defer workers.Close()
|
||||||
|
|
||||||
logger.Info().Msg("Connected to PostgreSQL")
|
logger.Println("Connected to PostgreSQL")
|
||||||
|
|
||||||
// Start health check HTTP server
|
ctx := context.Background()
|
||||||
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(); err != nil {
|
||||||
|
logger.Printf("Warning: Could not ensure collection: %v", err)
|
||||||
if err := ensureCollection(ctx); err != nil {
|
|
||||||
logger.Warn().Err(err).Msg("Could not ensure collection")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
|
@ -344,60 +326,65 @@ func main() {
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigChan
|
<-sigChan
|
||||||
logger.Info().Msg("Shutting down gracefully...")
|
logger.Println("Shutting down...")
|
||||||
cancel()
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
workers.Close()
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logger.Info().Msgf("Config: qdrant=%s, collection=%s, sleep=%ds, batch=%d",
|
logger.Printf("Config: qdrant=%s, ollama=%s, collection=%s, sleep=%ds, batch=%d",
|
||||||
qdrantURL, collection, sleepSec, batchSize)
|
qdrantURL, ollamaURL, 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.Error().Err(err).Msg("Error fetching pending translations")
|
logger.Printf("Error fetching pending translations: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(translations) == 0 {
|
if len(translations) == 0 {
|
||||||
logger.Info().Msg("No pending translations to process")
|
logger.Println("No pending translations to process")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info().Msgf("Processing %d translations...", len(translations))
|
logger.Printf("Processing %d translations...", len(translations))
|
||||||
|
|
||||||
// Generate point IDs once (used both in Qdrant and DB)
|
// Generate embeddings
|
||||||
|
embeddings := make([][]float64, len(translations))
|
||||||
|
for i, t := range translations {
|
||||||
|
text := fmt.Sprintf("%s %s", t.Titulo, t.Resumen)
|
||||||
|
emb, err := generateEmbedding(text)
|
||||||
|
if err != nil {
|
||||||
|
logger.Printf("Error generating embedding for %d: %v", t.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
embeddings[i] = emb
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to Qdrant
|
||||||
|
if err := uploadToQdrant(translations, embeddings); err != nil {
|
||||||
|
logger.Printf("Error uploading to Qdrant: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DB status
|
||||||
pointIDs := make([]string, len(translations))
|
pointIDs := make([]string, len(translations))
|
||||||
for i := range translations {
|
for i := range translations {
|
||||||
pointIDs[i] = uuid.New().String()
|
pointIDs[i] = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload to Qdrant
|
|
||||||
if err := uploadToQdrant(translations, pointIDs); err != nil {
|
|
||||||
logger.Error().Err(err).Msg("Error uploading to Qdrant")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update DB status
|
|
||||||
if err := updateTranslationStatus(ctx, translations, pointIDs); err != nil {
|
if err := updateTranslationStatus(ctx, translations, pointIDs); err != nil {
|
||||||
logger.Error().Err(err).Msg("Error updating status")
|
logger.Printf("Error updating status: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
totalProcessed += len(translations)
|
totalProcessed += len(translations)
|
||||||
logger.Info().Msgf("Processed %d translations (total: %d)", len(translations), totalProcessed)
|
logger.Printf("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.Info().Msgf("Stats: total=%d, vectorized=%d, pending=%d", total, vectorized, pending)
|
logger.Printf("Stats: total=%d, vectorized=%d, pending=%d", total, vectorized, pending)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -11,11 +10,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
|
||||||
|
|
@ -24,8 +23,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logLevel := os.Getenv("LOG_LEVEL")
|
logger = log.New(os.Stdout, "[RELATED] ", log.LstdFlags)
|
||||||
logger.Init("related-worker", logLevel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() {
|
func loadConfig() {
|
||||||
|
|
@ -337,36 +335,20 @@ func processBatch(ctx context.Context, model string) (int, error) {
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
logger.Info().Msg("Starting Related News Worker")
|
logger.Println("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.Fatal().Err(err).Msg("Failed to connect to database")
|
logger.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
dbPool = workers.GetPool()
|
dbPool = workers.GetPool()
|
||||||
defer workers.Close()
|
defer workers.Close()
|
||||||
|
|
||||||
// Start health check HTTP server
|
ctx := context.Background()
|
||||||
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.Error().Err(err).Msg("Error ensuring schema")
|
logger.Printf("Error ensuring schema: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
model := os.Getenv("EMB_MODEL")
|
model := os.Getenv("EMB_MODEL")
|
||||||
|
|
@ -379,29 +361,23 @@ func main() {
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigChan
|
<-sigChan
|
||||||
logger.Info().Msg("Shutting down gracefully...")
|
logger.Println("Shutting down...")
|
||||||
cancel()
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
workers.Close()
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logger.Info().Msgf("Config: sleep=%ds, topK=%d, batch=%d, model=%s", sleepSec, topK, batchSz, model)
|
logger.Printf("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.Error().Err(err).Msg("Error processing batch")
|
logger.Printf("Error processing batch: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
logger.Info().Msgf("Generated related news for %d translations", count)
|
logger.Printf("Generated related news for %d translations", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,29 +4,26 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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
|
||||||
enrichLimit = 20
|
|
||||||
scraperWorkers = 5
|
|
||||||
maxBodyBytes = int64(512 << 10)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type URLSource struct {
|
type URLSource struct {
|
||||||
|
|
@ -39,14 +36,6 @@ type URLSource struct {
|
||||||
Active bool
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Noticia struct {
|
|
||||||
ID string
|
|
||||||
Titulo string
|
|
||||||
Resumen string
|
|
||||||
URL string
|
|
||||||
FuenteNombre string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Article struct {
|
type Article struct {
|
||||||
Title string
|
Title string
|
||||||
Summary string
|
Summary string
|
||||||
|
|
@ -57,31 +46,13 @@ type Article struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logLevel := os.Getenv("LOG_LEVEL")
|
logger = log.New(os.Stdout, "[SCRAPER] ", log.LstdFlags)
|
||||||
logger.Init("scraper-worker", logLevel)
|
logger.SetOutput(os.Stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() {
|
func loadConfig() {
|
||||||
if v := os.Getenv("SCRAPER_INTERVAL"); v != "" {
|
sleepInterval = getEnvInt("SCRAPER_SLEEP", 60)
|
||||||
if i, err := strconv.Atoi(v); err == nil && i > 0 {
|
batchSize = getEnvInt("SCRAPER_BATCH", 10)
|
||||||
sleepInterval = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := os.Getenv("SCRAPER_BATCH"); v != "" {
|
|
||||||
if i, err := strconv.Atoi(v); err == nil && i > 0 {
|
|
||||||
batchSize = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := os.Getenv("SCRAPER_ENRICH_LIMIT"); v != "" {
|
|
||||||
if i, err := strconv.Atoi(v); err == nil && i > 0 {
|
|
||||||
enrichLimit = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := os.Getenv("SCRAPER_WORKERS"); v != "" {
|
|
||||||
if i, err := strconv.Atoi(v); err == nil && i > 0 {
|
|
||||||
scraperWorkers = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnvInt(key string, defaultValue int) int {
|
func getEnvInt(key string, defaultValue int) int {
|
||||||
|
|
@ -95,9 +66,9 @@ func getEnvInt(key string, defaultValue int) int {
|
||||||
|
|
||||||
func getActiveURLs(ctx context.Context) ([]URLSource, error) {
|
func getActiveURLs(ctx context.Context) ([]URLSource, error) {
|
||||||
rows, err := pool.Query(ctx, `
|
rows, err := pool.Query(ctx, `
|
||||||
SELECT id, nombre, url, categoria_id, pais_id, idioma, active
|
SELECT id, nombre, url, categoria_id, pais_id, idioma, activo
|
||||||
FROM fuentes_url
|
FROM fuentes_url
|
||||||
WHERE active = true
|
WHERE activo = true
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -128,39 +99,6 @@ func updateSourceStatus(ctx context.Context, sourceID int64, status, message str
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func getNoticiasToEnrich(ctx context.Context, limit int) ([]Noticia, error) {
|
|
||||||
rows, err := pool.Query(ctx, `
|
|
||||||
SELECT id, titulo, COALESCE(resumen, ''), url, fuente_nombre
|
|
||||||
FROM noticias
|
|
||||||
WHERE (resumen IS NULL OR LENGTH(resumen) < 200)
|
|
||||||
ORDER BY fecha DESC
|
|
||||||
LIMIT $1
|
|
||||||
`, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var noticias []Noticia
|
|
||||||
for rows.Next() {
|
|
||||||
var n Noticia
|
|
||||||
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.FuenteNombre); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
noticias = append(noticias, n)
|
|
||||||
}
|
|
||||||
return noticias, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateNoticiaResumen(ctx context.Context, noticiaID, newResumen string) error {
|
|
||||||
_, err := pool.Exec(ctx, `
|
|
||||||
UPDATE noticias
|
|
||||||
SET resumen = $1
|
|
||||||
WHERE id = $2
|
|
||||||
`, newResumen, noticiaID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractArticle(source URLSource) (*Article, error) {
|
func extractArticle(source URLSource) (*Article, error) {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
|
|
@ -185,7 +123,7 @@ func extractArticle(source URLSource) (*Article, error) {
|
||||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
doc, err := goquery.NewDocumentFromReader(io.LimitReader(resp.Body, maxBodyBytes))
|
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -248,94 +186,6 @@ func extractArticle(source URLSource) (*Article, error) {
|
||||||
return article, nil
|
return article, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractContentFromURL(url string) (string, error) {
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
|
||||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
|
||||||
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
doc, err := goquery.NewDocumentFromReader(io.LimitReader(resp.Body, maxBodyBytes))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
contentSelectors := []string{
|
|
||||||
"article",
|
|
||||||
"[role='main']",
|
|
||||||
"main",
|
|
||||||
".article-content",
|
|
||||||
".post-content",
|
|
||||||
".entry-content",
|
|
||||||
".content",
|
|
||||||
"#content",
|
|
||||||
".story-body",
|
|
||||||
".article-body",
|
|
||||||
}
|
|
||||||
|
|
||||||
var content string
|
|
||||||
for _, sel := range contentSelectors {
|
|
||||||
content = doc.Find(sel).First().Text()
|
|
||||||
if len(content) > 100 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(content) < 100 {
|
|
||||||
ps := doc.Find("p")
|
|
||||||
var paragraphs []string
|
|
||||||
ps.Each(func(i int, s *goquery.Selection) {
|
|
||||||
txt := strings.TrimSpace(s.Text())
|
|
||||||
if len(txt) > 50 {
|
|
||||||
paragraphs = append(paragraphs, txt)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
content = strings.Join(paragraphs, "\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(content), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func processEnrichment(ctx context.Context, noticia Noticia) bool {
|
|
||||||
logger.Printf("Enriching: %s", noticia.Titulo)
|
|
||||||
|
|
||||||
content, err := extractContentFromURL(noticia.URL)
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("Error extracting content from %s: %v", noticia.URL, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(content) < 50 {
|
|
||||||
logger.Printf("Not enough content extracted from %s", noticia.URL)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := updateNoticiaResumen(ctx, noticia.ID, content); err != nil {
|
|
||||||
logger.Printf("Error updating resumen for %s: %v", noticia.ID, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Printf("Enriched: %s (content length: %d)", noticia.Titulo, len(content))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveArticle(ctx context.Context, source URLSource, article *Article) (bool, error) {
|
func saveArticle(ctx context.Context, source URLSource, article *Article) (bool, error) {
|
||||||
finalURL := article.URL
|
finalURL := article.URL
|
||||||
if finalURL == "" {
|
if finalURL == "" {
|
||||||
|
|
@ -425,71 +275,20 @@ func processSource(ctx context.Context, source URLSource) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// enrichConcurrent enriches noticias in parallel with a bounded worker pool.
|
|
||||||
func enrichConcurrent(ctx context.Context, noticias []Noticia) {
|
|
||||||
limiter := make(chan struct{}, scraperWorkers)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for _, noticia := range noticias {
|
|
||||||
wg.Add(1)
|
|
||||||
limiter <- struct{}{}
|
|
||||||
go func(n Noticia) {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() { <-limiter }()
|
|
||||||
if processEnrichment(ctx, n) {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
}
|
|
||||||
}(noticia)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// scrapeSources processes fuentes_url in parallel with a bounded worker pool.
|
|
||||||
func scrapeSources(ctx context.Context, sources []URLSource) {
|
|
||||||
limiter := make(chan struct{}, scraperWorkers)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for _, source := range sources {
|
|
||||||
wg.Add(1)
|
|
||||||
limiter <- struct{}{}
|
|
||||||
go func(src URLSource) {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() { <-limiter }()
|
|
||||||
time.Sleep(1 * time.Second) // polite pacing across workers
|
|
||||||
processSource(ctx, src)
|
|
||||||
}(source)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
logger.Info().Msg("Starting Scraper Worker")
|
logger.Println("Starting Scraper Worker")
|
||||||
|
|
||||||
cfg := workers.LoadDBConfig()
|
cfg := workers.LoadDBConfig()
|
||||||
if err := workers.Connect(cfg); err != nil {
|
if err := workers.Connect(cfg); err != nil {
|
||||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
logger.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
pool = workers.GetPool()
|
pool = workers.GetPool()
|
||||||
defer workers.Close()
|
defer workers.Close()
|
||||||
|
|
||||||
logger.Info().Msg("Connected to PostgreSQL")
|
logger.Println("Connected to PostgreSQL")
|
||||||
|
|
||||||
// Start health check HTTP server
|
ctx := context.Background()
|
||||||
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)
|
||||||
|
|
@ -497,45 +296,35 @@ func main() {
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigChan
|
<-sigChan
|
||||||
logger.Info().Msg("Shutting down gracefully...")
|
logger.Println("Shutting down...")
|
||||||
cancel()
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
workers.Close()
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logger.Info().Msgf("Config: sleep=%ds, batch=%d, enrich=%d", sleepInterval, batchSize, enrichLimit)
|
logger.Printf("Config: sleep=%ds, batch=%d", sleepInterval, batchSize)
|
||||||
|
|
||||||
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)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error().Err(err).Msg("Error fetching noticias to enrich")
|
|
||||||
} else if len(noticias) > 0 {
|
|
||||||
logger.Info().Msgf("Enriching %d noticias (%d workers)", len(noticias), scraperWorkers)
|
|
||||||
enrichConcurrent(ctx, noticias)
|
|
||||||
}
|
|
||||||
|
|
||||||
sources, err := getActiveURLs(ctx)
|
sources, err := getActiveURLs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error().Err(err).Msg("Error fetching URLs")
|
logger.Printf("Error fetching URLs: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
logger.Info().Msg("No active URLs to process")
|
logger.Println("No active URLs to process")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info().Msgf("Processing %d sources (%d workers)", len(sources), scraperWorkers)
|
logger.Printf("Processing %d sources", len(sources))
|
||||||
scrapeSources(ctx, sources)
|
|
||||||
|
for _, source := range sources {
|
||||||
|
processSource(ctx, source)
|
||||||
|
time.Sleep(2 * time.Second) // Rate limiting
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,22 @@ package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/rss2/backend/internal/auth"
|
|
||||||
"github.com/rss2/backend/internal/cache"
|
"github.com/rss2/backend/internal/cache"
|
||||||
"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, `
|
||||||
|
|
@ -37,9 +32,9 @@ func initDB() {
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("Could not create entity_aliases table")
|
log.Printf("Warning: Could not create entity_aliases table: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Info().Msg("Table entity_aliases ready")
|
log.Println("Table entity_aliases ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Añadir columna role a users si no existe
|
// Añadir columna role a users si no existe
|
||||||
|
|
@ -47,9 +42,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.Warn().Err(err).Msg("Could not add role column")
|
log.Printf("Warning: Could not add role column: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Info().Msg("Column role ready")
|
log.Println("Column role ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear tabla de configuración si no existe
|
// Crear tabla de configuración si no existe
|
||||||
|
|
@ -79,120 +74,31 @@ func initDB() {
|
||||||
INSERT INTO config (key, value) VALUES ('translator_status', 'stopped')
|
INSERT INTO config (key, value) VALUES ('translator_status', 'stopped')
|
||||||
ON CONFLICT (key) DO NOTHING
|
ON CONFLICT (key) DO NOTHING
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Crear tabla de alertas si no existe
|
|
||||||
_, err = db.GetPool().Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS alertas (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
valor VARCHAR(255) NOT NULL,
|
|
||||||
tipo VARCHAR(32) NOT NULL,
|
|
||||||
periodo DATE NOT NULL,
|
|
||||||
hits INT NOT NULL,
|
|
||||||
baseline DOUBLE PRECISION NOT NULL,
|
|
||||||
ratio DOUBLE PRECISION NOT NULL,
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'nueva',
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
UNIQUE(valor, tipo, periodo)
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: Could not create alertas table: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Println("Table alertas ready")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crear tabla de remote_workers si no existe
|
|
||||||
_, err = db.GetPool().Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS remote_workers (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
api_key VARCHAR(64) UNIQUE NOT NULL,
|
|
||||||
capabilities VARCHAR(50) DEFAULT 'cpu',
|
|
||||||
status VARCHAR(20) DEFAULT 'offline',
|
|
||||||
last_seen TIMESTAMP,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: Could not create remote_workers table: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Println("Table remote_workers ready")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Añadir columnas a traducciones para workers remotos
|
|
||||||
_, err = db.GetPool().Exec(ctx, `
|
|
||||||
ALTER TABLE traducciones ADD COLUMN IF NOT EXISTS worker_id INTEGER REFERENCES remote_workers(id)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: Could not add worker_id column: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.GetPool().Exec(ctx, `
|
|
||||||
ALTER TABLE traducciones ADD COLUMN IF NOT EXISTS assigned_at TIMESTAMP
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: Could not add assigned_at column: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crear índices para optimizar consultas críticas
|
|
||||||
indexes := []string{
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_noticias_fecha ON noticias(fecha DESC)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_noticias_fuente_nombre ON noticias(fuente_nombre)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_noticias_categoria_id ON noticias(categoria_id)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_noticias_pais_id ON noticias(pais_id)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_noticias_lang ON noticias(lang)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_traducciones_noticia_id_lang_to ON traducciones(noticia_id, lang_to)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_traducciones_status ON traducciones(status)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_tags_noticia_noticia_id_tag_id ON tags_noticia(noticia_id, tag_id)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_tags_noticia_traduccion_id ON tags_noticia(traduccion_id)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_tags_valor_tipo ON tags(valor, tipo)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_entity_aliases_alias_tipo ON entity_aliases(alias, tipo)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_alertas_valor_tipo_periodo ON alertas(valor, tipo, periodo)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_alertas_status ON alertas(status)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_user_search_tags_user_id ON user_search_tags(user_id)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_feeds_activo ON feeds(activo)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_feeds_categoria_id ON feeds(categoria_id)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_feeds_pais_id ON feeds(pais_id)",
|
|
||||||
}
|
|
||||||
for _, idx := range indexes {
|
|
||||||
_, err := db.GetPool().Exec(ctx, idx)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn().Err(err).Msg("Could not create index")
|
|
||||||
} else {
|
|
||||||
log.Info().Msg("Index created/verified")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
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.Fatal().Err(err).Msg("Failed to connect to database")
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
log.Info().Msg("Connected to PostgreSQL")
|
log.Println("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.Warn().Err(err).Msg("Failed to connect to Redis")
|
log.Printf("Warning: Failed to connect to Redis: %v", err)
|
||||||
} else {
|
} else {
|
||||||
defer cache.Close()
|
defer cache.Close()
|
||||||
log.Info().Msg("Connected to Redis")
|
log.Println("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())
|
||||||
|
|
||||||
|
|
@ -200,25 +106,18 @@ 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))
|
wikiImagesDir := os.Getenv("WIKI_IMAGES_PATH")
|
||||||
|
if wikiImagesDir == "" {
|
||||||
// Stricter rate limiting for auth endpoints
|
wikiImagesDir = "/opt/rss2/data/wiki_images"
|
||||||
authGroup := api.Group("/auth")
|
|
||||||
authGroup.Use(middleware.RateLimitMiddleware(10)) // 10 req/min for auth
|
|
||||||
{
|
|
||||||
authGroup.POST("/login", handlers.Login)
|
|
||||||
authGroup.POST("/register", handlers.Register)
|
|
||||||
authGroup.GET("/check-first-user", handlers.CheckFirstUser)
|
|
||||||
}
|
}
|
||||||
|
api.StaticFS("/wiki-images", gin.Dir(wikiImagesDir, false))
|
||||||
|
|
||||||
// General rate limiting for API
|
api.POST("/auth/login", handlers.Login)
|
||||||
api.Use(middleware.RateLimitMiddleware(cfg.RateLimitPerMinute))
|
api.POST("/auth/register", handlers.Register)
|
||||||
|
api.GET("/auth/check-first-user", handlers.CheckFirstUser)
|
||||||
|
|
||||||
news := api.Group("/news")
|
news := api.Group("/news")
|
||||||
{
|
{
|
||||||
|
|
@ -241,16 +140,8 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
api.GET("/search", handlers.SearchNews)
|
api.GET("/search", handlers.SearchNews)
|
||||||
api.GET("/search/suggestions", middleware.AuthRequired(), handlers.SearchSuggestions)
|
|
||||||
api.POST("/searchlog", middleware.AuthRequired(), handlers.LogSearch)
|
|
||||||
|
|
||||||
api.GET("/entities", handlers.GetEntities)
|
api.GET("/entities", handlers.GetEntities)
|
||||||
api.GET("/entities/news", handlers.GetEntityNews)
|
|
||||||
api.GET("/entities/mentions", handlers.GetEntityMentions)
|
|
||||||
|
|
||||||
api.GET("/alerts", handlers.GetAlertas)
|
|
||||||
api.POST("/alerts/:id/read", middleware.AuthRequired(), handlers.MarkAlertaRead)
|
|
||||||
api.POST("/alerts/read-all", middleware.AuthRequired(), handlers.MarkAllAlertasRead)
|
|
||||||
|
|
||||||
api.GET("/stats", handlers.GetStats)
|
api.GET("/stats", handlers.GetStats)
|
||||||
|
|
||||||
|
|
@ -266,28 +157,16 @@ func main() {
|
||||||
admin.POST("/entities/retype", handlers.PatchEntityTipo)
|
admin.POST("/entities/retype", handlers.PatchEntityTipo)
|
||||||
admin.GET("/backup", handlers.BackupDatabase)
|
admin.GET("/backup", handlers.BackupDatabase)
|
||||||
admin.GET("/backup/news", handlers.BackupNewsZipped)
|
admin.GET("/backup/news", handlers.BackupNewsZipped)
|
||||||
admin.POST("/restore", handlers.RestoreDatabase)
|
|
||||||
admin.GET("/users", handlers.GetUsers)
|
admin.GET("/users", handlers.GetUsers)
|
||||||
admin.POST("/users/:id/promote", handlers.PromoteUser)
|
admin.POST("/users/:id/promote", handlers.PromoteUser)
|
||||||
admin.POST("/users/:id/demote", handlers.DemoteUser)
|
admin.POST("/users/:id/demote", handlers.DemoteUser)
|
||||||
admin.POST("/reset-db", handlers.ResetDatabase)
|
admin.POST("/reset-db", handlers.ResetDatabase)
|
||||||
admin.POST("/alerts/scan", handlers.ScanAlertasAdmin)
|
|
||||||
admin.GET("/workers/status", handlers.GetWorkerStatus)
|
admin.GET("/workers/status", handlers.GetWorkerStatus)
|
||||||
admin.GET("/workers/stats", handlers.GetTranslationStats)
|
|
||||||
admin.POST("/workers/config", handlers.SetWorkerConfig)
|
admin.POST("/workers/config", handlers.SetWorkerConfig)
|
||||||
admin.POST("/workers/start", handlers.StartWorkers)
|
admin.POST("/workers/start", handlers.StartWorkers)
|
||||||
admin.POST("/workers/stop", handlers.StopWorkers)
|
admin.POST("/workers/stop", handlers.StopWorkers)
|
||||||
|
|
||||||
admin.GET("/workers/remote", handlers.ListRemoteWorkers)
|
|
||||||
admin.POST("/workers/remote", handlers.CreateRemoteWorker)
|
|
||||||
admin.GET("/workers/remote/:id", handlers.GetRemoteWorker)
|
|
||||||
admin.DELETE("/workers/remote/:id", handlers.DeleteRemoteWorker)
|
|
||||||
admin.POST("/workers/remote/:id/toggle", handlers.ToggleRemoteWorker)
|
|
||||||
admin.POST("/workers/remote/:id/regenerate-key", handlers.RegenerateAPIKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
r.GET("/ws/worker", handlers.HandleWorkerWS)
|
|
||||||
|
|
||||||
auth := api.Group("/auth")
|
auth := api.Group("/auth")
|
||||||
auth.Use(middleware.AuthRequired())
|
auth.Use(middleware.AuthRequired())
|
||||||
{
|
{
|
||||||
|
|
@ -295,24 +174,21 @@ func main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.SetJWTSecret(cfg.SecretKey)
|
middleware.SetJWTSecret(cfg.SecretKey)
|
||||||
|
|
||||||
port := cfg.ServerPort
|
port := cfg.ServerPort
|
||||||
addr := fmt.Sprintf(":%s", port)
|
addr := fmt.Sprintf(":%s", port)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Info().Str("addr", addr).Msg("Server starting")
|
log.Printf("Server starting on %s", addr)
|
||||||
if err := r.Run(addr); err != nil {
|
if err := r.Run(addr); err != nil {
|
||||||
log.Fatal().Err(err).Msg("Failed to start server")
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
handlers.StartJobAssigner()
|
|
||||||
handlers.StartAlertScanner()
|
|
||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-quit
|
<-quit
|
||||||
|
|
||||||
log.Info().Msg("Shutting down server...")
|
log.Println("Shutting down server...")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -13,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
|
||||||
batchSz = 500
|
batchSz = 500
|
||||||
|
|
@ -36,8 +34,7 @@ type Country struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logLevel := os.Getenv("LOG_LEVEL")
|
logger = log.New(os.Stdout, "[TOPICS] ", log.LstdFlags)
|
||||||
logger.Init("topics-worker", logLevel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() {
|
func loadConfig() {
|
||||||
|
|
@ -279,46 +276,29 @@ func processBatch(ctx context.Context, topics []Topic, countries []Country) (int
|
||||||
processedIDs = append(processedIDs, item.ID)
|
processedIDs = append(processedIDs, item.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert topic relations (batched, upsert)
|
// Insert topic relations
|
||||||
if len(topicMatches) > 0 {
|
if len(topicMatches) > 0 {
|
||||||
const chunkSize = 500
|
for _, tm := range topicMatches {
|
||||||
for i := 0; i < len(topicMatches); i += chunkSize {
|
_, err := dbPool.Exec(ctx, `
|
||||||
end := i + chunkSize
|
|
||||||
if end > len(topicMatches) {
|
|
||||||
end = len(topicMatches)
|
|
||||||
}
|
|
||||||
chunk := topicMatches[i:end]
|
|
||||||
values := make([]string, 0, len(chunk))
|
|
||||||
args := make([]interface{}, 0, len(chunk)*3)
|
|
||||||
for j, tm := range chunk {
|
|
||||||
values = append(values, fmt.Sprintf("($%d, $%d, $%d)", j*3+1, j*3+2, j*3+3))
|
|
||||||
args = append(args, tm.NoticiaID, tm.TopicID, tm.Score)
|
|
||||||
}
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
INSERT INTO news_topics (noticia_id, topic_id, score)
|
INSERT INTO news_topics (noticia_id, topic_id, score)
|
||||||
VALUES %s
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (noticia_id, topic_id) DO UPDATE SET score = EXCLUDED.score
|
ON CONFLICT (noticia_id, topic_id) DO UPDATE SET score = EXCLUDED.score
|
||||||
`, strings.Join(values, ","))
|
`, tm.NoticiaID, tm.TopicID, tm.Score)
|
||||||
if _, err := dbPool.Exec(ctx, query, args...); err != nil {
|
if err != nil {
|
||||||
logger.Printf("Error batch inserting topics: %v", err)
|
logger.Printf("Error inserting topic: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update country (single bulk UPDATE)
|
// Update country
|
||||||
if len(countryUpdates) > 0 {
|
if len(countryUpdates) > 0 {
|
||||||
paisSlice := make([]int32, len(countryUpdates))
|
for _, cu := range countryUpdates {
|
||||||
idSlice := make([]string, len(countryUpdates))
|
_, err := dbPool.Exec(ctx, `
|
||||||
for i, cu := range countryUpdates {
|
UPDATE noticias SET pais_id = $1 WHERE id = $2
|
||||||
paisSlice[i] = int32(cu.PaisID)
|
`, cu.PaisID, cu.NoticiaID)
|
||||||
idSlice[i] = cu.NoticiaID
|
if err != nil {
|
||||||
|
logger.Printf("Error updating country: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := dbPool.Exec(ctx, `
|
|
||||||
UPDATE noticias n SET pais_id = u.pais
|
|
||||||
FROM unnest($1::int[], $2::varchar[]) AS u(pais, noticia_id)
|
|
||||||
WHERE n.id = u.noticia_id
|
|
||||||
`, paisSlice, idSlice); err != nil {
|
|
||||||
logger.Printf("Error bulk updating countries: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -337,36 +317,20 @@ func processBatch(ctx context.Context, topics []Topic, countries []Country) (int
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
logger.Info().Msg("Starting Topics Worker")
|
logger.Println("Starting Topics Worker")
|
||||||
|
|
||||||
cfg := workers.LoadDBConfig()
|
cfg := workers.LoadDBConfig()
|
||||||
if err := workers.Connect(cfg); err != nil {
|
if err := workers.Connect(cfg); err != nil {
|
||||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
logger.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
dbPool = workers.GetPool()
|
dbPool = workers.GetPool()
|
||||||
defer workers.Close()
|
defer workers.Close()
|
||||||
|
|
||||||
// Start health check HTTP server
|
ctx := context.Background()
|
||||||
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.Error().Err(err).Msg("Error ensuring schema")
|
logger.Printf("Error ensuring schema: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
|
@ -374,47 +338,41 @@ func main() {
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigChan
|
<-sigChan
|
||||||
logger.Info().Msg("Shutting down gracefully...")
|
logger.Println("Shutting down...")
|
||||||
cancel()
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
workers.Close()
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logger.Info().Msgf("Config: sleep=%ds, batch=%d", sleepSec, batchSz)
|
logger.Printf("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.Error().Err(err).Msg("Error loading topics")
|
logger.Printf("Error loading topics: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(topics) == 0 {
|
if len(topics) == 0 {
|
||||||
logger.Info().Msg("No topics found in DB")
|
logger.Println("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.Error().Err(err).Msg("Error loading countries")
|
logger.Printf("Error loading countries: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
count, err := processBatch(ctx, topics, countries)
|
count, err := processBatch(ctx, topics, countries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error().Err(err).Msg("Error processing batch")
|
logger.Printf("Error processing batch: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
logger.Info().Msgf("Processed %d news items", count)
|
logger.Printf("Processed %d news items", count)
|
||||||
}
|
}
|
||||||
|
|
||||||
if count < batchSz {
|
if count < batchSz {
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,15 @@ 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 = 50
|
||||||
imagesDir = "/app/data/wiki_images"
|
imagesDir = "/opt/rss2/data/wiki_images"
|
||||||
maxImageBytes = int64(2 << 20)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type WikiSummary struct {
|
type WikiSummary struct {
|
||||||
|
|
@ -52,8 +51,7 @@ type Tag struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logLevel := os.Getenv("LOG_LEVEL")
|
logger = log.New(os.Stdout, "[WIKI_WORKER] ", log.LstdFlags)
|
||||||
logger.Init("wiki-worker", logLevel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPendingTags(ctx context.Context) ([]Tag, error) {
|
func getPendingTags(ctx context.Context) ([]Tag, error) {
|
||||||
|
|
@ -103,17 +101,13 @@ func downloadImage(imgURL, destPath string) error {
|
||||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.ContentLength > maxImageBytes {
|
|
||||||
return fmt.Errorf("image too large (%d bytes)", resp.ContentLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := os.Create(destPath)
|
out, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
_, err = io.Copy(out, io.LimitReader(resp.Body, maxImageBytes))
|
_, err = io.Copy(out, resp.Body)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,101 +210,61 @@ func processTag(ctx context.Context, tag Tag) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var sleepVal, batchVal int
|
if val := os.Getenv("WIKI_IMAGES_PATH"); val != "" {
|
||||||
|
imagesDir = val
|
||||||
|
}
|
||||||
if val := os.Getenv("WIKI_SLEEP"); val != "" {
|
if val := os.Getenv("WIKI_SLEEP"); val != "" {
|
||||||
if _, err := fmt.Sscanf(val, "%d", &sleepVal); err == nil && sleepVal > 0 {
|
if sleep, err := fmt.Sscanf(val, "%d", &sleepInterval); err == nil && sleep > 0 {
|
||||||
sleepInterval = sleepVal
|
sleepInterval = sleep
|
||||||
}
|
|
||||||
}
|
|
||||||
if val := os.Getenv("WIKI_BATCH"); val != "" {
|
|
||||||
if _, err := fmt.Sscanf(val, "%d", &batchVal); err == nil && batchVal > 0 {
|
|
||||||
batchSize = batchVal
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info().Msg("Iniciando Wiki Worker...")
|
logger.Println("Iniciando Wiki Worker...")
|
||||||
|
|
||||||
if err := os.MkdirAll(imagesDir, 0755); err != nil {
|
if err := os.MkdirAll(imagesDir, 0755); err != nil {
|
||||||
logger.Fatal().Err(err).Msg("Error creando directorio de imágenes")
|
logger.Fatalf("Error creando directorio de imágenes: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := workers.LoadDBConfig()
|
cfg := workers.LoadDBConfig()
|
||||||
if err := workers.Connect(cfg); err != nil {
|
if err := workers.Connect(cfg); err != nil {
|
||||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
logger.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
pool = workers.GetPool()
|
pool = workers.GetPool()
|
||||||
defer workers.Close()
|
defer workers.Close()
|
||||||
|
|
||||||
// Start health check HTTP server
|
ctx := context.Background()
|
||||||
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.Info().Msg("Shutting down gracefully...")
|
logger.Println("Cerrando 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.Info().Msgf("Configuración: sleep=%ds, batch=%d", sleepInterval, batchSize)
|
logger.Printf("Configuración: sleep=%ds, batch=%d", sleepInterval, batchSize)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
logger.Info().Msg("Context cancelled, stopping worker")
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
tags, err := getPendingTags(ctx)
|
tags, err := getPendingTags(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error().Err(err).Msg("Error recuperando tags pendientes")
|
logger.Printf("Error recuperando tags pendientes: %v", err)
|
||||||
select {
|
time.Sleep(10 * time.Second)
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-time.After(10 * time.Second):
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tags) == 0 {
|
if len(tags) == 0 {
|
||||||
logger.Info().Msgf("No hay tags pendientes. Durmiendo %d segundos...", sleepInterval)
|
logger.Printf("No hay tags pendientes. Durmiendo %d segundos...", sleepInterval)
|
||||||
select {
|
time.Sleep(time.Duration(sleepInterval) * time.Second)
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-time.After(time.Duration(sleepInterval) * time.Second):
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info().Msgf("Recuperados %d tags para procesar...", len(tags))
|
logger.Printf("Recuperados %d tags para procesar...", len(tags))
|
||||||
|
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
processTag(ctx, tag)
|
processTag(ctx, tag)
|
||||||
select {
|
time.Sleep(3 * time.Second) // Increased delay to avoid Wikipedia Rate Limits (429)
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-time.After(3 * time.Second): // Increased delay to avoid Wikipedia Rate Limits (429)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
// 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
|
|
||||||
|
|
@ -1,29 +1,19 @@
|
||||||
module github.com/rss2/backend
|
module github.com/rss2/backend
|
||||||
|
|
||||||
go 1.23
|
go 1.22
|
||||||
|
|
||||||
toolchain go1.23.12
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.9.2
|
github.com/PuerkitoBio/goquery v1.9.2
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.1
|
|
||||||
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
|
||||||
github.com/rs/zerolog v1.32.0
|
golang.org/x/crypto v0.26.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
|
||||||
|
|
@ -31,39 +21,31 @@ 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.30.0 // indirect
|
golang.org/x/net v0.28.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.19.0 // indirect
|
golang.org/x/text v0.17.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,11 +1,5 @@
|
||||||
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=
|
||||||
|
|
@ -20,7 +14,6 @@ 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=
|
||||||
|
|
@ -29,22 +22,10 @@ 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=
|
||||||
|
|
@ -55,7 +36,6 @@ 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=
|
||||||
|
|
@ -63,8 +43,6 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
|
||||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
|
@ -73,29 +51,17 @@ 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=
|
||||||
|
|
@ -107,24 +73,18 @@ 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=
|
||||||
|
|
@ -132,12 +92,6 @@ 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=
|
||||||
|
|
@ -148,21 +102,17 @@ 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.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||||
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.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||||
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=
|
||||||
|
|
@ -170,16 +120,13 @@ 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=
|
||||||
|
|
@ -188,33 +135,22 @@ 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.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.17.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,69 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
var jwtSecret []byte
|
|
||||||
|
|
||||||
func SetJWTSecret(secret string) {
|
|
||||||
jwtSecret = []byte(secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetJWTSecret() []byte {
|
|
||||||
return jwtSecret
|
|
||||||
}
|
|
||||||
|
|
||||||
type Claims struct {
|
|
||||||
UserID int64 `json:"user_id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
IsAdmin bool `json:"is_admin"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenerateToken(userID int64, email, username string, isAdmin bool) (string, error) {
|
|
||||||
expirationTime := time.Now().Add(24 * time.Hour)
|
|
||||||
claims := &Claims{
|
|
||||||
UserID: userID,
|
|
||||||
Email: email,
|
|
||||||
Username: username,
|
|
||||||
IsAdmin: isAdmin,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
return token.SignedString(jwtSecret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateToken(tokenString string) (*Claims, error) {
|
|
||||||
claims := &Claims{}
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
return jwtSecret, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateAndValidateToken(t *testing.T) {
|
|
||||||
SetJWTSecret("test-secret-key-12345")
|
|
||||||
|
|
||||||
token, err := GenerateToken(1, "test@example.com", "testuser", true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateToken failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if token == "" {
|
|
||||||
t.Fatal("token should not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := ValidateToken(token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ValidateToken failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if claims.UserID != 1 {
|
|
||||||
t.Errorf("expected userID 1, got %d", claims.UserID)
|
|
||||||
}
|
|
||||||
if claims.Email != "test@example.com" {
|
|
||||||
t.Errorf("expected email test@example.com, got %s", claims.Email)
|
|
||||||
}
|
|
||||||
if claims.Username != "testuser" {
|
|
||||||
t.Errorf("expected username testuser, got %s", claims.Username)
|
|
||||||
}
|
|
||||||
if !claims.IsAdmin {
|
|
||||||
t.Error("expected isAdmin true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateInvalidToken(t *testing.T) {
|
|
||||||
SetJWTSecret("test-secret-key-12345")
|
|
||||||
|
|
||||||
_, err := ValidateToken("invalid-token")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for invalid token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateTokenWithWrongSecret(t *testing.T) {
|
|
||||||
SetJWTSecret("secret-1")
|
|
||||||
|
|
||||||
token, err := GenerateToken(1, "test@example.com", "testuser", false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateToken failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
SetJWTSecret("secret-2")
|
|
||||||
|
|
||||||
_, err = ValidateToken(token)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for token with different secret")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClaimsHaveExpiration(t *testing.T) {
|
|
||||||
SetJWTSecret("test-secret-key-12345")
|
|
||||||
|
|
||||||
token, err := GenerateToken(1, "test@example.com", "testuser", false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateToken failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := ValidateToken(token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ValidateToken failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if claims.ExpiresAt == nil {
|
|
||||||
t.Error("expected expiration time to be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
expTime := claims.ExpiresAt.Time
|
|
||||||
expectedExpiration := time.Now().Add(24 * time.Hour)
|
|
||||||
|
|
||||||
if expTime.Sub(expectedExpiration) > time.Minute {
|
|
||||||
t.Errorf("expiration time should be ~24 hours from now, got %v", expTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmptyToken(t *testing.T) {
|
|
||||||
SetJWTSecret("test-secret-key-12345")
|
|
||||||
|
|
||||||
_, err := ValidateToken("")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for empty token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
111
backend/internal/cache/redis.go
vendored
111
backend/internal/cache/redis.go
vendored
|
|
@ -11,26 +11,6 @@ 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 {
|
||||||
|
|
@ -60,39 +40,15 @@ 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("%s:%s:%s:%d:%d", KeyPrefixSearch, query, lang, page, perPage)
|
return fmt.Sprintf("search:%s:%s:%d:%d", query, lang, page, perPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewsKey(newsID int64, lang string) string {
|
func NewsKey(newsID int64, lang string) string {
|
||||||
return fmt.Sprintf("%s:%d:%s", KeyPrefixNews, newsID, lang)
|
return fmt.Sprintf("news:%d:%s", newsID, lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
func FeedKey(feedID int64) string {
|
func FeedKey(feedID int64) string {
|
||||||
return fmt.Sprintf("%s:%d", KeyPrefixFeed, feedID)
|
return fmt.Sprintf("feed:%d", 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 {
|
||||||
|
|
@ -107,62 +63,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -170,8 +70,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -20,29 +20,16 @@ type Config struct {
|
||||||
DefaultLang string
|
DefaultLang string
|
||||||
NewsPerPage int
|
NewsPerPage int
|
||||||
RateLimitPerMinute int
|
RateLimitPerMinute int
|
||||||
DockerComposeDir string
|
|
||||||
WikiImagesPath string
|
|
||||||
AllowedOrigins string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
secretKey := os.Getenv("SECRET_KEY")
|
|
||||||
if secretKey == "" {
|
|
||||||
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{
|
||||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://rss:rss@localhost:5432/rss"),
|
DatabaseURL: getEnv("DATABASE_URL", "postgres://rss:rss@localhost:5432/rss"),
|
||||||
RedisURL: getEnv("REDIS_URL", "redis://localhost:6379"),
|
RedisURL: getEnv("REDIS_URL", "redis://localhost:6379"),
|
||||||
QdrantHost: getEnv("QDRANT_HOST", "localhost"),
|
QdrantHost: getEnv("QDRANT_HOST", "localhost"),
|
||||||
QdrantPort: getEnvInt("QDRANT_PORT", 6333),
|
QdrantPort: getEnvInt("QDRANT_PORT", 6333),
|
||||||
SecretKey: secretKey,
|
SecretKey: getEnv("SECRET_KEY", "change-this-secret-key"),
|
||||||
JWTExpiration: getEnvDuration("JWT_EXPIRATION", 24*time.Hour),
|
JWTExpiration: getEnvDuration("JWT_EXPIRATION", 24*time.Hour),
|
||||||
TranslationURL: getEnv("TRANSLATION_URL", "http://libretranslate:7790"),
|
TranslationURL: getEnv("TRANSLATION_URL", "http://libretranslate:7790"),
|
||||||
OllamaURL: getEnv("OLLAMA_URL", "http://ollama:11434"),
|
OllamaURL: getEnv("OLLAMA_URL", "http://ollama:11434"),
|
||||||
|
|
@ -50,9 +37,6 @@ func Load() *Config {
|
||||||
DefaultLang: getEnv("DEFAULT_LANG", "es"),
|
DefaultLang: getEnv("DEFAULT_LANG", "es"),
|
||||||
NewsPerPage: getEnvInt("NEWS_PER_PAGE", 30),
|
NewsPerPage: getEnvInt("NEWS_PER_PAGE", 30),
|
||||||
RateLimitPerMinute: getEnvInt("RATE_LIMIT_PER_MINUTE", 60),
|
RateLimitPerMinute: getEnvInt("RATE_LIMIT_PER_MINUTE", 60),
|
||||||
DockerComposeDir: getEnv("DOCKER_COMPOSE_DIR", "/datos/rss2"),
|
|
||||||
WikiImagesPath: getEnv("WIKI_IMAGES_PATH", "/app/data/wiki_images"),
|
|
||||||
AllowedOrigins: allowedOrigins,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoadDefaults(t *testing.T) {
|
|
||||||
os.Clearenv()
|
|
||||||
|
|
||||||
cfg := Load()
|
|
||||||
|
|
||||||
if cfg.ServerPort != "8080" {
|
|
||||||
t.Errorf("expected ServerPort '8080', got %s", cfg.ServerPort)
|
|
||||||
}
|
|
||||||
if cfg.SecretKey != "change-this-secret-key" {
|
|
||||||
t.Errorf("expected SecretKey 'change-this-secret-key', got %s", cfg.SecretKey)
|
|
||||||
}
|
|
||||||
if cfg.DefaultLang != "es" {
|
|
||||||
t.Errorf("expected DefaultLang 'es', got %s", cfg.DefaultLang)
|
|
||||||
}
|
|
||||||
if cfg.NewsPerPage != 30 {
|
|
||||||
t.Errorf("expected NewsPerPage 30, got %d", cfg.NewsPerPage)
|
|
||||||
}
|
|
||||||
if cfg.DockerComposeDir != "/datos/rss2" {
|
|
||||||
t.Errorf("expected DockerComposeDir '/datos/rss2', got %s", cfg.DockerComposeDir)
|
|
||||||
}
|
|
||||||
if cfg.WikiImagesPath != "/app/data/wiki_images" {
|
|
||||||
t.Errorf("expected WikiImagesPath '/app/data/wiki_images', got %s", cfg.WikiImagesPath)
|
|
||||||
}
|
|
||||||
if cfg.AllowedOrigins != "*" {
|
|
||||||
t.Errorf("expected AllowedOrigins '*', got %s", cfg.AllowedOrigins)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadFromEnv(t *testing.T) {
|
|
||||||
os.Clearenv()
|
|
||||||
os.Setenv("SERVER_PORT", "9000")
|
|
||||||
os.Setenv("DATABASE_URL", "postgres://user:pass@host:5432/db")
|
|
||||||
os.Setenv("SECRET_KEY", "my-secret-key")
|
|
||||||
os.Setenv("DEFAULT_LANG", "en")
|
|
||||||
os.Setenv("NEWS_PER_PAGE", "50")
|
|
||||||
os.Setenv("JWT_EXPIRATION", "48h")
|
|
||||||
os.Setenv("RATE_LIMIT_PER_MINUTE", "100")
|
|
||||||
os.Setenv("DOCKER_COMPOSE_DIR", "/custom/path")
|
|
||||||
os.Setenv("WIKI_IMAGES_PATH", "/custom/images")
|
|
||||||
|
|
||||||
cfg := Load()
|
|
||||||
|
|
||||||
if cfg.ServerPort != "9000" {
|
|
||||||
t.Errorf("expected ServerPort '9000', got %s", cfg.ServerPort)
|
|
||||||
}
|
|
||||||
if cfg.DatabaseURL != "postgres://user:pass@host:5432/db" {
|
|
||||||
t.Errorf("expected DatabaseURL, got %s", cfg.DatabaseURL)
|
|
||||||
}
|
|
||||||
if cfg.SecretKey != "my-secret-key" {
|
|
||||||
t.Errorf("expected SecretKey 'my-secret-key', got %s", cfg.SecretKey)
|
|
||||||
}
|
|
||||||
if cfg.DefaultLang != "en" {
|
|
||||||
t.Errorf("expected DefaultLang 'en', got %s", cfg.DefaultLang)
|
|
||||||
}
|
|
||||||
if cfg.NewsPerPage != 50 {
|
|
||||||
t.Errorf("expected NewsPerPage 50, got %d", cfg.NewsPerPage)
|
|
||||||
}
|
|
||||||
if cfg.JWTExpiration != 48*time.Hour {
|
|
||||||
t.Errorf("expected JWTExpiration 48h, got %v", cfg.JWTExpiration)
|
|
||||||
}
|
|
||||||
if cfg.RateLimitPerMinute != 100 {
|
|
||||||
t.Errorf("expected RateLimitPerMinute 100, got %d", cfg.RateLimitPerMinute)
|
|
||||||
}
|
|
||||||
if cfg.DockerComposeDir != "/custom/path" {
|
|
||||||
t.Errorf("expected DockerComposeDir '/custom/path', got %s", cfg.DockerComposeDir)
|
|
||||||
}
|
|
||||||
if cfg.WikiImagesPath != "/custom/images" {
|
|
||||||
t.Errorf("expected WikiImagesPath '/custom/images', got %s", cfg.WikiImagesPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadInvalidInt(t *testing.T) {
|
|
||||||
os.Clearenv()
|
|
||||||
os.Setenv("NEWS_PER_PAGE", "invalid")
|
|
||||||
|
|
||||||
cfg := Load()
|
|
||||||
|
|
||||||
if cfg.NewsPerPage != 30 {
|
|
||||||
t.Errorf("expected default NewsPerPage 30 for invalid value, got %d", cfg.NewsPerPage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadInvalidDuration(t *testing.T) {
|
|
||||||
os.Clearenv()
|
|
||||||
os.Setenv("JWT_EXPIRATION", "invalid")
|
|
||||||
|
|
||||||
cfg := Load()
|
|
||||||
|
|
||||||
if cfg.JWTExpiration != 24*time.Hour {
|
|
||||||
t.Errorf("expected default JWTExpiration 24h for invalid value, got %v", cfg.JWTExpiration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetEnv(t *testing.T) {
|
|
||||||
os.Clearenv()
|
|
||||||
os.Setenv("TEST_KEY", "test_value")
|
|
||||||
|
|
||||||
result := getEnv("TEST_KEY", "default")
|
|
||||||
if result != "test_value" {
|
|
||||||
t.Errorf("expected 'test_value', got %s", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
result = getEnv("NON_EXISTENT", "default")
|
|
||||||
if result != "default" {
|
|
||||||
t.Errorf("expected 'default', got %s", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetEnvInt(t *testing.T) {
|
|
||||||
os.Clearenv()
|
|
||||||
os.Setenv("TEST_INT", "123")
|
|
||||||
|
|
||||||
result := getEnvInt("TEST_INT", 0)
|
|
||||||
if result != 123 {
|
|
||||||
t.Errorf("expected 123, got %d", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
result = getEnvInt("NON_EXISTENT", 99)
|
|
||||||
if result != 99 {
|
|
||||||
t.Errorf("expected 99, got %d", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetEnvDuration(t *testing.T) {
|
|
||||||
os.Clearenv()
|
|
||||||
os.Setenv("TEST_DURATION", "1h30m")
|
|
||||||
|
|
||||||
result := getEnvDuration("TEST_DURATION", 0)
|
|
||||||
if result != 90*time.Minute {
|
|
||||||
t.Errorf("expected 90m, got %v", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
result = getEnvDuration("NON_EXISTENT", 5*time.Minute)
|
|
||||||
if result != 5*time.Minute {
|
|
||||||
t.Errorf("expected 5m, got %v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,8 +3,6 @@ package db
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
@ -18,11 +16,10 @@ 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 = int32(getEnvInt("DB_MAX_CONNS", 25))
|
config.MaxConns = 25
|
||||||
config.MinConns = int32(getEnvInt("DB_MIN_CONNS", 5))
|
config.MinConns = 5
|
||||||
config.MaxConnLifetime = getEnvDuration("DB_MAX_CONN_LIFETIME", time.Hour)
|
config.MaxConnLifetime = time.Hour
|
||||||
config.MaxConnIdleTime = getEnvDuration("DB_MAX_CONN_IDLE_TIME", 30*time.Minute)
|
config.MaxConnIdleTime = 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 {
|
||||||
|
|
@ -45,28 +42,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,22 +6,20 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func CreateAlias(c *gin.Context) {
|
func CreateAlias(c *gin.Context) {
|
||||||
var req models.EntityAliasRequest
|
var req models.EntityAliasRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|
@ -112,6 +110,8 @@ func CreateAlias(c *gin.Context) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func ExportAliases(c *gin.Context) {
|
func ExportAliases(c *gin.Context) {
|
||||||
rows, err := db.GetPool().Query(c.Request.Context(),
|
rows, err := db.GetPool().Query(c.Request.Context(),
|
||||||
"SELECT alias, canonical_name, tipo FROM entity_aliases ORDER BY tipo, canonical_name")
|
"SELECT alias, canonical_name, tipo FROM entity_aliases ORDER BY tipo, canonical_name")
|
||||||
|
|
@ -387,51 +387,6 @@ func GetWorkerStatus(c *gin.Context) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTranslationStats returns real translation throughput and live worker count.
|
|
||||||
func GetTranslationStats(c *gin.Context) {
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
var last1min, last5min, activeWorkers int64
|
|
||||||
var elapsed1min, elapsed5min float64
|
|
||||||
|
|
||||||
db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT COALESCE(SUM(items_translated),0), COALESCE(SUM(elapsed_ms),0)
|
|
||||||
FROM translation_stats WHERE created_at >= NOW() - INTERVAL '1 minute'
|
|
||||||
`).Scan(&last1min, &elapsed1min)
|
|
||||||
|
|
||||||
db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT COALESCE(SUM(items_translated),0), COALESCE(SUM(elapsed_ms),0)
|
|
||||||
FROM translation_stats WHERE created_at >= NOW() - INTERVAL '5 minutes'
|
|
||||||
`).Scan(&last5min, &elapsed5min)
|
|
||||||
|
|
||||||
// Live workers = distinct hosts that recorded stats in the last 2 minutes
|
|
||||||
db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT COUNT(DISTINCT hostname) FROM translation_stats
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '2 minutes' AND hostname IS NOT NULL
|
|
||||||
`).Scan(&activeWorkers)
|
|
||||||
|
|
||||||
// Also count WS remote workers currently online
|
|
||||||
remoteOnline := 0
|
|
||||||
if err := db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM remote_workers WHERE status = 'online'
|
|
||||||
`).Scan(&remoteOnline); err != nil {
|
|
||||||
remoteOnline = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
rateSec := 0.0
|
|
||||||
if elapsed1min > 0 {
|
|
||||||
rateSec = float64(last1min) / (elapsed1min / 1000.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"translations_last_1min": last1min,
|
|
||||||
"translations_last_5min": last5min,
|
|
||||||
"rate_per_second": math.Round(rateSec*100) / 100,
|
|
||||||
"rate_per_minute": math.Round(rateSec*60*100) / 100,
|
|
||||||
"active_workers": activeWorkers + int64(remoteOnline),
|
|
||||||
"remote_workers_online": remoteOnline,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetWorkerConfig(c *gin.Context) {
|
func SetWorkerConfig(c *gin.Context) {
|
||||||
var req WorkerConfig
|
var req WorkerConfig
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|
@ -510,14 +465,13 @@ func StartWorkers(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detener cualquier translator existente
|
// Detener cualquier translator existente
|
||||||
composeDir := config.Load().DockerComposeDir
|
|
||||||
stopCmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
|
stopCmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
|
||||||
stopCmd.Dir = composeDir
|
stopCmd.Dir = "/datos/rss2"
|
||||||
stopCmd.Run()
|
stopCmd.Run()
|
||||||
|
|
||||||
// Iniciar con el número de workers
|
// Iniciar con el número de workers
|
||||||
startCmd := exec.Command("docker", "compose", "up", "-d", "--scale", fmt.Sprintf("%s=%d", serviceName, workers), serviceName)
|
startCmd := exec.Command("docker", "compose", "up", "-d", "--scale", fmt.Sprintf("%s=%d", serviceName, workers), serviceName)
|
||||||
startCmd.Dir = composeDir
|
startCmd.Dir = "/datos/rss2"
|
||||||
output, err := startCmd.CombinedOutput()
|
output, err := startCmd.CombinedOutput()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -529,15 +483,9 @@ func StartWorkers(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actualizar estado en BD
|
// Actualizar estado en BD
|
||||||
if _, err := db.GetPool().Exec(ctx, "UPDATE config SET value = 'running', updated_at = NOW() WHERE key = 'translator_status'"); err != nil {
|
db.GetPool().Exec(ctx, "UPDATE config SET value = 'running', updated_at = NOW() WHERE key = 'translator_status'")
|
||||||
log.Printf("Failed to update translator_status: %v", err)
|
db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_type'", translatorType)
|
||||||
}
|
db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_workers'", translatorWorkers)
|
||||||
if _, err := db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_type'", translatorType); err != nil {
|
|
||||||
log.Printf("Failed to update translator_type: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_workers'", translatorWorkers); err != nil {
|
|
||||||
log.Printf("Failed to update translator_workers: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Workers started successfully",
|
"message": "Workers started successfully",
|
||||||
|
|
@ -549,9 +497,8 @@ func StartWorkers(c *gin.Context) {
|
||||||
|
|
||||||
func StopWorkers(c *gin.Context) {
|
func StopWorkers(c *gin.Context) {
|
||||||
// Detener traductores
|
// Detener traductores
|
||||||
composeDir := config.Load().DockerComposeDir
|
|
||||||
cmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
|
cmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
|
||||||
cmd.Dir = composeDir
|
cmd.Dir = "/datos/rss2"
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -563,9 +510,7 @@ func StopWorkers(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actualizar estado en BD
|
// Actualizar estado en BD
|
||||||
if _, err := db.GetPool().Exec(c.Request.Context(), "UPDATE config SET value = 'stopped', updated_at = NOW() WHERE key = 'translator_status'"); err != nil {
|
db.GetPool().Exec(c.Request.Context(), "UPDATE config SET value = 'stopped', updated_at = NOW() WHERE key = 'translator_status'")
|
||||||
log.Printf("Failed to update translator_status: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Workers stopped successfully",
|
"message": "Workers stopped successfully",
|
||||||
|
|
@ -684,96 +629,41 @@ func PatchEntityTipo(c *gin.Context) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// pgConnInfo holds PostgreSQL connection parameters for pg_dump
|
// BackupDatabase runs pg_dump and returns the SQL as a downloadable file
|
||||||
type pgConnInfo struct {
|
|
||||||
host, port, name, user, pass string
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolvePgConnInfo prefers DATABASE_URL (the variable the API container gets)
|
|
||||||
// and falls back to the individual DB_* variables used by the workers.
|
|
||||||
func resolvePgConnInfo() pgConnInfo {
|
|
||||||
if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" {
|
|
||||||
if u, err := url.Parse(dbURL); err == nil && u.Hostname() != "" {
|
|
||||||
info := pgConnInfo{
|
|
||||||
host: u.Hostname(),
|
|
||||||
user: u.User.Username(),
|
|
||||||
name: strings.TrimPrefix(u.Path, "/"),
|
|
||||||
port: u.Port(),
|
|
||||||
}
|
|
||||||
if p, ok := u.User.Password(); ok {
|
|
||||||
info.pass = p
|
|
||||||
}
|
|
||||||
if info.port == "" {
|
|
||||||
info.port = "5432"
|
|
||||||
}
|
|
||||||
if info.user == "" {
|
|
||||||
info.user = "postgres"
|
|
||||||
}
|
|
||||||
if info.name == "" {
|
|
||||||
info.name = "postgres"
|
|
||||||
}
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info := pgConnInfo{
|
|
||||||
host: os.Getenv("DB_HOST"),
|
|
||||||
port: os.Getenv("DB_PORT"),
|
|
||||||
name: os.Getenv("DB_NAME"),
|
|
||||||
user: os.Getenv("DB_USER"),
|
|
||||||
pass: os.Getenv("DB_PASS"),
|
|
||||||
}
|
|
||||||
if info.host == "" {
|
|
||||||
info.host = "db"
|
|
||||||
}
|
|
||||||
if info.port == "" {
|
|
||||||
info.port = "5432"
|
|
||||||
}
|
|
||||||
if info.name == "" {
|
|
||||||
info.name = "rss"
|
|
||||||
}
|
|
||||||
if info.user == "" {
|
|
||||||
info.user = "rss"
|
|
||||||
}
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
dbHost := os.Getenv("DB_HOST")
|
||||||
|
if dbHost == "" {
|
||||||
|
dbHost = "db"
|
||||||
|
}
|
||||||
|
dbPort := os.Getenv("DB_PORT")
|
||||||
|
if dbPort == "" {
|
||||||
|
dbPort = "5432"
|
||||||
|
}
|
||||||
|
dbName := os.Getenv("DB_NAME")
|
||||||
|
if dbName == "" {
|
||||||
|
dbName = "rss"
|
||||||
|
}
|
||||||
|
dbUser := os.Getenv("DB_USER")
|
||||||
|
if dbUser == "" {
|
||||||
|
dbUser = "rss"
|
||||||
|
}
|
||||||
|
dbPass := os.Getenv("DB_PASS")
|
||||||
|
|
||||||
cmd := exec.Command("pg_dump",
|
cmd := exec.Command("pg_dump",
|
||||||
"-h", info.host,
|
"-h", dbHost,
|
||||||
"-p", info.port,
|
"-p", dbPort,
|
||||||
"-U", info.user,
|
"-U", dbUser,
|
||||||
"-d", info.name,
|
"-d", dbName,
|
||||||
"--no-password",
|
"--no-password",
|
||||||
"--format=plain",
|
|
||||||
)
|
)
|
||||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
|
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPass))
|
||||||
|
|
||||||
pr, pw := io.Pipe()
|
var out bytes.Buffer
|
||||||
cmd.Stdout = pw
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Run(); 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(),
|
||||||
|
|
@ -785,40 +675,37 @@ 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.Status(http.StatusOK)
|
c.Data(http.StatusOK, "application/octet-stream", out.Bytes())
|
||||||
|
|
||||||
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()
|
dbHost := os.Getenv("DB_HOST")
|
||||||
|
if dbHost == "" {
|
||||||
|
dbHost = "db"
|
||||||
|
}
|
||||||
|
dbPort := os.Getenv("DB_PORT")
|
||||||
|
if dbPort == "" {
|
||||||
|
dbPort = "5432"
|
||||||
|
}
|
||||||
|
dbName := os.Getenv("DB_NAME")
|
||||||
|
if dbName == "" {
|
||||||
|
dbName = "rss"
|
||||||
|
}
|
||||||
|
dbUser := os.Getenv("DB_USER")
|
||||||
|
if dbUser == "" {
|
||||||
|
dbUser = "rss"
|
||||||
|
}
|
||||||
|
dbPass := os.Getenv("DB_PASS")
|
||||||
|
|
||||||
// Tables to backup
|
// Tables to backup
|
||||||
tables := []string{"noticias", "traducciones", "tags", "tags_noticia"}
|
tables := []string{"noticias", "traducciones", "tags", "tags_noticia"}
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"-h", info.host,
|
"-h", dbHost,
|
||||||
"-p", info.port,
|
"-p", dbPort,
|
||||||
"-U", info.user,
|
"-U", dbUser,
|
||||||
"-d", info.name,
|
"-d", dbName,
|
||||||
"--no-password",
|
"--no-password",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -827,14 +714,14 @@ 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", dbPass))
|
||||||
|
|
||||||
pr, pw := io.Pipe()
|
var sqlOut bytes.Buffer
|
||||||
cmd.Stdout = pw
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &sqlOut
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Run(); 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(),
|
||||||
|
|
@ -842,142 +729,32 @@ func BackupNewsZipped(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("backup_noticias_%s.zip", time.Now().Format("2006-01-02_15-04-05"))
|
// Create ZIP
|
||||||
c.Header("Content-Type", "application/zip")
|
buf := new(bytes.Buffer)
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
zw := zip.NewWriter(buf)
|
||||||
c.Header("Cache-Control", "no-cache")
|
|
||||||
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"))
|
sqlFileName := fmt.Sprintf("backup_noticias_%s.sql", time.Now().Format("2006-01-02"))
|
||||||
f, err := zw.Create(sqlFileName)
|
f, err := zw.Create(sqlFileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("BackupNewsZipped: failed to create ZIP entry: %v", err)
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create ZIP entry", "message": err.Error()})
|
||||||
pw.Close()
|
|
||||||
pr.Close()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
copyDone := make(chan struct{})
|
_, err = f.Write(sqlOut.Bytes())
|
||||||
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
|
|
||||||
func RestoreDatabase(c *gin.Context) {
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write to ZIP", "message": err.Error()})
|
||||||
"error": "No file uploaded",
|
|
||||||
"message": "Se requiere un archivo .sql o .zip",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uploaded, err := file.Open()
|
if err := zw.Close(); err != nil {
|
||||||
if err != nil {
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to close ZIP writer", "message": err.Error()})
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to open uploaded file"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer uploaded.Close()
|
|
||||||
|
|
||||||
data, err := io.ReadAll(uploaded)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read uploaded file"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var sqlData []byte
|
filename := fmt.Sprintf("backup_noticias_%s.zip", time.Now().Format("2006-01-02_15-04-05"))
|
||||||
lowerName := strings.ToLower(file.Filename)
|
c.Header("Content-Type", "application/zip")
|
||||||
switch {
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
case strings.HasSuffix(lowerName, ".sql"):
|
c.Header("Cache-Control", "no-cache")
|
||||||
sqlData = data
|
c.Data(http.StatusOK, "application/zip", buf.Bytes())
|
||||||
case strings.HasSuffix(lowerName, ".zip"):
|
|
||||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ZIP file", "message": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, zf := range zr.File {
|
|
||||||
if zf.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(zf.Name), ".sql") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rc, err := zf.Open()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to open SQL inside ZIP", "message": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sqlData, err = io.ReadAll(rc)
|
|
||||||
rc.Close()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read SQL inside ZIP", "message": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No SQL file found inside ZIP"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "Unsupported file type",
|
|
||||||
"message": "Solo se permiten archivos .sql o .zip",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sqlData) == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Empty backup file"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
info := resolvePgConnInfo()
|
|
||||||
|
|
||||||
cmd := exec.Command("psql",
|
|
||||||
"-h", info.host,
|
|
||||||
"-p", info.port,
|
|
||||||
"-U", info.user,
|
|
||||||
"-d", info.name,
|
|
||||||
"--no-password",
|
|
||||||
"-v", "ON_ERROR_STOP=1",
|
|
||||||
)
|
|
||||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
|
|
||||||
cmd.Stdin = bytes.NewReader(sqlData)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &out
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "Restore failed",
|
|
||||||
"details": stderr.String(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "Base de datos restaurada correctamente",
|
|
||||||
"filename": file.Filename,
|
|
||||||
"output": out.String(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,245 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/rss2/backend/internal/db"
|
|
||||||
"github.com/rss2/backend/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Alerta struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Valor string `json:"valor"`
|
|
||||||
Tipo string `json:"tipo"`
|
|
||||||
Periodo string `json:"periodo"`
|
|
||||||
Hits int `json:"hits"`
|
|
||||||
Baseline float64 `json:"baseline"`
|
|
||||||
Ratio float64 `json:"ratio"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetAlertas(c *gin.Context) {
|
|
||||||
status := c.Query("status")
|
|
||||||
limitStr := c.DefaultQuery("limit", "100")
|
|
||||||
|
|
||||||
limit, _ := strconv.Atoi(limitStr)
|
|
||||||
if limit < 1 || limit > 500 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT id, valor, tipo, periodo::text, hits, baseline, ratio, status, to_char(created_at, 'YYYY-MM-DD HH24:MI') AS created_at
|
|
||||||
FROM alertas
|
|
||||||
`
|
|
||||||
args := []interface{}{}
|
|
||||||
if status != "" {
|
|
||||||
query += fmt.Sprintf(" WHERE status = $%d", len(args)+1)
|
|
||||||
args = append(args, status)
|
|
||||||
}
|
|
||||||
query += fmt.Sprintf(" ORDER BY periodo DESC, ratio DESC, id DESC LIMIT $%d", len(args)+1)
|
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
rows, err := db.GetPool().Query(c.Request.Context(), query, args...)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get alerts", Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
alertas := []Alerta{}
|
|
||||||
for rows.Next() {
|
|
||||||
var a Alerta
|
|
||||||
if err := rows.Scan(&a.ID, &a.Valor, &a.Tipo, &a.Periodo, &a.Hits, &a.Baseline, &a.Ratio, &a.Status, &a.CreatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
alertas = append(alertas, a)
|
|
||||||
}
|
|
||||||
|
|
||||||
var nuevas int
|
|
||||||
db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*)::int FROM alertas WHERE status = 'nueva'").Scan(&nuevas)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"alertas": alertas,
|
|
||||||
"total": len(alertas),
|
|
||||||
"nuevas": nuevas,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func MarkAlertaRead(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid alert id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.GetPool().Exec(c.Request.Context(), "UPDATE alertas SET status = 'leida' WHERE id = $1", id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to update alert", Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func MarkAllAlertasRead(c *gin.Context) {
|
|
||||||
_, err := db.GetPool().Exec(c.Request.Context(), "UPDATE alertas SET status = 'leida' WHERE status = 'nueva'")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to update alerts", Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func ScanAlertasAdmin(c *gin.Context) {
|
|
||||||
inserted, err := RunAlertScan(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Scan failed", Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "new_alerts": inserted})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunAlertScan busca conceptos (persona, lugar, organizacion, tema) cuya
|
|
||||||
// actividad supera significativamente su media de los días anteriores.
|
|
||||||
//
|
|
||||||
// En lugar de comparar siempre contra CURRENT_DATE (que se queda vacío si la
|
|
||||||
// cadena de traducción/NER va por detrás de la ingesta), se toma como día de
|
|
||||||
// referencia el día más reciente que tenga datos de tags etiquetados y se
|
|
||||||
// compara contra la media de los ALERTS_LOOKBACK_DAYS días previos, contando
|
|
||||||
// los días sin actividad como 0.
|
|
||||||
func RunAlertScan(ctx context.Context) (int, error) {
|
|
||||||
minHits := 5
|
|
||||||
minRatio := 5.0
|
|
||||||
minBaseline := 2.0
|
|
||||||
lookbackDays := 8
|
|
||||||
if v := os.Getenv("ALERTS_MIN_HITS"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
||||||
minHits = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := os.Getenv("ALERTS_MIN_RATIO"); v != "" {
|
|
||||||
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
|
|
||||||
minRatio = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := os.Getenv("ALERTS_MIN_BASELINE"); v != "" {
|
|
||||||
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
|
|
||||||
minBaseline = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := os.Getenv("ALERTS_LOOKBACK_DAYS"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
||||||
lookbackDays = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
WITH daily AS (
|
|
||||||
SELECT t.id AS tag_id, t.valor, t.tipo, n.fecha::date AS dia,
|
|
||||||
COUNT(DISTINCT n.id) AS cnt
|
|
||||||
FROM tags_noticia tn
|
|
||||||
JOIN tags t ON tn.tag_id = t.id
|
|
||||||
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
|
||||||
JOIN noticias n ON tr.noticia_id = n.id
|
|
||||||
WHERE n.fecha >= CURRENT_DATE - 30
|
|
||||||
AND n.fecha <= CURRENT_DATE
|
|
||||||
AND t.tipo IN ('persona', 'lugar', 'organizacion', 'tema')
|
|
||||||
GROUP BY t.id, t.valor, t.tipo, n.fecha::date
|
|
||||||
),
|
|
||||||
ref AS (
|
|
||||||
SELECT MAX(dia) AS ref_date FROM daily
|
|
||||||
),
|
|
||||||
prev_series AS (
|
|
||||||
SELECT generate_series((SELECT ref_date - $3::int FROM ref),
|
|
||||||
(SELECT ref_date - 1 FROM ref), '1 day')::date AS dia
|
|
||||||
),
|
|
||||||
today AS (
|
|
||||||
SELECT tag_id, valor, tipo, SUM(cnt)::int AS hits
|
|
||||||
FROM daily
|
|
||||||
WHERE dia = (SELECT ref_date FROM ref)
|
|
||||||
GROUP BY tag_id, valor, tipo
|
|
||||||
),
|
|
||||||
base AS (
|
|
||||||
SELECT t.tag_id, t.valor, t.tipo,
|
|
||||||
AVG(COALESCE(d.cnt, 0))::float AS baseline
|
|
||||||
FROM today t
|
|
||||||
CROSS JOIN prev_series s
|
|
||||||
LEFT JOIN daily d ON d.tag_id = t.tag_id AND d.dia = s.dia
|
|
||||||
GROUP BY t.tag_id, t.valor, t.tipo
|
|
||||||
)
|
|
||||||
SELECT t.valor, t.tipo, t.hits, b.baseline,
|
|
||||||
(t.hits::float / b.baseline) AS ratio,
|
|
||||||
(SELECT ref_date FROM ref)::date AS periodo
|
|
||||||
FROM today t
|
|
||||||
JOIN base b USING (tag_id)
|
|
||||||
WHERE t.hits >= $1
|
|
||||||
AND b.baseline >= $4
|
|
||||||
AND (t.hits::float / b.baseline) >= $2
|
|
||||||
`
|
|
||||||
|
|
||||||
rows, err := db.GetPool().Query(ctx, query, minHits, minRatio, lookbackDays, minBaseline)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
inserted := 0
|
|
||||||
for rows.Next() {
|
|
||||||
var valor, tipo string
|
|
||||||
var hits int
|
|
||||||
var baseline, ratio float64
|
|
||||||
var periodo time.Time
|
|
||||||
if err := rows.Scan(&valor, &tipo, &hits, &baseline, &ratio, &periodo); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
res, err := db.GetPool().Exec(ctx, `
|
|
||||||
INSERT INTO alertas (valor, tipo, periodo, hits, baseline, ratio)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (valor, tipo, periodo) DO NOTHING
|
|
||||||
`, valor, tipo, periodo, hits, baseline, ratio)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("alerts: insert failed for %s (%s): %v", valor, tipo, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if n := res.RowsAffected(); n > 0 {
|
|
||||||
inserted++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return inserted, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartAlertScanner lanza una goroutine que revisa picos de actividad
|
|
||||||
// periódicamente (cada ALERTS_SCAN_INTERVAL_MIN, por defecto 120 min).
|
|
||||||
func StartAlertScanner() {
|
|
||||||
interval := 120
|
|
||||||
if v := os.Getenv("ALERTS_SCAN_INTERVAL_MIN"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
||||||
interval = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
time.Sleep(45 * time.Second)
|
|
||||||
for {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
||||||
inserted, err := RunAlertScan(ctx)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("alerts: scan error: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("alerts: scan finished, %d new alerts", inserted)
|
|
||||||
}
|
|
||||||
time.Sleep(time.Duration(interval) * time.Minute)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
@ -2,22 +2,18 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/rss2/backend/internal/auth"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"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"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckFirstUser checks if this is the first user registration
|
var jwtSecret []byte
|
||||||
// @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)
|
||||||
|
|
@ -28,18 +24,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
|
func InitAuth(secret string) {
|
||||||
// @Summary User login
|
jwtSecret = []byte(secret)
|
||||||
// @Description Authenticate with email and password to receive a JWT token
|
}
|
||||||
// @Tags auth
|
|
||||||
// @Accept json
|
type Claims struct {
|
||||||
// @Produce json
|
UserID int64 `json:"user_id"`
|
||||||
// @Param request body models.LoginRequest true "Login credentials"
|
Email string `json:"email"`
|
||||||
// @Success 200 {object} models.AuthResponse
|
Username string `json:"username"`
|
||||||
// @Failure 400 {object} models.ErrorResponse
|
IsAdmin bool `json:"is_admin"`
|
||||||
// @Failure 401 {object} models.ErrorResponse
|
jwt.RegisteredClaims
|
||||||
// @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 {
|
||||||
|
|
@ -64,7 +60,20 @@ func Login(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenString, err := auth.GenerateToken(user.ID, user.Email, user.Username, user.IsAdmin)
|
expirationTime := time.Now().Add(24 * time.Hour)
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
Username: user.Username,
|
||||||
|
IsAdmin: user.IsAdmin,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err := token.SignedString(jwtSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to generate token"})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to generate token"})
|
||||||
return
|
return
|
||||||
|
|
@ -76,17 +85,6 @@ 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 {
|
||||||
|
|
@ -129,7 +127,20 @@ func Register(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenString, err := auth.GenerateToken(user.ID, user.Email, user.Username, user.IsAdmin)
|
expirationTime := time.Now().Add(24 * time.Hour)
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
Username: user.Username,
|
||||||
|
IsAdmin: user.IsAdmin,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err := token.SignedString(jwtSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to generate token"})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to generate token"})
|
||||||
return
|
return
|
||||||
|
|
@ -142,16 +153,6 @@ 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 {
|
||||||
|
|
@ -159,7 +160,7 @@ func GetCurrentUser(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := userVal.(*auth.Claims)
|
claims := userVal.(*Claims)
|
||||||
|
|
||||||
var user models.User
|
var user models.User
|
||||||
err := db.GetPool().QueryRow(c.Request.Context(), `
|
err := db.GetPool().QueryRow(c.Request.Context(), `
|
||||||
|
|
@ -175,3 +176,8 @@ func GetCurrentUser(c *gin.Context) {
|
||||||
|
|
||||||
c.JSON(http.StatusOK, user)
|
c.JSON(http.StatusOK, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cfg := config.Load()
|
||||||
|
InitAuth(cfg.SecretKey)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/rss2/backend/internal/auth"
|
|
||||||
"github.com/rss2/backend/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
auth.SetJWTSecret("test-secret-key-12345")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginInvalidRequest tests that an empty request body returns 400
|
|
||||||
func TestLoginInvalidRequest(t *testing.T) {
|
|
||||||
router := gin.New()
|
|
||||||
router.POST("/auth/login", Login)
|
|
||||||
|
|
||||||
body := []byte(`{}`)
|
|
||||||
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.StatusBadRequest {
|
|
||||||
t.Errorf("expected status 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp models.ErrorResponse
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
|
|
||||||
if resp.Error != "Invalid request" {
|
|
||||||
t.Errorf("expected 'Invalid request', got %s", resp.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRegisterInvalidRequest tests that an empty request body returns 400
|
|
||||||
func TestRegisterInvalidRequest(t *testing.T) {
|
|
||||||
router := gin.New()
|
|
||||||
router.POST("/auth/register", Register)
|
|
||||||
|
|
||||||
body := []byte(`{}`)
|
|
||||||
req, _ := http.NewRequest("POST", "/auth/register", bytes.NewBuffer(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("expected status 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetCurrentUserUnauthorized tests that unauthenticated request returns 401
|
|
||||||
func TestGetCurrentUserUnauthorized(t *testing.T) {
|
|
||||||
router := gin.New()
|
|
||||||
router.GET("/auth/me", GetCurrentUser)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/auth/me", nil)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("expected status 401, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,54 +3,46 @@ package handlers
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetFeeds returns paginated feeds with optional filters
|
type FeedResponse struct {
|
||||||
// @Summary List feeds
|
ID int64 `json:"id"`
|
||||||
// @Description Returns paginated feeds with optional filtering by active status, category, and country
|
Nombre string `json:"nombre"`
|
||||||
// @Tags feeds
|
Descripcion *string `json:"descripcion"`
|
||||||
// @Produce json
|
URL string `json:"url"`
|
||||||
// @Param page query int false "Page number" default(1) minimum(1)
|
CategoriaID *int64 `json:"categoria_id"`
|
||||||
// @Param per_page query int false "Items per page" default(50) minimum(1) maximum(100)
|
PaisID *int64 `json:"pais_id"`
|
||||||
// @Param activo query string false "Active status filter"
|
Idioma *string `json:"idioma"`
|
||||||
// @Param categoria_id query string false "Category ID filter"
|
Activo bool `json:"activo"`
|
||||||
// @Param pais_id query string false "Country ID filter"
|
Fallos *int64 `json:"fallos"`
|
||||||
// @Success 200 {object} map[string]interface{} "feeds, total, page, per_page, total_pages"
|
LastError *string `json:"last_error"`
|
||||||
// @Failure 500 {object} models.ErrorResponse
|
FuenteURLID *int64 `json:"fuente_url_id"`
|
||||||
// @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")
|
||||||
|
|
||||||
offset := (page - 1) * perPage
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
if perPage < 1 || perPage > 100 {
|
||||||
|
perPage = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
where := "1=1"
|
where := "1=1"
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
|
|
@ -74,7 +66,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(ctx, 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 feeds", Message: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count feeds", Message: err.Error()})
|
||||||
return
|
return
|
||||||
|
|
@ -82,7 +74,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.last_fetched,
|
f.categoria_id, f.pais_id, f.idioma, f.activo, f.fallos, f.last_error,
|
||||||
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
|
||||||
|
|
@ -95,7 +87,7 @@ func GetFeeds(c *gin.Context) {
|
||||||
|
|
||||||
args = append(args, perPage, offset)
|
args = append(args, perPage, offset)
|
||||||
|
|
||||||
rows, err := db.GetPool().Query(ctx, sqlQuery, args...)
|
rows, err := db.GetPool().Query(c.Request.Context(), 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
|
||||||
|
|
@ -103,7 +95,7 @@ func GetFeeds(c *gin.Context) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
type FeedWithStats struct {
|
type FeedWithStats struct {
|
||||||
models.Feed
|
FeedResponse
|
||||||
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"`
|
||||||
|
|
@ -114,7 +106,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.LastFetched,
|
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos, &f.LastError,
|
||||||
&f.Categoria, &f.Pais, &f.NoticiasCount,
|
&f.Categoria, &f.Pais, &f.NoticiasCount,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -125,20 +117,13 @@ func GetFeeds(c *gin.Context) {
|
||||||
|
|
||||||
totalPages := (total + perPage - 1) / perPage
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
|
||||||
response := gin.H{
|
c.JSON(http.StatusOK, 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) {
|
||||||
|
|
@ -148,12 +133,12 @@ func GetFeedByID(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var f models.Feed
|
var f FeedResponse
|
||||||
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, last_fetched
|
SELECT id, nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos
|
||||||
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.LastFetched,
|
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos,
|
||||||
)
|
)
|
||||||
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"})
|
||||||
|
|
@ -192,9 +177,6 @@ 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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,10 +227,6 @@ 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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,10 +248,6 @@ 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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,10 +270,6 @@ 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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -322,10 +292,6 @@ 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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,46 +397,11 @@ func ImportFeeds(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
reader := csv.NewReader(strings.NewReader(string(content)))
|
reader := csv.NewReader(strings.NewReader(string(content)))
|
||||||
records, err := reader.ReadAll()
|
_, err = reader.Read()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid CSV format"})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid CSV format"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(records) == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Empty CSV file"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
colIndex := func(header []string, names ...string) int {
|
|
||||||
for i, h := range header {
|
|
||||||
h = strings.ToLower(strings.TrimSpace(h))
|
|
||||||
for _, n := range names {
|
|
||||||
if h == n {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
header := records[0]
|
|
||||||
isHeader := colIndex(header, "url", "link", "feed", "nombre", "name", "titulo") != -1
|
|
||||||
|
|
||||||
var dataRows [][]string
|
|
||||||
iNombre, iDesc, iURL, iCat, iPais, iLang, iActivo, iFallos := -1, -1, -1, -1, -1, -1, -1, -1
|
|
||||||
if isHeader {
|
|
||||||
iNombre = colIndex(header, "nombre", "name", "titulo", "title")
|
|
||||||
iDesc = colIndex(header, "descripcion", "description")
|
|
||||||
iURL = colIndex(header, "url", "link", "feed")
|
|
||||||
iCat = colIndex(header, "categoria_id", "category_id", "categoria", "category")
|
|
||||||
iPais = colIndex(header, "pais_id", "country_id", "pais", "country")
|
|
||||||
iLang = colIndex(header, "idioma", "language", "lang")
|
|
||||||
iActivo = colIndex(header, "activo", "active", "enabled")
|
|
||||||
iFallos = colIndex(header, "fallos", "failures", "fails")
|
|
||||||
dataRows = records[1:]
|
|
||||||
} else {
|
|
||||||
dataRows = records
|
|
||||||
}
|
|
||||||
|
|
||||||
imported := 0
|
imported := 0
|
||||||
skipped := 0
|
skipped := 0
|
||||||
|
|
@ -482,122 +413,73 @@ 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
|
||||||
}
|
}
|
||||||
committed := false
|
defer tx.Rollback(context.Background())
|
||||||
defer func() {
|
|
||||||
if !committed {
|
|
||||||
tx.Rollback(context.Background())
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
field := func(record []string, idx int) string {
|
for {
|
||||||
if idx < 0 || idx >= len(record) {
|
record, err := reader.Read()
|
||||||
return ""
|
if err == io.EOF {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(record[idx])
|
if err != nil {
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, record := range dataRows {
|
if len(record) < 4 {
|
||||||
if len(record) == 0 {
|
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var nombre, url string
|
nombre := strings.TrimSpace(record[1])
|
||||||
if isHeader {
|
url := strings.TrimSpace(record[3])
|
||||||
nombre = field(record, iNombre)
|
|
||||||
url = field(record, iURL)
|
|
||||||
} else if len(record) == 1 {
|
|
||||||
url = field(record, 0)
|
|
||||||
} else if len(record) == 2 {
|
|
||||||
a := field(record, 0)
|
|
||||||
b := field(record, 1)
|
|
||||||
if looksLikeURL(a) {
|
|
||||||
url, nombre = a, b
|
|
||||||
} else {
|
|
||||||
nombre, url = a, b
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
nombre = field(record, 1)
|
|
||||||
url = field(record, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
if url == "" {
|
if nombre == "" || url == "" {
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if nombre == "" {
|
|
||||||
nombre = nombreFromURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
var descripcion *string
|
var descripcion *string
|
||||||
|
if len(record) > 2 && strings.TrimSpace(record[2]) != "" {
|
||||||
|
descripcionStr := strings.TrimSpace(record[2])
|
||||||
|
descripcion = &descripcionStr
|
||||||
|
}
|
||||||
|
|
||||||
var categoriaID *int64
|
var categoriaID *int64
|
||||||
|
if len(record) > 4 && strings.TrimSpace(record[4]) != "" {
|
||||||
|
catID, err := strconv.ParseInt(strings.TrimSpace(record[4]), 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
categoriaID = &catID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var paisID *int64
|
var paisID *int64
|
||||||
|
if len(record) > 6 && strings.TrimSpace(record[6]) != "" {
|
||||||
|
pID, err := strconv.ParseInt(strings.TrimSpace(record[6]), 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
paisID = &pID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var idioma *string
|
var idioma *string
|
||||||
|
if len(record) > 8 && strings.TrimSpace(record[8]) != "" {
|
||||||
|
lang := strings.TrimSpace(record[8])
|
||||||
|
if len(lang) > 2 {
|
||||||
|
lang = lang[:2]
|
||||||
|
}
|
||||||
|
idioma = &lang
|
||||||
|
}
|
||||||
|
|
||||||
activo := true
|
activo := true
|
||||||
|
if len(record) > 9 && strings.TrimSpace(record[9]) != "" {
|
||||||
|
activo = strings.ToLower(strings.TrimSpace(record[9])) == "true"
|
||||||
|
}
|
||||||
|
|
||||||
var fallos int64
|
var fallos int64
|
||||||
|
if len(record) > 10 && strings.TrimSpace(record[10]) != "" {
|
||||||
if isHeader {
|
f, err := strconv.ParseInt(strings.TrimSpace(record[10]), 10, 64)
|
||||||
if d := field(record, iDesc); d != "" {
|
if err == nil {
|
||||||
descripcion = &d
|
fallos = f
|
||||||
}
|
}
|
||||||
if v := field(record, iCat); v != "" {
|
|
||||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
||||||
categoriaID = &id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := field(record, iPais); v != "" {
|
|
||||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
||||||
paisID = &id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if l := field(record, iLang); l != "" {
|
|
||||||
if len(l) > 2 {
|
|
||||||
l = l[:2]
|
|
||||||
}
|
|
||||||
idioma = &l
|
|
||||||
}
|
|
||||||
if a := field(record, iActivo); a != "" {
|
|
||||||
activo = strings.ToLower(a) == "true"
|
|
||||||
}
|
|
||||||
if f := field(record, iFallos); f != "" {
|
|
||||||
if v, err := strconv.ParseInt(f, 10, 64); err == nil {
|
|
||||||
fallos = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if d := field(record, 2); d != "" {
|
|
||||||
descripcion = &d
|
|
||||||
}
|
|
||||||
if v := field(record, 4); v != "" {
|
|
||||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
||||||
categoriaID = &id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := field(record, 6); v != "" {
|
|
||||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
||||||
paisID = &id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if l := field(record, 8); l != "" {
|
|
||||||
if len(l) > 2 {
|
|
||||||
l = l[:2]
|
|
||||||
}
|
|
||||||
idioma = &l
|
|
||||||
}
|
|
||||||
if a := field(record, 9); a != "" {
|
|
||||||
activo = strings.ToLower(a) == "true"
|
|
||||||
}
|
|
||||||
if f := field(record, 10); f != "" {
|
|
||||||
if v, err := strconv.ParseInt(f, 10, 64); err == nil {
|
|
||||||
fallos = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(context.Background(), "SAVEPOINT sp"); err != nil {
|
|
||||||
failed++
|
|
||||||
errors = append(errors, fmt.Sprintf("Error for %s: %v", url, err))
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingID int64
|
var existingID int64
|
||||||
|
|
@ -608,27 +490,22 @@ func ImportFeeds(c *gin.Context) {
|
||||||
WHERE id=$8`,
|
WHERE id=$8`,
|
||||||
nombre, descripcion, categoriaID, paisID, idioma, activo, fallos, existingID,
|
nombre, descripcion, categoriaID, paisID, idioma, activo, fallos, existingID,
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
failed++
|
||||||
|
errors = append(errors, fmt.Sprintf("Error updating %s: %v", url, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_, err = tx.Exec(context.Background(), `
|
_, err = tx.Exec(context.Background(), `
|
||||||
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos)
|
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
nombre, descripcion, url, categoriaID, paisID, idioma, activo, fallos,
|
nombre, descripcion, url, categoriaID, paisID, idioma, activo, fallos,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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 inserting %s: %v", url, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(context.Background(), "RELEASE SAVEPOINT sp"); err != nil {
|
|
||||||
failed++
|
|
||||||
errors = append(errors, fmt.Sprintf("Error for %s: %v", url, err))
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
imported++
|
imported++
|
||||||
|
|
@ -638,7 +515,6 @@ 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,
|
||||||
|
|
@ -649,22 +525,6 @@ func ImportFeeds(c *gin.Context) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func looksLikeURL(s string) bool {
|
|
||||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://www.") || strings.HasPrefix(s, "https://www.") || strings.HasPrefix(s, "www.") || strings.HasPrefix(s, "feed://")
|
|
||||||
}
|
|
||||||
|
|
||||||
func nombreFromURL(u string) string {
|
|
||||||
u = strings.TrimSpace(u)
|
|
||||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
|
||||||
u = "https://" + u
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(u)
|
|
||||||
if err != nil || parsed.Host == "" {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
return strings.TrimPrefix(parsed.Host, "www.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringOrEmpty(s *string) string {
|
func stringOrEmpty(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +1,46 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getTargetLang returns the target language for translations, defaulting to config DefaultLang
|
type NewsResponse struct {
|
||||||
func getTargetLang(c *gin.Context) string {
|
ID string `json:"id"`
|
||||||
lang := c.Query("lang")
|
Titulo string `json:"titulo"`
|
||||||
if lang == "" {
|
Resumen string `json:"resumen"`
|
||||||
cfg := config.Load()
|
URL string `json:"url"`
|
||||||
lang = cfg.DefaultLang
|
Fecha *time.Time `json:"fecha"`
|
||||||
}
|
ImagenURL *string `json:"imagen_url"`
|
||||||
return lang
|
CategoriaID *int64 `json:"categoria_id"`
|
||||||
|
PaisID *int64 `json:"pais_id"`
|
||||||
|
FuenteNombre string `json:"fuente_nombre"`
|
||||||
|
TitleTranslated *string `json:"title_translated"`
|
||||||
|
SummaryTranslated *string `json:"summary_translated"`
|
||||||
|
LangTranslated *string `json:"lang_translated"`
|
||||||
|
Entities []Entity `json:"entities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|
||||||
|
|
@ -57,13 +49,7 @@ func GetNews(c *gin.Context) {
|
||||||
argNum := 1
|
argNum := 1
|
||||||
|
|
||||||
if query != "" {
|
if query != "" {
|
||||||
if translatedOnly {
|
|
||||||
// With translated_only, search the translated fields too so users can
|
|
||||||
// find translated news by the target language wording.
|
|
||||||
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d OR t.titulo_trad ILIKE $%d OR t.resumen_trad ILIKE $%d)", argNum, argNum, argNum, argNum)
|
|
||||||
} else {
|
|
||||||
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d)", argNum, argNum)
|
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d)", argNum, argNum)
|
||||||
}
|
|
||||||
args = append(args, "%"+query+"%")
|
args = append(args, "%"+query+"%")
|
||||||
argNum++
|
argNum++
|
||||||
}
|
}
|
||||||
|
|
@ -82,8 +68,7 @@ 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 = $%d WHERE %s", argNum, where)
|
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)
|
||||||
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()})
|
||||||
|
|
@ -101,18 +86,19 @@ func GetNews(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlQuery := `
|
sqlQuery := fmt.Sprintf(`
|
||||||
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.contenido, n.url, n.fecha, n.imagen_url,
|
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
|
||||||
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
|
n.categoria_id, n.pais_id, n.fuente_nombre,
|
||||||
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 = $` + strconv.Itoa(argNum) + `
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
|
||||||
WHERE ` + where + `
|
WHERE %s
|
||||||
ORDER BY n.fecha DESC LIMIT $` + strconv.Itoa(argNum+1) + ` OFFSET $` + strconv.Itoa(argNum+2)
|
ORDER BY n.fecha DESC LIMIT $%d OFFSET $%d
|
||||||
|
`, where, argNum, argNum+1)
|
||||||
|
|
||||||
args = append(args, targetLang, perPage, offset)
|
args = append(args, 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 {
|
||||||
|
|
@ -121,16 +107,15 @@ func GetNews(c *gin.Context) {
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var newsList []models.NewsWithTranslations
|
var newsList []NewsResponse
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n models.NewsWithTranslations
|
var n NewsResponse
|
||||||
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.Contenido, &n.URL, &n.Fecha, &imagenURL,
|
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
|
||||||
&categoriaID, &paisID, &fuenteNombre, &lang,
|
&categoriaID, &paisID, &fuenteNombre,
|
||||||
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -144,13 +129,12 @@ func GetNews(c *gin.Context) {
|
||||||
}
|
}
|
||||||
if categoriaID != nil {
|
if categoriaID != nil {
|
||||||
catID := int64(*categoriaID)
|
catID := int64(*categoriaID)
|
||||||
n.CategoryID = &catID
|
n.CategoriaID = &catID
|
||||||
}
|
}
|
||||||
if paisID != nil {
|
if paisID != nil {
|
||||||
pID := int64(*paisID)
|
pID := int64(*paisID)
|
||||||
n.CountryID = &pID
|
n.PaisID = &pID
|
||||||
}
|
}
|
||||||
n.Lang = lang
|
|
||||||
newsList = append(newsList, n)
|
newsList = append(newsList, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,169 +149,27 @@ func GetNews(c *gin.Context) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEntityNews returns the (translated) news that mention a given entity/alias value.
|
|
||||||
func GetEntityNews(c *gin.Context) {
|
|
||||||
valor := c.Query("valor")
|
|
||||||
tipo := c.DefaultQuery("tipo", "persona")
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
|
||||||
page, perPage = validatePageParams(page, perPage, 20, 50)
|
|
||||||
offset := (page - 1) * perPage
|
|
||||||
targetLang := getTargetLang(c)
|
|
||||||
|
|
||||||
daysStr := c.Query("days")
|
|
||||||
days := 0
|
|
||||||
if daysStr != "" {
|
|
||||||
days, _ = strconv.Atoi(daysStr)
|
|
||||||
if days < 1 {
|
|
||||||
days = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
today := c.Query("today") == "true"
|
|
||||||
offsetStr := c.Query("day_offset")
|
|
||||||
dayOffset := 0
|
|
||||||
useDayOffset := false
|
|
||||||
if offsetStr != "" {
|
|
||||||
dayOffset, _ = strconv.Atoi(offsetStr)
|
|
||||||
if dayOffset < 0 {
|
|
||||||
dayOffset = 0
|
|
||||||
}
|
|
||||||
useDayOffset = true
|
|
||||||
}
|
|
||||||
|
|
||||||
params := []interface{}{valor, tipo, targetLang}
|
|
||||||
next := 4
|
|
||||||
timeCond := ""
|
|
||||||
switch {
|
|
||||||
case useDayOffset:
|
|
||||||
// Día exacto: desde el inicio del día (hoy - offset) hasta el inicio del día siguiente
|
|
||||||
timeCond = fmt.Sprintf(
|
|
||||||
" AND n.fecha >= (CURRENT_DATE - %d)::timestamp AND n.fecha < (CURRENT_DATE - %d + 1)::timestamp",
|
|
||||||
dayOffset, dayOffset)
|
|
||||||
case today:
|
|
||||||
timeCond = " AND n.fecha >= date_trunc('day', NOW())"
|
|
||||||
case days > 0:
|
|
||||||
timeCond = fmt.Sprintf(" AND n.fecha >= NOW() - make_interval(days => $%d)", next)
|
|
||||||
params = append(params, days)
|
|
||||||
next++
|
|
||||||
}
|
|
||||||
|
|
||||||
base := fmt.Sprintf(`
|
|
||||||
FROM tags_noticia tn
|
|
||||||
JOIN tags t ON tn.tag_id = t.id
|
|
||||||
JOIN traducciones tr ON tn.traduccion_id = tr.id AND tr.lang_to = $3
|
|
||||||
JOIN noticias n ON tr.noticia_id = n.id
|
|
||||||
LEFT JOIN entity_aliases ea ON LOWER(ea.alias) = LOWER(t.valor) AND ea.tipo = t.tipo
|
|
||||||
WHERE LOWER(COALESCE(ea.canonical_name, t.valor)) = LOWER($1) AND t.tipo = $2%s`, timeCond)
|
|
||||||
|
|
||||||
var total int
|
|
||||||
if err := db.GetPool().QueryRow(c.Request.Context(),
|
|
||||||
fmt.Sprintf("SELECT COUNT(DISTINCT tn.noticia_id) %s", base), params...).Scan(&total); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count entity news", Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
SELECT DISTINCT n.id, n.titulo, COALESCE(n.resumen,''), n.contenido, n.url, n.fecha, n.imagen_url,
|
|
||||||
n.fuente_nombre, n.lang, tr.titulo_trad, tr.resumen_trad
|
|
||||||
%s
|
|
||||||
ORDER BY n.fecha DESC
|
|
||||||
LIMIT $%d OFFSET $%d`, base, next, next+1)
|
|
||||||
|
|
||||||
rows, err := db.GetPool().Query(c.Request.Context(), query, append(params, perPage, offset)...)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get entity news", Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var news []models.NewsWithTranslations
|
|
||||||
for rows.Next() {
|
|
||||||
var n models.NewsWithTranslations
|
|
||||||
var img *string
|
|
||||||
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &img,
|
|
||||||
&n.FuenteNombre, &n.Lang, &n.TitleTranslated, &n.SummaryTranslated); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if img != nil {
|
|
||||||
n.ImagenURL = img
|
|
||||||
}
|
|
||||||
news = append(news, n)
|
|
||||||
}
|
|
||||||
if news == nil {
|
|
||||||
news = []models.NewsWithTranslations{}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPages := (total + perPage - 1) / perPage
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"news": news,
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"per_page": perPage,
|
|
||||||
"total_pages": totalPages,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
|
||||||
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
|
||||||
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 = $1
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
|
||||||
LEFT JOIN (
|
WHERE n.id = $1`
|
||||||
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 models.NewsWithTranslations
|
var n NewsResponse
|
||||||
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, targetLang, id).Scan(
|
err := db.GetPool().QueryRow(c.Request.Context(), sqlQuery, id).Scan(
|
||||||
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL,
|
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
|
||||||
&categoriaID, &paisID, &fuenteNombre, &lang,
|
&categoriaID, &paisID, &fuenteNombre,
|
||||||
&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"})
|
||||||
|
|
@ -342,23 +184,36 @@ func GetNewsByID(c *gin.Context) {
|
||||||
}
|
}
|
||||||
if categoriaID != nil {
|
if categoriaID != nil {
|
||||||
catID := int64(*categoriaID)
|
catID := int64(*categoriaID)
|
||||||
n.CategoryID = &catID
|
n.CategoriaID = &catID
|
||||||
}
|
}
|
||||||
if paisID != nil {
|
if paisID != nil {
|
||||||
pID := int64(*paisID)
|
pID := int64(*paisID)
|
||||||
n.CountryID = &pID
|
n.PaisID = &pID
|
||||||
}
|
}
|
||||||
n.Lang = lang
|
|
||||||
|
|
||||||
// Parse entities from JSON
|
// Fetch entities for this news
|
||||||
if entitiesJSON != nil && *entitiesJSON != "null" {
|
entitiesQuery := `
|
||||||
var entities []models.Entity
|
SELECT t.valor, t.tipo, 1 as cnt, t.wiki_summary, t.wiki_url, t.image_path
|
||||||
if err := json.Unmarshal([]byte(*entitiesJSON), &entities); err == nil {
|
FROM tags_noticia tn
|
||||||
|
JOIN tags t ON tn.tag_id = t.id
|
||||||
|
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
||||||
|
WHERE tr.noticia_id = $1 AND t.tipo IN ('persona', 'organizacion')
|
||||||
|
`
|
||||||
|
rows, err := db.GetPool().Query(c.Request.Context(), entitiesQuery, id)
|
||||||
|
var entities []Entity
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var e Entity
|
||||||
|
if err := rows.Scan(&e.Valor, &e.Tipo, &e.Count, &e.WikiSummary, &e.WikiURL, &e.ImagePath); err == nil {
|
||||||
|
entities = append(entities, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entities == nil {
|
||||||
|
entities = []Entity{}
|
||||||
|
}
|
||||||
n.Entities = entities
|
n.Entities = entities
|
||||||
}
|
|
||||||
} else {
|
|
||||||
n.Entities = []models.Entity{}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, n)
|
c.JSON(http.StatusOK, n)
|
||||||
}
|
}
|
||||||
|
|
@ -380,20 +235,23 @@ 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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEntities returns paginated entities with optional filters
|
type Entity struct {
|
||||||
// @Summary List entities
|
Valor string `json:"valor"`
|
||||||
// @Description Returns paginated entities (personas, organizations, locations) with optional filters
|
Tipo string `json:"tipo"`
|
||||||
// @Tags entities
|
Count int `json:"count"`
|
||||||
// @Produce json
|
WikiSummary *string `json:"wiki_summary"`
|
||||||
// @Param tipo query string false "Entity type" default("persona") Enums(persona, organizacion, lugar, tema)
|
WikiURL *string `json:"wiki_url"`
|
||||||
// @Param page query int false "Page number" default(1) minimum(1)
|
ImagePath *string `json:"image_path"`
|
||||||
// @Param per_page query int false "Items per page" default(50) minimum(1) maximum(100)
|
}
|
||||||
// @Param country_id query string false "Country ID filter"
|
|
||||||
// @Param category_id query string false "Category ID filter"
|
type EntityListResponse struct {
|
||||||
// @Param q query string false "Search query"
|
Entities []Entity `json:"entities"`
|
||||||
// @Success 200 {object} models.EntityListResponse
|
Total int `json:"total"`
|
||||||
// @Failure 500 {object} models.ErrorResponse
|
Page int `json:"page"`
|
||||||
// @Router /entities [get]
|
PerPage int `json:"per_page"`
|
||||||
|
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")
|
||||||
|
|
@ -401,9 +259,18 @@ func GetEntities(c *gin.Context) {
|
||||||
|
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
pageStr := c.DefaultQuery("page", "1")
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
perPageStr := 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
|
||||||
|
|
||||||
|
|
@ -444,8 +311,8 @@ func GetEntities(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
c.JSON(http.StatusOK, models.EntityListResponse{
|
c.JSON(http.StatusOK, EntityListResponse{
|
||||||
Entities: []models.Entity{},
|
Entities: []Entity{},
|
||||||
Total: 0,
|
Total: 0,
|
||||||
Page: page,
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
|
|
@ -477,9 +344,9 @@ func GetEntities(c *gin.Context) {
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var entities []models.Entity
|
var entities []Entity
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e models.Entity
|
var e 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
|
||||||
}
|
}
|
||||||
|
|
@ -487,12 +354,12 @@ func GetEntities(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if entities == nil {
|
if entities == nil {
|
||||||
entities = []models.Entity{}
|
entities = []Entity{}
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPages := (total + perPage - 1) / perPage
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.EntityListResponse{
|
c.JSON(http.StatusOK, EntityListResponse{
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
Total: total,
|
Total: total,
|
||||||
Page: page,
|
Page: page,
|
||||||
|
|
@ -500,123 +367,3 @@ func GetEntities(c *gin.Context) {
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetEntityMentions(c *gin.Context) {
|
|
||||||
valuesRaw := c.DefaultQuery("values", "")
|
|
||||||
days := 30
|
|
||||||
if v := c.Query("days"); v != "" {
|
|
||||||
if d, err := strconv.Atoi(v); err == nil && d >= 1 && d <= 365 {
|
|
||||||
days = d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var values []string
|
|
||||||
for _, v := range strings.Split(valuesRaw, ",") {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if v != "" {
|
|
||||||
values = append(values, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(values) == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "values is required (comma separated)"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(values) > 20 {
|
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Too many values (max 20)"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now().AddDate(0, 0, -(days - 1)).Truncate(24 * time.Hour)
|
|
||||||
|
|
||||||
series := make([]models.MentionSeries, 0, len(values))
|
|
||||||
for _, valor := range values {
|
|
||||||
s, err := buildMentionSeries(c.Request.Context(), valor, days)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get mentions", Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
m := map[string]int{}
|
|
||||||
for _, p := range s.Data {
|
|
||||||
m[p.Fecha] = p.Count
|
|
||||||
}
|
|
||||||
data := make([]models.MentionPoint, 0, days)
|
|
||||||
for i := 0; i < days; i++ {
|
|
||||||
fecha := start.AddDate(0, 0, i).Format("2006-01-02")
|
|
||||||
data = append(data, models.MentionPoint{Fecha: fecha, Count: m[fecha]})
|
|
||||||
}
|
|
||||||
s.Data = data
|
|
||||||
s.Count = 0
|
|
||||||
for _, p := range s.Data {
|
|
||||||
s.Count += p.Count
|
|
||||||
}
|
|
||||||
if s.Tipo == "" {
|
|
||||||
s.Tipo = "desconocido"
|
|
||||||
}
|
|
||||||
series = append(series, *s)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.MentionsResponse{Days: days, Series: series})
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildMentionSeries(ctx context.Context, valor string, days int) (*models.MentionSeries, error) {
|
|
||||||
s := &models.MentionSeries{Valor: valor}
|
|
||||||
|
|
||||||
var tagIDs []int64
|
|
||||||
rows, err := db.GetPool().Query(ctx, `
|
|
||||||
SELECT DISTINCT t.id, t.tipo
|
|
||||||
FROM tags t
|
|
||||||
LEFT JOIN entity_aliases ea ON ea.tipo = t.tipo AND LOWER(ea.alias) = LOWER(t.valor)
|
|
||||||
WHERE LOWER(t.valor) = LOWER($1)
|
|
||||||
OR LOWER(COALESCE(ea.canonical_name, t.valor)) = LOWER($1)
|
|
||||||
`, valor)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var id int64
|
|
||||||
var tipo string
|
|
||||||
if err := rows.Scan(&id, &tipo); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tagIDs = append(tagIDs, id)
|
|
||||||
if s.Tipo == "" {
|
|
||||||
s.Tipo = tipo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
|
|
||||||
if len(tagIDs) == 0 {
|
|
||||||
s.Data = []models.MentionPoint{}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
drows, err := db.GetPool().Query(ctx, `
|
|
||||||
SELECT n.fecha::date::text AS dia, COUNT(DISTINCT n.id) AS cnt
|
|
||||||
FROM tags_noticia tn
|
|
||||||
JOIN tags t ON tn.tag_id = t.id
|
|
||||||
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
|
||||||
JOIN noticias n ON tr.noticia_id = n.id
|
|
||||||
WHERE t.id = ANY($1) AND n.fecha >= CURRENT_DATE - ($2::int - 1)
|
|
||||||
GROUP BY n.fecha::date
|
|
||||||
ORDER BY n.fecha::date
|
|
||||||
`, tagIDs, days)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer drows.Close()
|
|
||||||
|
|
||||||
for drows.Next() {
|
|
||||||
var fecha string
|
|
||||||
var cnt int
|
|
||||||
if err := drows.Scan(&fecha, &cnt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s.Data = append(s.Data, models.MentionPoint{Fecha: fecha, Count: cnt})
|
|
||||||
}
|
|
||||||
if s.Data == nil {
|
|
||||||
s.Data = []models.MentionPoint{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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,187 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/rss2/backend/internal/db"
|
|
||||||
"github.com/rss2/backend/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CreateRemoteWorker(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Capabilities string `json:"capabilities"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "message": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Capabilities == "" {
|
|
||||||
req.Capabilities = "cpu"
|
|
||||||
}
|
|
||||||
|
|
||||||
apiKey := generateAPIKey()
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
var workerID int
|
|
||||||
err := db.GetPool().QueryRow(ctx, `
|
|
||||||
INSERT INTO remote_workers (name, api_key, capabilities, status, created_at)
|
|
||||||
VALUES ($1, $2, $3, 'offline', NOW())
|
|
||||||
RETURNING id
|
|
||||||
`, req.Name, apiKey, req.Capabilities).Scan(&workerID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error creating remote worker: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create worker"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"id": workerID,
|
|
||||||
"name": req.Name,
|
|
||||||
"api_key": apiKey,
|
|
||||||
"capabilities": req.Capabilities,
|
|
||||||
"status": "offline",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListRemoteWorkers(c *gin.Context) {
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
rows, err := db.GetPool().Query(ctx, `
|
|
||||||
SELECT id, name, api_key, capabilities, status, last_seen, created_at
|
|
||||||
FROM remote_workers
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error listing remote workers: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list workers"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var workers []models.RemoteWorker
|
|
||||||
for rows.Next() {
|
|
||||||
var w models.RemoteWorker
|
|
||||||
if err := rows.Scan(&w.ID, &w.Name, &w.APIKey, &w.Capabilities, &w.Status, &w.LastSeen, &w.CreatedAt); err != nil {
|
|
||||||
log.Printf("Error scanning worker: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
workers = append(workers, w)
|
|
||||||
}
|
|
||||||
|
|
||||||
if workers == nil {
|
|
||||||
workers = []models.RemoteWorker{}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, workers)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetRemoteWorker(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
var w models.RemoteWorker
|
|
||||||
err := db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT id, name, api_key, capabilities, status, last_seen, created_at
|
|
||||||
FROM remote_workers
|
|
||||||
WHERE id = $1
|
|
||||||
`, id).Scan(&w.ID, &w.Name, &w.APIKey, &w.Capabilities, &w.Status, &w.LastSeen, &w.CreatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Worker not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats struct {
|
|
||||||
JobsCompleted int `json:"jobs_completed"`
|
|
||||||
JobsPending int `json:"jobs_pending"`
|
|
||||||
}
|
|
||||||
|
|
||||||
db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM traducciones WHERE worker_id = $1 AND status = 'done'
|
|
||||||
`, id).Scan(&stats.JobsCompleted)
|
|
||||||
|
|
||||||
db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM traducciones WHERE worker_id = $1 AND status = 'pending'
|
|
||||||
`, id).Scan(&stats.JobsPending)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"worker": w,
|
|
||||||
"stats": stats,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteRemoteWorker(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
result, err := db.GetPool().Exec(ctx, `
|
|
||||||
DELETE FROM remote_workers WHERE id = $1
|
|
||||||
`, id)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error deleting remote worker: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete worker"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.RowsAffected() == 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Worker not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Worker deleted"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func ToggleRemoteWorker(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
var currentStatus string
|
|
||||||
err := db.GetPool().QueryRow(ctx, `SELECT status FROM remote_workers WHERE id = $1`, id).Scan(¤tStatus)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Worker not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newStatus := "disabled"
|
|
||||||
if currentStatus == "disabled" {
|
|
||||||
newStatus = "offline"
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.GetPool().Exec(ctx, `UPDATE remote_workers SET status = $1 WHERE id = $2`, newStatus, id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update worker status"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": newStatus})
|
|
||||||
}
|
|
||||||
|
|
||||||
func RegenerateAPIKey(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
newAPIKey := generateAPIKey()
|
|
||||||
|
|
||||||
_, err := db.GetPool().Exec(ctx, `UPDATE remote_workers SET api_key = $1 WHERE id = $2`, newAPIKey, id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate API key"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"api_key": newAPIKey})
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateAPIKey() string {
|
|
||||||
bytes := make([]byte, 32)
|
|
||||||
rand.Read(bytes)
|
|
||||||
return hex.EncodeToString(bytes)
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +1,19 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/rss2/backend/internal/auth"
|
|
||||||
"github.com/rss2/backend/internal/cache"
|
|
||||||
"github.com/rss2/backend/internal/db"
|
"github.com/rss2/backend/internal/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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LogSearch records a logged-in user's search term so frequent searches gain priority.
|
|
||||||
func LogSearch(c *gin.Context) {
|
|
||||||
user := c.MustGet("user").(*auth.Claims)
|
|
||||||
var req struct {
|
|
||||||
Query string `json:"q"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
term := strings.ToLower(strings.TrimSpace(req.Query))
|
|
||||||
if term == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "q requerido"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(term) > 100 {
|
|
||||||
term = term[:100]
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := db.GetPool().Exec(c.Request.Context(), `
|
|
||||||
INSERT INTO user_search_tags (user_id, term, count, last_used)
|
|
||||||
VALUES ($1, $2, 1, NOW())
|
|
||||||
ON CONFLICT (user_id, term)
|
|
||||||
DO UPDATE SET count = user_search_tags.count + 1, last_used = NOW()
|
|
||||||
`, user.UserID, term)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to save search", Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchSuggestions returns the user's most used search terms (dynamic tags).
|
|
||||||
func SearchSuggestions(c *gin.Context) {
|
|
||||||
user := c.MustGet("user").(*auth.Claims)
|
|
||||||
q := strings.TrimSpace(c.Query("q"))
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT term FROM user_search_tags
|
|
||||||
WHERE user_id = $1`
|
|
||||||
args := []interface{}{user.UserID}
|
|
||||||
if q != "" {
|
|
||||||
query += ` AND term ILIKE $2`
|
|
||||||
args = append(args, "%"+q+"%")
|
|
||||||
}
|
|
||||||
query += ` ORDER BY count DESC, last_used DESC LIMIT 10`
|
|
||||||
|
|
||||||
rows, err := db.GetPool().Query(c.Request.Context(), query, args...)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get suggestions", Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
terms := []string{}
|
|
||||||
for rows.Next() {
|
|
||||||
var t string
|
|
||||||
if err := rows.Scan(&t); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
terms = append(terms, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
||||||
|
|
@ -112,6 +24,13 @@ 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"
|
||||||
|
|
@ -129,58 +48,59 @@ 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
|
||||||
args := []interface{}{lang}
|
args := []interface{}{}
|
||||||
argNum := 2
|
argNum := 1
|
||||||
whereClause := "WHERE 1=1"
|
whereClause := "WHERE 1=1"
|
||||||
|
|
||||||
if query != "" {
|
if query != "" {
|
||||||
whereClause += " AND (n.titulo ILIKE $" + strconv.Itoa(argNum) + " OR n.resumen ILIKE $" + strconv.Itoa(argNum) + ")"
|
whereClause += " AND (n.titulo ILIKE $" + strconv.Itoa(argNum) + " OR n.resumen ILIKE $" + strconv.Itoa(argNum) + " OR n.contenido ILIKE $" + strconv.Itoa(argNum) + ")"
|
||||||
args = append(args, "%"+query+"%")
|
args = append(args, "%"+query+"%")
|
||||||
argNum++
|
argNum++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if lang != "" {
|
||||||
|
whereClause += " AND t.lang_to = $" + strconv.Itoa(argNum)
|
||||||
|
args = append(args, lang)
|
||||||
|
argNum++
|
||||||
|
}
|
||||||
|
|
||||||
if categoriaID != "" {
|
if categoriaID != "" {
|
||||||
if catID, err := strconv.ParseInt(categoriaID, 10, 64); err == nil {
|
|
||||||
whereClause += " AND n.categoria_id = $" + strconv.Itoa(argNum)
|
whereClause += " AND n.categoria_id = $" + strconv.Itoa(argNum)
|
||||||
|
catID, err := strconv.ParseInt(categoriaID, 10, 64)
|
||||||
|
if err == nil {
|
||||||
args = append(args, catID)
|
args = append(args, catID)
|
||||||
argNum++
|
argNum++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if paisID != "" {
|
if paisID != "" {
|
||||||
if pID, err := strconv.ParseInt(paisID, 10, 64); err == nil {
|
|
||||||
whereClause += " AND n.pais_id = $" + strconv.Itoa(argNum)
|
whereClause += " AND n.pais_id = $" + strconv.Itoa(argNum)
|
||||||
|
pID, err := strconv.ParseInt(paisID, 10, 64)
|
||||||
|
if err == nil {
|
||||||
args = append(args, pID)
|
args = append(args, pID)
|
||||||
argNum++
|
argNum++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlQuery := `
|
|
||||||
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.contenido, n.url, n.fecha, n.imagen_url,
|
|
||||||
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
|
|
||||||
t.titulo_trad,
|
|
||||||
t.resumen_trad,
|
|
||||||
t.lang_to as lang_trad
|
|
||||||
FROM noticias n
|
|
||||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1
|
|
||||||
` + whereClause + `
|
|
||||||
ORDER BY n.fecha DESC
|
|
||||||
LIMIT $` + strconv.Itoa(argNum) + ` OFFSET $` + strconv.Itoa(argNum+1)
|
|
||||||
|
|
||||||
args = append(args, perPage, offset)
|
args = append(args, perPage, offset)
|
||||||
|
|
||||||
|
sqlQuery := `
|
||||||
|
SELECT n.id, n.titulo, n.resumen, n.contenido, n.url, n.imagen,
|
||||||
|
n.feed_id, n.lang, n.categoria_id, n.pais_id, n.created_at, n.updated_at,
|
||||||
|
COALESCE(t.titulo_trad, '') as titulo_trad,
|
||||||
|
COALESCE(t.resumen_trad, '') as resumen_trad,
|
||||||
|
t.lang_to as lang_trad,
|
||||||
|
f.nombre as fuente_nombre
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $` + strconv.Itoa(argNum) + `
|
||||||
|
LEFT JOIN feeds f ON f.id = n.feed_id
|
||||||
|
` + whereClause + `
|
||||||
|
ORDER BY n.created_at DESC
|
||||||
|
LIMIT $` + strconv.Itoa(argNum+1) + ` OFFSET $` + strconv.Itoa(argNum+2)
|
||||||
|
|
||||||
rows, err := db.GetPool().Query(ctx, sqlQuery, args...)
|
rows, err := db.GetPool().Query(ctx, sqlQuery, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Search failed", Message: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Search failed", Message: err.Error()})
|
||||||
|
|
@ -191,37 +111,30 @@ func SearchNews(c *gin.Context) {
|
||||||
var newsList []models.NewsWithTranslations
|
var newsList []models.NewsWithTranslations
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n models.NewsWithTranslations
|
var n models.NewsWithTranslations
|
||||||
var imagenURL, fuenteNombre *string
|
var imagen *string
|
||||||
var categoriaIDp, paisIDp *int64
|
|
||||||
var lang string
|
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL,
|
&n.ID, &n.Title, &n.Summary, &n.Content, &n.URL, &imagen,
|
||||||
&categoriaIDp, &paisIDp, &fuenteNombre, &lang,
|
&n.FeedID, &n.Lang, &n.CategoryID, &n.CountryID, &n.CreatedAt, &n.UpdatedAt,
|
||||||
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if imagenURL != nil {
|
if imagen != nil {
|
||||||
n.ImagenURL = imagenURL
|
n.ImageURL = imagen
|
||||||
}
|
}
|
||||||
if fuenteNombre != nil {
|
|
||||||
n.FuenteNombre = *fuenteNombre
|
|
||||||
}
|
|
||||||
n.CategoryID = categoriaIDp
|
|
||||||
n.CountryID = paisIDp
|
|
||||||
n.Lang = lang
|
|
||||||
newsList = append(newsList, n)
|
newsList = append(newsList, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get total count
|
// Get total count
|
||||||
countArgs := args[:len(args)-2]
|
countArgs := args[:len(args)-2]
|
||||||
|
|
||||||
|
// Remove LIMIT/OFFSET from args for count
|
||||||
var total int
|
var total int
|
||||||
err = db.GetPool().QueryRow(ctx, `
|
err = db.GetPool().QueryRow(ctx, `
|
||||||
SELECT COUNT(*) FROM noticias n
|
SELECT COUNT(*) FROM noticias n
|
||||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $`+strconv.Itoa(argNum)+`
|
||||||
`+whereClause, countArgs...).Scan(&total)
|
`+whereClause, countArgs...).Scan(&total)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
total = len(newsList)
|
total = len(newsList)
|
||||||
|
|
@ -229,42 +142,21 @@ func SearchNews(c *gin.Context) {
|
||||||
|
|
||||||
totalPages := (total + perPage - 1) / perPage
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
|
||||||
response := gin.H{
|
response := models.NewsListResponse{
|
||||||
"news": newsList,
|
News: newsList,
|
||||||
"total": total,
|
Total: total,
|
||||||
"page": page,
|
Page: page,
|
||||||
"per_page": perPage,
|
PerPage: perPage,
|
||||||
"total_pages": totalPages,
|
TotalPages: 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)
|
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(ctx, `
|
err := db.GetPool().QueryRow(c.Request.Context(), `
|
||||||
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,
|
||||||
|
|
@ -283,7 +175,7 @@ func GetStats(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.GetPool().Query(ctx, `
|
rows, err := db.GetPool().Query(c.Request.Context(), `
|
||||||
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
|
||||||
|
|
@ -295,12 +187,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.CategoriaID, &cs.CategoriaName, &cs.Count)
|
rows.Scan(&cs.CategoryID, &cs.CategoryName, &cs.Count)
|
||||||
stats.TopCategories = append(stats.TopCategories, cs)
|
stats.TopCategories = append(stats.TopCategories, cs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err = db.GetPool().Query(ctx, `
|
rows, err = db.GetPool().Query(c.Request.Context(), `
|
||||||
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
|
||||||
|
|
@ -312,28 +204,16 @@ 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.PaisID, &cs.PaisName, &cs.FlagEmoji, &cs.Count)
|
rows.Scan(&cs.CountryID, &cs.CountryName, &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) {
|
||||||
ctx := c.Request.Context()
|
rows, err := db.GetPool().Query(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()})
|
||||||
|
|
@ -341,30 +221,23 @@ func GetCategories(c *gin.Context) {
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var categories []models.Category
|
type Category struct {
|
||||||
for rows.Next() {
|
ID int64 `json:"id"`
|
||||||
var cat models.Category
|
Nombre string `json:"nombre"`
|
||||||
rows.Scan(&cat.ID, &cat.Nombre)
|
|
||||||
categories = append(categories, cat)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if data, err := json.Marshal(categories); err == nil {
|
var categories []Category
|
||||||
cache.Set(ctx, cacheKey, string(data), cache.TTLLong)
|
for rows.Next() {
|
||||||
|
var cat Category
|
||||||
|
rows.Scan(&cat.ID, &cat.Nombre)
|
||||||
|
categories = append(categories, cat)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, categories)
|
c.JSON(http.StatusOK, categories)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCountries(c *gin.Context) {
|
func GetCountries(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
rows, err := db.GetPool().Query(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
|
||||||
|
|
@ -375,16 +248,18 @@ func GetCountries(c *gin.Context) {
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var countries []models.Country
|
type Country struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Nombre string `json:"nombre"`
|
||||||
|
Continente string `json:"continente"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var countries []Country
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var country models.Country
|
var country 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,344 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
"github.com/rss2/backend/internal/db"
|
|
||||||
"github.com/rss2/backend/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
|
||||||
}
|
|
||||||
|
|
||||||
type WSWorker struct {
|
|
||||||
conn *websocket.Conn
|
|
||||||
workerID int
|
|
||||||
capabilities string
|
|
||||||
workerName string
|
|
||||||
lastHeartbeat time.Time
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
workers = make(map[int]*WSWorker)
|
|
||||||
workersByAPI = make(map[string]*WSWorker)
|
|
||||||
workersMu sync.RWMutex
|
|
||||||
)
|
|
||||||
|
|
||||||
func HandleWorkerWS(c *gin.Context) {
|
|
||||||
apiKey := c.Query("api_key")
|
|
||||||
if apiKey == "" {
|
|
||||||
apiKey = c.GetHeader("X-API-Key")
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiKey == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
var workerID int
|
|
||||||
var capabilities string
|
|
||||||
err := db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT id, capabilities FROM remote_workers
|
|
||||||
WHERE api_key = $1 AND status != 'disabled'
|
|
||||||
`, apiKey).Scan(&workerID, &capabilities)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("WebSocket upgrade error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
wsWorker := &WSWorker{
|
|
||||||
conn: conn,
|
|
||||||
workerID: workerID,
|
|
||||||
capabilities: capabilities,
|
|
||||||
lastHeartbeat: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
workersMu.Lock()
|
|
||||||
workers[workerID] = wsWorker
|
|
||||||
workersByAPI[apiKey] = wsWorker
|
|
||||||
workersMu.Unlock()
|
|
||||||
|
|
||||||
db.GetPool().Exec(ctx, `
|
|
||||||
UPDATE remote_workers SET status = 'online', last_seen = NOW() WHERE id = $1
|
|
||||||
`, workerID)
|
|
||||||
|
|
||||||
go heartbeatLoop(wsWorker)
|
|
||||||
go writeLoop(wsWorker)
|
|
||||||
readLoop(wsWorker, apiKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readLoop(wsWorker *WSWorker, apiKey string) {
|
|
||||||
defer func() {
|
|
||||||
cleanupWorker(wsWorker, apiKey)
|
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
|
||||||
var msg models.WSClientMessage
|
|
||||||
err := wsWorker.conn.ReadJSON(&msg)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Worker %d read error: %v", wsWorker.workerID, err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg.Type {
|
|
||||||
case "register":
|
|
||||||
wsWorker.mu.Lock()
|
|
||||||
if msg.WorkerName != "" {
|
|
||||||
wsWorker.workerName = msg.WorkerName
|
|
||||||
}
|
|
||||||
if msg.Capabilities != "" {
|
|
||||||
wsWorker.capabilities = msg.Capabilities
|
|
||||||
}
|
|
||||||
wsWorker.mu.Unlock()
|
|
||||||
|
|
||||||
sendWS(wsWorker, models.WSServerMessage{Type: "ack", Job: nil})
|
|
||||||
|
|
||||||
case "heartbeat":
|
|
||||||
wsWorker.mu.Lock()
|
|
||||||
wsWorker.lastHeartbeat = time.Now()
|
|
||||||
wsWorker.mu.Unlock()
|
|
||||||
sendWS(wsWorker, models.WSServerMessage{Type: "ack", Job: nil})
|
|
||||||
|
|
||||||
case "result":
|
|
||||||
if msg.Result != nil {
|
|
||||||
handleTranslationResult(wsWorker.workerID, msg.Result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeLoop(wsWorker *WSWorker) {
|
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
wsWorker.mu.Lock()
|
|
||||||
err := wsWorker.conn.WriteMessage(websocket.PingMessage, nil)
|
|
||||||
wsWorker.mu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func heartbeatLoop(wsWorker *WSWorker) {
|
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
wsWorker.mu.Lock()
|
|
||||||
elapsed := time.Since(wsWorker.lastHeartbeat)
|
|
||||||
wsWorker.mu.Unlock()
|
|
||||||
|
|
||||||
if elapsed > 60*time.Second {
|
|
||||||
log.Printf("Worker %d heartbeat timeout", wsWorker.workerID)
|
|
||||||
wsWorker.conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sendWS(wsWorker, models.WSServerMessage{Type: "ping", Job: nil})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendWS(wsWorker *WSWorker, msg models.WSServerMessage) {
|
|
||||||
data, _ := json.Marshal(msg)
|
|
||||||
wsWorker.mu.Lock()
|
|
||||||
wsWorker.conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
wsWorker.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanupWorker(wsWorker *WSWorker, apiKey string) {
|
|
||||||
workersMu.Lock()
|
|
||||||
delete(workers, wsWorker.workerID)
|
|
||||||
delete(workersByAPI, apiKey)
|
|
||||||
workersMu.Unlock()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
db.GetPool().Exec(ctx, `
|
|
||||||
UPDATE remote_workers SET status = 'offline', last_seen = NOW() WHERE id = $1
|
|
||||||
`, wsWorker.workerID)
|
|
||||||
|
|
||||||
wsWorker.conn.Close()
|
|
||||||
|
|
||||||
db.GetPool().Exec(ctx, `
|
|
||||||
UPDATE traducciones
|
|
||||||
SET status = 'pending', worker_id = NULL, assigned_at = NULL
|
|
||||||
WHERE worker_id = $1 AND status = 'assigned'
|
|
||||||
`, wsWorker.workerID)
|
|
||||||
|
|
||||||
log.Printf("Worker %d disconnected", wsWorker.workerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleTranslationResult(workerID int, result *models.TranslationResult) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if result.Error != "" {
|
|
||||||
db.GetPool().Exec(ctx, `
|
|
||||||
UPDATE traducciones
|
|
||||||
SET status = 'error', worker_id = NULL, assigned_at = NULL
|
|
||||||
WHERE id = $1
|
|
||||||
`, result.JobID)
|
|
||||||
log.Printf("Job %d failed: %s", result.JobID, result.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := db.GetPool().Exec(ctx, `
|
|
||||||
UPDATE traducciones
|
|
||||||
SET titulo_trad = $1, resumen_trad = $2, status = 'done',
|
|
||||||
worker_id = $3, assigned_at = NULL
|
|
||||||
WHERE id = $4
|
|
||||||
`, result.TitleTr, result.SummaryTr, workerID, result.JobID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error updating translation result: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db.GetPool().Exec(ctx, `
|
|
||||||
UPDATE remote_workers SET last_seen = NOW() WHERE id = $1
|
|
||||||
`, workerID)
|
|
||||||
|
|
||||||
log.Printf("Job %d completed by worker %d", result.JobID, workerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func AssignJobToWorker(workerID int) *models.TranslationJob {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
workersMu.RLock()
|
|
||||||
wsWorker, exists := workers[workerID]
|
|
||||||
workersMu.RUnlock()
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
wsWorker.mu.Lock()
|
|
||||||
workerCapabilities := wsWorker.capabilities
|
|
||||||
wsWorker.mu.Unlock()
|
|
||||||
|
|
||||||
_ = workerCapabilities
|
|
||||||
|
|
||||||
var job models.TranslationJob
|
|
||||||
err := db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT t.id, t.noticia_id, t.lang_from, t.lang_to, n.titulo, n.resumen
|
|
||||||
FROM traducciones t
|
|
||||||
JOIN noticias n ON n.id = t.noticia_id
|
|
||||||
WHERE t.status = 'pending'
|
|
||||||
AND (t.worker_id IS NULL OR t.status = 'assigned' AND t.assigned_at < NOW() - INTERVAL '5 minutes')
|
|
||||||
AND t.lang_to = 'es'
|
|
||||||
AND (t.titulo_trad IS NULL OR t.resumen_trad IS NULL)
|
|
||||||
ORDER BY n.fecha DESC
|
|
||||||
LIMIT 1
|
|
||||||
FOR UPDATE SKIP LOCKED
|
|
||||||
`).Scan(&job.ID, &job.NewsID, &job.LangFrom, &job.LangTo, &job.Title, &job.Summary)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
db.GetPool().Exec(ctx, `
|
|
||||||
UPDATE traducciones
|
|
||||||
SET status = 'assigned', worker_id = $1, assigned_at = NOW()
|
|
||||||
WHERE id = $2
|
|
||||||
`, workerID, job.ID)
|
|
||||||
|
|
||||||
sendWS(wsWorker, models.WSServerMessage{
|
|
||||||
Type: "job",
|
|
||||||
Job: &job,
|
|
||||||
})
|
|
||||||
|
|
||||||
return &job
|
|
||||||
}
|
|
||||||
|
|
||||||
func StartJobAssigner() {
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
workersMu.RLock()
|
|
||||||
for workerID := range workers {
|
|
||||||
workersMu.RUnlock()
|
|
||||||
AssignJobToWorker(workerID)
|
|
||||||
workersMu.RLock()
|
|
||||||
}
|
|
||||||
workersMu.RUnlock()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
db.GetPool().Exec(ctx, `
|
|
||||||
UPDATE traducciones
|
|
||||||
SET status = 'pending', worker_id = NULL, assigned_at = NULL
|
|
||||||
WHERE status = 'assigned'
|
|
||||||
AND assigned_at < NOW() - INTERVAL '10 minutes'
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetModelDownloadURL(c *gin.Context) {
|
|
||||||
apiKey := c.Query("api_key")
|
|
||||||
if apiKey == "" {
|
|
||||||
apiKey = c.GetHeader("X-API-Key")
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiKey == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
var workerID int
|
|
||||||
err := db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT id FROM remote_workers WHERE api_key = $1
|
|
||||||
`, apiKey).Scan(&workerID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var modelURL, modelPath string
|
|
||||||
db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT value FROM config WHERE key = 'remote_worker_model_url'
|
|
||||||
`).Scan(&modelURL)
|
|
||||||
|
|
||||||
db.GetPool().QueryRow(ctx, `
|
|
||||||
SELECT value FROM config WHERE key = 'remote_worker_model_path'
|
|
||||||
`).Scan(&modelPath)
|
|
||||||
|
|
||||||
if modelPath == "" {
|
|
||||||
modelPath = "/app/models/nllb-ct2"
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"model_download_url": modelURL,
|
|
||||||
"model_path": modelPath,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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,19 +1,27 @@
|
||||||
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/golang-jwt/jwt/v5"
|
||||||
"github.com/rss2/backend/internal/config"
|
|
||||||
"github.com/rss2/backend/internal/logger"
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var jwtSecret []byte
|
||||||
|
|
||||||
|
func SetJWTSecret(secret string) {
|
||||||
|
jwtSecret = []byte(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
func AuthRequired() gin.HandlerFunc {
|
func AuthRequired() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
|
@ -30,8 +38,12 @@ func AuthRequired() gin.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := auth.ValidateToken(tokenString)
|
claims := &Claims{}
|
||||||
if err != nil {
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return jwtSecret, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
|
|
@ -51,7 +63,7 @@ func AdminRequired() gin.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := userVal.(*auth.Claims)
|
claims := userVal.(*Claims)
|
||||||
if !claims.IsAdmin {
|
if !claims.IsAdmin {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
|
|
@ -63,26 +75,9 @@ func AdminRequired() gin.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
func CORSMiddleware() gin.HandlerFunc {
|
func CORSMiddleware() gin.HandlerFunc {
|
||||||
cfg := config.Load()
|
|
||||||
allowedOrigins := strings.Split(cfg.AllowedOrigins, ",")
|
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
origin := c.Request.Header.Get("Origin")
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
allowed := false
|
|
||||||
for _, o := range allowedOrigins {
|
|
||||||
o = strings.TrimSpace(o)
|
|
||||||
if o == origin {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if allowed {
|
|
||||||
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-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")
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
||||||
|
|
||||||
|
|
@ -97,101 +92,17 @@ 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()
|
||||||
clientIP := c.ClientIP()
|
if status >= 400 {
|
||||||
method := c.Request.Method
|
// Log error responses
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/rss2/backend/internal/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
auth.SetJWTSecret("test-secret-key-12345")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthRequiredNoHeader(t *testing.T) {
|
|
||||||
router := gin.New()
|
|
||||||
router.Use(AuthRequired())
|
|
||||||
router.GET("/protected", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/protected", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("expected status 401, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthRequiredInvalidToken(t *testing.T) {
|
|
||||||
router := gin.New()
|
|
||||||
router.Use(AuthRequired())
|
|
||||||
router.GET("/protected", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/protected", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("expected status 401, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthRequiredValidToken(t *testing.T) {
|
|
||||||
router := gin.New()
|
|
||||||
router.Use(AuthRequired())
|
|
||||||
router.GET("/protected", func(c *gin.Context) {
|
|
||||||
userVal, _ := c.Get("user")
|
|
||||||
claims := userVal.(*auth.Claims)
|
|
||||||
c.JSON(200, gin.H{"user_id": claims.UserID})
|
|
||||||
})
|
|
||||||
|
|
||||||
token, _ := auth.GenerateToken(42, "test@test.com", "testuser", false)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/protected", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("expected status 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminRequiredNotAdmin(t *testing.T) {
|
|
||||||
router := gin.New()
|
|
||||||
router.Use(AuthRequired())
|
|
||||||
router.Use(AdminRequired())
|
|
||||||
router.GET("/admin", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
token, _ := auth.GenerateToken(1, "test@test.com", "testuser", false)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/admin", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("expected status 403, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminRequiredIsAdmin(t *testing.T) {
|
|
||||||
router := gin.New()
|
|
||||||
router.Use(AuthRequired())
|
|
||||||
router.Use(AdminRequired())
|
|
||||||
router.GET("/admin", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
token, _ := auth.GenerateToken(1, "test@test.com", "adminuser", true)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/admin", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("expected status 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORSMiddleware(t *testing.T) {
|
|
||||||
router := gin.New()
|
|
||||||
router.Use(CORSMiddleware())
|
|
||||||
router.GET("/test", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/test", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Header().Get("Access-Control-Allow-Origin") == "" {
|
|
||||||
t.Error("expected CORS header to be set")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORSPreflight(t *testing.T) {
|
|
||||||
router := gin.New()
|
|
||||||
router.Use(CORSMiddleware())
|
|
||||||
router.GET("/test", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("OPTIONS", "/test", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNoContent {
|
|
||||||
t.Errorf("expected status 204 for preflight, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,12 +6,12 @@ import (
|
||||||
|
|
||||||
type News struct {
|
type News struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Titulo string `json:"titulo"`
|
Title string `json:"title"`
|
||||||
Resumen string `json:"resumen"`
|
Summary string `json:"summary"`
|
||||||
Contenido string `json:"contenido"`
|
Content string `json:"content"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
ImagenURL *string `json:"imagen_url"`
|
ImageURL *string `json:"image_url"`
|
||||||
Fecha *time.Time `json:"fecha"`
|
PublishedAt *time.Time `json:"published_at"`
|
||||||
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,73 +20,35 @@ type News struct {
|
||||||
|
|
||||||
type NewsWithTranslations struct {
|
type NewsWithTranslations struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Titulo string `json:"titulo"`
|
Title string `json:"title"`
|
||||||
Resumen string `json:"resumen"`
|
Summary string `json:"summary"`
|
||||||
Contenido string `json:"contenido"`
|
Content string `json:"content"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
ImagenURL *string `json:"imagen_url"`
|
ImageURL *string `json:"image_url"`
|
||||||
Fecha *string `json:"fecha"`
|
PublishedAt *string `json:"published_at"`
|
||||||
Lang string `json:"lang"`
|
Lang string `json:"lang"`
|
||||||
FeedID int64 `json:"feed_id"`
|
FeedID int64 `json:"feed_id"`
|
||||||
CategoryID *int64 `json:"categoria_id"`
|
CategoryID *int64 `json:"category_id"`
|
||||||
CountryID *int64 `json:"pais_id"`
|
CountryID *int64 `json:"country_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"`
|
||||||
Nombre string `json:"nombre"`
|
Title string `json:"title"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
SiteURL *string `json:"site_url"`
|
SiteURL *string `json:"site_url"`
|
||||||
Descripcion *string `json:"descripcion"`
|
Description *string `json:"description"`
|
||||||
ImagenURL *string `json:"imagen_url"`
|
ImageURL *string `json:"image_url"`
|
||||||
Idioma *string `json:"idioma"`
|
Language *string `json:"language"`
|
||||||
CategoriaID *int64 `json:"categoria_id"`
|
CategoryID *int64 `json:"category_id"`
|
||||||
PaisID *int64 `json:"pais_id"`
|
CountryID *int64 `json:"country_id"`
|
||||||
Activo bool `json:"activo"`
|
Active bool `json:"active"`
|
||||||
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"`
|
||||||
|
|
@ -94,7 +56,7 @@ type Feed struct {
|
||||||
|
|
||||||
type Category struct {
|
type Category struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Nombre string `json:"nombre"`
|
Name string `json:"name"`
|
||||||
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"`
|
||||||
|
|
@ -102,19 +64,19 @@ type Category struct {
|
||||||
|
|
||||||
type Country struct {
|
type Country struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Nombre string `json:"nombre"`
|
Name string `json:"name"`
|
||||||
Codigo string `json:"codigo"`
|
Code string `json:"code"`
|
||||||
Continente string `json:"continente"`
|
Continent string `json:"continent"`
|
||||||
FlagEmoji string `json:"flag_emoji"`
|
FlagEmoji string `json:"flag_emoji"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Translation struct {
|
type Translation struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
NoticiaID int64 `json:"noticia_id"`
|
NewsID int64 `json:"news_id"`
|
||||||
LangFrom string `json:"lang_from"`
|
LangFrom string `json:"lang_from"`
|
||||||
LangTo string `json:"lang_to"`
|
LangTo string `json:"lang_to"`
|
||||||
Titulo string `json:"titulo"`
|
Title string `json:"title"`
|
||||||
Resumen string `json:"resumen"`
|
Summary string `json:"summary"`
|
||||||
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"`
|
||||||
|
|
@ -127,7 +89,6 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
@ -136,8 +97,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"`
|
||||||
CategoriaID *int64 `json:"categoria_id"`
|
CategoryID *int64 `json:"category_id"`
|
||||||
PaisID *int64 `json:"pais_id"`
|
CountryID *int64 `json:"country_id"`
|
||||||
ResultsCount int `json:"results_count"`
|
ResultsCount int `json:"results_count"`
|
||||||
SearchedAt time.Time `json:"searched_at"`
|
SearchedAt time.Time `json:"searched_at"`
|
||||||
}
|
}
|
||||||
|
|
@ -171,14 +132,14 @@ type Stats struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryStat struct {
|
type CategoryStat struct {
|
||||||
CategoriaID int64 `json:"categoria_id"`
|
CategoryID int64 `json:"category_id"`
|
||||||
CategoriaName string `json:"categoria_nombre"`
|
CategoryName string `json:"category_name"`
|
||||||
Count int64 `json:"count"`
|
Count int64 `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CountryStat struct {
|
type CountryStat struct {
|
||||||
PaisID int64 `json:"pais_id"`
|
CountryID int64 `json:"country_id"`
|
||||||
PaisName string `json:"pais_nombre"`
|
CountryName string `json:"country_name"`
|
||||||
FlagEmoji string `json:"flag_emoji"`
|
FlagEmoji string `json:"flag_emoji"`
|
||||||
Count int64 `json:"count"`
|
Count int64 `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type RemoteWorker struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
APIKey string `json:"api_key,omitempty"`
|
|
||||||
Capabilities string `json:"capabilities"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
LastSeen *time.Time `json:"last_seen,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TranslationJob struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
NewsID int64 `json:"noticia_id"`
|
|
||||||
LangFrom string `json:"lang_from"`
|
|
||||||
LangTo string `json:"lang_to"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TranslationResult struct {
|
|
||||||
JobID int64 `json:"job_id"`
|
|
||||||
TitleTr string `json:"title_trad"`
|
|
||||||
SummaryTr string `json:"resumen_trad"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WSClientMessage struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Capabilities string `json:"capabilities,omitempty"`
|
|
||||||
WorkerName string `json:"worker_name,omitempty"`
|
|
||||||
Result *TranslationResult `json:"result,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WSServerMessage struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Job *TranslationJob `json:"job,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
@ -8,9 +8,10 @@ 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
|
||||||
|
|
@ -30,23 +31,39 @@ func LoadDBConfig() *Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
func Connect(cfg *Config) error {
|
func Connect(cfg *Config) error {
|
||||||
// Just verify the main pool is accessible
|
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||||
if db.GetPool() == nil {
|
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
|
||||||
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 db.GetPool()
|
return pool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Close() {
|
func Close() {
|
||||||
// No-op: main db package manages the pool
|
if pool != nil {
|
||||||
|
pool.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func HealthCheck(ctx context.Context) error {
|
|
||||||
return db.HealthCheck(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
func getEnv(key, defaultValue string) string {
|
||||||
|
|
|
||||||
16
backup.sh
16
backup.sh
|
|
@ -1,16 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Backup diario de PostgreSQL rss2
|
|
||||||
set -e
|
|
||||||
|
|
||||||
BACKUP_DIR="/home/x/rss2/backups"
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
OUT="$BACKUP_DIR/rss-${TIMESTAMP}.sql.gz"
|
|
||||||
LOG="$BACKUP_DIR/backup.log"
|
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
docker exec rss2_db pg_dump -U rss -d rss --no-owner --no-privileges | gzip > "$OUT"
|
|
||||||
|
|
||||||
# Retener solo los ultimos 30 dias (borrado seguro de ficheros concretos, nunca rm -rf)
|
|
||||||
find "$BACKUP_DIR" -maxdepth 1 -name 'rss-*.sql.gz' -mtime +30 -delete
|
|
||||||
|
|
||||||
echo "$(date '+%F %T') Backup OK: $OUT ($(du -h "$OUT" | cut -f1))" >> "$LOG"
|
|
||||||
448
bugs.md
448
bugs.md
|
|
@ -1,448 +0,0 @@
|
||||||
# Análisis de Errores e Inconsistencias - RSS2
|
|
||||||
|
|
||||||
Fecha de análisis: 2026-04-01
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resumen Ejecutivo
|
|
||||||
|
|
||||||
Se han identificado **13 problemas** clasificados en:
|
|
||||||
- 3 Errores Críticos
|
|
||||||
- 7 Inconsistencias
|
|
||||||
- 3 Warnings de Arquitectura
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Errores Críticos
|
|
||||||
|
|
||||||
### 1.1 Rate Limiting No Implementado
|
|
||||||
|
|
||||||
**Ubicación:** `backend/internal/middleware/auth.go:104-108`
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
La función existe pero no hace nada:
|
|
||||||
```go
|
|
||||||
func RateLimitMiddleware(requestsPerMinute int) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
No implementa ningún tipo de limitación de rate. Vulnerable a ataques de fuerza bruta y DoS.
|
|
||||||
|
|
||||||
**Impacto:** Alto - Seguridad comprometida.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.2 CORS Demasiado Permisivo
|
|
||||||
|
|
||||||
**Ubicación:** `backend/internal/middleware/auth.go:77-90`
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
```go
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
Permite cualquier origen, lo cual es inseguro para APIs de producción. Permite ataques CSRF y expone datos a orígenes maliciosos.
|
|
||||||
|
|
||||||
**Impacto:** Alto - Seguridad comprometida.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.3 Secret Key Por Defecto Hardcodeada
|
|
||||||
|
|
||||||
**Ubicación:** `backend/internal/config/config.go:32`
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
```go
|
|
||||||
SecretKey: getEnv("SECRET_KEY", "change-this-secret-key"),
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
Si no se proporciona la variable de entorno, la aplicación usa una clave por defecto conocida. Cualquier despliegue sin configurar esta variable es vulnerable.
|
|
||||||
|
|
||||||
**Impacto:** Crítico - Compromiso total de autenticación.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Inconsistencias
|
|
||||||
|
|
||||||
### 2.1 Mapeo de Campos Inconsistente (Snake Case vs Camel Case)
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
La aplicación usa convenciones de nomenclatura diferentes en distintas capas:
|
|
||||||
|
|
||||||
| Capa | Estructura | Ejemplo |
|
|
||||||
|------|------------|---------|
|
|
||||||
| Models | Snake case | `Title`, `Summary`, `ImageURL` |
|
|
||||||
| Handlers | Pascal case | `Titulo`, `Resumen`, `ImagenURL` |
|
|
||||||
| Frontend TS | camelCase | `titulo`, `resumen`, `imagen_url` |
|
|
||||||
|
|
||||||
**Archivos afectados:**
|
|
||||||
- `backend/internal/models/models.go:7-19`
|
|
||||||
- `backend/internal/handlers/news.go:14-28`
|
|
||||||
- `frontend/src/services/api.ts:20-34`
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
Dificulta el mantenimiento y genera potenciales errores de serialización.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.2 Idioma de Traducción Hardcodeado
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
El idioma destino de traducción (`es`) está hardcodeado en múltiples lugares:
|
|
||||||
|
|
||||||
**news.go:67:**
|
|
||||||
```go
|
|
||||||
where += " AND t.status = 'done' AND t.titulo_trad IS NOT NULL"
|
|
||||||
```
|
|
||||||
|
|
||||||
**news.go:96:**
|
|
||||||
```go
|
|
||||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
|
|
||||||
```
|
|
||||||
|
|
||||||
**search.go:34-37:**
|
|
||||||
```go
|
|
||||||
if lang == "" {
|
|
||||||
lang = "es"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
No es configurable. Si se quiere traducir a otros idiomas, requiere cambios en múltiples archivos.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.3 Columna `role` en Users Sin映射 en Modelo
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
Se añade una columna `role` a la tabla users:
|
|
||||||
|
|
||||||
**cmd/server/main.go:41-48:**
|
|
||||||
```go
|
|
||||||
_, err = db.GetPool().Exec(ctx, `
|
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'user'
|
|
||||||
`)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pero el modelo User no tiene este campo:**
|
|
||||||
|
|
||||||
**models/models.go:86-94:**
|
|
||||||
```go
|
|
||||||
type User struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
PasswordHash string `json:"-"`
|
|
||||||
IsAdmin bool `json:"is_admin"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
La columna `role` nunca se usa realmente. Hay dos campos (`role` y `is_admin`) que pueden causar confusión.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.4 Feature No Implementada en Discovery
|
|
||||||
|
|
||||||
**Ubicación:** `backend/cmd/discovery/main.go:129-133`
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
```go
|
|
||||||
func findFeedLinksInHTML(baseURL string) ([]string, error) {
|
|
||||||
// Simple feed link finder - returns empty for now
|
|
||||||
// In production, use goquery to parse HTML and find RSS/Atom links
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
El worker de discovery no puede encontrar feeds RSS embebidos en páginas HTML. Solo detecta feeds directos.
|
|
||||||
|
|
||||||
**Impacto:** Medio - Reduce la efectividad del descubrimiento de feeds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.5 LoggerMiddleware No Funcional
|
|
||||||
|
|
||||||
**Ubicación:** `backend/internal/middleware/auth.go:93-101`
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
```go
|
|
||||||
func LoggerMiddleware() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
c.Next()
|
|
||||||
|
|
||||||
status := c.Writer.Status()
|
|
||||||
if status >= 400 {
|
|
||||||
// Log error responses - pero no hace nada!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
El logging de errores está commented out. No se registra nada.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.6 Columnas de BD Diferentes a Modelos
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
El handler `search.go` referencia columnas que no existen en los modelos:
|
|
||||||
|
|
||||||
**search.go:91-92:**
|
|
||||||
```go
|
|
||||||
SELECT n.id, n.titulo, n.resumen, n.contenido, n.url, n.imagen,
|
|
||||||
n.feed_id, n.lang, n.categoria_id, n.pais_id, n.created_at, n.updated_at,
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pero en models.go:7-19:**
|
|
||||||
```go
|
|
||||||
type News struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
ImageURL *string `json:"image_url"`
|
|
||||||
PublishedAt *time.Time `json:"published_at"`
|
|
||||||
Lang string `json:"lang"`
|
|
||||||
FeedID int64 `json:"feed_id"`
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
- Modelo usa `Title`, BD usa `titulo`
|
|
||||||
- Modelo usa `Summary`, BD usa `resumen`
|
|
||||||
- Modelo usa `ImageURL`, BD usa `imagen`
|
|
||||||
- Modelo usa `PublishedAt`, BD usa `fecha`
|
|
||||||
|
|
||||||
Esto causa errores de serialización potenciales.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.7 Inicialización de Base de Datos Duplicada
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
Existen dos formas de conectar a la base de datos:
|
|
||||||
|
|
||||||
1. `db.Connect()` en `backend/internal/db/postgres.go:13`
|
|
||||||
2. `workers.Connect()` en `backend/internal/workers/db.go`
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
Códigos duplicados que mantienen conexiones separadas. Puede causar problemas de pool de conexiones.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Warnings de Arquitectura
|
|
||||||
|
|
||||||
### 3.1 Creación de Tablas en Runtime
|
|
||||||
|
|
||||||
**Ubicación:** `backend/cmd/server/main.go:20-77`
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
En lugar de usar migraciones formales, el servidor crea tablas al iniciar:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func initDB() {
|
|
||||||
_, err := db.GetPool().Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS entity_aliases (...)
|
|
||||||
`)
|
|
||||||
// más tablas...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
- No hay control de versiones
|
|
||||||
- No hay rollback
|
|
||||||
- Difícil de mantener en producción
|
|
||||||
|
|
||||||
**Recomendación:** Usar golang-migrate o similar.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.2 No Hay Tests Unitarios
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
No se encontraron archivos `*_test.go` en el código backend.
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
Sin tests, cualquier cambio puede romper funcionalidad existente sin detección.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.3 Pool de Conexiones Sin Validación Periódica
|
|
||||||
|
|
||||||
**Ubicación:** `backend/internal/db/postgres.go`
|
|
||||||
|
|
||||||
**Descripción:**
|
|
||||||
El pool se crea pero no hay health check periódico.
|
|
||||||
|
|
||||||
**Problema:**
|
|
||||||
Las conexiones zombie pueden acumularse sin detección.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Recomendaciones de Corrección
|
|
||||||
|
|
||||||
### Alta Prioridad
|
|
||||||
|
|
||||||
1. **Unificar JWT Secret:** Usar un único package para manejar el secret
|
|
||||||
2. **Implementar Rate Limiting:** Usar una librería como `golang.org/x/time/rate`
|
|
||||||
3. **Configurar CORS correctamente:** Usar variable de entorno para orígenes permitidos
|
|
||||||
4. **Forzar Secret Key:** Hacer requerida la variable `SECRET_KEY`
|
|
||||||
|
|
||||||
### Media Prioridad
|
|
||||||
|
|
||||||
5. **Traducir idioma configurable:** Pasar idioma como parámetro o configuración
|
|
||||||
6. **Usar migraciones:** Implementar golang-migrate
|
|
||||||
7. **Añadir tests:** Crear tests unitarios para handlers críticos
|
|
||||||
8. **Corregir mapeo de campos:** Estandarizar snake_case en toda la aplicación
|
|
||||||
|
|
||||||
### Baja Prioridad
|
|
||||||
|
|
||||||
9. **Implementar LoggerMiddleware:** Añadir logging real
|
|
||||||
10. **Implementar findFeedLinksInHTML:** Usar goquery para parsing HTML
|
|
||||||
11. **Consolidar DB:** Unificar inicialización de base de datos
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Estadísticas
|
|
||||||
|
|
||||||
| Tipo | Cantidad |
|
|
||||||
|------|----------|
|
|
||||||
| Errores Críticos | 3 |
|
|
||||||
| Inconsistencias | 7 |
|
|
||||||
| Warnings | 3 |
|
|
||||||
| **Total** | **13** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Problemas Adicionales Detectados
|
|
||||||
|
|
||||||
### 6.1 Mapeo de Campos Inconsistente (Frontend)
|
|
||||||
|
|
||||||
**Descripción:** El frontend usa `snake_case` pero los modelos Go usan `PascalCase`.
|
|
||||||
|
|
||||||
**Archivos:**
|
|
||||||
- `frontend/src/services/api.ts` - usa `titulo`, `resumen`
|
|
||||||
- `backend/internal/models/models.go` - usa `Title`, `Summary`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.2 Structs Duplicados para Mismo Recurso
|
|
||||||
|
|
||||||
**Descripción:** `handlers/news.go` define `NewsResponse` diferente a `models/models.go:News`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.3 Query Params Inconsistentes
|
|
||||||
|
|
||||||
**Descripción:** Algunos handlers usan `category_id` (inglés) y otros `categoria_id` (español).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.4 Duplicación Config DB
|
|
||||||
|
|
||||||
**Descripción:** API usa `DATABASE_URL`, workers usan `DB_HOST`, `DB_PORT`, etc.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.5 Sin Health Checks en Workers
|
|
||||||
|
|
||||||
**Descripción:** Workers no implementan endpoint `/health`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.6 Manejo de Errores Inconsistente
|
|
||||||
|
|
||||||
**Descripción:** Mezcla de `gin.H{"error": ...}` y `models.ErrorResponse`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Errores y Problemas Críticos Detectados
|
|
||||||
|
|
||||||
### 7.1 ~~Errores Ignorados Silenciosamente~~ **(RESUELTO)**
|
|
||||||
|
|
||||||
**Descripción:** Múltiples lugares donde errores eran ignorados sin logging.
|
|
||||||
|
|
||||||
**Ejemplos corregidos:**
|
|
||||||
- `admin.go:488-492` - Ahora verifica errores de `db.GetPool().Exec()`
|
|
||||||
- `admin.go:522` - Verifica error al actualizar estado
|
|
||||||
|
|
||||||
**Impacto:** Fallos silenciosos que pasan desapercibidos.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7.2 ~~Rutas Hardcodeadas~~ **(RESUELTO)**
|
|
||||||
|
|
||||||
**Descripción:** Rutas de archivos hardcodeadas en el código.
|
|
||||||
|
|
||||||
**Correcciones:**
|
|
||||||
- `admin.go:469,474,509` - Ahora usa `config.Load().DockerComposeDir`
|
|
||||||
- `server/main.go:113` - Ahora usa `cfg.WikiImagesPath`
|
|
||||||
- Añadidos nuevos campos en config: `DockerComposeDir`, `WikiImagesPath`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7.3 Secret Key Por Defecto
|
|
||||||
|
|
||||||
**Descripción:** `config.go:32` usa clave por defecto known.
|
|
||||||
|
|
||||||
```go
|
|
||||||
SecretKey: getEnv("SECRET_KEY", "change-this-secret-key")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impacto:** Medio - Si no se configura, usar clave insegura.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7.4 Rate Limiting No Implementado
|
|
||||||
|
|
||||||
**Descripción:** `middleware/auth.go:104-108` tiene función vacía.
|
|
||||||
|
|
||||||
**Impacto:** Alto - Vulnerable a ataques de fuerza bruta.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7.5 CORS Demasiado Permisivo
|
|
||||||
|
|
||||||
**Descripción:** `middleware/auth.go:79` permite cualquier origen.
|
|
||||||
|
|
||||||
**Impacto:** Alto - Expuesto a ataques CSRF.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Recomendaciones de Corrección (Actualizado)
|
|
||||||
|
|
||||||
### Alta Prioridad (CRÍTICO)
|
|
||||||
|
|
||||||
1. ~~Unificar JWT Secret~~ ✅
|
|
||||||
2. Implementar Rate Limiting
|
|
||||||
3. ~~**Configurar CORS correctamente:** Usar variable de entorno~~ ✅
|
|
||||||
4. ~~**Corregir manejo de errores:** No ignorar silenciosamente~~ ✅
|
|
||||||
5. ~~**Eliminar rutas hardcodeadas**~~ ✅
|
|
||||||
|
|
||||||
### Media Prioridad
|
|
||||||
|
|
||||||
7. Idioma traducciones configurable
|
|
||||||
8. Usar migraciones
|
|
||||||
9. Añadir tests
|
|
||||||
10. Corregir mapeo de campos
|
|
||||||
|
|
||||||
### Baja Prioridad
|
|
||||||
|
|
||||||
11. Implementar LoggerMiddleware
|
|
||||||
12. Implementar findFeedLinksInHTML
|
|
||||||
13. Consolidar DB
|
|
||||||
14. Estandarizar respuestas API
|
|
||||||
15. Añadir health checks
|
|
||||||
|
|
@ -99,16 +99,8 @@
|
||||||
"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": [
|
||||||
18
data/feeds.csv
Normal file
18
data/feeds.csv
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
id,nombre,descripcion,url,categoria_id,categoria,pais_id,pais,idioma,activo,fallos
|
||||||
|
19,8am Daily – Dari,روزنامه ۸صبح افغانستان به زبان دری.,https://8am.af/feed,7,Internacional,1,Afganistán,fa,False,30
|
||||||
|
20,8am Daily – Pashto,د ۸صبح ورځپاڼې پښتو خپرونه.,https://8am.af/ps/feed,7,Internacional,1,Afganistán,ps,False,30
|
||||||
|
1,Afghanistan News.Net – Noticias,Cobertura continua de noticias generales sobre Afganistán y su entorno regional.,https://feeds.afghanistannews.net/rss/6e1d5c8e1f98f17c,7,Internacional,1,Afganistán,en,True,0
|
||||||
|
36,Arezo TV – Dari,آرزو تلویزیون – خبر و گزارش به دری.,https://arezo.tv/fa/feed,7,Internacional,1,Afganistán,fa,False,30
|
||||||
|
37,Arezo TV – Pashto,د آرزو تلویزیون پښتو خبرونه.,https://arezo.tv/ps/feed,7,Internacional,1,Afganistán,ps,False,30
|
||||||
|
4,Ariana News – Dari,خبرها و تحلیلها از افغانستان به زبان دری.,https://ariananews.af/feed,7,Internacional,1,Afganistán,fa,True,0
|
||||||
|
28,Avapress – Dari,خبرگزاری صدای افغان (آوا) به زبان دری.,https://avapress.com/fa/rss,7,Internacional,1,Afganistán,fa,False,30
|
||||||
|
29,Avapress – Pashto,د افغان غږ خبري آژانس په پښتو ژبه.,https://avapress.com/ps/rss,7,Internacional,1,Afganistán,ps,False,30
|
||||||
|
23,Bakhtar News Agency – Dari,آژانس خبری باختر به زبان دری.,https://bakhtarnews.af/fa/feed,7,Internacional,1,Afganistán,fa,False,5
|
||||||
|
24,Bakhtar News Agency – Pashto,د باختر خبري اژانس پښتو پاڼه.,https://bakhtarnews.af/ps/feed,7,Internacional,1,Afganistán,ps,False,5
|
||||||
|
38,Barya News – Dari,باریانیوز – رسانه خبری افغانستان.,https://barya.news/feed,7,Internacional,1,Afganistán,fa,False,30
|
||||||
|
39,Barya News – Pashto,باریانیوز پښتو خپرونې.,https://barya.news/ps/feed,7,Internacional,1,Afganistán,ps,False,30
|
||||||
|
47,Chaprast News – Dari,چپرست نیوز – خبرهای افغانستان.,https://chaprast.com/feed,7,Internacional,1,Afganistán,fa,False,30
|
||||||
|
30,Ensaf News – Dari,رسانه تحلیلی به زبان دری.,https://www.ensafnews.com/fa/feed,7,Internacional,1,Afganistán,fa,False,30
|
||||||
|
27,Hamshahri Afghanistan – Dari,نسخه افغانستان همشهری به زبان دری.,https://hamshahri.af/feed,7,Internacional,1,Afganistán,fa,False,30
|
||||||
|
44,Jomhor News – Dari,جمهور نیوز – خبرگزاری مستقل دری.,https://jomhornews.com/fa/rss,7,Internacional,1,Afganistán,fa,False,30
|
||||||
|
45,Jomhor News – Pashto,جمهور نیوز پښتو.,https://jomhornews.com/ps/rss,7,Internacional,1,Afganistán,ps,False,30
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Script para despliegue limpio de RSS2
|
|
||||||
|
|
||||||
echo "=== RSS2 Clean Deployment Script ==="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Detener contenedores
|
|
||||||
echo "1. Deteniendo contenedores..."
|
|
||||||
docker compose down -v 2>/dev/null
|
|
||||||
|
|
||||||
# Eliminar volúmenes de datos (si hay permisos)
|
|
||||||
echo "2. Eliminando volúmenes de datos..."
|
|
||||||
docker volume rm rss2_db 2>/dev/null || true
|
|
||||||
docker volume rm rss2_redis 2>/dev/null || true
|
|
||||||
|
|
||||||
# Si los volúmenes Docker tienen problemas, intentar con rm
|
|
||||||
echo " Intentando limpiar /data/..."
|
|
||||||
sudo rm -rf /datos/rss2/data/pgdata 2>/dev/null || true
|
|
||||||
sudo rm -rf /datos/rss2/data/redis-data 2>/dev/null || true
|
|
||||||
|
|
||||||
# Iniciar base de datos
|
|
||||||
echo "3. Iniciando base de datos..."
|
|
||||||
docker compose up -d db
|
|
||||||
|
|
||||||
# Esperar a que esté lista
|
|
||||||
echo "4. Esperando a que la base de datos esté lista..."
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# Verificar estado
|
|
||||||
if docker compose ps db | grep -q "healthy"; then
|
|
||||||
echo " ✓ Base de datos iniciada correctamente"
|
|
||||||
|
|
||||||
# Ejecutar script de schema
|
|
||||||
echo "5. Ejecutando script de inicialización..."
|
|
||||||
docker compose exec -T db psql -U rss -d rss -f /docker-entrypoint-initdb.d/00-complete-schema.sql 2>&1 | tail -5
|
|
||||||
|
|
||||||
# Iniciar demás servicios
|
|
||||||
echo "6. Iniciando servicios..."
|
|
||||||
docker compose up -d redis backend-go rss2_frontend nginx rss-ingestor-go
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Despliegue completado ==="
|
|
||||||
echo "Accede a: http://localhost:8001"
|
|
||||||
else
|
|
||||||
echo " ✗ Error: La base de datos no está healthy"
|
|
||||||
docker compose logs db
|
|
||||||
fi
|
|
||||||
74
deploy/debian/build.sh
Executable file
74
deploy/debian/build.sh
Executable file
|
|
@ -0,0 +1,74 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# RSS2 - Recompila binarios y frontend (sin reinstalar el sistema)
|
||||||
|
# Usar despues de actualizar el codigo: bash build.sh
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RSS2_HOME="/opt/rss2"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||||
|
info() { echo -e "${GREEN}[BUILD]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
|
||||||
|
# --- Go Backend + Workers ---
|
||||||
|
if [[ -d "$REPO_ROOT/backend" ]]; then
|
||||||
|
info "Compilando backend Go..."
|
||||||
|
(cd "$REPO_ROOT/backend" && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/server" ./cmd/server)
|
||||||
|
info " [OK] server"
|
||||||
|
|
||||||
|
for cmd in scraper discovery wiki_worker topics related; do
|
||||||
|
[[ -d "$REPO_ROOT/backend/cmd/$cmd" ]] || continue
|
||||||
|
(cd "$REPO_ROOT/backend" && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/$cmd" "./cmd/$cmd")
|
||||||
|
info " [OK] $cmd"
|
||||||
|
done
|
||||||
|
# qdrant worker: nombre del binario debe coincidir con el service
|
||||||
|
[[ -d "$REPO_ROOT/backend/cmd/qdrant" ]] && \
|
||||||
|
(cd "$REPO_ROOT/backend" && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/qdrant_worker" "./cmd/qdrant")
|
||||||
|
info " [OK] qdrant_worker"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Ingestor Go ---
|
||||||
|
if [[ -d "$REPO_ROOT/rss-ingestor-go" ]]; then
|
||||||
|
info "Compilando ingestor Go..."
|
||||||
|
(cd "$REPO_ROOT/rss-ingestor-go" && \
|
||||||
|
GOTOOLCHAIN=local CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/ingestor" .)
|
||||||
|
info " [OK] ingestor"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Frontend React ---
|
||||||
|
if [[ -d "$REPO_ROOT/frontend" ]]; then
|
||||||
|
info "Compilando frontend React..."
|
||||||
|
(cd "$REPO_ROOT/frontend" && \
|
||||||
|
npm install --silent && \
|
||||||
|
VITE_API_URL=/api npm run build -- --outDir "$RSS2_HOME/frontend/dist")
|
||||||
|
info " [OK] frontend"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Workers Python ---
|
||||||
|
info "Sincronizando workers Python..."
|
||||||
|
rsync -a --delete "$REPO_ROOT/workers/" "$RSS2_HOME/src/workers/"
|
||||||
|
cp "$REPO_ROOT/config/entity_config.json" "$RSS2_HOME/src/" 2>/dev/null || true
|
||||||
|
info " [OK] workers Python"
|
||||||
|
|
||||||
|
chown -R rss2:rss2 "$RSS2_HOME/bin" "$RSS2_HOME/frontend/dist" "$RSS2_HOME/src"
|
||||||
|
|
||||||
|
# --- Restart servicios ---
|
||||||
|
info "Reiniciando servicios..."
|
||||||
|
GO_SERVICES=(rss2-backend rss2-ingestor rss2-scraper rss2-discovery rss2-wiki rss2-topics rss2-related rss2-qdrant-worker)
|
||||||
|
PY_SERVICES=(rss2-langdetect rss2-translation-scheduler rss2-translator rss2-embeddings rss2-ner rss2-cluster rss2-categorizer)
|
||||||
|
|
||||||
|
for svc in "${GO_SERVICES[@]}" "${PY_SERVICES[@]}"; do
|
||||||
|
systemctl is-active --quiet "$svc" && systemctl restart "$svc" && info " restarted $svc" || true
|
||||||
|
done
|
||||||
|
|
||||||
|
systemctl reload nginx 2>/dev/null || true
|
||||||
|
|
||||||
|
info "Build completado."
|
||||||
342
deploy/debian/check.sh
Executable file
342
deploy/debian/check.sh
Executable file
|
|
@ -0,0 +1,342 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# COCONEWS - Diagnóstico del sistema
|
||||||
|
# Verifica que todos los servicios estén OK y muestra estado + sugerencias
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# bash deploy/debian/check.sh # diagnóstico completo
|
||||||
|
# bash deploy/debian/check.sh --quick # solo servicios arriba/abajo
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
RSS2_HOME="/opt/rss2"
|
||||||
|
API_PORT="8080"
|
||||||
|
QUICK=false
|
||||||
|
[[ "${1:-}" == "--quick" ]] && QUICK=true
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
|
||||||
|
OK() { echo -e " ${GREEN}[✓]${NC} $*"; }
|
||||||
|
FAIL() { echo -e " ${RED}[✗]${NC} $*"; }
|
||||||
|
WARN() { echo -e " ${YELLOW}[!]${NC} $*"; }
|
||||||
|
INFO() { echo -e " ${DIM} $*${NC}"; }
|
||||||
|
HEAD() { echo -e "\n${BOLD}${CYAN}── $* ${NC}"; }
|
||||||
|
FIX() { echo -e " ${YELLOW} → Fix:${NC} $*"; }
|
||||||
|
|
||||||
|
ERRORS=0
|
||||||
|
WARNINGS=0
|
||||||
|
|
||||||
|
fail_with_fix() {
|
||||||
|
FAIL "$1"
|
||||||
|
shift
|
||||||
|
for fix in "$@"; do FIX "$fix"; done
|
||||||
|
(( ERRORS++ )) || true
|
||||||
|
}
|
||||||
|
|
||||||
|
warn_with_fix() {
|
||||||
|
WARN "$1"
|
||||||
|
shift
|
||||||
|
for fix in "$@"; do FIX "$fix"; done
|
||||||
|
(( WARNINGS++ )) || true
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}╔═══════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${BOLD}║ COCONEWS · Diagnóstico del Sistema ║${NC}"
|
||||||
|
echo -e "${BOLD}╚═══════════════════════════════════════════╝${NC}"
|
||||||
|
echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S') · $(hostname)${NC}"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 1. SERVICIOS SYSTEMD
|
||||||
|
# =============================================================================
|
||||||
|
HEAD "Servicios systemd"
|
||||||
|
|
||||||
|
GO_SERVICES=(
|
||||||
|
"rss2-backend:API REST Go"
|
||||||
|
"rss2-ingestor:Ingestor RSS"
|
||||||
|
"rss2-scraper:Scraper HTML"
|
||||||
|
"rss2-discovery:Discovery de feeds"
|
||||||
|
"rss2-wiki:Wiki worker"
|
||||||
|
"rss2-topics:Topics matcher"
|
||||||
|
"rss2-related:Related news"
|
||||||
|
"rss2-qdrant-worker:Qdrant sync"
|
||||||
|
)
|
||||||
|
PY_SERVICES=(
|
||||||
|
"rss2-langdetect:Detección de idioma"
|
||||||
|
"rss2-translation-scheduler:Scheduler traducción"
|
||||||
|
"rss2-translator:Traductor NLLB-200"
|
||||||
|
"rss2-embeddings:Embeddings ML"
|
||||||
|
"rss2-ner:NER entidades"
|
||||||
|
"rss2-cluster:Clustering eventos"
|
||||||
|
"rss2-categorizer:Categorizador"
|
||||||
|
)
|
||||||
|
INFRA_SERVICES=(
|
||||||
|
"rss2-qdrant:Vector DB Qdrant"
|
||||||
|
"postgresql:PostgreSQL"
|
||||||
|
"redis-server:Redis"
|
||||||
|
"nginx:Nginx"
|
||||||
|
)
|
||||||
|
|
||||||
|
check_service() {
|
||||||
|
local svc="${1%%:*}"
|
||||||
|
local name="${1##*:}"
|
||||||
|
if ! command -v systemctl &>/dev/null; then
|
||||||
|
WARN "$name (systemctl no disponible)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local state
|
||||||
|
state=$(systemctl is-active "$svc" 2>/dev/null || echo "unknown")
|
||||||
|
case "$state" in
|
||||||
|
active)
|
||||||
|
OK "$name ($svc)"
|
||||||
|
;;
|
||||||
|
failed)
|
||||||
|
fail_with_fix "$name ($svc) — FAILED" \
|
||||||
|
"journalctl -u $svc -n 30 --no-pager" \
|
||||||
|
"systemctl restart $svc"
|
||||||
|
;;
|
||||||
|
inactive)
|
||||||
|
warn_with_fix "$name ($svc) — inactivo" \
|
||||||
|
"systemctl start $svc" \
|
||||||
|
"systemctl enable $svc"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
warn_with_fix "$name ($svc) — $state" \
|
||||||
|
"systemctl status $svc"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
echo -e "\n ${DIM}Infraestructura:${NC}"
|
||||||
|
for svc in "${INFRA_SERVICES[@]}"; do check_service "$svc"; done
|
||||||
|
|
||||||
|
echo -e "\n ${DIM}Workers Go:${NC}"
|
||||||
|
for svc in "${GO_SERVICES[@]}"; do check_service "$svc"; done
|
||||||
|
|
||||||
|
echo -e "\n ${DIM}Workers Python (ML):${NC}"
|
||||||
|
for svc in "${PY_SERVICES[@]}"; do check_service "$svc"; done
|
||||||
|
|
||||||
|
[[ "$QUICK" == "true" ]] && {
|
||||||
|
echo ""
|
||||||
|
[[ "$ERRORS" -eq 0 && "$WARNINGS" -eq 0 ]] && \
|
||||||
|
echo -e " ${GREEN}${BOLD}Todo OK${NC}" || \
|
||||||
|
echo -e " ${RED}Errores: $ERRORS ${YELLOW}Advertencias: $WARNINGS${NC}"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 2. CONECTIVIDAD
|
||||||
|
# =============================================================================
|
||||||
|
HEAD "Conectividad"
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
PG_ENV="$RSS2_HOME/.env"
|
||||||
|
if [[ -f "$PG_ENV" ]]; then
|
||||||
|
source "$PG_ENV" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
DB_NAME="${POSTGRES_DB:-rss}"
|
||||||
|
DB_USER="${POSTGRES_USER:-rss}"
|
||||||
|
|
||||||
|
if pg_isready -q 2>/dev/null; then
|
||||||
|
OK "PostgreSQL acepta conexiones"
|
||||||
|
# Probar conexión con usuario rss2
|
||||||
|
if sudo -u postgres psql -d "$DB_NAME" -c "SELECT 1" &>/dev/null 2>&1; then
|
||||||
|
NEWS=$(sudo -u postgres psql -tq -d "$DB_NAME" \
|
||||||
|
-c "SELECT COUNT(*) FROM noticias;" 2>/dev/null | tr -d ' \n' || echo "?")
|
||||||
|
TRANS=$(sudo -u postgres psql -tq -d "$DB_NAME" \
|
||||||
|
-c "SELECT COUNT(*) FROM traducciones WHERE status='done';" 2>/dev/null | tr -d ' \n' || echo "?")
|
||||||
|
PEND=$(sudo -u postgres psql -tq -d "$DB_NAME" \
|
||||||
|
-c "SELECT COUNT(*) FROM traducciones WHERE status='pending';" 2>/dev/null | tr -d ' \n' || echo "?")
|
||||||
|
INFO "Base de datos: $DB_NAME"
|
||||||
|
INFO "Noticias: $NEWS | Traducciones hechas: $TRANS | Pendientes: $PEND"
|
||||||
|
else
|
||||||
|
warn_with_fix "No se puede conectar a la BD '$DB_NAME' con usuario '$DB_USER'" \
|
||||||
|
"sudo -u postgres psql -c \"\\du\" # listar usuarios" \
|
||||||
|
"Revisa DB_USER y DB_PASS en $RSS2_HOME/.env"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail_with_fix "PostgreSQL no responde" \
|
||||||
|
"sudo systemctl start postgresql" \
|
||||||
|
"sudo journalctl -u postgresql -n 20 --no-pager" \
|
||||||
|
"sudo pg_ctlcluster 16 main status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_PASS="${REDIS_PASSWORD:-}"
|
||||||
|
REDIS_AUTH=""
|
||||||
|
[[ -n "$REDIS_PASS" ]] && REDIS_AUTH="-a $REDIS_PASS"
|
||||||
|
if redis-cli $REDIS_AUTH ping 2>/dev/null | grep -q PONG; then
|
||||||
|
REDIS_MEM=$(redis-cli $REDIS_AUTH info memory 2>/dev/null | grep used_memory_human | cut -d: -f2 | tr -d '\r' || echo "?")
|
||||||
|
OK "Redis responde (memoria usada: ${REDIS_MEM})"
|
||||||
|
else
|
||||||
|
fail_with_fix "Redis no responde" \
|
||||||
|
"sudo systemctl start redis-server" \
|
||||||
|
"redis-cli ping # sin auth" \
|
||||||
|
"Verifica REDIS_PASSWORD en $RSS2_HOME/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Qdrant
|
||||||
|
if curl -sf "http://127.0.0.1:6333/healthz" &>/dev/null; then
|
||||||
|
QDRANT_VER=$(curl -sf "http://127.0.0.1:6333/" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('version','?'))" 2>/dev/null || echo "?")
|
||||||
|
OK "Qdrant responde (v${QDRANT_VER})"
|
||||||
|
else
|
||||||
|
warn_with_fix "Qdrant no responde en :6333" \
|
||||||
|
"systemctl start rss2-qdrant" \
|
||||||
|
"journalctl -u rss2-qdrant -n 20 --no-pager" \
|
||||||
|
"Búsqueda semántica no funcionará hasta que Qdrant esté activo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# API Backend Go
|
||||||
|
if curl -sf "http://127.0.0.1:${API_PORT}/api/stats" &>/dev/null; then
|
||||||
|
STATS=$(curl -sf "http://127.0.0.1:${API_PORT}/api/stats" 2>/dev/null | \
|
||||||
|
python3 -c "import sys,json; d=json.load(sys.stdin); print(f\"noticias={d.get('total_news','?')} feeds={d.get('total_feeds','?')}\")" 2>/dev/null || echo "")
|
||||||
|
OK "API backend responde (:${API_PORT}) — $STATS"
|
||||||
|
else
|
||||||
|
fail_with_fix "API backend no responde en :${API_PORT}" \
|
||||||
|
"systemctl restart rss2-backend" \
|
||||||
|
"journalctl -u rss2-backend -n 30 --no-pager" \
|
||||||
|
"curl http://127.0.0.1:${API_PORT}/api/stats # prueba manual"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Nginx
|
||||||
|
if curl -sf "http://127.0.0.1:8001/health" &>/dev/null; then
|
||||||
|
OK "Nginx responde (:8001)"
|
||||||
|
else
|
||||||
|
fail_with_fix "Nginx no responde en :8001" \
|
||||||
|
"nginx -t # verificar config" \
|
||||||
|
"systemctl restart nginx" \
|
||||||
|
"journalctl -u nginx -n 20 --no-pager"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 3. BINARIOS GO
|
||||||
|
# =============================================================================
|
||||||
|
HEAD "Binarios compilados"
|
||||||
|
|
||||||
|
BINS=(server ingestor scraper discovery wiki_worker topics related qdrant_worker)
|
||||||
|
for bin in "${BINS[@]}"; do
|
||||||
|
BIN_PATH="$RSS2_HOME/bin/$bin"
|
||||||
|
if [[ -x "$BIN_PATH" ]]; then
|
||||||
|
SIZE=$(du -sh "$BIN_PATH" 2>/dev/null | cut -f1)
|
||||||
|
OK "$bin (${SIZE})"
|
||||||
|
else
|
||||||
|
fail_with_fix "$bin no encontrado o sin permisos de ejecución en $BIN_PATH" \
|
||||||
|
"sudo bash deploy/debian/build.sh # recompila todos los binarios"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 4. MODELOS ML
|
||||||
|
# =============================================================================
|
||||||
|
HEAD "Modelos ML"
|
||||||
|
|
||||||
|
# NLLB-200 CTranslate2
|
||||||
|
if [[ -d "$RSS2_HOME/models/nllb-ct2" ]] && \
|
||||||
|
[[ -f "$RSS2_HOME/models/nllb-ct2/model.bin" ]]; then
|
||||||
|
SIZE=$(du -sh "$RSS2_HOME/models/nllb-ct2" 2>/dev/null | cut -f1)
|
||||||
|
OK "NLLB-200 CTranslate2 (${SIZE})"
|
||||||
|
else
|
||||||
|
warn_with_fix "Modelo NLLB-200 no encontrado en $RSS2_HOME/models/nllb-ct2" \
|
||||||
|
"El traductor no funcionará hasta que esté el modelo" \
|
||||||
|
"Convierte con: ver sección 3 de DEPLOY_DEBIAN.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# spaCy
|
||||||
|
if "$RSS2_HOME/venv/bin/python" -c "import spacy; spacy.load('es_core_news_lg')" &>/dev/null 2>&1; then
|
||||||
|
OK "spaCy es_core_news_lg"
|
||||||
|
else
|
||||||
|
warn_with_fix "Modelo spaCy es_core_news_lg no disponible" \
|
||||||
|
"$RSS2_HOME/venv/bin/python -m spacy download es_core_news_lg" \
|
||||||
|
"El worker NER no funcionará hasta instalarlo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# sentence-transformers (embeddings)
|
||||||
|
if "$RSS2_HOME/venv/bin/python" -c "from sentence_transformers import SentenceTransformer" &>/dev/null 2>&1; then
|
||||||
|
OK "sentence-transformers disponible"
|
||||||
|
else
|
||||||
|
warn_with_fix "sentence-transformers no instalado" \
|
||||||
|
"$RSS2_HOME/venv/bin/pip install sentence-transformers==3.0.1" \
|
||||||
|
"Los embeddings y búsqueda semántica no funcionarán"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ctranslate2
|
||||||
|
if "$RSS2_HOME/venv/bin/python" -c "import ctranslate2" &>/dev/null 2>&1; then
|
||||||
|
OK "ctranslate2 disponible"
|
||||||
|
else
|
||||||
|
warn_with_fix "ctranslate2 no instalado" \
|
||||||
|
"$RSS2_HOME/venv/bin/pip install ctranslate2>=4.0.0" \
|
||||||
|
"La traducción NLLB-200 no funcionará"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 5. DISCO Y PERMISOS
|
||||||
|
# =============================================================================
|
||||||
|
HEAD "Disco y permisos"
|
||||||
|
|
||||||
|
# Espacio en disco
|
||||||
|
DISK_FREE=$(df -h "$RSS2_HOME" 2>/dev/null | tail -1 | awk '{print $4}')
|
||||||
|
DISK_PCT=$(df "$RSS2_HOME" 2>/dev/null | tail -1 | awk '{print $5}' | tr -d '%')
|
||||||
|
if [[ "${DISK_PCT:-0}" -gt 90 ]]; then
|
||||||
|
fail_with_fix "Disco al ${DISK_PCT}% — quedan solo ${DISK_FREE}" \
|
||||||
|
"du -sh $RSS2_HOME/* | sort -rh | head -10 # ver qué ocupa más" \
|
||||||
|
"Los modelos ML necesitan ~5 GB libres"
|
||||||
|
elif [[ "${DISK_PCT:-0}" -gt 75 ]]; then
|
||||||
|
warn_with_fix "Disco al ${DISK_PCT}% (libre: ${DISK_FREE})" \
|
||||||
|
"Revisa el espacio antes de descargar modelos ML"
|
||||||
|
else
|
||||||
|
OK "Disco: ${DISK_PCT}% usado, ${DISK_FREE} libres"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Permisos de /opt/rss2
|
||||||
|
if [[ -d "$RSS2_HOME" ]]; then
|
||||||
|
OWNER=$(stat -c '%U' "$RSS2_HOME" 2>/dev/null || echo "?")
|
||||||
|
if [[ "$OWNER" == "rss2" ]]; then
|
||||||
|
OK "Propietario de $RSS2_HOME: rss2"
|
||||||
|
else
|
||||||
|
warn_with_fix "Propietario de $RSS2_HOME es '$OWNER', debería ser 'rss2'" \
|
||||||
|
"sudo chown -R rss2:rss2 $RSS2_HOME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# .env
|
||||||
|
if [[ -f "$RSS2_HOME/.env" ]]; then
|
||||||
|
ENV_PERMS=$(stat -c '%a' "$RSS2_HOME/.env" 2>/dev/null || echo "?")
|
||||||
|
if [[ "$ENV_PERMS" == "600" ]]; then
|
||||||
|
OK ".env con permisos 600 (correcto)"
|
||||||
|
else
|
||||||
|
warn_with_fix ".env con permisos ${ENV_PERMS} (debería ser 600)" \
|
||||||
|
"chmod 600 $RSS2_HOME/.env"
|
||||||
|
fi
|
||||||
|
# Verificar que no quedan valores por defecto peligrosos
|
||||||
|
if grep -q "CAMBIA_ESTO\|changeme\|change_this" "$RSS2_HOME/.env" 2>/dev/null; then
|
||||||
|
warn_with_fix ".env tiene valores por defecto sin cambiar" \
|
||||||
|
"nano $RSS2_HOME/.env # cambia POSTGRES_PASSWORD, REDIS_PASSWORD, SECRET_KEY"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail_with_fix ".env no encontrado en $RSS2_HOME/.env" \
|
||||||
|
"cp deploy/debian/env.example $RSS2_HOME/.env" \
|
||||||
|
"nano $RSS2_HOME/.env # edita contraseñas"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RESUMEN FINAL
|
||||||
|
# =============================================================================
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
|
||||||
|
if [[ "$ERRORS" -eq 0 && "$WARNINGS" -eq 0 ]]; then
|
||||||
|
echo -e " ${GREEN}${BOLD}Sistema OK — todo funcionando correctamente${NC}"
|
||||||
|
elif [[ "$ERRORS" -eq 0 ]]; then
|
||||||
|
echo -e " ${YELLOW}${BOLD}Sistema funcional con $WARNINGS advertencia(s)${NC}"
|
||||||
|
echo -e " ${DIM}Las advertencias no bloquean el funcionamiento básico${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}${BOLD}$ERRORS error(es) encontrado(s)${YELLOW} $WARNINGS advertencia(s)${NC}"
|
||||||
|
echo -e " ${DIM}Sigue los fixes indicados arriba para resolverlos${NC}"
|
||||||
|
fi
|
||||||
|
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${DIM}Logs de servicios: journalctl -u rss2-backend -f${NC}"
|
||||||
|
echo -e " ${DIM}Diagnóstico rápido: bash deploy/debian/check.sh --quick${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
exit $(( ERRORS > 0 ? 1 : 0 ))
|
||||||
112
deploy/debian/env.example
Normal file
112
deploy/debian/env.example
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# =============================================================================
|
||||||
|
# RSS2 - Variables de entorno para despliegue Debian nativo
|
||||||
|
# Copiar a /opt/rss2/.env y editar valores antes de instalar
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- PostgreSQL ---
|
||||||
|
POSTGRES_DB=rss
|
||||||
|
POSTGRES_USER=rss
|
||||||
|
POSTGRES_PASSWORD=CAMBIA_ESTO_postgres_password
|
||||||
|
|
||||||
|
# Usadas por workers Go (equivalente a DATABASE_URL)
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=rss
|
||||||
|
DB_USER=rss
|
||||||
|
DB_PASS=CAMBIA_ESTO_postgres_password
|
||||||
|
|
||||||
|
# URL completa para backend API Go
|
||||||
|
DATABASE_URL=postgres://rss:CAMBIA_ESTO_postgres_password@127.0.0.1:5432/rss?sslmode=disable
|
||||||
|
|
||||||
|
# --- Redis ---
|
||||||
|
REDIS_PASSWORD=CAMBIA_ESTO_redis_password
|
||||||
|
REDIS_URL=redis://:CAMBIA_ESTO_redis_password@127.0.0.1:6379
|
||||||
|
|
||||||
|
# --- JWT Secret (minimo 32 caracteres, aleatorio) ---
|
||||||
|
SECRET_KEY=CAMBIA_ESTO_jwt_secret_muy_largo_y_aleatorio
|
||||||
|
|
||||||
|
# --- Backend API ---
|
||||||
|
SERVER_PORT=8080
|
||||||
|
|
||||||
|
# --- Zona horaria ---
|
||||||
|
TZ=Europe/Madrid
|
||||||
|
|
||||||
|
# --- HuggingFace cache (modelos ML) ---
|
||||||
|
HF_HOME=/opt/rss2/hf_cache
|
||||||
|
|
||||||
|
# --- Endpoints ML opcionales (solo si corres Ollama o LibreTranslate por separado) ---
|
||||||
|
# Los workers Python van directo a BD; estos endpoints solo los usa el backend para
|
||||||
|
# llamadas on-demand desde el panel admin (NER, traduccion manual, etc.)
|
||||||
|
TRANSLATION_URL=http://127.0.0.1:7790
|
||||||
|
OLLAMA_URL=http://127.0.0.1:11434
|
||||||
|
SPACY_URL=http://127.0.0.1:8000
|
||||||
|
|
||||||
|
# --- Qdrant (local, sin Docker) ---
|
||||||
|
QDRANT_HOST=127.0.0.1
|
||||||
|
QDRANT_PORT=6333
|
||||||
|
QDRANT_COLLECTION=news_vectors
|
||||||
|
|
||||||
|
# --- Translator (NLLB-200 via CTranslate2) ---
|
||||||
|
TARGET_LANGS=es
|
||||||
|
TRANSLATOR_BATCH=32
|
||||||
|
CT2_MODEL_PATH=/opt/rss2/models/nllb-ct2
|
||||||
|
CT2_DEVICE=cpu
|
||||||
|
CT2_COMPUTE_TYPE=int8
|
||||||
|
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
|
||||||
|
|
||||||
|
# --- Embeddings ---
|
||||||
|
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
||||||
|
EMB_BATCH=64
|
||||||
|
EMB_SLEEP_IDLE=5
|
||||||
|
EMB_LANGS=es
|
||||||
|
EMB_LIMIT=1000
|
||||||
|
DEVICE=cpu
|
||||||
|
|
||||||
|
# --- NER ---
|
||||||
|
NER_LANG=es
|
||||||
|
NER_BATCH=64
|
||||||
|
|
||||||
|
# --- Ingestor RSS ---
|
||||||
|
RSS_MAX_WORKERS=100
|
||||||
|
RSS_POKE_INTERVAL_MIN=15
|
||||||
|
|
||||||
|
# --- Scraper ---
|
||||||
|
SCRAPER_SLEEP=60
|
||||||
|
SCRAPER_BATCH=10
|
||||||
|
|
||||||
|
# --- Discovery ---
|
||||||
|
DISCOVERY_INTERVAL=900
|
||||||
|
DISCOVERY_BATCH=10
|
||||||
|
MAX_FEEDS_PER_URL=5
|
||||||
|
|
||||||
|
# --- Wiki Worker ---
|
||||||
|
WIKI_SLEEP=10
|
||||||
|
WIKI_IMAGES_PATH=/opt/rss2/data/wiki_images
|
||||||
|
|
||||||
|
# --- Topics ---
|
||||||
|
TOPICS_SLEEP=10
|
||||||
|
TOPICS_BATCH=500
|
||||||
|
|
||||||
|
# --- Related ---
|
||||||
|
RELATED_SLEEP=10
|
||||||
|
RELATED_BATCH=200
|
||||||
|
RELATED_TOPK=10
|
||||||
|
|
||||||
|
# --- Cluster ---
|
||||||
|
EVENT_DIST_THRESHOLD=0.35
|
||||||
|
|
||||||
|
# --- Categorizer ---
|
||||||
|
CATEGORIZER_BATCH_SIZE=10
|
||||||
|
CATEGORIZER_SLEEP_IDLE=5
|
||||||
|
|
||||||
|
# --- Scheduler traduccion ---
|
||||||
|
SCHEDULER_BATCH=1000
|
||||||
|
SCHEDULER_SLEEP=30
|
||||||
|
|
||||||
|
# --- Lang Detect ---
|
||||||
|
LANG_DETECT_SLEEP=60
|
||||||
|
LANG_DETECT_BATCH=1000
|
||||||
|
|
||||||
|
# --- Qdrant Worker ---
|
||||||
|
QDRANT_SLEEP=30
|
||||||
|
QDRANT_BATCH=100
|
||||||
309
deploy/debian/install.sh
Executable file
309
deploy/debian/install.sh
Executable file
|
|
@ -0,0 +1,309 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# RSS2 - Instalacion en Debian (sin Docker)
|
||||||
|
# Ejecutar como root: bash install.sh
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RSS2_USER="rss2"
|
||||||
|
RSS2_HOME="/opt/rss2"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
|
[[ "$EUID" -ne 0 ]] && error "Ejecutar como root: sudo bash install.sh"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 1. DEPENDENCIAS DEL SISTEMA
|
||||||
|
# =============================================================================
|
||||||
|
info "Instalando dependencias del sistema..."
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
curl wget git build-essential \
|
||||||
|
postgresql postgresql-client \
|
||||||
|
redis-server \
|
||||||
|
nginx \
|
||||||
|
python3 python3-pip python3-venv python3-dev \
|
||||||
|
nodejs npm \
|
||||||
|
ca-certificates tzdata \
|
||||||
|
libpq-dev
|
||||||
|
|
||||||
|
# Go (rss-ingestor-go requiere Go 1.25)
|
||||||
|
_need_go=false
|
||||||
|
if ! command -v go &>/dev/null; then
|
||||||
|
_need_go=true
|
||||||
|
else
|
||||||
|
_gover=$(go version | awk '{print $3}' | tr -d 'go')
|
||||||
|
IFS='.' read -ra _gv <<< "$_gover"
|
||||||
|
if [[ "${_gv[0]:-0}" -lt 1 ]] || [[ "${_gv[0]:-0}" -eq 1 && "${_gv[1]:-0}" -lt 25 ]]; then
|
||||||
|
_need_go=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$_need_go" == "true" ]]; then
|
||||||
|
info "Instalando Go 1.25..."
|
||||||
|
GO_VERSION="1.25.0"
|
||||||
|
ARCH=$(dpkg --print-architecture)
|
||||||
|
case "$ARCH" in
|
||||||
|
amd64) GO_ARCH="amd64" ;;
|
||||||
|
arm64) GO_ARCH="arm64" ;;
|
||||||
|
*) error "Arquitectura no soportada: $ARCH" ;;
|
||||||
|
esac
|
||||||
|
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" -o /tmp/go.tar.gz
|
||||||
|
rm -rf /usr/local/go
|
||||||
|
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||||
|
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
rm /tmp/go.tar.gz
|
||||||
|
fi
|
||||||
|
info "Go: $(go version)"
|
||||||
|
|
||||||
|
# Qdrant (binario oficial)
|
||||||
|
if [[ ! -f "$RSS2_HOME/qdrant/qdrant" ]]; then
|
||||||
|
info "Descargando Qdrant..."
|
||||||
|
QDRANT_VERSION="v1.12.1"
|
||||||
|
ARCH=$(dpkg --print-architecture)
|
||||||
|
case "$ARCH" in
|
||||||
|
amd64) QDRANT_ARCH="x86_64-unknown-linux-musl" ;;
|
||||||
|
arm64) QDRANT_ARCH="aarch64-unknown-linux-musl" ;;
|
||||||
|
*) error "Arquitectura no soportada para Qdrant: $ARCH" ;;
|
||||||
|
esac
|
||||||
|
mkdir -p "$RSS2_HOME/qdrant"
|
||||||
|
curl -fsSL "https://github.com/qdrant/qdrant/releases/download/${QDRANT_VERSION}/qdrant-${QDRANT_ARCH}.tar.gz" \
|
||||||
|
-o /tmp/qdrant.tar.gz
|
||||||
|
tar -C "$RSS2_HOME/qdrant" -xzf /tmp/qdrant.tar.gz
|
||||||
|
chmod +x "$RSS2_HOME/qdrant/qdrant"
|
||||||
|
rm /tmp/qdrant.tar.gz
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 2. USUARIO Y DIRECTORIOS
|
||||||
|
# =============================================================================
|
||||||
|
info "Creando usuario $RSS2_USER y directorios..."
|
||||||
|
id "$RSS2_USER" &>/dev/null || useradd -r -m -d "$RSS2_HOME" -s /bin/bash "$RSS2_USER"
|
||||||
|
|
||||||
|
mkdir -p \
|
||||||
|
"$RSS2_HOME/bin" \
|
||||||
|
"$RSS2_HOME/src" \
|
||||||
|
"$RSS2_HOME/data/wiki_images" \
|
||||||
|
"$RSS2_HOME/data/qdrant_storage" \
|
||||||
|
"$RSS2_HOME/hf_cache" \
|
||||||
|
"$RSS2_HOME/models" \
|
||||||
|
"$RSS2_HOME/frontend/dist" \
|
||||||
|
"$RSS2_HOME/logs"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 3. CONFIGURACION ENTORNO
|
||||||
|
# =============================================================================
|
||||||
|
if [[ ! -f "$RSS2_HOME/.env" ]]; then
|
||||||
|
if [[ -f "$SCRIPT_DIR/env.example" ]]; then
|
||||||
|
cp "$SCRIPT_DIR/env.example" "$RSS2_HOME/.env"
|
||||||
|
warn "Copia env.example en $RSS2_HOME/.env - EDITA LAS CONTRASENAS antes de continuar"
|
||||||
|
warn "Presiona Enter cuando hayas editado el .env, o Ctrl+C para salir"
|
||||||
|
read -r
|
||||||
|
else
|
||||||
|
error "No se encontro env.example en $SCRIPT_DIR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 4. POSTGRESQL
|
||||||
|
# =============================================================================
|
||||||
|
info "Configurando PostgreSQL..."
|
||||||
|
source "$RSS2_HOME/.env" 2>/dev/null || true
|
||||||
|
|
||||||
|
DB_NAME="${POSTGRES_DB:-rss}"
|
||||||
|
DB_USER="${POSTGRES_USER:-rss}"
|
||||||
|
DB_PASS="${POSTGRES_PASSWORD:-changeme}"
|
||||||
|
|
||||||
|
systemctl enable --now postgresql
|
||||||
|
|
||||||
|
# Crear usuario y base de datos si no existen
|
||||||
|
sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1 || \
|
||||||
|
sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
|
||||||
|
|
||||||
|
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1 || \
|
||||||
|
sudo -u postgres createdb -O "$DB_USER" "$DB_NAME"
|
||||||
|
|
||||||
|
# Ejecutar migraciones SQL
|
||||||
|
if [[ -d "$REPO_ROOT/migrations" ]]; then
|
||||||
|
info "Ejecutando migraciones..."
|
||||||
|
for sql_file in "$REPO_ROOT/migrations"/*.sql; do
|
||||||
|
[[ -f "$sql_file" ]] || continue
|
||||||
|
info " Aplicando $(basename "$sql_file")..."
|
||||||
|
sudo -u postgres psql -d "$DB_NAME" -f "$sql_file" 2>/dev/null || warn " (ya aplicada o error ignorado)"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ejecutar init-db scripts (schema inicial)
|
||||||
|
if [[ -d "$REPO_ROOT/init-db" ]]; then
|
||||||
|
info "Ejecutando scripts de init-db..."
|
||||||
|
for sql_file in "$REPO_ROOT/init-db"/*.sql; do
|
||||||
|
[[ -f "$sql_file" ]] || continue
|
||||||
|
info " $(basename "$sql_file")..."
|
||||||
|
sudo -u postgres psql -d "$DB_NAME" -f "$sql_file" 2>/dev/null || warn " (ya aplicada o error ignorado)"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 5. REDIS
|
||||||
|
# =============================================================================
|
||||||
|
info "Configurando Redis..."
|
||||||
|
REDIS_PASS="${REDIS_PASSWORD:-changeme_redis}"
|
||||||
|
|
||||||
|
# Agregar autenticacion y limites de memoria a redis.conf
|
||||||
|
REDIS_CONF="/etc/redis/redis.conf"
|
||||||
|
grep -q "requirepass $REDIS_PASS" "$REDIS_CONF" 2>/dev/null || {
|
||||||
|
echo "requirepass $REDIS_PASS" >> "$REDIS_CONF"
|
||||||
|
echo "maxmemory 512mb" >> "$REDIS_CONF"
|
||||||
|
echo "maxmemory-policy allkeys-lru" >> "$REDIS_CONF"
|
||||||
|
echo "appendonly yes" >> "$REDIS_CONF"
|
||||||
|
}
|
||||||
|
systemctl enable --now redis-server
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 6. PYTHON VIRTUALENV + DEPENDENCIAS ML
|
||||||
|
# =============================================================================
|
||||||
|
info "Creando virtualenv Python y instalando dependencias..."
|
||||||
|
python3 -m venv "$RSS2_HOME/venv"
|
||||||
|
"$RSS2_HOME/venv/bin/pip" install --upgrade pip -q
|
||||||
|
|
||||||
|
if [[ -f "$REPO_ROOT/requirements.txt" ]]; then
|
||||||
|
"$RSS2_HOME/venv/bin/pip" install -r "$REPO_ROOT/requirements.txt" -q
|
||||||
|
fi
|
||||||
|
|
||||||
|
# spaCy modelo en español
|
||||||
|
"$RSS2_HOME/venv/bin/python" -m spacy download es_core_news_lg 2>/dev/null || \
|
||||||
|
warn "spaCy model es_core_news_lg no se pudo descargar, hazlo manualmente"
|
||||||
|
|
||||||
|
# Copiar workers Python al directorio de trabajo
|
||||||
|
info "Copiando workers Python..."
|
||||||
|
rsync -a --delete "$REPO_ROOT/workers/" "$RSS2_HOME/src/workers/"
|
||||||
|
cp "$REPO_ROOT/config/entity_config.json" "$RSS2_HOME/src/" 2>/dev/null || true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 7. COMPILAR GO (backend + workers)
|
||||||
|
# =============================================================================
|
||||||
|
info "Compilando binarios Go..."
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
export GOPATH=/tmp/go-build-rss2
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
if [[ -d "$REPO_ROOT/backend" ]]; then
|
||||||
|
(cd "$REPO_ROOT/backend" && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/server" ./cmd/server && \
|
||||||
|
info " [OK] server") || warn " [FAIL] server"
|
||||||
|
for cmd in scraper discovery wiki_worker topics related; do
|
||||||
|
[[ -d "$REPO_ROOT/backend/cmd/$cmd" ]] || continue
|
||||||
|
(cd "$REPO_ROOT/backend" && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/$cmd" "./cmd/$cmd" && \
|
||||||
|
info " [OK] $cmd") || warn " [FAIL] $cmd"
|
||||||
|
done
|
||||||
|
# qdrant worker: output como qdrant_worker para coincidir con el service
|
||||||
|
[[ -d "$REPO_ROOT/backend/cmd/qdrant" ]] && \
|
||||||
|
(cd "$REPO_ROOT/backend" && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/qdrant_worker" "./cmd/qdrant" && \
|
||||||
|
info " [OK] qdrant_worker") || warn " [FAIL] qdrant_worker"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# RSS Ingestor Go (repo separado, requiere Go 1.25)
|
||||||
|
if [[ -d "$REPO_ROOT/rss-ingestor-go" ]]; then
|
||||||
|
(cd "$REPO_ROOT/rss-ingestor-go" && \
|
||||||
|
GOTOOLCHAIN=local CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/ingestor" . && \
|
||||||
|
info " [OK] ingestor") || warn " [FAIL] ingestor"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 8. FRONTEND REACT
|
||||||
|
# =============================================================================
|
||||||
|
info "Compilando frontend React..."
|
||||||
|
if [[ -d "$REPO_ROOT/frontend" ]]; then
|
||||||
|
(cd "$REPO_ROOT/frontend" && \
|
||||||
|
npm install --silent && \
|
||||||
|
VITE_API_URL=/api npm run build -- --outDir "$RSS2_HOME/frontend/dist" && \
|
||||||
|
info " [OK] frontend compilado") || warn " [FAIL] frontend"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 9. NGINX
|
||||||
|
# =============================================================================
|
||||||
|
info "Configurando Nginx..."
|
||||||
|
cp "$SCRIPT_DIR/nginx.conf" /etc/nginx/nginx.conf
|
||||||
|
nginx -t && systemctl enable --now nginx && systemctl reload nginx
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 10. SYSTEMD SERVICES
|
||||||
|
# =============================================================================
|
||||||
|
info "Instalando servicios systemd..."
|
||||||
|
SERVICES=(
|
||||||
|
rss2-qdrant
|
||||||
|
rss2-backend
|
||||||
|
rss2-ingestor
|
||||||
|
rss2-scraper
|
||||||
|
rss2-discovery
|
||||||
|
rss2-wiki
|
||||||
|
rss2-topics
|
||||||
|
rss2-related
|
||||||
|
rss2-qdrant-worker
|
||||||
|
rss2-langdetect
|
||||||
|
rss2-translation-scheduler
|
||||||
|
rss2-translator
|
||||||
|
rss2-embeddings
|
||||||
|
rss2-ner
|
||||||
|
rss2-cluster
|
||||||
|
rss2-categorizer
|
||||||
|
)
|
||||||
|
|
||||||
|
for svc in "${SERVICES[@]}"; do
|
||||||
|
svc_file="$SCRIPT_DIR/systemd/${svc}.service"
|
||||||
|
if [[ -f "$svc_file" ]]; then
|
||||||
|
cp "$svc_file" "/etc/systemd/system/${svc}.service"
|
||||||
|
else
|
||||||
|
warn "No se encontro $svc_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
for svc in "${SERVICES[@]}"; do
|
||||||
|
systemctl enable "$svc" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 11. PERMISOS FINALES
|
||||||
|
# =============================================================================
|
||||||
|
info "Ajustando permisos..."
|
||||||
|
chown -R "$RSS2_USER:$RSS2_USER" "$RSS2_HOME"
|
||||||
|
chmod 600 "$RSS2_HOME/.env"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 12. ARRANCAR SERVICIOS
|
||||||
|
# =============================================================================
|
||||||
|
info "Arrancando servicios..."
|
||||||
|
# Infraestructura primero
|
||||||
|
systemctl start rss2-qdrant
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# API y workers Go
|
||||||
|
for svc in rss2-backend rss2-ingestor rss2-scraper rss2-discovery rss2-wiki rss2-topics rss2-related rss2-qdrant-worker; do
|
||||||
|
systemctl start "$svc" || warn "No se pudo arrancar $svc"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Workers Python (modelos pesados, arrancan despues)
|
||||||
|
for svc in rss2-langdetect rss2-translation-scheduler rss2-translator rss2-embeddings rss2-ner rss2-cluster rss2-categorizer; do
|
||||||
|
systemctl start "$svc" || warn "No se pudo arrancar $svc"
|
||||||
|
done
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
echo ""
|
||||||
|
info "============================================="
|
||||||
|
info " RSS2 instalado en $RSS2_HOME"
|
||||||
|
info " Acceder en: http://$(hostname -I | awk '{print $1}'):8001"
|
||||||
|
info ""
|
||||||
|
info " Ver logs: journalctl -u rss2-backend -f"
|
||||||
|
info " Ver estado: systemctl status rss2-backend"
|
||||||
|
info " Editar env: nano $RSS2_HOME/.env"
|
||||||
|
info "============================================="
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
user nginx;
|
user www-data;
|
||||||
worker_processes auto;
|
worker_processes auto;
|
||||||
error_log /var/log/nginx/error.log warn;
|
error_log /var/log/nginx/error.log warn;
|
||||||
pid /var/run/nginx.pid;
|
pid /run/nginx.pid;
|
||||||
|
|
||||||
events {
|
events {
|
||||||
worker_connections 2048;
|
worker_connections 2048;
|
||||||
|
|
@ -33,40 +33,36 @@ http {
|
||||||
application/json application/javascript
|
application/json application/javascript
|
||||||
application/xml text/xml;
|
application/xml text/xml;
|
||||||
|
|
||||||
# Upstream for Go API
|
# Go API backend (proceso nativo en localhost)
|
||||||
upstream api_backend {
|
upstream api_backend {
|
||||||
server backend-go:8080;
|
server 127.0.0.1:8080;
|
||||||
keepalive 32;
|
keepalive 32;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Upstream for React Frontend
|
|
||||||
upstream frontend {
|
|
||||||
server rss2_frontend:80;
|
|
||||||
keepalive 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 8001;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
client_body_timeout 60s;
|
client_body_timeout 60s;
|
||||||
client_header_timeout 60s;
|
client_header_timeout 60s;
|
||||||
send_timeout 300s;
|
send_timeout 300s;
|
||||||
|
|
||||||
# Serve React Frontend
|
# Frontend React (archivos estaticos compilados)
|
||||||
|
root /opt/rss2/frontend/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend;
|
try_files $uri $uri/ /index.html;
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy to Go API
|
# Imagenes Wikipedia servidas directamente
|
||||||
|
location /wiki-images/ {
|
||||||
|
alias /opt/rss2/data/wiki_images/;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy al API Go
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api_backend/api/;
|
proxy_pass http://api_backend/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
@ -81,13 +77,11 @@ http {
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check
|
|
||||||
location /health {
|
location /health {
|
||||||
access_log off;
|
access_log off;
|
||||||
return 200 "ok";
|
return 200 "ok";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Block sensitive files
|
|
||||||
location ~ /\. {
|
location ~ /\. {
|
||||||
deny all;
|
deny all;
|
||||||
access_log off;
|
access_log off;
|
||||||
327
deploy/debian/prerequisites.sh
Executable file
327
deploy/debian/prerequisites.sh
Executable file
|
|
@ -0,0 +1,327 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# COCONEWS - Instalacion de prerequisites en Debian 12 / Ubuntu 22.04+
|
||||||
|
# Ejecutar ANTES de install.sh
|
||||||
|
# Uso: sudo bash prerequisites.sh
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||||
|
info() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
|
step() { echo -e "${BLUE}[-->]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
|
[[ "$EUID" -ne 0 ]] && error "Ejecutar como root: sudo bash prerequisites.sh"
|
||||||
|
|
||||||
|
# Detectar OS
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
. /etc/os-release
|
||||||
|
OS_ID="$ID"
|
||||||
|
OS_VER="$VERSION_ID"
|
||||||
|
else
|
||||||
|
error "No se puede detectar el sistema operativo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$OS_ID" == "debian" || "$OS_ID" == "ubuntu" ]] || \
|
||||||
|
error "Solo soportado en Debian/Ubuntu. Detectado: $OS_ID"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================================================="
|
||||||
|
echo " COCONEWS - Instalador de Prerequisites"
|
||||||
|
echo " OS: $PRETTY_NAME"
|
||||||
|
echo "================================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 1. PAQUETES APT BASE
|
||||||
|
# =============================================================================
|
||||||
|
# Comprobar espacio en disco (mínimo 10 GB libres)
|
||||||
|
DISK_FREE_GB=$(df / --output=avail -BG | tail -1 | tr -d 'G ')
|
||||||
|
if [[ "${DISK_FREE_GB:-0}" -lt 10 ]]; then
|
||||||
|
warn "Espacio libre en disco: ${DISK_FREE_GB} GB (recomendado mínimo 10 GB)"
|
||||||
|
warn "Los modelos ML necesitan ~5 GB. Continua bajo tu responsabilidad."
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Actualizando repositorios apt..."
|
||||||
|
apt-get update -qq 2>/tmp/apt-update.log || {
|
||||||
|
echo -e "${RED}Error al actualizar repositorios apt.${NC}"
|
||||||
|
echo " Posibles causas:"
|
||||||
|
echo " • Sin conexión a internet"
|
||||||
|
echo " • Repositorios con errores: cat /tmp/apt-update.log"
|
||||||
|
echo " • Solución: apt-get update --fix-missing"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
step "Instalando paquetes del sistema..."
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
curl wget git build-essential \
|
||||||
|
ca-certificates gnupg lsb-release \
|
||||||
|
software-properties-common apt-transport-https \
|
||||||
|
tzdata locales \
|
||||||
|
rsync \
|
||||||
|
openssl \
|
||||||
|
libpq-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libbz2-dev \
|
||||||
|
libreadline-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
zlib1g-dev
|
||||||
|
info "Paquetes base instalados"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 2. POSTGRESQL 16
|
||||||
|
# =============================================================================
|
||||||
|
step "Instalando PostgreSQL 16..."
|
||||||
|
if ! command -v psql &>/dev/null; then
|
||||||
|
# Repositorio oficial de PostgreSQL
|
||||||
|
install -d /usr/share/postgresql-common/pgdg
|
||||||
|
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||||
|
| gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg
|
||||||
|
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] \
|
||||||
|
https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
|
||||||
|
> /etc/apt/sources.list.d/pgdg.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y postgresql-16 postgresql-client-16
|
||||||
|
fi
|
||||||
|
info "PostgreSQL: $(psql --version)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 3. REDIS
|
||||||
|
# =============================================================================
|
||||||
|
step "Instalando Redis..."
|
||||||
|
if ! command -v redis-server &>/dev/null; then
|
||||||
|
apt-get install -y redis-server
|
||||||
|
fi
|
||||||
|
info "Redis: $(redis-server --version | cut -d' ' -f3)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 4. NGINX
|
||||||
|
# =============================================================================
|
||||||
|
step "Instalando Nginx..."
|
||||||
|
if ! command -v nginx &>/dev/null; then
|
||||||
|
apt-get install -y nginx
|
||||||
|
fi
|
||||||
|
info "Nginx: $(nginx -v 2>&1 | cut -d'/' -f2)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 5. PYTHON 3 + pip + venv
|
||||||
|
# =============================================================================
|
||||||
|
step "Instalando Python 3..."
|
||||||
|
apt-get install -y python3 python3-pip python3-venv python3-dev
|
||||||
|
info "Python: $(python3 --version)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 6. NODE.JS 20 LTS
|
||||||
|
# =============================================================================
|
||||||
|
step "Instalando Node.js 20 LTS..."
|
||||||
|
if ! command -v node &>/dev/null || [[ "$(node -v | tr -d 'v' | cut -d. -f1)" -lt 18 ]]; then
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
fi
|
||||||
|
info "Node.js: $(node --version) | npm: $(npm --version)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 7. GO 1.25
|
||||||
|
# =============================================================================
|
||||||
|
step "Instalando Go 1.25..."
|
||||||
|
GO_VERSION="1.25.0"
|
||||||
|
INSTALLED_GO=$(go version 2>/dev/null | awk '{print $3}' | tr -d 'go' || echo "0")
|
||||||
|
|
||||||
|
# Comparar version instalada
|
||||||
|
needs_go=false
|
||||||
|
if ! command -v go &>/dev/null; then
|
||||||
|
needs_go=true
|
||||||
|
else
|
||||||
|
IFS='.' read -ra INS <<< "$INSTALLED_GO"
|
||||||
|
IFS='.' read -ra REQ <<< "$GO_VERSION"
|
||||||
|
if [[ "${INS[0]}" -lt "${REQ[0]}" ]] || \
|
||||||
|
([[ "${INS[0]}" == "${REQ[0]}" ]] && [[ "${INS[1]:-0}" -lt "${REQ[1]:-0}" ]]); then
|
||||||
|
needs_go=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$needs_go" == "true" ]]; then
|
||||||
|
ARCH=$(dpkg --print-architecture)
|
||||||
|
case "$ARCH" in
|
||||||
|
amd64) GO_ARCH="amd64" ;;
|
||||||
|
arm64) GO_ARCH="arm64" ;;
|
||||||
|
*) error "Arquitectura no soportada para Go: $ARCH" ;;
|
||||||
|
esac
|
||||||
|
step " Descargando Go ${GO_VERSION} (${GO_ARCH})..."
|
||||||
|
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" -o /tmp/go.tar.gz
|
||||||
|
rm -rf /usr/local/go
|
||||||
|
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||||
|
rm /tmp/go.tar.gz
|
||||||
|
# Perfil global
|
||||||
|
cat > /etc/profile.d/golang.sh << 'GOEOF'
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
export GOPATH=$HOME/go
|
||||||
|
export PATH=$PATH:$GOPATH/bin
|
||||||
|
GOEOF
|
||||||
|
chmod +x /etc/profile.d/golang.sh
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
info "Go instalado: $(go version)"
|
||||||
|
else
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
info "Go ya instalado: $(go version)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 8. QDRANT (binario oficial)
|
||||||
|
# =============================================================================
|
||||||
|
step "Instalando Qdrant..."
|
||||||
|
QDRANT_VERSION="v1.12.1"
|
||||||
|
QDRANT_INSTALL_DIR="/opt/rss2/qdrant"
|
||||||
|
mkdir -p "$QDRANT_INSTALL_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f "$QDRANT_INSTALL_DIR/qdrant" ]]; then
|
||||||
|
ARCH=$(dpkg --print-architecture)
|
||||||
|
case "$ARCH" in
|
||||||
|
amd64) QDRANT_ARCH="x86_64-unknown-linux-musl" ;;
|
||||||
|
arm64) QDRANT_ARCH="aarch64-unknown-linux-musl" ;;
|
||||||
|
*) error "Arquitectura no soportada para Qdrant: $ARCH" ;;
|
||||||
|
esac
|
||||||
|
step " Descargando Qdrant ${QDRANT_VERSION}..."
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/qdrant/qdrant/releases/download/${QDRANT_VERSION}/qdrant-${QDRANT_ARCH}.tar.gz" \
|
||||||
|
-o /tmp/qdrant.tar.gz
|
||||||
|
tar -C "$QDRANT_INSTALL_DIR" -xzf /tmp/qdrant.tar.gz
|
||||||
|
chmod +x "$QDRANT_INSTALL_DIR/qdrant"
|
||||||
|
rm /tmp/qdrant.tar.gz
|
||||||
|
info "Qdrant ${QDRANT_VERSION} instalado en ${QDRANT_INSTALL_DIR}"
|
||||||
|
else
|
||||||
|
info "Qdrant ya instalado en ${QDRANT_INSTALL_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 9. USUARIO DEL SISTEMA rss2
|
||||||
|
# =============================================================================
|
||||||
|
step "Creando usuario del sistema 'rss2'..."
|
||||||
|
if ! id rss2 &>/dev/null; then
|
||||||
|
useradd -r -m -d /opt/rss2 -s /bin/bash rss2
|
||||||
|
info "Usuario 'rss2' creado"
|
||||||
|
else
|
||||||
|
info "Usuario 'rss2' ya existe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Crear estructura de directorios
|
||||||
|
mkdir -p \
|
||||||
|
/opt/rss2/bin \
|
||||||
|
/opt/rss2/src \
|
||||||
|
/opt/rss2/data/wiki_images \
|
||||||
|
/opt/rss2/data/qdrant_storage \
|
||||||
|
/opt/rss2/hf_cache \
|
||||||
|
/opt/rss2/models \
|
||||||
|
/opt/rss2/frontend/dist \
|
||||||
|
/opt/rss2/logs \
|
||||||
|
/opt/rss2/backups
|
||||||
|
chown -R rss2:rss2 /opt/rss2
|
||||||
|
info "Directorios /opt/rss2 creados"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 10. PYTHON VIRTUALENV + DEPENDENCIAS BASE
|
||||||
|
# =============================================================================
|
||||||
|
step "Creando virtualenv Python en /opt/rss2/venv..."
|
||||||
|
if [[ ! -d /opt/rss2/venv ]]; then
|
||||||
|
python3 -m venv /opt/rss2/venv
|
||||||
|
fi
|
||||||
|
/opt/rss2/venv/bin/pip install --upgrade pip setuptools wheel -q
|
||||||
|
info "Virtualenv listo"
|
||||||
|
|
||||||
|
# Dependencias base (sin los modelos pesados de ML)
|
||||||
|
step "Instalando dependencias Python base..."
|
||||||
|
/opt/rss2/venv/bin/pip install -q \
|
||||||
|
psycopg2-binary \
|
||||||
|
langdetect \
|
||||||
|
python-dotenv \
|
||||||
|
requests \
|
||||||
|
beautifulsoup4 \
|
||||||
|
lxml \
|
||||||
|
redis \
|
||||||
|
qdrant-client \
|
||||||
|
numpy \
|
||||||
|
scikit-learn \
|
||||||
|
tqdm
|
||||||
|
info "Dependencias Python base instaladas"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 11. DEPENDENCIAS ML (pesadas - opcional en este paso)
|
||||||
|
# =============================================================================
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}[?]${NC} Instalar dependencias ML pesadas ahora?"
|
||||||
|
echo " (ctranslate2, transformers, sentence-transformers, spaCy)"
|
||||||
|
echo " Puede tardar 20-40 minutos y usar ~5 GB de disco."
|
||||||
|
echo -n " [s/N]: "
|
||||||
|
read -r install_ml
|
||||||
|
|
||||||
|
if [[ "${install_ml,,}" == "s" || "${install_ml,,}" == "si" || "${install_ml,,}" == "y" ]]; then
|
||||||
|
step "Instalando dependencias ML (esto tarda)..."
|
||||||
|
/opt/rss2/venv/bin/pip install -q \
|
||||||
|
ctranslate2>=4.0.0 \
|
||||||
|
transformers==4.43.3 \
|
||||||
|
sentencepiece \
|
||||||
|
sacremoses \
|
||||||
|
accelerate \
|
||||||
|
sentence-transformers==3.0.1 \
|
||||||
|
"spacy>=3.7,<4.0" \
|
||||||
|
torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
info "Dependencias ML instaladas"
|
||||||
|
|
||||||
|
step "Descargando modelo spaCy en español..."
|
||||||
|
/opt/rss2/venv/bin/python -m spacy download es_core_news_lg
|
||||||
|
info "Modelo spaCy es_core_news_lg listo"
|
||||||
|
|
||||||
|
step "Convirtiendo modelo NLLB-200 a CTranslate2..."
|
||||||
|
warn "Esto puede tardar 10-30 minutos y requiere ~2 GB de RAM"
|
||||||
|
mkdir -p /opt/rss2/models
|
||||||
|
/opt/rss2/venv/bin/python - <<'EOF'
|
||||||
|
import os, sys
|
||||||
|
os.makedirs("/opt/rss2/models/nllb-ct2", exist_ok=True)
|
||||||
|
os.environ["HF_HOME"] = "/opt/rss2/hf_cache"
|
||||||
|
try:
|
||||||
|
from ctranslate2.converters import OpusMTConverter
|
||||||
|
converter = OpusMTConverter("facebook/nllb-200-distilled-600M")
|
||||||
|
converter.convert("/opt/rss2/models/nllb-ct2", quantization="int8", force=True)
|
||||||
|
print("[OK] Modelo NLLB-200 convertido en /opt/rss2/models/nllb-ct2")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] {e}")
|
||||||
|
print("Convierte manualmente despues con: deploy/debian/convert_model.sh")
|
||||||
|
sys.exit(0)
|
||||||
|
EOF
|
||||||
|
chown -R rss2:rss2 /opt/rss2/models /opt/rss2/hf_cache
|
||||||
|
else
|
||||||
|
warn "ML omitido. Ejecuta 'deploy/debian/install.sh' para instalarlas junto con el resto."
|
||||||
|
warn "Sin ML: la traduccion y los embeddings no funcionaran."
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown -R rss2:rss2 /opt/rss2
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RESUMEN Y SIGUIENTES PASOS
|
||||||
|
# =============================================================================
|
||||||
|
echo ""
|
||||||
|
echo "================================================="
|
||||||
|
echo -e " ${GREEN}Prerequisites instalados correctamente${NC}"
|
||||||
|
echo "================================================="
|
||||||
|
echo ""
|
||||||
|
echo " Sistema: $PRETTY_NAME"
|
||||||
|
echo " Go: $(go version 2>/dev/null | awk '{print $3}')"
|
||||||
|
echo " Python: $(python3 --version)"
|
||||||
|
echo " Node.js: $(node --version)"
|
||||||
|
echo " PostgreSQL: $(psql --version | awk '{print $3}')"
|
||||||
|
echo " Redis: $(redis-server --version | awk '{print $3}' | tr -d ',')"
|
||||||
|
echo " Nginx: $(nginx -v 2>&1 | cut -d'/' -f2)"
|
||||||
|
echo ""
|
||||||
|
echo " Siguiente paso:"
|
||||||
|
echo " sudo bash deploy/debian/install.sh"
|
||||||
|
echo ""
|
||||||
|
echo " Si algo falló durante la instalación:"
|
||||||
|
echo " • apt falla: apt-get update --fix-missing"
|
||||||
|
echo " • Go no descarga: verifica conectividad → curl https://go.dev"
|
||||||
|
echo " • Qdrant no baja: descárgalo manualmente en"
|
||||||
|
echo " https://github.com/qdrant/qdrant/releases"
|
||||||
|
echo " • pip falla: python3 -m venv /opt/rss2/venv --clear"
|
||||||
|
echo " • Diagnóstico: bash deploy/debian/check.sh"
|
||||||
|
echo ""
|
||||||
28
deploy/debian/systemd/rss2-backend.service
Normal file
28
deploy/debian/systemd/rss2-backend.service
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Backend API (Go)
|
||||||
|
After=network.target postgresql.service redis.service
|
||||||
|
Requires=postgresql.service redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
# Sobreescribir hostnames Docker por localhost (los workers Python van directo a DB)
|
||||||
|
Environment=TRANSLATION_URL=http://127.0.0.1:7790
|
||||||
|
Environment=OLLAMA_URL=http://127.0.0.1:11434
|
||||||
|
Environment=SPACY_URL=http://127.0.0.1:8000
|
||||||
|
ExecStart=/opt/rss2/bin/server
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-backend
|
||||||
|
|
||||||
|
# Limites de recursos
|
||||||
|
LimitNOFILE=65536
|
||||||
|
MemoryMax=1G
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
25
deploy/debian/systemd/rss2-categorizer.service
Normal file
25
deploy/debian/systemd/rss2-categorizer.service
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Categorizer Worker (Python)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2/src
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=CATEGORIZER_BATCH_SIZE=10
|
||||||
|
Environment=CATEGORIZER_SLEEP_IDLE=5
|
||||||
|
ExecStart=/opt/rss2/venv/bin/python -m workers.simple_categorizer_worker
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-categorizer
|
||||||
|
|
||||||
|
MemoryMax=1G
|
||||||
|
CPUQuota=200%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
26
deploy/debian/systemd/rss2-cluster.service
Normal file
26
deploy/debian/systemd/rss2-cluster.service
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Cluster Worker - Agrupacion de noticias (Python)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2/src
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=EVENT_DIST_THRESHOLD=0.35
|
||||||
|
Environment=EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
||||||
|
Environment=HF_HOME=/opt/rss2/hf_cache
|
||||||
|
ExecStart=/opt/rss2/venv/bin/python -m workers.cluster_worker
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-cluster
|
||||||
|
|
||||||
|
MemoryMax=2G
|
||||||
|
CPUQuota=200%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
26
deploy/debian/systemd/rss2-discovery.service
Normal file
26
deploy/debian/systemd/rss2-discovery.service
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Discovery de Feeds (Go)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=DISCOVERY_INTERVAL=900
|
||||||
|
Environment=DISCOVERY_BATCH=10
|
||||||
|
Environment=MAX_FEEDS_PER_URL=5
|
||||||
|
ExecStart=/opt/rss2/bin/discovery
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-discovery
|
||||||
|
|
||||||
|
MemoryMax=512M
|
||||||
|
CPUQuota=100%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
30
deploy/debian/systemd/rss2-embeddings.service
Normal file
30
deploy/debian/systemd/rss2-embeddings.service
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Embeddings Worker (Python)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2/src
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
||||||
|
Environment=EMB_BATCH=64
|
||||||
|
Environment=EMB_SLEEP_IDLE=5
|
||||||
|
Environment=EMB_LANGS=es
|
||||||
|
Environment=EMB_LIMIT=1000
|
||||||
|
Environment=DEVICE=cpu
|
||||||
|
Environment=HF_HOME=/opt/rss2/hf_cache
|
||||||
|
ExecStart=/opt/rss2/venv/bin/python -m workers.embeddings_worker
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-embeddings
|
||||||
|
|
||||||
|
MemoryMax=3G
|
||||||
|
CPUQuota=200%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
26
deploy/debian/systemd/rss2-ingestor.service
Normal file
26
deploy/debian/systemd/rss2-ingestor.service
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Ingestor RSS (Go)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=RSS_MAX_WORKERS=100
|
||||||
|
Environment=RSS_POKE_INTERVAL_MIN=15
|
||||||
|
ExecStart=/opt/rss2/bin/ingestor
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-ingestor
|
||||||
|
|
||||||
|
LimitNOFILE=65536
|
||||||
|
MemoryMax=2G
|
||||||
|
CPUQuota=200%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
25
deploy/debian/systemd/rss2-langdetect.service
Normal file
25
deploy/debian/systemd/rss2-langdetect.service
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Language Detection Worker (Python)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2/src
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=LANG_DETECT_SLEEP=60
|
||||||
|
Environment=LANG_DETECT_BATCH=1000
|
||||||
|
ExecStart=/opt/rss2/venv/bin/python -m workers.langdetect_worker
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-langdetect
|
||||||
|
|
||||||
|
MemoryMax=512M
|
||||||
|
CPUQuota=50%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
26
deploy/debian/systemd/rss2-ner.service
Normal file
26
deploy/debian/systemd/rss2-ner.service
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 NER Worker - Extraccion de Entidades (Python)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2/src
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=NER_LANG=es
|
||||||
|
Environment=NER_BATCH=64
|
||||||
|
Environment=HF_HOME=/opt/rss2/hf_cache
|
||||||
|
ExecStart=/opt/rss2/venv/bin/python -m workers.ner_worker
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-ner
|
||||||
|
|
||||||
|
MemoryMax=2G
|
||||||
|
CPUQuota=200%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
28
deploy/debian/systemd/rss2-qdrant-worker.service
Normal file
28
deploy/debian/systemd/rss2-qdrant-worker.service
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Qdrant Sync Worker (Go)
|
||||||
|
After=network.target postgresql.service rss2-qdrant.service
|
||||||
|
Requires=postgresql.service rss2-qdrant.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=QDRANT_HOST=127.0.0.1
|
||||||
|
Environment=QDRANT_PORT=6333
|
||||||
|
Environment=QDRANT_COLLECTION=news_vectors
|
||||||
|
Environment=QDRANT_SLEEP=30
|
||||||
|
Environment=QDRANT_BATCH=100
|
||||||
|
ExecStart=/opt/rss2/bin/qdrant_worker
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-qdrant-worker
|
||||||
|
|
||||||
|
MemoryMax=1G
|
||||||
|
CPUQuota=100%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
25
deploy/debian/systemd/rss2-qdrant.service
Normal file
25
deploy/debian/systemd/rss2-qdrant.service
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Qdrant Vector Database
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2/qdrant
|
||||||
|
ExecStart=/opt/rss2/qdrant/qdrant
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-qdrant
|
||||||
|
|
||||||
|
Environment=QDRANT__SERVICE__HTTP_PORT=6333
|
||||||
|
Environment=QDRANT__SERVICE__GRPC_PORT=6334
|
||||||
|
Environment=QDRANT__STORAGE__STORAGE_PATH=/opt/rss2/data/qdrant_storage
|
||||||
|
|
||||||
|
MemoryMax=4G
|
||||||
|
CPUQuota=400%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
26
deploy/debian/systemd/rss2-related.service
Normal file
26
deploy/debian/systemd/rss2-related.service
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Related News Worker (Go)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=RELATED_SLEEP=10
|
||||||
|
Environment=RELATED_BATCH=200
|
||||||
|
Environment=RELATED_TOPK=10
|
||||||
|
ExecStart=/opt/rss2/bin/related
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-related
|
||||||
|
|
||||||
|
MemoryMax=1G
|
||||||
|
CPUQuota=100%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
25
deploy/debian/systemd/rss2-scraper.service
Normal file
25
deploy/debian/systemd/rss2-scraper.service
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Scraper HTML (Go)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=SCRAPER_SLEEP=60
|
||||||
|
Environment=SCRAPER_BATCH=10
|
||||||
|
ExecStart=/opt/rss2/bin/scraper
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-scraper
|
||||||
|
|
||||||
|
MemoryMax=512M
|
||||||
|
CPUQuota=100%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
25
deploy/debian/systemd/rss2-topics.service
Normal file
25
deploy/debian/systemd/rss2-topics.service
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Topics Worker (Go)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=TOPICS_SLEEP=10
|
||||||
|
Environment=TOPICS_BATCH=500
|
||||||
|
ExecStart=/opt/rss2/bin/topics
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-topics
|
||||||
|
|
||||||
|
MemoryMax=512M
|
||||||
|
CPUQuota=100%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
26
deploy/debian/systemd/rss2-translation-scheduler.service
Normal file
26
deploy/debian/systemd/rss2-translation-scheduler.service
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Translation Scheduler (Python)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2/src
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=TARGET_LANGS=es
|
||||||
|
Environment=SCHEDULER_BATCH=1000
|
||||||
|
Environment=SCHEDULER_SLEEP=30
|
||||||
|
ExecStart=/opt/rss2/venv/bin/python -m workers.translation_scheduler
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-translation-scheduler
|
||||||
|
|
||||||
|
MemoryMax=256M
|
||||||
|
CPUQuota=50%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
31
deploy/debian/systemd/rss2-translator.service
Normal file
31
deploy/debian/systemd/rss2-translator.service
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Translator Worker NLLB-200 (Python)
|
||||||
|
After=network.target postgresql.service rss2-translation-scheduler.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2/src
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=TARGET_LANGS=es
|
||||||
|
Environment=TRANSLATOR_BATCH=32
|
||||||
|
Environment=CT2_MODEL_PATH=/opt/rss2/models/nllb-ct2
|
||||||
|
Environment=CT2_DEVICE=cpu
|
||||||
|
Environment=CT2_COMPUTE_TYPE=int8
|
||||||
|
Environment=UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
|
||||||
|
Environment=HF_HOME=/opt/rss2/hf_cache
|
||||||
|
ExecStart=/opt/rss2/venv/bin/python -m workers.ctranslator_worker
|
||||||
|
Restart=always
|
||||||
|
RestartSec=15
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-translator
|
||||||
|
|
||||||
|
# El modelo NLLB-200 consume bastante RAM en CPU
|
||||||
|
MemoryMax=4G
|
||||||
|
CPUQuota=200%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
24
deploy/debian/systemd/rss2-wiki.service
Normal file
24
deploy/debian/systemd/rss2-wiki.service
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RSS2 Wiki Worker - imagenes Wikipedia (Go)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rss2
|
||||||
|
Group=rss2
|
||||||
|
WorkingDirectory=/opt/rss2
|
||||||
|
EnvironmentFile=/opt/rss2/.env
|
||||||
|
Environment=WIKI_SLEEP=10
|
||||||
|
ExecStart=/opt/rss2/bin/wiki_worker
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=rss2-wiki
|
||||||
|
|
||||||
|
MemoryMax=256M
|
||||||
|
CPUQuota=50%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
# Override local: permite acceder a Grafana en http://localhost:3001
|
|
||||||
# La red monitoring es internal:true y compose descarta los puertos de servicios
|
|
||||||
# que solo están en redes internas. Añadir la red frontend (no internal) permite
|
|
||||||
# publicar el puerto 127.0.0.1:3001.
|
|
||||||
# Uso: docker compose -f docker-compose.yml -f docker-compose.local.yml up -d
|
|
||||||
services:
|
|
||||||
grafana:
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
- frontend
|
|
||||||
|
|
@ -1,664 +0,0 @@
|
||||||
# ==================================================================================
|
|
||||||
# CONFIGURACIÓN DE SECRETOS - Se cargan desde .env
|
|
||||||
# ==================================================================================
|
|
||||||
# Las variables POSTGRES_PASSWORD, REDIS_PASSWORD, DB_PASS, SECRET_KEY, GRAFANA_PASSWORD
|
|
||||||
# deben estar definidas en un archivo .env antes de ejecutar docker compose up
|
|
||||||
#
|
|
||||||
# Para generar credenciales seguras automáticamente:
|
|
||||||
# ./generate_secure_credentials.sh --force
|
|
||||||
#
|
|
||||||
# Despliegue simple:
|
|
||||||
# docker compose up -d
|
|
||||||
# ==================================================================================
|
|
||||||
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgres:18-alpine
|
|
||||||
container_name: rss2_db
|
|
||||||
shm_size: 4gb
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-rss}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-rss}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
|
|
||||||
LANG: C.UTF-8
|
|
||||||
LC_ALL: C.UTF-8
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
PGDATA: /var/lib/postgresql/data/18/main
|
|
||||||
volumes:
|
|
||||||
- ./data/pgdata:/var/lib/postgresql/data
|
|
||||||
- ./init-db:/docker-entrypoint-initdb.d:rw
|
|
||||||
- ./docker-entrypoint-db.sh:/docker-entrypoint-db.sh:ro
|
|
||||||
entrypoint: ["bash", "/docker-entrypoint-db.sh"]
|
|
||||||
networks:
|
|
||||||
backend:
|
|
||||||
aliases:
|
|
||||||
- db
|
|
||||||
- rss2_db
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $$POSTGRES_USER -d $$POSTGRES_DB || exit 1" ]
|
|
||||||
# NOTA: Si falla, verifica que POSTGRES_PASSWORD esté definido en .env
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
start_period: 20s
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '2'
|
|
||||||
memory: 8G
|
|
||||||
reservations:
|
|
||||||
memory: 4G
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: rss2_redis
|
|
||||||
environment:
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
# SEGURIDAD: Redis con autenticación
|
|
||||||
command: >
|
|
||||||
redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD:-!ERROR!REDIS_PASSWORD_NEEDED!}
|
|
||||||
volumes:
|
|
||||||
- ./data/redis-data:/data
|
|
||||||
- /etc/timezone:/etc/timezone:ro
|
|
||||||
- /etc/localtime:/etc/localtime:ro
|
|
||||||
networks:
|
|
||||||
backend:
|
|
||||||
aliases:
|
|
||||||
- redis
|
|
||||||
- rss2_redis
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
# VALIDACIÓN: Si REDIS_PASSWORD está vacío, esto fallará y detendrá el despliegue
|
|
||||||
test: [ "CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping" ]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 768M
|
|
||||||
reservations:
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
rss-ingestor-go:
|
|
||||||
build:
|
|
||||||
context: ./rss-ingestor-go
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_ingestor_go
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
RSS_MAX_WORKERS: ${RSS_MAX_WORKERS:-100}
|
|
||||||
RSS_POKE_INTERVAL_MIN: ${RSS_POKE_INTERVAL_MIN:-60}
|
|
||||||
RSS_HOST_DELAY_MS: ${RSS_HOST_DELAY_MS:-800}
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '2'
|
|
||||||
memory: 2G
|
|
||||||
reservations:
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
langdetect:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_langdetect_py
|
|
||||||
command: bash -lc "python -m workers.langdetect_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
LANG_DETECT_SLEEP: 60
|
|
||||||
LANG_DETECT_BATCH: 1000
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '0.5'
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# SCRAPER WORKER (Go) - Extrae artículos de URLs
|
|
||||||
# ==================================================================================
|
|
||||||
scraper:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.scraper
|
|
||||||
container_name: rss2_scraper
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
SCRAPER_SLEEP: 120
|
|
||||||
SCRAPER_BATCH: 10
|
|
||||||
SCRAPER_ENRICH_LIMIT: 10
|
|
||||||
SCRAPER_WORKERS: ${SCRAPER_WORKERS:-2}
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# DISCOVERY WORKER (Go) - Descubre RSS feeds
|
|
||||||
# ==================================================================================
|
|
||||||
discovery:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.discovery
|
|
||||||
container_name: rss2_discovery
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
DISCOVERY_INTERVAL: 900
|
|
||||||
DISCOVERY_BATCH: 10
|
|
||||||
MAX_FEEDS_PER_URL: 5
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# WIKI WORKER (Go) - Wikipedia info and thumbnails
|
|
||||||
# ==================================================================================
|
|
||||||
wiki-worker:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.wiki
|
|
||||||
container_name: rss2_wiki_worker
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
WIKI_SLEEP: 10
|
|
||||||
WIKI_BATCH: 20
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./data/wiki_images:/app/data/wiki_images
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '0.5'
|
|
||||||
memory: 256M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# BACKEND GO (API REST)
|
|
||||||
# ==================================================================================
|
|
||||||
backend-go:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_backend_go
|
|
||||||
environment:
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-rss}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-rss}?sslmode=disable
|
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-rss_redis_pass_2024}@redis:6379
|
|
||||||
SECRET_KEY: ${SECRET_KEY:-change_this_to_a_long_random_string}
|
|
||||||
SERVER_PORT: "8080"
|
|
||||||
volumes:
|
|
||||||
- ./data/wiki_images:/app/data/wiki_images
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
- frontend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '2'
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# FRONTEND REACT
|
|
||||||
# ==================================================================================
|
|
||||||
rss2_frontend:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_frontend
|
|
||||||
environment:
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
VITE_API_URL: /api
|
|
||||||
networks:
|
|
||||||
- frontend
|
|
||||||
depends_on:
|
|
||||||
- backend-go
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# NGINX (Puerto 8001 - sirve React + proxy API)
|
|
||||||
# ==================================================================================
|
|
||||||
nginx:
|
|
||||||
image: nginx:alpine
|
|
||||||
container_name: rss2_nginx
|
|
||||||
ports:
|
|
||||||
- "8888:80"
|
|
||||||
volumes:
|
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
|
||||||
networks:
|
|
||||||
- frontend
|
|
||||||
depends_on:
|
|
||||||
- rss2_frontend
|
|
||||||
- backend-go
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# TRANSLATOR CPU (CTranslate2) - Scale with: docker compose up -d --scale translator=3
|
|
||||||
# ==================================================================================
|
|
||||||
translator:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.translator
|
|
||||||
image: rss2-translator:latest
|
|
||||||
command: bash -lc "python -m workers.ctranslator_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
TARGET_LANGS: es
|
|
||||||
TRANSLATOR_BATCH: ${TRANSLATOR_BATCH:-128}
|
|
||||||
CT2_MODEL_PATH: /app/models/nllb-ct2
|
|
||||||
CT2_DEVICE: cpu
|
|
||||||
CT2_COMPUTE_TYPE: int8
|
|
||||||
CT2_INTRA_THREADS: 4
|
|
||||||
UNIVERSAL_MODEL: facebook/nllb-200-distilled-600M
|
|
||||||
HF_HOME: /app/hf_cache
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
TRANSLATOR_ID: ${TRANSLATOR_ID:-}
|
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-rss_redis_pass_2024}@redis:6379
|
|
||||||
PYTORCH_ENABLE_MPS_FALLBACK: 1
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
- ./hf_cache:/app/hf_cache
|
|
||||||
- ./models:/app/models
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
# Prioridad BAJA: el traductor no debe acaparar toda la CPU.
|
|
||||||
# cpu_shares menor que el resto (1024) + tope que deje CPU para NER, embeddings, etc.
|
|
||||||
cpu_shares: 512
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '4'
|
|
||||||
memory: 6G
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# TRANSLATOR CPU Nº2 - Segundo worker CPU (paralelo al translator)
|
|
||||||
# ==================================================================================
|
|
||||||
translator-gpu:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.translator
|
|
||||||
image: rss2-translator:latest
|
|
||||||
command: bash -lc "python -m workers.ctranslator_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
TARGET_LANGS: es
|
|
||||||
TRANSLATOR_BATCH: ${TRANSLATOR_BATCH:-128}
|
|
||||||
CT2_MODEL_PATH: /app/models/nllb-ct2
|
|
||||||
CT2_DEVICE: cpu
|
|
||||||
CT2_COMPUTE_TYPE: int8
|
|
||||||
CT2_INTRA_THREADS: 4
|
|
||||||
UNIVERSAL_MODEL: facebook/nllb-200-distilled-600M
|
|
||||||
HF_HOME: /app/hf_cache
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
TRANSLATOR_ID: ${TRANSLATOR_ID:-}
|
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-rss_redis_pass_2024}@redis:6379
|
|
||||||
PYTORCH_ENABLE_MPS_FALLBACK: 1
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
- ./hf_cache:/app/hf_cache
|
|
||||||
- ./models:/app/models
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
# Prioridad BAJA: mismo trato que el translator.
|
|
||||||
cpu_shares: 512
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '4'
|
|
||||||
memory: 6G
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# TRANSLATION SCHEDULER - Creates translation jobs
|
|
||||||
# ==================================================================================
|
|
||||||
translation-scheduler:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.scheduler
|
|
||||||
image: rss2-scheduler:latest
|
|
||||||
container_name: rss2_translation_scheduler
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
TARGET_LANGS: es
|
|
||||||
SCHEDULER_BATCH: 200
|
|
||||||
SCHEDULER_AGE_DAYS: 30
|
|
||||||
SCHEDULER_SLEEP: 30
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '0.5'
|
|
||||||
memory: 256M
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
embeddings:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_embeddings_py
|
|
||||||
command: bash -lc "python -m workers.embeddings_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
EMB_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
EMB_BATCH: 32
|
|
||||||
EMB_SLEEP_IDLE: 5
|
|
||||||
EMB_LANGS: es
|
|
||||||
EMB_LIMIT: 300
|
|
||||||
DEVICE: cpu
|
|
||||||
HF_HOME: /app/hf_cache
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
- ./hf_cache:/app/hf_cache
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '2'
|
|
||||||
memory: 6G
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# TOPICS WORKER (Go) - Matching temas y países
|
|
||||||
# ==================================================================================
|
|
||||||
topics:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.topics
|
|
||||||
container_name: rss2_topics
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
TOPICS_SLEEP: 10
|
|
||||||
TOPICS_BATCH: 500
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# RELATED WORKER (Go) - Noticias relacionadas
|
|
||||||
# ==================================================================================
|
|
||||||
related:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.related
|
|
||||||
container_name: rss2_related
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
RELATED_SLEEP: 10
|
|
||||||
RELATED_BATCH: 200
|
|
||||||
RELATED_TOPK: 10
|
|
||||||
EMB_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 1G
|
|
||||||
|
|
||||||
qdrant:
|
|
||||||
image: qdrant/qdrant:latest
|
|
||||||
container_name: rss2_qdrant
|
|
||||||
environment:
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
QDRANT__SERVICE__GRPC_PORT: 6334
|
|
||||||
volumes:
|
|
||||||
- ./data/qdrant_storage:/qdrant/storage
|
|
||||||
- /etc/timezone:/etc/timezone:ro
|
|
||||||
- /etc/localtime:/etc/localtime:ro
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '4'
|
|
||||||
memory: 4G
|
|
||||||
reservations:
|
|
||||||
memory: 2G
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# QDRANT WORKER (Go) - Vectorización y búsqueda semántica
|
|
||||||
# ==================================================================================
|
|
||||||
qdrant-worker:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.qdrant
|
|
||||||
container_name: rss2_qdrant_worker
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
QDRANT_HOST: qdrant
|
|
||||||
QDRANT_PORT: 6333
|
|
||||||
QDRANT_COLLECTION: news_vectors
|
|
||||||
OLLAMA_URL: http://ollama:11434
|
|
||||||
QDRANT_SLEEP: 30
|
|
||||||
QDRANT_BATCH: 100
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
qdrant:
|
|
||||||
condition: service_started
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 1G
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# NER WORKER (Python) - Extracción de entidades
|
|
||||||
# ==================================================================================
|
|
||||||
ner:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_ner
|
|
||||||
command: bash -lc "python -m workers.ner_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
NER_LANG: es
|
|
||||||
NER_BATCH: 24
|
|
||||||
HF_HOME: /app/hf_cache
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
- ./hf_cache:/app/hf_cache
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '2'
|
|
||||||
memory: 2G
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# CLUSTER WORKER (Python) - Agrupación de noticias
|
|
||||||
# ==================================================================================
|
|
||||||
cluster:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_cluster_py
|
|
||||||
command: bash -lc "python -m workers.cluster_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
EVENT_DIST_THRESHOLD: 0.35
|
|
||||||
EMB_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '2'
|
|
||||||
memory: 2G
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# REDES SEGMENTADAS
|
|
||||||
# ==================================================================================
|
|
||||||
networks:
|
|
||||||
# Red frontal - Solo nginx y web app
|
|
||||||
frontend:
|
|
||||||
name: rss2_frontend
|
|
||||||
driver: bridge
|
|
||||||
internal: false
|
|
||||||
|
|
||||||
# Red backend - Base de datos, workers, redis, qdrant
|
|
||||||
backend:
|
|
||||||
name: rss2_backend
|
|
||||||
driver: bridge
|
|
||||||
internal: false # Acceso externo permitido (necesario para ingestor)
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
torch_extensions:
|
|
||||||
|
|
@ -1,802 +0,0 @@
|
||||||
# ==================================================================================
|
|
||||||
# CONFIGURACIÓN DE SECRETOS - Se cargan desde .env
|
|
||||||
# ==================================================================================
|
|
||||||
# Las variables POSTGRES_PASSWORD, REDIS_PASSWORD, DB_PASS, SECRET_KEY, GRAFANA_PASSWORD
|
|
||||||
# deben estar definidas en un archivo .env antes de ejecutar docker compose up
|
|
||||||
#
|
|
||||||
# Para generar credenciales seguras automáticamente:
|
|
||||||
# ./generate_secure_credentials.sh --force
|
|
||||||
#
|
|
||||||
# Despliegue simple:
|
|
||||||
# docker compose up -d
|
|
||||||
# ==================================================================================
|
|
||||||
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgres:18-alpine
|
|
||||||
container_name: rss2_db
|
|
||||||
shm_size: 4gb
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-rss}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-rss}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
|
|
||||||
LANG: C.UTF-8
|
|
||||||
LC_ALL: C.UTF-8
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
PGDATA: /var/lib/postgresql/data/18/main
|
|
||||||
volumes:
|
|
||||||
- ./data/pgdata:/var/lib/postgresql/data
|
|
||||||
- ./init-db:/docker-entrypoint-initdb.d:rw
|
|
||||||
- ./docker-entrypoint-db.sh:/docker-entrypoint-db.sh:ro
|
|
||||||
entrypoint: ["bash", "/docker-entrypoint-db.sh"]
|
|
||||||
networks:
|
|
||||||
backend:
|
|
||||||
aliases:
|
|
||||||
- db
|
|
||||||
- rss2_db
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $$POSTGRES_USER -d $$POSTGRES_DB || exit 1" ]
|
|
||||||
# NOTA: Si falla, verifica que POSTGRES_PASSWORD esté definido en .env
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
start_period: 20s
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 8G
|
|
||||||
reservations:
|
|
||||||
memory: 4G
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: rss2_redis
|
|
||||||
environment:
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
# SEGURIDAD: Redis con autenticación
|
|
||||||
command: >
|
|
||||||
redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD:-!ERROR!REDIS_PASSWORD_NEEDED!}
|
|
||||||
volumes:
|
|
||||||
- ./data/redis-data:/data
|
|
||||||
- /etc/timezone:/etc/timezone:ro
|
|
||||||
- /etc/localtime:/etc/localtime:ro
|
|
||||||
networks:
|
|
||||||
backend:
|
|
||||||
aliases:
|
|
||||||
- redis
|
|
||||||
- rss2_redis
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
# VALIDACIÓN: Si REDIS_PASSWORD está vacío, esto fallará y detendrá el despliegue
|
|
||||||
test: [ "CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping" ]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 768M
|
|
||||||
reservations:
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
rss-ingestor-go:
|
|
||||||
build:
|
|
||||||
context: ./rss-ingestor-go
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_ingestor_go
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
RSS_MAX_WORKERS: 100
|
|
||||||
RSS_POKE_INTERVAL_MIN: 60
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '2'
|
|
||||||
memory: 2G
|
|
||||||
reservations:
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
langdetect:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_langdetect_py
|
|
||||||
command: bash -lc "python -m workers.langdetect_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
LANG_DETECT_SLEEP: 60
|
|
||||||
LANG_DETECT_BATCH: 1000
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '0.5'
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# SCRAPER WORKER (Go) - Extrae artículos de URLs
|
|
||||||
# ==================================================================================
|
|
||||||
scraper:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.scraper
|
|
||||||
container_name: rss2_scraper
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
SCRAPER_SLEEP: 60
|
|
||||||
SCRAPER_BATCH: 10
|
|
||||||
SCRAPER_ENRICH_LIMIT: 20
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# DISCOVERY WORKER (Go) - Descubre RSS feeds
|
|
||||||
# ==================================================================================
|
|
||||||
discovery:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.discovery
|
|
||||||
container_name: rss2_discovery
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
DISCOVERY_INTERVAL: 900
|
|
||||||
DISCOVERY_BATCH: 10
|
|
||||||
MAX_FEEDS_PER_URL: 5
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# WIKI WORKER (Go) - Wikipedia info and thumbnails
|
|
||||||
# ==================================================================================
|
|
||||||
wiki-worker:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.wiki
|
|
||||||
container_name: rss2_wiki_worker
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
WIKI_SLEEP: 10
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./data/wiki_images:/app/data/wiki_images
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '0.5'
|
|
||||||
memory: 256M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# BACKEND GO (API REST)
|
|
||||||
# ==================================================================================
|
|
||||||
backend-go:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_backend_go
|
|
||||||
environment:
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-rss}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-rss}?sslmode=disable
|
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-rss_redis_pass_2024}@redis:6379
|
|
||||||
SECRET_KEY: ${SECRET_KEY:-change_this_to_a_long_random_string}
|
|
||||||
SERVER_PORT: "8080"
|
|
||||||
volumes:
|
|
||||||
- ./data/wiki_images:/app/data/wiki_images
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
- frontend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# FRONTEND REACT
|
|
||||||
# ==================================================================================
|
|
||||||
rss2_frontend:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_frontend
|
|
||||||
environment:
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
VITE_API_URL: /api
|
|
||||||
networks:
|
|
||||||
- frontend
|
|
||||||
depends_on:
|
|
||||||
- backend-go
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# NGINX (Puerto 8001 - sirve React + proxy API)
|
|
||||||
# ==================================================================================
|
|
||||||
nginx:
|
|
||||||
image: nginx:alpine
|
|
||||||
container_name: rss2_nginx
|
|
||||||
ports:
|
|
||||||
- "8888:80"
|
|
||||||
volumes:
|
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
|
||||||
networks:
|
|
||||||
- frontend
|
|
||||||
depends_on:
|
|
||||||
- rss2_frontend
|
|
||||||
- backend-go
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# TRANSLATOR CPU (CTranslate2) - Scale with: docker compose up -d --scale translator=3
|
|
||||||
# ==================================================================================
|
|
||||||
translator:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.translator
|
|
||||||
image: rss2-translator:latest
|
|
||||||
command: bash -lc "python -m workers.ctranslator_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
TARGET_LANGS: es
|
|
||||||
TRANSLATOR_BATCH: 32
|
|
||||||
CT2_MODEL_PATH: /app/models/nllb-ct2
|
|
||||||
CT2_DEVICE: cpu
|
|
||||||
CT2_COMPUTE_TYPE: int8
|
|
||||||
UNIVERSAL_MODEL: facebook/nllb-200-distilled-600M
|
|
||||||
HF_HOME: /app/hf_cache
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
TRANSLATOR_ID: ${TRANSLATOR_ID:-}
|
|
||||||
PYTORCH_ENABLE_MPS_FALLBACK: 1
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
- ./hf_cache:/app/hf_cache
|
|
||||||
- ./models:/app/models
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
profiles:
|
|
||||||
- cpu-only
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# TRANSLATOR GPU - Multiple instances with --scale for parallel GPU processing
|
|
||||||
# Configure multiple GPU workers: docker compose up -d --scale translator-gpu=2
|
|
||||||
# ==================================================================================
|
|
||||||
translator-gpu:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.translator-gpu
|
|
||||||
image: rss2-translator-gpu:latest
|
|
||||||
command: bash -lc "python -m workers.ctranslator_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
TARGET_LANGS: es
|
|
||||||
TRANSLATOR_BATCH: 128
|
|
||||||
CT2_MODEL_PATH: /app/models/nllb-ct2
|
|
||||||
CT2_DEVICE: cuda
|
|
||||||
CT2_COMPUTE_TYPE: float16
|
|
||||||
UNIVERSAL_MODEL: facebook/nllb-200-distilled-600M
|
|
||||||
HF_HOME: /app/hf_cache
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
TRANSLATOR_ID: ${TRANSLATOR_ID:-}
|
|
||||||
PYTORCH_CUDA_ALLOC_CONF: max_split_size_mb:512
|
|
||||||
NCCL_DEBUG: INFO
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
- ./hf_cache:/app/hf_cache
|
|
||||||
- ./models:/app/models
|
|
||||||
- /dev/nvidia:/dev/nvidia
|
|
||||||
- /usr/local/nvidia:/usr/local/nvidia:ro
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
profiles:
|
|
||||||
- gpu
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
count: 1
|
|
||||||
capabilities: [gpu]
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# REMOTE TRANSLATOR WORKER - Worker remoto que se conecta por WebSocket
|
|
||||||
# ==================================================================================
|
|
||||||
remote-translator:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.remote-worker
|
|
||||||
container_name: rss2_remote_translator
|
|
||||||
environment:
|
|
||||||
WORKER_NAME: ${WORKER_NAME:-remote-worker-1}
|
|
||||||
WORKER_API_KEY: ${WORKER_API_KEY:-}
|
|
||||||
WORKER_SERVER: ${WORKER_SERVER:-ws://backend-go:8080/ws/worker}
|
|
||||||
CT2_DEVICE: ${CT2_DEVICE:-cuda}
|
|
||||||
CT2_MODEL_PATH: /app/models/nllb-ct2
|
|
||||||
CT2_COMPUTE_TYPE: ${CT2_COMPUTE_TYPE:-float16}
|
|
||||||
UNIVERSAL_MODEL: facebook/nllb-200-1.3B
|
|
||||||
HF_HOME: /app/hf_cache
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./hf_cache:/app/hf_cache
|
|
||||||
- ./models:/app/models
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
profiles:
|
|
||||||
- remote
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 6G
|
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
count: 1
|
|
||||||
capabilities: [ gpu ]
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# TRANSLATION SCHEDULER - Creates translation jobs
|
|
||||||
# ==================================================================================
|
|
||||||
translation-scheduler:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.scheduler
|
|
||||||
image: rss2-scheduler:latest
|
|
||||||
container_name: rss2_translation_scheduler
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
TARGET_LANGS: es
|
|
||||||
SCHEDULER_BATCH: 1000
|
|
||||||
SCHEDULER_SLEEP: 30
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '0.5'
|
|
||||||
memory: 256M
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
embeddings:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_embeddings_py
|
|
||||||
command: bash -lc "python -m workers.embeddings_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
EMB_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
EMB_BATCH: 64
|
|
||||||
EMB_SLEEP_IDLE: 5
|
|
||||||
EMB_LANGS: es
|
|
||||||
EMB_LIMIT: 1000
|
|
||||||
DEVICE: cuda
|
|
||||||
HF_HOME: /app/hf_cache
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
- ./hf_cache:/app/hf_cache
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 6G
|
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
count: 1
|
|
||||||
capabilities: [ gpu ]
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# TOPICS WORKER (Go) - Matching temas y países
|
|
||||||
# ==================================================================================
|
|
||||||
topics:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.topics
|
|
||||||
container_name: rss2_topics
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
TOPICS_SLEEP: 10
|
|
||||||
TOPICS_BATCH: 500
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# RELATED WORKER (Go) - Noticias relacionadas
|
|
||||||
# ==================================================================================
|
|
||||||
related:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.related
|
|
||||||
container_name: rss2_related
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
RELATED_SLEEP: 10
|
|
||||||
RELATED_BATCH: 200
|
|
||||||
RELATED_TOPK: 10
|
|
||||||
EMB_MODEL: mxbai-embed-large
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 1G
|
|
||||||
|
|
||||||
qdrant:
|
|
||||||
image: qdrant/qdrant:latest
|
|
||||||
container_name: rss2_qdrant
|
|
||||||
environment:
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
QDRANT__SERVICE__GRPC_PORT: 6334
|
|
||||||
volumes:
|
|
||||||
- ./data/qdrant_storage:/qdrant/storage
|
|
||||||
- /etc/timezone:/etc/timezone:ro
|
|
||||||
- /etc/localtime:/etc/localtime:ro
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '4'
|
|
||||||
memory: 4G
|
|
||||||
reservations:
|
|
||||||
memory: 2G
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# QDRANT WORKER (Go) - Vectorización y búsqueda semántica
|
|
||||||
# ==================================================================================
|
|
||||||
qdrant-worker:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.qdrant
|
|
||||||
container_name: rss2_qdrant_worker
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
QDRANT_HOST: qdrant
|
|
||||||
QDRANT_PORT: 6333
|
|
||||||
QDRANT_COLLECTION: news_vectors
|
|
||||||
OLLAMA_URL: http://ollama:11434
|
|
||||||
QDRANT_SLEEP: 30
|
|
||||||
QDRANT_BATCH: 100
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
qdrant:
|
|
||||||
condition: service_started
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 1G
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# NER WORKER (Python) - Extracción de entidades
|
|
||||||
# ==================================================================================
|
|
||||||
ner:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_ner
|
|
||||||
command: bash -lc "python -m workers.ner_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
NER_LANG: es
|
|
||||||
NER_BATCH: 64
|
|
||||||
HF_HOME: /app/hf_cache
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
- ./hf_cache:/app/hf_cache
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '2'
|
|
||||||
memory: 2G
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# CLUSTER WORKER (Python) - Agrupación de noticias
|
|
||||||
# ==================================================================================
|
|
||||||
cluster:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_cluster_py
|
|
||||||
command: bash -lc "python -m workers.cluster_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
EVENT_DIST_THRESHOLD: 0.35
|
|
||||||
EMB_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '2'
|
|
||||||
memory: 2G
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# LLM CATEGORIZER (Python) - Categorización con Ollama
|
|
||||||
# ==================================================================================
|
|
||||||
llm-categorizer:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: rss2_llm_categorizer
|
|
||||||
command: bash -lc "python -m workers.simple_categorizer_worker"
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${DB_NAME:-rss}
|
|
||||||
DB_USER: ${DB_USER:-rss}
|
|
||||||
DB_PASS: ${DB_PASS}
|
|
||||||
CATEGORIZER_BATCH_SIZE: 10
|
|
||||||
CATEGORIZER_SLEEP_IDLE: 5
|
|
||||||
TZ: Europe/Madrid
|
|
||||||
volumes:
|
|
||||||
- ./workers:/app/workers
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '2'
|
|
||||||
memory: 1G
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# MONITORING STACK - SECURED
|
|
||||||
# ==================================================================================
|
|
||||||
|
|
||||||
prometheus:
|
|
||||||
image: prom/prometheus:latest
|
|
||||||
container_name: rss2_prometheus
|
|
||||||
volumes:
|
|
||||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
|
||||||
- prometheus_data:/prometheus
|
|
||||||
command:
|
|
||||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
|
||||||
- '--storage.tsdb.path=/prometheus'
|
|
||||||
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
|
|
||||||
- '--web.console.templates=/usr/share/prometheus/consoles'
|
|
||||||
# SEGURIDAD: Sin exposición de puertos - acceso solo vía Grafana o túnel SSH
|
|
||||||
# ports:
|
|
||||||
# - "9090:9090"
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 2G
|
|
||||||
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:latest
|
|
||||||
container_name: rss2_grafana
|
|
||||||
# SEGURIDAD: Acceso solo en localhost o vía túnel SSH
|
|
||||||
# Para acceso remoto, usar túnel SSH: ssh -L 3001:localhost:3001 user@server
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3001:3000"
|
|
||||||
environment:
|
|
||||||
# SEGURIDAD: Cambiar este password en producción
|
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-change_this_password}
|
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
|
||||||
- GF_SERVER_ROOT_URL=http://localhost:3001
|
|
||||||
- GF_SECURITY_COOKIE_SECURE=false
|
|
||||||
- GF_SECURITY_COOKIE_SAMESITE=lax
|
|
||||||
volumes:
|
|
||||||
- grafana_data:/var/lib/grafana
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
depends_on:
|
|
||||||
- prometheus
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 1G
|
|
||||||
|
|
||||||
cadvisor:
|
|
||||||
image: gcr.io/cadvisor/cadvisor:latest
|
|
||||||
container_name: rss2_cadvisor
|
|
||||||
# SEGURIDAD: Sin exposición de puertos - solo acceso interno
|
|
||||||
# ports:
|
|
||||||
# - "8081:8080"
|
|
||||||
volumes:
|
|
||||||
- /:/rootfs:ro
|
|
||||||
- /var/run:/var/run:ro
|
|
||||||
- /sys:/sys:ro
|
|
||||||
- /var/lib/docker/:/var/lib/docker:ro
|
|
||||||
- /dev/disk/:/dev/disk:ro
|
|
||||||
devices:
|
|
||||||
- /dev/kmsg
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '0.5'
|
|
||||||
memory: 512M
|
|
||||||
|
|
||||||
# ==================================================================================
|
|
||||||
# REDES SEGMENTADAS
|
|
||||||
# ==================================================================================
|
|
||||||
networks:
|
|
||||||
# Red frontal - Solo nginx y web app
|
|
||||||
frontend:
|
|
||||||
name: rss2_frontend
|
|
||||||
driver: bridge
|
|
||||||
internal: false
|
|
||||||
|
|
||||||
# Red backend - Base de datos, workers, redis, qdrant
|
|
||||||
backend:
|
|
||||||
name: rss2_backend
|
|
||||||
driver: bridge
|
|
||||||
internal: false # Acceso externo permitido (necesario para ingestor)
|
|
||||||
|
|
||||||
# Red de monitoreo - Prometheus, Grafana, cAdvisor
|
|
||||||
monitoring:
|
|
||||||
name: rss2_monitoring
|
|
||||||
driver: bridge
|
|
||||||
internal: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
prometheus_data:
|
|
||||||
grafana_data:
|
|
||||||
torch_extensions:
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Directorio de datos de PostgreSQL
|
|
||||||
PGDATA_DIR="/var/lib/postgresql/data/18/main"
|
|
||||||
|
|
||||||
echo "RSS2: Checking database presence..."
|
|
||||||
|
|
||||||
# REGLA: nunca se borra ni se modifica el directorio de datos automáticamente.
|
|
||||||
# Solo se informa del estado y se deja que el entrypoint oficial de PostgreSQL
|
|
||||||
# inicialice (si no existe) o arranque con los datos existentes.
|
|
||||||
if [ -f "$PGDATA_DIR/PG_VERSION" ]; then
|
|
||||||
echo "RSS2: Existing database found at $PGDATA_DIR - starting normally"
|
|
||||||
else
|
|
||||||
echo "RSS2: No database found at $PGDATA_DIR - docker-entrypoint will initialize it"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ejecutar el entrypoint original con los parámetros de PostgreSQL.
|
|
||||||
# Si la base de datos estuviera corrupta, PostgreSQL fallará al arrancar y
|
|
||||||
# registrará el error en los logs; NO se elimina ningún dato de forma automática.
|
|
||||||
exec docker-entrypoint.sh \
|
|
||||||
postgres \
|
|
||||||
-c max_connections=200 \
|
|
||||||
-c shared_buffers=4GB \
|
|
||||||
-c effective_cache_size=12GB \
|
|
||||||
-c work_mem=16MB \
|
|
||||||
-c maintenance_work_mem=512MB \
|
|
||||||
-c autovacuum_max_workers=3 \
|
|
||||||
-c autovacuum_vacuum_scale_factor=0.02 \
|
|
||||||
-c autovacuum_vacuum_cost_limit=1000 \
|
|
||||||
-c max_worker_processes=8 \
|
|
||||||
-c max_parallel_workers=6 \
|
|
||||||
-c max_parallel_workers_per_gather=2 \
|
|
||||||
-c wal_level=replica \
|
|
||||||
-c max_wal_senders=5 \
|
|
||||||
-c wal_keep_size=1GB \
|
|
||||||
-c hot_standby=on \
|
|
||||||
"$@"
|
|
||||||
54
docs/DEPLOY.md
Normal file
54
docs/DEPLOY.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
This guide describes how to deploy the application to a new server.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
* **Linux Server** (Ubuntu 22.04+ recommended)
|
||||||
|
* **NVIDIA GPU**: Required for translation, embeddings, and NER services.
|
||||||
|
* **NVIDIA Container Toolkit**: Must be installed to allow Docker to access the GPU.
|
||||||
|
* **Docker** & **Docker Compose**: Latest versions.
|
||||||
|
* **Git**: To clone the repository.
|
||||||
|
* **External Service**: An instance of [AllTalk](https://github.com/erew123/alltalk_tts) running externally or on the host (port 7851 by default).
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
1. **Clone the Repository**
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd <your-repo-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure Environment Variables**
|
||||||
|
Copy the example configuration file:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
Edit `.env` and set secure passwords and configuration:
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
* Change `POSTGRES_PASSWORD` and `DB_PASS` to a strong unique password.
|
||||||
|
* Change `SECRET_KEY` to a long random string.
|
||||||
|
* Verify `ALLTALK_URL` points to your AllTalk instance (default assumes host machine access).
|
||||||
|
|
||||||
|
3. **Start the Services**
|
||||||
|
Run the following command to build and start the application:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Database Initialization**
|
||||||
|
The database will automatically initialize on the first run using the scripts in `init-db/`. This may take a few minutes. Check logs with:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f db
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Verify Deployment**
|
||||||
|
Access the application at `http://<your-server-ip>:8001`.
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
* **Models**: The application mounts `./models` and `./hf_cache` to persist AI models. On the first run, it will attempt to download necessary models (NLLB, BERT, etc.), which requires significant bandwidth and time.
|
||||||
|
* **Data Persistence**: Database data is stored in `./pgdata` (mapped in docker-compose). Ensure this directory is backed up.
|
||||||
|
* **Security**: Ensure port 5432 (Postgres) and 6379 (Redis) are firewall-protected and not exposed to the public internet unless intended (Docker maps them to the host network).
|
||||||
287
docs/DEPLOY_DEBIAN.md
Normal file
287
docs/DEPLOY_DEBIAN.md
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
# COCONEWS · Despliegue en Debian (sin Docker)
|
||||||
|
|
||||||
|
Guía completa para instalar y operar COCONEWS en un servidor Debian 12 (Bookworm) o Ubuntu 22.04+, sin Docker ni contenedores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requisitos de hardware
|
||||||
|
|
||||||
|
| Modo | CPU | RAM | Disco |
|
||||||
|
|------|-----|-----|-------|
|
||||||
|
| **Mínimo (solo CPU)** | 4 cores | 8 GB | 40 GB |
|
||||||
|
| **Recomendado** | 8 cores | 16 GB | 80 GB |
|
||||||
|
| **Con GPU** | 8 cores + NVIDIA | 16 GB | 80 GB |
|
||||||
|
|
||||||
|
> Los modelos de IA (NLLB-200 + MiniLM + spaCy) ocupan ~5 GB en disco una vez descargados.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Servicios que se instalan en el servidor
|
||||||
|
|
||||||
|
| Servicio | Tecnología | Gestionado por |
|
||||||
|
|----------|-----------|----------------|
|
||||||
|
| Base de datos | PostgreSQL 16 | apt + systemd |
|
||||||
|
| Caché | Redis 7 | apt + systemd |
|
||||||
|
| Búsqueda vectorial | Qdrant (binario) | systemd |
|
||||||
|
| API REST | Go (backend) | systemd |
|
||||||
|
| Ingestor RSS | Go | systemd |
|
||||||
|
| Scraper / Discovery / Wiki / Topics / Related / Qdrant-worker | Go | systemd |
|
||||||
|
| Traducción (NLLB-200) | Python + CTranslate2 | systemd |
|
||||||
|
| Embeddings | Python + Sentence-Transformers | systemd |
|
||||||
|
| NER | Python + spaCy | systemd |
|
||||||
|
| Clustering / Categorización | Python | systemd |
|
||||||
|
| Frontend | React (estático compilado) | nginx |
|
||||||
|
| Proxy / Web | nginx | apt + systemd |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instalación paso a paso
|
||||||
|
|
||||||
|
### 1. Clonar el repositorio
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.laenre.net/pietre/rss2.git /opt/src/rss2
|
||||||
|
cd /opt/src/rss2
|
||||||
|
git checkout coconews
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configurar variables de entorno
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp deploy/debian/env.example /opt/rss2/.env
|
||||||
|
nano /opt/rss2/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores que **debes cambiar obligatoriamente**:
|
||||||
|
|
||||||
|
```env
|
||||||
|
POSTGRES_PASSWORD=contraseña_segura_postgres
|
||||||
|
DB_PASS=contraseña_segura_postgres
|
||||||
|
REDIS_PASSWORD=contraseña_segura_redis
|
||||||
|
SECRET_KEY=cadena_aleatoria_minimo_32_caracteres
|
||||||
|
```
|
||||||
|
|
||||||
|
Genera claves seguras con:
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Descargar y convertir el modelo de traducción (NLLB-200)
|
||||||
|
|
||||||
|
Este paso se hace **una sola vez** y puede tardar 10-30 minutos dependiendo de la conexión.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias Python primero (si aun no se hizo)
|
||||||
|
python3 -m venv /opt/rss2/venv
|
||||||
|
/opt/rss2/venv/bin/pip install ctranslate2 transformers sentencepiece
|
||||||
|
|
||||||
|
# Convertir modelo NLLB-200 a formato CTranslate2 (tarda 10-30 min)
|
||||||
|
mkdir -p /opt/rss2/models/nllb-ct2
|
||||||
|
HF_HOME=/opt/rss2/hf_cache \
|
||||||
|
/opt/rss2/venv/bin/ct2-transformers-converter \
|
||||||
|
--model facebook/nllb-200-distilled-600M \
|
||||||
|
--output_dir /opt/rss2/models/nllb-ct2 \
|
||||||
|
--quantization int8 \
|
||||||
|
--force
|
||||||
|
|
||||||
|
# Verificar que se generó correctamente
|
||||||
|
ls /opt/rss2/models/nllb-ct2/model.bin && echo "Modelo OK"
|
||||||
|
```
|
||||||
|
|
||||||
|
> El modelo ocupa ~600 MB convertido. Si la descarga de HuggingFace falla:
|
||||||
|
> `export HF_ENDPOINT=https://huggingface.co` antes del comando de conversión.
|
||||||
|
|
||||||
|
> **Nota:** El worker convierte el modelo automáticamente si no lo encuentra,
|
||||||
|
> pero hacerlo a mano evita que el primer arranque tarde 30 minutos.
|
||||||
|
|
||||||
|
### 4. Ejecutar el instalador
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/src/rss2/deploy/debian/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
El script hace automáticamente:
|
||||||
|
- Instala PostgreSQL, Redis, nginx, Go, Node.js via apt
|
||||||
|
- Descarga el binario de Qdrant
|
||||||
|
- Crea el usuario `rss2` del sistema
|
||||||
|
- Crea la base de datos y ejecuta las migraciones
|
||||||
|
- Compila los 8 binarios Go
|
||||||
|
- Compila el frontend React
|
||||||
|
- Instala y habilita los 16 servicios systemd
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verificar que funciona
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Estado de todos los servicios
|
||||||
|
systemctl status rss2-backend rss2-ingestor rss2-translator rss2-embeddings
|
||||||
|
|
||||||
|
# Ver logs en tiempo real
|
||||||
|
journalctl -u rss2-backend -f
|
||||||
|
journalctl -u rss2-translator -f
|
||||||
|
|
||||||
|
# Comprobar que el API responde
|
||||||
|
curl http://localhost:8080/api/stats
|
||||||
|
|
||||||
|
# Acceder al frontend
|
||||||
|
# http://IP_DEL_SERVIDOR:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gestión de servicios
|
||||||
|
|
||||||
|
### Iniciar / parar / reiniciar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Un servicio concreto
|
||||||
|
systemctl start rss2-backend
|
||||||
|
systemctl stop rss2-translator
|
||||||
|
systemctl restart rss2-embeddings
|
||||||
|
|
||||||
|
# Todos los workers de una vez
|
||||||
|
systemctl restart rss2-backend rss2-ingestor rss2-scraper rss2-discovery \
|
||||||
|
rss2-wiki rss2-topics rss2-related rss2-qdrant-worker \
|
||||||
|
rss2-langdetect rss2-translation-scheduler rss2-translator \
|
||||||
|
rss2-embeddings rss2-ner rss2-cluster rss2-categorizer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ver logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u rss2-backend -f # API Go
|
||||||
|
journalctl -u rss2-translator -f # Traductor
|
||||||
|
journalctl -u rss2-embeddings -f # Embeddings
|
||||||
|
journalctl -u rss2-ner -f # NER entidades
|
||||||
|
journalctl -u rss2-ingestor -f # Ingestor RSS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Actualizar el código
|
||||||
|
|
||||||
|
Cuando hay nuevos cambios en el repositorio:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/src/rss2
|
||||||
|
git pull
|
||||||
|
sudo bash deploy/debian/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
El script `build.sh` recompila los binarios Go, el frontend y sincroniza los workers Python, y reinicia los servicios automáticamente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura de directorios en el servidor
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/rss2/
|
||||||
|
├── .env # Variables de entorno (permisos 600)
|
||||||
|
├── bin/ # Binarios Go compilados
|
||||||
|
│ ├── server # API REST
|
||||||
|
│ ├── ingestor # Ingestor RSS
|
||||||
|
│ ├── scraper
|
||||||
|
│ ├── discovery
|
||||||
|
│ ├── wiki_worker
|
||||||
|
│ ├── topics
|
||||||
|
│ ├── related
|
||||||
|
│ └── qdrant_worker
|
||||||
|
├── src/
|
||||||
|
│ └── workers/ # Workers Python
|
||||||
|
├── venv/ # Virtualenv Python (ML)
|
||||||
|
├── models/
|
||||||
|
│ └── nllb-ct2/ # Modelo traduccion CTranslate2
|
||||||
|
├── hf_cache/ # Cache HuggingFace (embeddings, NER)
|
||||||
|
├── frontend/
|
||||||
|
│ └── dist/ # Frontend React compilado (servido por nginx)
|
||||||
|
├── data/
|
||||||
|
│ ├── wiki_images/ # Imagenes Wikipedia descargadas
|
||||||
|
│ └── qdrant_storage/ # Datos vectoriales Qdrant
|
||||||
|
└── qdrant/
|
||||||
|
└── qdrant # Binario Qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requisitos de red / firewall
|
||||||
|
|
||||||
|
Solo exponer al exterior el puerto **8001** (nginx). El resto deben ser internos:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Con ufw:
|
||||||
|
ufw allow 8001/tcp # COCONEWS web
|
||||||
|
ufw deny 5432/tcp # PostgreSQL - solo localhost
|
||||||
|
ufw deny 6379/tcp # Redis - solo localhost
|
||||||
|
ufw deny 6333/tcp # Qdrant - solo localhost
|
||||||
|
ufw deny 8080/tcp # API Go - solo localhost (nginx hace proxy)
|
||||||
|
ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backup de datos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PostgreSQL
|
||||||
|
sudo -u postgres pg_dump rss > /opt/rss2/backups/rss_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# Datos Qdrant
|
||||||
|
systemctl stop rss2-qdrant
|
||||||
|
tar -czf /opt/rss2/backups/qdrant_$(date +%Y%m%d).tar.gz /opt/rss2/data/qdrant_storage
|
||||||
|
systemctl start rss2-qdrant
|
||||||
|
|
||||||
|
# Imagenes Wikipedia (opcional, se pueden re-descargar)
|
||||||
|
tar -czf /opt/rss2/backups/wiki_images_$(date +%Y%m%d).tar.gz /opt/rss2/data/wiki_images
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solución de problemas frecuentes
|
||||||
|
|
||||||
|
### El traductor no arranca
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u rss2-translator -n 50
|
||||||
|
# Si dice "model not found": el modelo NLLB-200 no está convertido
|
||||||
|
# Ejecutar el paso 3 de la instalación
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL rechaza la conexión
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar que el .env tiene DB_HOST=127.0.0.1 (no "db")
|
||||||
|
grep DB_HOST /opt/rss2/.env
|
||||||
|
|
||||||
|
# Verificar que el usuario existe
|
||||||
|
sudo -u postgres psql -c "\du"
|
||||||
|
```
|
||||||
|
|
||||||
|
### nginx devuelve 502 Bad Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# El backend Go no está corriendo
|
||||||
|
systemctl status rss2-backend
|
||||||
|
journalctl -u rss2-backend -n 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memoria insuficiente para los modelos Python
|
||||||
|
|
||||||
|
Con 8 GB RAM el translator + embeddings + NER pueden coincidir. Si el servidor tiene poca RAM, deshabilitar el translator-gpu y bajar el batch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# En /opt/rss2/.env
|
||||||
|
TRANSLATOR_BATCH=8
|
||||||
|
EMB_BATCH=32
|
||||||
|
NER_BATCH=16
|
||||||
|
systemctl restart rss2-translator rss2-embeddings rss2-ner
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Primer inicio de sesión
|
||||||
|
|
||||||
|
1. Abrir `http://IP:8001` en el navegador
|
||||||
|
2. Al no haber usuarios, el sistema te redirige al registro
|
||||||
|
3. El primer usuario registrado se convierte en **administrador**
|
||||||
|
4. Ir a Configuración → Feeds → Importar el `feeds.csv` del repositorio para empezar con fuentes precargadas
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue