Add docker-compose.local.yml for local development setup
- Introduced a new docker-compose.local.yml file to facilitate local development. - Disabled nginx-proxy and acme-companion services for local use. - Exposed database and application ports directly to the host for easier access. - Configured PostgreSQL to be accessible on localhost:5433. - Mapped application ports for studio and experience services.
This commit is contained in:
@@ -193,11 +193,55 @@ echo ""
|
||||
echo "✅ Archivos sincronizados exitosamente!"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# BUILD LOCAL + STREAM AL REMOTO
|
||||
# ============================================================================
|
||||
if [ "$BUILD_LOCAL" = "true" ]; then
|
||||
echo "========================================"
|
||||
echo " Compilando imágenes localmente"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Exportar build args desde .env local
|
||||
export NEXT_PUBLIC_CMS_API_URL=$(grep '^NEXT_PUBLIC_CMS_API_URL=' .env | cut -d'=' -f2-)
|
||||
export NEXT_PUBLIC_LMS_API_URL=$(grep '^NEXT_PUBLIC_LMS_API_URL=' .env | cut -d'=' -f2-)
|
||||
export NEXT_PUBLIC_STUDIO_DOMAIN=$(grep '^NEXT_PUBLIC_STUDIO_DOMAIN=' .env | cut -d'=' -f2-)
|
||||
export NEXT_PUBLIC_LEARNING_DOMAIN=$(grep '^NEXT_PUBLIC_LEARNING_DOMAIN=' .env | cut -d'=' -f2-)
|
||||
export COMPOSE_PARALLEL_LIMIT=1
|
||||
|
||||
echo " CMS API URL : $NEXT_PUBLIC_CMS_API_URL"
|
||||
echo " LMS API URL : $NEXT_PUBLIC_LMS_API_URL"
|
||||
echo " Studio : $NEXT_PUBLIC_STUDIO_DOMAIN"
|
||||
echo " Learning : $NEXT_PUBLIC_LEARNING_DOMAIN"
|
||||
echo ""
|
||||
|
||||
echo "🔨 Compilando imagen studio..."
|
||||
docker compose -f docker-compose.yml build --no-cache studio
|
||||
|
||||
echo ""
|
||||
echo "🔨 Compilando imagen experience..."
|
||||
docker compose -f docker-compose.yml build --no-cache experience
|
||||
|
||||
echo ""
|
||||
echo "📤 Transfiriendo studio al servidor (streaming SSH)..."
|
||||
docker save openccb-studio | gzip | \
|
||||
ssh -i "$PEM_PATH" "$REMOTE_USER@$REMOTE_HOST" "gunzip | sudo docker load"
|
||||
|
||||
echo "📤 Transfiriendo experience al servidor (streaming SSH)..."
|
||||
docker save openccb-experience | gzip | \
|
||||
ssh -i "$PEM_PATH" "$REMOTE_USER@$REMOTE_HOST" "gunzip | sudo docker load"
|
||||
|
||||
echo ""
|
||||
echo "✅ Imágenes transferidas correctamente"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# SCRIPT REMOTO PARA GESTIÓN DE CONTENEDORES
|
||||
# ============================================================================
|
||||
echo "🔧 Ejecutando gestión de contenedores en remoto..."
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# PREGUNTAR DATOS DEL ADMINISTRADOR (LOCAL)
|
||||
@@ -313,6 +357,25 @@ else
|
||||
echo "✅ Configuración: HTTP (sin SSL)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "----------------------------------------"
|
||||
echo "Dónde compilar las imágenes Docker"
|
||||
echo "----------------------------------------"
|
||||
echo ""
|
||||
echo "¿Compilar imágenes en esta máquina y enviar al servidor?"
|
||||
echo " - local: Compilar aquí y transferir vía SSH (recomendado - CPU i7 >> t3a.large)"
|
||||
echo " - remote: El servidor compila (más lento en t3a.large 2vCPU/8GB)"
|
||||
echo ""
|
||||
read -p "¿Compilar localmente? [Y/n]: " BUILD_LOCAL_CHOICE
|
||||
BUILD_LOCAL_CHOICE=${BUILD_LOCAL_CHOICE:-Y}
|
||||
if [[ "$BUILD_LOCAL_CHOICE" =~ ^[Yy]$ ]]; then
|
||||
BUILD_LOCAL="true"
|
||||
echo "✅ Compilación local - imágenes se streamearan via SSH"
|
||||
else
|
||||
BUILD_LOCAL="false"
|
||||
echo "✅ El servidor compilará las imágenes"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " Resumen de Configuración"
|
||||
@@ -325,6 +388,7 @@ echo " Protocolo: $PROTOCOL"
|
||||
echo " SSL Staging: $LETSENCRYPT_STAGING"
|
||||
echo " Preservar SSL: $PRESERVE_SSL_CERTS"
|
||||
echo " Reiniciar DB: $RESET_DATABASE"
|
||||
echo " Compilar local: $BUILD_LOCAL"
|
||||
echo ""
|
||||
|
||||
# Crear script remoto en un archivo temporal
|
||||
@@ -338,6 +402,7 @@ PRESERVE_SSL_CERTS=$PRESERVE_SSL_CERTS
|
||||
PROTOCOL=$PROTOCOL
|
||||
STUDIO_DOMAIN=$STUDIO_DOMAIN
|
||||
LEARNING_DOMAIN=$LEARNING_DOMAIN
|
||||
BUILD_LOCAL=$BUILD_LOCAL
|
||||
|
||||
cd /var/www/openccb
|
||||
|
||||
@@ -663,9 +728,13 @@ $DOCKER_CMD builder prune -f 2>/dev/null || true
|
||||
# Evitar builds concurrentes que puedan competir por cachés compartidas
|
||||
export COMPOSE_PARALLEL_LIMIT=1
|
||||
|
||||
# Reconstruir con las URLs correctas (sin cache para asegurar que tome los cambios)
|
||||
echo "Reconstruyendo contenedores con las URLs configuradas..."
|
||||
run_docker_compose build --no-cache studio experience db
|
||||
if [ "$BUILD_LOCAL" = "true" ]; then
|
||||
echo "Imágenes pre-cargadas localmente - saltando build remoto"
|
||||
else
|
||||
# Reconstruir con las URLs correctas (sin cache para asegurar que tome los cambios)
|
||||
echo "Reconstruyendo contenedores con las URLs configuradas..."
|
||||
run_docker_compose build --no-cache studio experience db
|
||||
fi
|
||||
|
||||
# Iniciar nginx-proxy y acme-companion primero
|
||||
echo "Iniciando nginx-proxy y acme-companion - SSL..."
|
||||
@@ -702,6 +771,17 @@ if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# SIEMPRE sincronizar la contraseña del rol con el .env actual
|
||||
# Evita el error 28P01 (password authentication failed) cuando el deploy regenera DB_PASSWORD
|
||||
CURRENT_DB_PASS=$(grep "^DB_PASSWORD=" .env | cut -d"=" -f2-)
|
||||
if [ -n "$CURRENT_DB_PASS" ]; then
|
||||
echo " Sincronizando contraseña del rol 'user' con .env..."
|
||||
$DOCKER_CMD exec openccb-db psql -U user -d postgres \
|
||||
-c "ALTER USER \"user\" WITH PASSWORD '$CURRENT_DB_PASS';" >/dev/null 2>&1 && \
|
||||
echo " ✅ Contraseña sincronizada" || \
|
||||
echo " ⚠️ No se pudo sincronizar contraseña (el rol usará la del volumen)"
|
||||
fi
|
||||
|
||||
# Verificar e crear bases de datos
|
||||
CMS_EXISTS=$($DOCKER_CMD exec openccb-db psql -U user -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='openccb_cms';" 2>/dev/null | tr -d '[:space:]')
|
||||
LMS_EXISTS=$($DOCKER_CMD exec openccb-db psql -U user -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='openccb_lms';" 2>/dev/null | tr -d '[:space:]')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# docker-compose.local.yml — Override para desarrollo local
|
||||
# Uso: docker compose -f docker-compose.yml -f docker-compose.local.yml [comando]
|
||||
#
|
||||
# Diferencias con producción:
|
||||
# - nginx-proxy y acme-companion deshabilitados (profiles: production)
|
||||
# - Puertos expuestos directamente al host
|
||||
# - DB accesible en localhost:5433
|
||||
|
||||
services:
|
||||
nginx-proxy:
|
||||
profiles:
|
||||
- production
|
||||
|
||||
acme-companion:
|
||||
profiles:
|
||||
- production
|
||||
|
||||
db:
|
||||
ports:
|
||||
- "5433:5432"
|
||||
|
||||
studio:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3001:3001"
|
||||
|
||||
experience:
|
||||
ports:
|
||||
- "3003:3003"
|
||||
- "3002:3002"
|
||||
+23
-20
@@ -1,30 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
# OpenCCB Unified Installation Script
|
||||
# This script automates the setup of OpenCCB:
|
||||
# 1. Prerequisite checks (Rust, Node.js, Docker, sqlx-cli)
|
||||
# 2. Hardware detection (NVIDIA GPU vs CPU)
|
||||
# 3. Environment configuration (.env) - Dev/Prod support
|
||||
# 4. Database creation and migrations (CMS, LMS, AI Bridge)
|
||||
# 5. System initialization (Admin account and Organization)
|
||||
# 6. Optional: Production deployment with SSH sync
|
||||
# Version: 3.0 - Dev/Prod + Deployment Support
|
||||
# OpenCCB Local Development Setup
|
||||
# Levanta el stack completo en local usando Docker:
|
||||
# - PostgreSQL en localhost:5433
|
||||
# - CMS API en localhost:3001
|
||||
# - Studio (Next.js) en localhost:3000
|
||||
# - LMS API en localhost:3002
|
||||
# - Experience (Next.js) en localhost:3003
|
||||
#
|
||||
# Uso: ./install.sh [--fast] [--clean]
|
||||
# --fast Salta instalación de dependencias del sistema
|
||||
# --clean Elimina volúmenes de DB antes de iniciar (instalación limpia)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# ============================================================================
|
||||
# CONFIGURACIÓN DE PRODUCCIÓN
|
||||
# PARÁMETROS LOCALES (no editar — se derivan del docker-compose.local.yml)
|
||||
# ============================================================================
|
||||
PEM_PATH="ubuntu.pem"
|
||||
REMOTE_USER="ubuntu"
|
||||
REMOTE_HOST="ec2-18-224-137-67.us-east-2.compute.amazonaws.com"
|
||||
REMOTE_PATH="/var/www/openccb"
|
||||
# ============================================================================
|
||||
# CONFIGURACIÓN SAM (Sistema de Administración Académica)
|
||||
# ============================================================================
|
||||
# URL de conexión a la base de datos SAM externa
|
||||
# Formato: postgresql://usuario:contraseña@host:puerto/sige_sam_v3
|
||||
SAM_DATABASE_URL=""
|
||||
LOCAL_DB_PORT="5433"
|
||||
LOCAL_DB_USER="user"
|
||||
LOCAL_DB_PASS="password"
|
||||
LOCAL_CMS_URL="http://localhost:3001"
|
||||
LOCAL_LMS_URL="http://localhost:3002/lms-api"
|
||||
LOCAL_STUDIO_DOMAIN="localhost"
|
||||
LOCAL_LEARNING_DOMAIN="localhost"
|
||||
DB_CONTAINER="openccb-db"
|
||||
COMPOSE_LOCAL="docker compose -f docker-compose.yml -f docker-compose.local.yml"
|
||||
# ============================================================================
|
||||
|
||||
echo "===================================================="
|
||||
|
||||
@@ -21,6 +21,7 @@ use std::env;
|
||||
use std::path::Path as StdPath;
|
||||
use tokio::process::Command;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AssetUploadResponse {
|
||||
@@ -161,10 +162,28 @@ async fn maybe_push_local_file_to_s3(
|
||||
let client = build_s3_client(&settings).await?;
|
||||
let key = build_s3_object_key(org_id, course_id, storage_filename);
|
||||
|
||||
let (storage_path, public_url) = push_bytes_to_s3(
|
||||
&client,
|
||||
&settings,
|
||||
&key,
|
||||
mimetype,
|
||||
bytes,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some((storage_path, public_url)))
|
||||
}
|
||||
|
||||
async fn push_bytes_to_s3(
|
||||
client: &S3Client,
|
||||
settings: &S3Settings,
|
||||
key: &str,
|
||||
mimetype: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<(String, String), (StatusCode, String)> {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(&settings.bucket)
|
||||
.key(&key)
|
||||
.key(key)
|
||||
.content_type(mimetype)
|
||||
.body(bytes.into())
|
||||
.send()
|
||||
@@ -172,8 +191,8 @@ async fn maybe_push_local_file_to_s3(
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 upload failed: {}", e)))?;
|
||||
|
||||
let storage_path = format!("s3://{}/{}", settings.bucket, key);
|
||||
let public_url = build_s3_public_url(&settings, &key);
|
||||
Ok(Some((storage_path, public_url)))
|
||||
let public_url = build_s3_public_url(settings, key);
|
||||
Ok((storage_path, public_url))
|
||||
}
|
||||
|
||||
async fn delete_storage_path(storage_path: &str) -> Result<(), (StatusCode, String)> {
|
||||
@@ -637,6 +656,149 @@ struct ZipEntryData {
|
||||
is_flv: bool,
|
||||
}
|
||||
|
||||
async fn process_zip_entry_without_rag(
|
||||
entry: ZipEntryData,
|
||||
org_id: Uuid,
|
||||
user_id: Uuid,
|
||||
pool: PgPool,
|
||||
course_id: Option<Uuid>,
|
||||
english_level: Option<String>,
|
||||
sam_plan_id: Option<i32>,
|
||||
sam_course_id: Option<i32>,
|
||||
split_midpoint: Option<i32>,
|
||||
sam_course_id_r1: Option<i32>,
|
||||
sam_course_id_r2: Option<i32>,
|
||||
s3_settings: Option<S3Settings>,
|
||||
s3_client: Option<S3Client>,
|
||||
) -> Result<(), String> {
|
||||
let ZipEntryData {
|
||||
entry_name,
|
||||
safe_filename,
|
||||
content,
|
||||
unit_number,
|
||||
guessed_mimetype,
|
||||
is_flv,
|
||||
..
|
||||
} = entry;
|
||||
|
||||
let effective_sam_course_id = match (split_midpoint, unit_number) {
|
||||
(Some(mid), Some(u)) => {
|
||||
if u <= mid { sam_course_id_r1 } else { sam_course_id_r2 }
|
||||
}
|
||||
_ => sam_course_id,
|
||||
};
|
||||
|
||||
let asset_id = Uuid::new_v4();
|
||||
|
||||
let (storage_path, stored_filename, mimetype) = if is_flv {
|
||||
let temp_storage_filename = format!("{}.flv", asset_id);
|
||||
let temp_storage_path = format!("uploads/{}", temp_storage_filename);
|
||||
tokio::fs::write(&temp_storage_path, &content)
|
||||
.await
|
||||
.map_err(|e| format!("{}: local write failed ({})", entry_name, e))?;
|
||||
|
||||
let final_storage_filename = format!("{}.mp4", asset_id);
|
||||
let final_storage_path = format!("uploads/{}", final_storage_filename);
|
||||
if let Err((_, msg)) = transcode_flv_to_mp4(&temp_storage_path, &final_storage_path).await {
|
||||
let _ = tokio::fs::remove_file(&temp_storage_path).await;
|
||||
return Err(format!("{}: flv transcode failed ({})", entry_name, msg));
|
||||
}
|
||||
let _ = tokio::fs::remove_file(&temp_storage_path).await;
|
||||
|
||||
(
|
||||
final_storage_path,
|
||||
replace_extension(&safe_filename, "mp4"),
|
||||
"video/mp4".to_string(),
|
||||
)
|
||||
} else {
|
||||
let extension = StdPath::new(&safe_filename)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let storage_filename = if extension.is_empty() {
|
||||
asset_id.to_string()
|
||||
} else {
|
||||
format!("{}.{}", asset_id, extension)
|
||||
};
|
||||
let storage_path = format!("uploads/{}", storage_filename);
|
||||
|
||||
(storage_path, safe_filename.clone(), guessed_mimetype)
|
||||
};
|
||||
|
||||
let storage_filename_for_s3 = StdPath::new(&storage_path)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let (db_storage_path, persisted_size, _asset_public_url) = if !storage_filename_for_s3.is_empty() {
|
||||
if let (Some(settings), Some(client)) = (s3_settings.as_ref(), s3_client.as_ref()) {
|
||||
let key = build_s3_object_key(org_id, course_id, &storage_filename_for_s3);
|
||||
let upload_bytes = if is_flv {
|
||||
tokio::fs::read(&storage_path)
|
||||
.await
|
||||
.map_err(|e| format!("{}: local read failed ({})", entry_name, e))?
|
||||
} else {
|
||||
content
|
||||
};
|
||||
let uploaded_len = upload_bytes.len() as i64;
|
||||
let (s3_path, public_url) = push_bytes_to_s3(client, settings, &key, &mimetype, upload_bytes)
|
||||
.await
|
||||
.map_err(|(_, msg)| format!("{}: s3 upload failed ({})", entry_name, msg))?;
|
||||
|
||||
if is_flv {
|
||||
let _ = tokio::fs::remove_file(&storage_path).await;
|
||||
}
|
||||
|
||||
(s3_path, uploaded_len, public_url)
|
||||
} else {
|
||||
if !is_flv {
|
||||
tokio::fs::write(&storage_path, &content)
|
||||
.await
|
||||
.map_err(|e| format!("{}: local write failed ({})", entry_name, e))?;
|
||||
}
|
||||
|
||||
let size = tokio::fs::metadata(&storage_path)
|
||||
.await
|
||||
.map(|m| m.len() as i64)
|
||||
.unwrap_or(content.len() as i64);
|
||||
|
||||
(
|
||||
storage_path.clone(),
|
||||
size,
|
||||
format!("/assets/{}", storage_filename_for_s3),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(storage_path.clone(), content.len() as i64, storage_path.clone())
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO assets (id, organization_id, uploaded_by, course_id, english_level, sam_plan_id, sam_course_id, unit_number, filename, storage_path, mimetype, size_bytes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
"#,
|
||||
)
|
||||
.bind(asset_id)
|
||||
.bind(org_id)
|
||||
.bind(user_id)
|
||||
.bind(course_id)
|
||||
.bind(&english_level)
|
||||
.bind(sam_plan_id)
|
||||
.bind(effective_sam_course_id)
|
||||
.bind(unit_number)
|
||||
.bind(&stored_filename)
|
||||
.bind(&db_storage_path)
|
||||
.bind(&mimetype)
|
||||
.bind(persisted_size)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| format!("{}: db insert failed ({})", entry_name, e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn import_assets_zip(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
@@ -824,6 +986,17 @@ pub async fn import_assets_zip(
|
||||
// Sort: audio/video first so their asset IDs are known when text is ingested
|
||||
all_entries.sort_by_key(|e| if e.is_audio_video { 0usize } else { 1 });
|
||||
|
||||
let s3_settings = get_s3_settings();
|
||||
let s3_client = if let Some(settings) = &s3_settings {
|
||||
Some(build_s3_client(settings).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tokio::fs::create_dir_all("uploads")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// ── Phase 2: process entries ───────────────────────────────────────────────
|
||||
let mut imported_assets = 0usize;
|
||||
let mut rag_ingested_assets = 0usize;
|
||||
@@ -847,6 +1020,70 @@ pub async fn import_assets_zip(
|
||||
let ollama_url = ai::get_ollama_url();
|
||||
let model = ai::get_embedding_model();
|
||||
|
||||
if !ingest_rag {
|
||||
let org_id = org_ctx.id;
|
||||
let user_id = claims.sub;
|
||||
let concurrency = env::var("ZIP_IMPORT_CONCURRENCY")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.map(|v| v.clamp(1, 16))
|
||||
.unwrap_or(4);
|
||||
|
||||
let mut join_set: JoinSet<Result<(), String>> = JoinSet::new();
|
||||
|
||||
for entry in all_entries {
|
||||
while join_set.len() >= concurrency {
|
||||
match join_set.join_next().await {
|
||||
Some(Ok(Ok(()))) => imported_assets += 1,
|
||||
Some(Ok(Err(msg))) => failed_entries.push(msg),
|
||||
Some(Err(e)) => failed_entries.push(format!("zip worker failed: {}", e)),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
let pool_cl = pool.clone();
|
||||
let english_level_cl = english_level.clone();
|
||||
let s3_settings_cl = s3_settings.clone();
|
||||
let s3_client_cl = s3_client.clone();
|
||||
|
||||
join_set.spawn(async move {
|
||||
process_zip_entry_without_rag(
|
||||
entry,
|
||||
org_id,
|
||||
user_id,
|
||||
pool_cl,
|
||||
course_id,
|
||||
english_level_cl,
|
||||
sam_plan_id,
|
||||
sam_course_id,
|
||||
split_midpoint,
|
||||
sam_course_id_r1,
|
||||
sam_course_id_r2,
|
||||
s3_settings_cl,
|
||||
s3_client_cl,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(result) = join_set.join_next().await {
|
||||
match result {
|
||||
Ok(Ok(())) => imported_assets += 1,
|
||||
Ok(Err(msg)) => failed_entries.push(msg),
|
||||
Err(e) => failed_entries.push(format!("zip worker failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tokio::fs::remove_file(&zip_path).await;
|
||||
|
||||
return Ok(Json(AssetZipImportResponse {
|
||||
imported_assets,
|
||||
rag_ingested_assets: 0,
|
||||
rag_chunks_ingested: 0,
|
||||
failed_entries,
|
||||
}));
|
||||
}
|
||||
|
||||
for entry in all_entries {
|
||||
let ZipEntryData {
|
||||
entry_name,
|
||||
@@ -871,9 +1108,6 @@ pub async fn import_assets_zip(
|
||||
let (storage_path, stored_filename, mimetype) = if is_flv {
|
||||
let temp_storage_filename = format!("{}.flv", asset_id);
|
||||
let temp_storage_path = format!("uploads/{}", temp_storage_filename);
|
||||
tokio::fs::create_dir_all("uploads")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
tokio::fs::write(&temp_storage_path, &content)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
@@ -901,13 +1135,6 @@ pub async fn import_assets_zip(
|
||||
};
|
||||
let storage_path = format!("uploads/{}", storage_filename);
|
||||
|
||||
tokio::fs::create_dir_all("uploads")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
tokio::fs::write(&storage_path, &content)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
(storage_path, safe_filename.clone(), guessed_mimetype)
|
||||
};
|
||||
|
||||
@@ -918,18 +1145,44 @@ pub async fn import_assets_zip(
|
||||
.to_string();
|
||||
|
||||
let (db_storage_path, asset_public_url) = if !storage_filename_for_s3.is_empty() {
|
||||
if let Some((s3_path, public_url)) = maybe_push_local_file_to_s3(
|
||||
&storage_path,
|
||||
&storage_filename_for_s3,
|
||||
&mimetype,
|
||||
org_ctx.id,
|
||||
course_id,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let _ = tokio::fs::remove_file(&storage_path).await;
|
||||
(s3_path, public_url)
|
||||
if let (Some(settings), Some(client)) = (s3_settings.as_ref(), s3_client.as_ref()) {
|
||||
let key = build_s3_object_key(org_ctx.id, course_id, &storage_filename_for_s3);
|
||||
let upload_bytes = if is_flv {
|
||||
match tokio::fs::read(&storage_path).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
failed_entries.push(format!("{}: local read failed ({})", entry_name, e));
|
||||
let _ = tokio::fs::remove_file(&storage_path).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content.clone()
|
||||
};
|
||||
|
||||
match push_bytes_to_s3(client, settings, &key, &mimetype, upload_bytes).await {
|
||||
Ok((s3_path, public_url)) => {
|
||||
if is_flv {
|
||||
let _ = tokio::fs::remove_file(&storage_path).await;
|
||||
}
|
||||
(s3_path, public_url)
|
||||
}
|
||||
Err((_, msg)) => {
|
||||
if is_flv {
|
||||
let _ = tokio::fs::remove_file(&storage_path).await;
|
||||
}
|
||||
failed_entries.push(format!("{}: s3 upload failed ({})", entry_name, msg));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !is_flv {
|
||||
if let Err(e) = tokio::fs::write(&storage_path, &content).await {
|
||||
failed_entries.push(format!("{}: local write failed ({})", entry_name, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
storage_path.clone(),
|
||||
format!("/assets/{}", storage_filename_for_s3),
|
||||
|
||||
+60
-45
@@ -980,55 +980,70 @@ export const cmsApi = {
|
||||
samCourseIdR2?: number,
|
||||
): Promise<AssetZipImportResult> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('ingest_rag', ingestRag ? 'true' : 'false');
|
||||
if (courseId) formData.append('course_id', courseId);
|
||||
if (englishLevel) formData.append('english_level', englishLevel);
|
||||
if (samPlanId) formData.append('sam_plan_id', String(samPlanId));
|
||||
if (samCourseId) formData.append('sam_course_id', String(samCourseId));
|
||||
if (splitToRegular) {
|
||||
formData.append('split_to_regular', 'true');
|
||||
if (samCourseIdR1) formData.append('sam_course_id_r1', String(samCourseIdR1));
|
||||
if (samCourseIdR2) formData.append('sam_course_id_r2', String(samCourseIdR2));
|
||||
}
|
||||
const maxNetworkRetries = 2;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${API_BASE_URL}/api/assets/import-zip`);
|
||||
|
||||
const token = getToken();
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
const selectedOrgId = getSelectedOrgId();
|
||||
if (selectedOrgId) xhr.setRequestHeader('X-Organization-Id', selectedOrgId);
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
let msg = `ZIP import failed (HTTP ${xhr.status})`;
|
||||
try {
|
||||
const parsed = JSON.parse(xhr.responseText);
|
||||
msg = parsed.message || parsed.error || msg;
|
||||
} catch {
|
||||
const raw = (xhr.responseText || '').trim();
|
||||
if (raw) {
|
||||
const compact = raw.replace(/\s+/g, ' ').slice(0, 240);
|
||||
msg = `${msg}: ${compact}`;
|
||||
}
|
||||
}
|
||||
reject(new Error(msg));
|
||||
const startAttempt = (attempt: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('ingest_rag', ingestRag ? 'true' : 'false');
|
||||
if (courseId) formData.append('course_id', courseId);
|
||||
if (englishLevel) formData.append('english_level', englishLevel);
|
||||
if (samPlanId) formData.append('sam_plan_id', String(samPlanId));
|
||||
if (samCourseId) formData.append('sam_course_id', String(samCourseId));
|
||||
if (splitToRegular) {
|
||||
formData.append('split_to_regular', 'true');
|
||||
if (samCourseIdR1) formData.append('sam_course_id_r1', String(samCourseIdR1));
|
||||
if (samCourseIdR2) formData.append('sam_course_id_r2', String(samCourseIdR2));
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${API_BASE_URL}/api/assets/import-zip`);
|
||||
|
||||
const token = getToken();
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
const selectedOrgId = getSelectedOrgId();
|
||||
if (selectedOrgId) xhr.setRequestHeader('X-Organization-Id', selectedOrgId);
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
let msg = `ZIP import failed (HTTP ${xhr.status})`;
|
||||
try {
|
||||
const parsed = JSON.parse(xhr.responseText);
|
||||
msg = parsed.message || parsed.error || msg;
|
||||
} catch {
|
||||
const raw = (xhr.responseText || '').trim();
|
||||
if (raw) {
|
||||
const compact = raw.replace(/\s+/g, ' ').slice(0, 240);
|
||||
msg = `${msg}: ${compact}`;
|
||||
}
|
||||
}
|
||||
reject(new Error(msg));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
if (attempt < maxNetworkRetries) {
|
||||
const delayMs = 1200 * (attempt + 1);
|
||||
setTimeout(() => startAttempt(attempt + 1), delayMs);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Network error'));
|
||||
};
|
||||
|
||||
if (onProgress) {
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable) return;
|
||||
const pct = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress(Math.max(0, Math.min(100, pct)));
|
||||
};
|
||||
}
|
||||
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('Network error'));
|
||||
if (onProgress) {
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable) return;
|
||||
const pct = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress(Math.max(0, Math.min(100, pct)));
|
||||
};
|
||||
}
|
||||
xhr.send(formData);
|
||||
startAttempt(0);
|
||||
});
|
||||
},
|
||||
uploadAsset: (
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user