From 05dd345205183c61dc86ed6a903d7ca91565c552 Mon Sep 17 00:00:00 2001 From: TraceLumen Date: Fri, 18 Sep 2026 20:25:02 +0300 Subject: [PATCH] test --- src/main.rs | 436 +++++++++++++--------------------------------------- 1 file changed, 110 insertions(+), 326 deletions(-) diff --git a/src/main.rs b/src/main.rs index 40df531..238355e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,6 @@ use axum::{ routing::{get, post}, Json, Router, }; - use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, @@ -15,23 +14,20 @@ use std::{ atomic::{AtomicU64, Ordering}, Arc, Mutex, }, - time::{Duration, Instant}, }; use tempfile::{NamedTempFile, TempPath}; use tokio::fs; use tower_http::cors::CorsLayer; -use tracing::{error, info, warn}; - +use tracing::info; async fn get_version() -> impl IntoResponse { Json(serde_json::json!({ - "version": "1.0.4", + "version": "1.0.5", "timestamp": chrono::Utc::now().timestamp() })) } // ─────────────────────────────── Модели ─────────────────────────────── - #[derive(Deserialize, Clone, Debug)] struct PrintSettings { filename: String, @@ -51,8 +47,9 @@ struct PrintSettings { enum FileStatus { Queued, Printing, + Sent, + //AwaitingClearOutput, AwaitingFlip, - Awaiting, Done, Error, Cancelled, @@ -63,6 +60,7 @@ enum FileStatus { enum JobPhase { WaitingStart, Printing, + AwaitingClearOutput, AwaitingFlip, AwaitingPickup, Finished, @@ -75,7 +73,6 @@ struct JobFileView { status: FileStatus, #[serde(skip_serializing_if = "Option::is_none")] error: Option, - /// true → нечётное кол-во страниц, нужно убрать верхний лист перед 2-м проходом #[serde(default)] remove_top_sheet: bool, } @@ -89,7 +86,7 @@ struct JobView { struct JobFile { name: String, - path: TempPath, // живёт, пока жив job → автоочистка temp + path: TempPath, settings: Option, status: FileStatus, error: Option, @@ -119,11 +116,10 @@ impl Job { } } - /// Нечетных страниц больше, чем чётных → лишний лист сверху fn should_remove_top_sheet(settings: &Option) -> bool { let Some(s) = settings else { return false }; if s.sides.as_deref() != Some("Двусторонняя") { return false; } - if s.pages == "all" { return false; } // для офисных не знаем кол-во + if s.pages == "all" { return false; } let nums: Vec = s.pages.split(',') .filter_map(|p| p.trim().parse().ok()) .collect(); @@ -146,7 +142,6 @@ type JobStore = Arc>>; static JOB_COUNTER: AtomicU64 = AtomicU64::new(1); // ─────────────────────────────── main ─────────────────────────────── - #[tokio::main] async fn main() { tracing_subscriber::fmt() @@ -165,7 +160,7 @@ async fn main() { .route("/print/{job_id}", get(job_status)) .route("/print/{job_id}/advance", post(job_advance)) .route("/print/{job_id}/cancel", post(job_cancel)) - .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) // 50 MB + .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) .layer(cors) .with_state(store); @@ -177,93 +172,56 @@ async fn main() { } // ─────────────────────────── Обработчики ─────────────────────────── - async fn create_print( State(store): State, mut multipart: Multipart, ) -> Result, (StatusCode, String)> { - info!("📥 Получен запрос на печать (создание задания)"); - + info!("📥 Получен запрос на печать"); let mut settings_map: HashMap = HashMap::new(); let mut files_data: Vec<(String, bytes::Bytes)> = Vec::new(); while let Some(field) = multipart.next_field().await.unwrap_or(None) { let field_name = field.name().unwrap_or("").to_string(); if field_name == "settings" { - match field.text().await { - Ok(text) => match serde_json::from_str::(&text) { - Ok(s) => { - info!(filename = %s.filename, "✅ Настройки распарсены"); - settings_map.insert(s.filename.clone(), s); - } - Err(e) => error!(raw = %text, error = %e, "❌ Не удалось распарсить settings"), - }, - Err(e) => error!(error = %e, "❌ Ошибка чтения поля settings"), + if let Ok(text) = field.text().await { + if let Ok(s) = serde_json::from_str::(&text) { + settings_map.insert(s.filename.clone(), s); + } } } else if field_name == "files" { let file_name = field.file_name().unwrap_or("unknown").to_string(); - match field.bytes().await { - Ok(data) => { - info!(file_name = %file_name, size_bytes = data.len(), "📎 Получен файл"); - files_data.push((file_name, data)); - } - Err(e) => error!(file_name = %file_name, error = %e, "❌ Ошибка чтения файла"), + if let Ok(data) = field.bytes().await { + files_data.push((file_name, data)); } - } else { - warn!(field_name = %field_name, "⚠️ Неизвестное поле в multipart, пропущено"); } } if files_data.is_empty() { - return Err(( - StatusCode::BAD_REQUEST, - "В запросе отсутствуют файлы для печати".into(), - )); + return Err((StatusCode::BAD_REQUEST, "Нет файлов".into())); } - // Спулим каждый файл во временный (TempPath живёт вместе с job) let mut job_files: Vec = Vec::new(); for (file_name, data) in files_data { - let ext = Path::new(&file_name) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - let temp = if ext.is_empty() { - NamedTempFile::new() - } else { - NamedTempFile::with_suffix(&format!(".{}", ext)) - }; - let temp = match temp { - Ok(f) => f, - Err(e) => { - error!(file_name = %file_name, error = %e, "❌ Не удалось создать temp файл"); - continue; + let ext = Path::new(&file_name).extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + let temp = if ext.is_empty() { NamedTempFile::new() } else { NamedTempFile::with_suffix(&format!(".{}", ext)) }; + if let Ok(temp) = temp { + let path = temp.into_temp_path(); + if fs::write(&*path, &data).await.is_ok() { + let settings = settings_map.get(&file_name).cloned(); + + job_files.push(JobFile { + name: file_name, + path, + settings, + status: FileStatus::Queued, + error: None, + }); } - }; - let path = temp.into_temp_path(); - if let Err(e) = fs::write(&*path, &data).await { - error!(file_name = %file_name, error = %e, "❌ Ошибка записи во временный файл"); - continue; } - let settings = settings_map.get(&file_name).cloned(); - if settings.is_none() { - warn!(file_name = %file_name, "⚠️ Настройки не найдены, печать по умолчанию"); - } - job_files.push(JobFile { - name: file_name, - path, - settings, - status: FileStatus::Queued, - error: None, - }); } if job_files.is_empty() { - return Err(( - StatusCode::BAD_REQUEST, - "Не удалось сохранить ни один файл".into(), - )); + return Err((StatusCode::BAD_REQUEST, "Не удалось сохранить файлы".into())); } let job_id = format!("job-{}", JOB_COUNTER.fetch_add(1, Ordering::SeqCst)); @@ -276,7 +234,6 @@ async fn create_print( }; let view = job.view(); store.lock().unwrap().insert(job_id.clone(), job); - info!(job_id = %job_id, files = view.files.len(), "🆕 Задание создано, ждём старт от клиента"); Ok(Json(view)) } @@ -287,10 +244,9 @@ async fn job_status( let m = store.lock().unwrap(); m.get(&job_id) .map(|j| Json(j.view())) - .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id))) + .ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into())) } -/// Подтверждение клиента: старт первого файла ИЛИ «документ забран, печатай следующий» async fn job_advance( State(store): State, AxumPath(job_id): AxumPath, @@ -298,42 +254,42 @@ async fn job_advance( let (view, spawn_info) = { let mut m = store.lock().unwrap(); let job = m.get_mut(&job_id) - .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?; - + .ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))?; + let si = match job.phase { JobPhase::WaitingStart => { - info!(job_id = %job_id, "▶️ Старт первого файла"); - Some(start_printing_locked(job, 0)) - } - - JobPhase::AwaitingFlip => { - if let Some(c) = job.current { - info!(job_id = %job_id, file = %job.files[c].name, - "🔄 Запуск второго прохода (чётные страницы)"); - job.files[c].status = FileStatus::Printing; - job.phase = JobPhase::Printing; - job.cups_id = None; - Some(SpawnInfo { - job_id: job.id.clone(), - idx: c, - path: job.files[c].path.to_path_buf(), - settings: job.files[c].settings.clone(), - is_second_pass: true, - }) - } else { + let next_idx = 0; + job.current = Some(next_idx); + if is_duplex_and_needs_clear(job, next_idx) { + job.phase = JobPhase::AwaitingClearOutput; None + } else { + Some(start_printing_locked(job, next_idx)) } } - + JobPhase::AwaitingClearOutput => { + let c = job.current.unwrap_or(0); + Some(start_printing_locked(job, c)) + } + JobPhase::AwaitingFlip => { + let c = job.current.unwrap(); + job.files[c].status = FileStatus::Printing; + job.phase = JobPhase::Printing; + Some(SpawnInfo { + job_id: job.id.clone(), + idx: c, + path: job.files[c].path.to_path_buf(), + settings: job.files[c].settings.clone(), + is_second_pass: true, + }) + } JobPhase::AwaitingPickup => match job.current.take() { Some(c) => { job.files[c].status = FileStatus::Done; - info!(job_id = %job_id, file = %job.files[c].name, "✔️ Документ забран"); begin_next_locked(job, c) } None => { job.phase = JobPhase::Finished; None } }, - _ => None, }; (job.view(), si) @@ -351,43 +307,36 @@ async fn job_cancel( ) -> Result, (StatusCode, String)> { let cups_ids: Vec = { let mut m = store.lock().unwrap(); - let job = m - .get_mut(&job_id) - .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?; + let job = m.get_mut(&job_id) + .ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))?; + if job.phase == JobPhase::Finished || job.phase == JobPhase::Cancelled { return Ok(Json(job.view())); } - // собираем ВСЕ известные CUPS id для этого job (обычно один, но на всякий случай) + let ids = job.cups_id.take().into_iter().collect::>(); for f in job.files.iter_mut() { - if matches!( - f.status, - FileStatus::Queued | FileStatus::Printing | FileStatus::Awaiting - ) { + if matches!(f.status, FileStatus::Queued | FileStatus::Printing) { f.status = FileStatus::Cancelled; } } job.phase = JobPhase::Cancelled; job.current = None; - warn!(job_id = %job_id, "🛑 Задание отменено клиентом"); ids }; - // отменяем каждый известный CUPS-job + for id in cups_ids { let cancel_bin = cups_bin("cancel"); let _ = tokio::task::spawn_blocking(move || { Command::new(cancel_bin).arg(id).env("LC_ALL", "C").output() - }) - .await; + }).await; } - // также попробуем отменить по всем заданиям с нашего temp-префикса (belt and suspenders) - // — но обычно CUPS-id'ов достаточно + let m = store.lock().unwrap(); Ok(Json(m.get(&job_id).unwrap().view())) } // ─────────────────────── Печать: state machine ─────────────────────── - fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo { job.files[idx].status = FileStatus::Printing; job.current = Some(idx); @@ -404,39 +353,44 @@ fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo { fn begin_next_locked(job: &mut Job, from: usize) -> Option { match (from + 1..job.files.len()).find(|&i| job.files[i].status == FileStatus::Queued) { - Some(i) => Some(start_printing_locked(job, i)), + Some(i) => { + job.current = Some(i); + if is_duplex_and_needs_clear(job, i) { + job.phase = JobPhase::AwaitingClearOutput; + None + } else { + Some(start_printing_locked(job, i)) + } + } None => { job.phase = JobPhase::Finished; job.current = None; - job.cups_id = None; - info!(job_id = %job.id, "🎉 Задание полностью завершено"); None } } } +fn is_duplex_and_needs_clear(job: &Job, next_idx: usize) -> bool { + let next_file = &job.files[next_idx]; + let is_duplex = next_file.settings.as_ref() + .map(|s| s.sides.as_deref() == Some("Двусторонняя")) + .unwrap_or(false); + is_duplex && next_idx > 0 +} + fn spawn_print_task(store: JobStore, info: SpawnInfo) { tokio::spawn(async move { let path = info.path.clone(); let settings = info.settings.clone(); - - let is_duplex = settings.as_ref() - .map(|s| s.sides.as_deref() == Some("Двусторонняя")) - .unwrap_or(false); - + let is_duplex = settings.as_ref().map(|s| s.sides.as_deref() == Some("Двусторонняя")).unwrap_or(false); let duplex_pass: Option<&str> = if is_duplex { if info.is_second_pass { Some("even") } else { Some("odd") } - } else { - None - }; + } else { None }; - // 1) Спулим в CUPS let p = path.clone(); let s = settings.clone(); let dp = duplex_pass.map(|d| d.to_string()); - let spool = tokio::task::spawn_blocking(move || { - run_lp(&p, s.as_ref(), dp.as_deref()) - }).await; + let spool = tokio::task::spawn_blocking(move || run_lp(&p, s.as_ref(), dp.as_deref())).await; let cups_id = match spool { Ok(Ok(id)) => id, @@ -444,92 +398,53 @@ fn spawn_print_task(store: JobStore, info: SpawnInfo) { Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await, }; - // 2) Ждём физического завершения - if !cups_id.is_empty() { - { - let mut m = store.lock().unwrap(); - if let Some(job) = m.get_mut(&info.job_id) { - if job.phase != JobPhase::Cancelled { - job.cups_id = Some(cups_id.clone()); - } else { return; } - } else { return; } - } - let cid = cups_id.clone(); - let wait = tokio::task::spawn_blocking(move || wait_for_cups(&cid)).await; - match wait { - Ok(Ok(())) => {} - Ok(Err(e)) => return mark_error_and_continue(store, info, e).await, - Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await, - } - } - - // 3) Обновляем статус let mut m = store.lock().unwrap(); let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return }; - if job.phase == JobPhase::Cancelled { return; } if job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing) { return; } + if !cups_id.is_empty() { + job.cups_id = Some(cups_id); + } + if is_duplex && !info.is_second_pass { - // Первый проход завершён → ждём, пока пользователь переложит бумагу job.files[info.idx].status = FileStatus::AwaitingFlip; job.phase = JobPhase::AwaitingFlip; - job.cups_id = None; - info!(job_id = %info.job_id, file = %job.files[info.idx].name, - "🔄 Первый проход завершён, ждём переворота бумаги"); } else { - // Обычная печать или второй проход дуплекса → ждём, пока заберут - job.files[info.idx].status = FileStatus::Awaiting; + job.files[info.idx].status = FileStatus::Sent; job.phase = JobPhase::AwaitingPickup; - job.cups_id = None; - info!(job_id = %info.job_id, file = %job.files[info.idx].name, - "✅ Печать завершена, ждём подтверждения"); } + job.cups_id = None; }); } async fn mark_error_and_continue(store: JobStore, info: SpawnInfo, msg: String) { - error!(job_id = %info.job_id, idx = info.idx, error = %msg, "❌ Ошибка печати файла"); let next = { let mut m = store.lock().unwrap(); - let job = match m.get_mut(&info.job_id) { - Some(j) => j, - None => return, - }; - if job.phase == JobPhase::Cancelled { - return; - } + let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return }; + if job.phase == JobPhase::Cancelled { return; } if let Some(f) = job.files.get_mut(info.idx) { - if f.status != FileStatus::Printing { - return; - } + if f.status != FileStatus::Printing { return; } f.status = FileStatus::Error; f.error = Some(msg); } begin_next_locked(job, info.idx) }; - if let Some(si) = next { - spawn_print_task(store, si); - } + if let Some(si) = next { spawn_print_task(store, si); } } -// ─────────────────────────── lp / lpstat ─────────────────────────── - -/// Ищем бинарник CUPS в известных локациях (NixOS/Guix/стандартный Linux) +// ─────────────────────────── lp / CUPS ─────────────────────────── fn cups_bin(name: &str) -> std::path::PathBuf { let candidates = [ - format!("/pkg/gnu/cups/bin/{}", name), // Guix - format!("/run/current-system/sw/bin/{}", name), // NixOS + format!("/pkg/gnu/cups/bin/{}", name), + format!("/run/current-system/sw/bin/{}", name), format!("/usr/bin/{}", name), format!("/usr/local/bin/{}", name), ]; for p in &candidates { let path = std::path::Path::new(p); - if path.exists() { - return path.to_path_buf(); - } + if path.exists() { return path.to_path_buf(); } } - // fallback — надеемся на PATH std::path::PathBuf::from(name) } @@ -544,54 +459,30 @@ fn normalize_format(format: &str) -> String { fn run_lp(path: &Path, settings: Option<&PrintSettings>, duplex_pass: Option<&str>) -> Result { let lp = cups_bin("lp"); let mut cmd = Command::new(&lp); - if let Some(s) = settings { cmd.arg("-n").arg(s.copies.to_string()); - - if s.pages != "all" && !s.pages.is_empty() { - cmd.arg("-P").arg(&s.pages); - } - if s.color_mode == "bw" { - cmd.arg("-o").arg("ColorModel=Gray"); - } + if s.pages != "all" && !s.pages.is_empty() { cmd.arg("-P").arg(&s.pages); } + if s.color_mode == "bw" { cmd.arg("-o").arg("ColorModel=Gray"); } let fmt = normalize_format(&s.format); - if !fmt.is_empty() { - cmd.arg("-o").arg(format!("PageSize={}", fmt)); - } - - // Аппаратный дуплекс только если НЕ ручная двухпроходная печать + if !fmt.is_empty() { cmd.arg("-o").arg(format!("PageSize={}", fmt)); } if s.sides.as_deref() == Some("Двусторонняя") && duplex_pass.is_none() { cmd.arg("-o").arg("sides=two-sided-long-edge"); } - if let Some(d) = s.dpi.as_deref() { - if !d.is_empty() && d != "Авто" { - cmd.arg("-o").arg(format!("printer-resolution={}dpi", d)); - } + if !d.is_empty() && d != "Авто" { cmd.arg("-o").arg(format!("printer-resolution={}dpi", d)); } } } - // Выбор лотка (InputSlot) + // СЛОТЫ: Upper для обычной, Lower для ручной (дуплекс) if let Some(pass) = duplex_pass { - if pass == "even" { - // Второй проход (чётные страницы) — ручная подача - cmd.arg("-o").arg("InputSlot=Manual"); - // Поворот на 180° для второго прохода - cmd.arg("-o").arg("orientation-requested=6"); - } else { - // Первый проход (нечётные страницы) — нижний лоток - cmd.arg("-o").arg("InputSlot=Lower"); - } - } else { - // Обычная печать — нижний лоток cmd.arg("-o").arg("InputSlot=Lower"); + if pass == "even" { cmd.arg("-o").arg("orientation-requested=6"); } + } else { + cmd.arg("-o").arg("InputSlot=Upper"); } - // Ручной дуплекс: разбивка на чёт/нечёт match duplex_pass { - Some("odd") => { - cmd.arg("-o").arg("page-set=odd"); - } + Some("odd") => { cmd.arg("-o").arg("page-set=odd"); } Some("even") => { cmd.arg("-o").arg("page-set=even"); cmd.arg("-o").arg("outputorder=reverse"); @@ -600,114 +491,7 @@ fn run_lp(path: &Path, settings: Option<&PrintSettings>, duplex_pass: Option<&st } cmd.arg(path); - - let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect(); - info!(command = format!("{} {}", lp.display(), args.join(" ")), "🖨️ Команда печати"); - - // ВАЖНО: LC_ALL=C гарантирует, что ответ будет на английском и парсинг ID не сломается - let out = cmd.env("LC_ALL", "C").output().map_err(|e| format!("Не удалось запустить lp: {}", e))?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - Ok(stdout.split_whitespace().nth(3).unwrap_or("").to_string()) -} - -fn has_job_in(output: &Option, id: &str) -> bool { - output - .as_deref() - .map(|s| { - s.lines() - .any(|l| l.split_whitespace().next() == Some(id)) - }) - .unwrap_or(false) -} - -fn lpstat_raw(which: &str) -> Option { - let lpstat = cups_bin("lpstat"); - Command::new(&lpstat) - .arg("-W") - .arg(which) - .arg("-o") - .env("LC_ALL", "C") - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) -} - -fn wait_for_cups(id: &str) -> Result<(), String> { - let lpstat_path = cups_bin("lpstat"); - if !lpstat_path.exists() && lpstat_path.to_str().unwrap_or("") == "lpstat" { - if Command::new("which") - .arg("lpstat") - .env("LC_ALL", "C") - .output() - .map(|o| !o.status.success()) - .unwrap_or(true) - { - return Err( - "lpstat не найден — не могу отслеживать статус печати в CUPS".into(), - ); - } - } - - let started = Instant::now(); - let timeout = Duration::from_secs(30 * 60); // 30 минут максимум - - let probe = lpstat_raw("not-completed"); - if probe.is_none() { - return Err("lpstat не отвечает (возможно, не запущен cupsd)".into()); - } - - // Получаем имя принтера по умолчанию для проверки физического статуса - let printer_name = get_default_printer().unwrap_or_else(|| "Auto_HP_P2015".to_string()); - - loop { - let not_completed = lpstat_raw("not-completed"); - let in_queue = has_job_in(¬_completed, id); - - if !in_queue { - // Задачи уже нет в очереди CUPS. Проверяем, допечатал ли принтер физически. - if !is_printer_physically_printing(&printer_name) { - info!(cups_id = %id, "✔ CUPS-задание выполнено и принтер физически допечатал"); - return Ok(()); - } else { - info!(cups_id = %id, "⏳ CUPS передал данные, но принтер всё ещё физически печатает из буфера..."); - } - } - - if started.elapsed() > timeout { - return Err("Таймаут ожидания завершения печати (30 мин)".into()); - } - - std::thread::sleep(Duration::from_millis(1500)); - } -} - -fn get_default_printer() -> Option { - let out = Command::new("lpstat") - .arg("-d") - .env("LC_ALL", "C") - .output() - .ok()?; - let stdout = String::from_utf8_lossy(&out.stdout); - // Формат: "system default destination: Auto_HP_P2015" - stdout.split(':').nth(1).map(|s| s.trim().to_string()) -} - -fn is_printer_physically_printing(printer_name: &str) -> bool { - if let Ok(out) = Command::new("lpstat") - .arg("-p") - .arg(printer_name) - .env("LC_ALL", "C") - .output() - { - let stdout = String::from_utf8_lossy(&out.stdout); - // "now printing" или "processing" означают, что принтер физически печатает из буфера - if stdout.contains("now printing") || stdout.contains("processing") { - return true; - } - } - false + let out = cmd.env("LC_ALL", "C").output().map_err(|e| format!("lp: {}", e))?; + if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); } + Ok(String::from_utf8_lossy(&out.stdout).split_whitespace().nth(3).unwrap_or("").to_string()) }