commit 2f1bdac921f3fcd66145e2add135f74dee741dc2 Author: lan Date: Thu Dec 11 19:50:03 2025 +0800 Add initial project structure with configuration, printing, and routing functionality - Created a new Rust project with Cargo configuration. - Added `.gitignore` to exclude target directory. - Implemented `Cargo.lock` for dependency management. - Defined application configuration in `config/app.yaml` and `src/config.rs`. - Developed printing logic in `src/printer.rs` using `cups_rs` for printer interactions. - Established routing and request handling in `src/routes.rs` for health checks and print requests. - Introduced a scheduler in `src/scheduler.rs` to manage print job distribution. - Created models for print parameters and job responses in `src/models.rs`. - Set up the main application entry point in `src/main.rs` with server initialization and configuration loading. - Included necessary dependencies in `Cargo.toml` for async handling, tracing, and configuration management. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ea2cf1b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1377 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "nom", + "pathdiff", + "serde", + "yaml-rust2", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cups_rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ad179868ddceb8948487316c7094aa251e3efb0ecf15cab88b08f22eabf724" +dependencies = [ + "bindgen", + "chrono", + "libc", + "pkg-config", + "thiserror", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "print-backend" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "base64", + "config", + "cups_rs", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "thiserror", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1572e85 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "print-backend" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +async-trait = "0.1" +axum = { version = "0.7", features = ["multipart", "json"] } +base64 = "0.21" +config = { version = "0.14", default-features = false, features = ["yaml"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +thiserror = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "signal", "net"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +tower-http = { version = "0.5", features = ["trace"] } +cups_rs = "0.3" +tempfile = "3" diff --git a/config/app.yaml b/config/app.yaml new file mode 100644 index 0000000..1705990 --- /dev/null +++ b/config/app.yaml @@ -0,0 +1,28 @@ +server: + bind: "0.0.0.0" + port: 8080 + +cups: + server: "http://v4.hitwh.games:631" + user: "root" + password: "lanyimin123" +# 如需指定 CUPS 服务器/用户,示例: +# cups: +# server: "http://cups.local:631" +# user: "printsvc" + +scheduler: + strategy: "least_queued" # round_robin | least_queued + refresh_interval_secs: 10 + +retry: + attempts: 3 + backoff_ms: 1500 + +defaults: + copies: 1 + duplex: "two_sided_long_edge" # one_sided | two_sided_long_edge | two_sided_short_edge + color: "auto" # auto | color | monochrome + media: "A4" + quality: "normal" # draft | normal | high + diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..72fd9e5 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,210 @@ +use std::time::Duration; + +use anyhow::Context; +use config as cfg; +use serde::Deserialize; + +use crate::models::{ColorModeSetting, DuplexSetting, PrintDefaults, QualitySetting}; + +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + #[serde(default)] + pub server: ServerConfig, + #[serde(default)] + pub cups: CupsConfig, + #[serde(default)] + pub scheduler: SchedulerConfig, + #[serde(default)] + pub retry: RetryConfig, + #[serde(default)] + pub defaults: PrintDefaults, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ServerConfig { + #[serde(default = "default_bind")] + pub bind: String, + #[serde(default = "default_port")] + pub port: u16, + /// 请求体大小上限(字节) + #[serde(default = "default_body_limit")] + pub body_limit_bytes: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CupsConfig { + /// 例如 http://cups.local:631 或 /run/cups/cups.sock + #[serde(default)] + pub server: Option, + #[serde(default)] + pub user: Option, + #[serde(default)] + pub password: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SchedulerConfig { + #[serde(default = "default_strategy")] + pub strategy: LoadBalanceStrategy, + /// 后台刷新打印机状态的最小间隔,避免频繁访问 CUPS + #[serde(default = "default_refresh_secs")] + pub refresh_interval_secs: u64, + /// 黑白默认 PPM(当打印机未上报时) + #[serde(default = "default_ppm_bw")] + pub default_ppm_bw: u32, + /// 彩色默认 PPM(当打印机未上报时) + #[serde(default = "default_ppm_color")] + pub default_ppm_color: u32, + /// 期望的最大等待时间(分钟),仅用于排序估计 + #[serde(default)] + pub target_wait_minutes: Option, + /// 打印机速度覆盖 + #[serde(default)] + pub printer_speeds: std::collections::HashMap, +} + +#[derive(Debug, Clone, Deserialize, Copy)] +#[serde(rename_all = "snake_case")] +pub enum LoadBalanceStrategy { + RoundRobin, + LeastQueued, +} + +impl Default for LoadBalanceStrategy { + fn default() -> Self { + LoadBalanceStrategy::LeastQueued + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RetryConfig { + #[serde(default = "default_attempts")] + pub attempts: usize, + #[serde(default = "default_backoff_ms")] + pub backoff_ms: u64, +} + +impl RetryConfig { + pub fn backoff(&self) -> Duration { + Duration::from_millis(self.backoff_ms) + } +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + bind: default_bind(), + port: default_port(), + body_limit_bytes: default_body_limit(), + } + } +} + +impl Default for CupsConfig { + fn default() -> Self { + Self { + server: None, + user: None, + password: None, + } + } +} + +impl Default for SchedulerConfig { + fn default() -> Self { + Self { + strategy: default_strategy(), + refresh_interval_secs: default_refresh_secs(), + default_ppm_bw: default_ppm_bw(), + default_ppm_color: default_ppm_color(), + target_wait_minutes: None, + printer_speeds: Default::default(), + } + } +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + attempts: default_attempts(), + backoff_ms: default_backoff_ms(), + } + } +} + +impl AppConfig { + pub fn load(path: Option<&str>) -> Result { + let path = path.unwrap_or("config/app.yaml"); + let builder = cfg::Config::builder() + .add_source(cfg::File::with_name(path)) + .add_source(cfg::Environment::with_prefix("PRINT").separator("__")); + + builder + .build() + .context("读取配置失败")? + .try_deserialize::() + .context("配置格式错误") + } +} + +fn default_bind() -> String { + "0.0.0.0".to_string() +} + +fn default_port() -> u16 { + 8080 +} + +fn default_body_limit() -> u64 { + 20 * 1024 * 1024 // 20MB +} + +fn default_attempts() -> usize { + 3 +} + +fn default_backoff_ms() -> u64 { + 1500 +} + +fn default_strategy() -> LoadBalanceStrategy { + LoadBalanceStrategy::LeastQueued +} + +fn default_refresh_secs() -> u64 { + 10 +} + +fn default_ppm_bw() -> u32 { + 30 +} + +fn default_ppm_color() -> u32 { + 20 +} + +// Defaults reused by PrintDefaults +pub(crate) fn default_copies() -> u32 { + 1 +} + +pub(crate) fn default_duplex() -> DuplexSetting { + DuplexSetting::OneSided +} + +pub(crate) fn default_color() -> ColorModeSetting { + ColorModeSetting::Auto +} + +pub(crate) fn default_quality() -> QualitySetting { + QualitySetting::Normal +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PrinterSpeedOverride { + #[serde(default)] + pub ppm: Option, + #[serde(default)] + pub ppm_color: Option, +} + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..189c091 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,109 @@ +mod config; +mod error; +mod models; +mod printer; +mod routes; +mod scheduler; + +use std::{net::SocketAddr, sync::Arc}; + +use anyhow::Context; +use axum::serve; +use printer::{CupsClient, SharedPrinterAdapter}; +use routes::{build_router, AppState}; +use scheduler::Scheduler; +use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer}; +use tracing_subscriber::{fmt, EnvFilter}; + +#[tokio::main] +async fn main() -> Result<(), anyhow::Error> { + init_tracing(); + + let config_path = std::env::var("CONFIG_PATH").ok(); + let config = config::AppConfig::load(config_path.as_deref())?; + + if let Some(server) = &config.cups.server { + let normalized = normalize_server(server); + unsafe { + std::env::set_var("CUPS_SERVER", &normalized); + } + cups_rs::config::set_server(Some(&normalized)).context("设置 CUPS 服务器失败")?; + tracing::info!("已配置远程 CUPS 服务器: {}", normalized); + } + if let Some(user) = &config.cups.user { + unsafe { + std::env::set_var("CUPS_USER", user); + } + cups_rs::config::set_user(Some(user)).context("设置 CUPS 用户失败")?; + } + if let Some(password) = &config.cups.password { + // 设置 CUPS_PASSWORD 供远程认证使用 + unsafe { + std::env::set_var("CUPS_PASSWORD", password); + } + } + + let adapter: SharedPrinterAdapter = Arc::new(CupsClient::default()); + let scheduler = Arc::new(Scheduler::new( + config.scheduler.strategy, + config.scheduler.refresh_interval_secs, + adapter, + config.scheduler.default_ppm_bw, + config.scheduler.default_ppm_color, + config.scheduler.target_wait_minutes, + config.scheduler.printer_speeds.clone(), + )); + + let state = AppState { + scheduler, + config: config.clone(), + }; + + let app = build_router(state) + .layer(TraceLayer::new_for_http()) + .layer(RequestBodyLimitLayer::new( + config.server + .body_limit_bytes + .try_into() + .unwrap_or(usize::MAX), + )); + + let addr: SocketAddr = format!("{}:{}", config.server.bind, config.server.port) + .parse() + .context("解析监听地址失败")?; + let listener = tokio::net::TcpListener::bind(addr) + .await + .context("监听端口失败")?; + + tracing::info!("服务启动于 http://{}", listener.local_addr()?); + + serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .context("HTTP 服务异常")?; + + Ok(()) +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + fmt().with_env_filter(filter).init(); +} + +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; + tracing::info!("收到退出信号,准备退出"); +} + +fn normalize_server(input: &str) -> String { + let without_scheme = if let Some((_, rest)) = input.split_once("://") { + rest + } else { + input + }; + without_scheme + .split('/') + .next() + .unwrap_or(without_scheme) + .to_string() +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..d8e13e9 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,191 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use cups_rs::job::{ColorMode, DuplexMode, Orientation, PrintOptions, PrintQuality}; +use serde::{Deserialize, Serialize}; + +use crate::config; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ColorModeSetting { + Auto, + Color, + Monochrome, +} + +impl Default for ColorModeSetting { + fn default() -> Self { + ColorModeSetting::Auto + } +} + +impl From for ColorMode { + fn from(value: ColorModeSetting) -> Self { + match value { + ColorModeSetting::Auto => ColorMode::Auto, + ColorModeSetting::Color => ColorMode::Color, + ColorModeSetting::Monochrome => ColorMode::Monochrome, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DuplexSetting { + OneSided, + TwoSidedLongEdge, + TwoSidedShortEdge, +} + +impl Default for DuplexSetting { + fn default() -> Self { + DuplexSetting::OneSided + } +} + +impl From for DuplexMode { + fn from(value: DuplexSetting) -> Self { + match value { + DuplexSetting::OneSided => DuplexMode::OneSided, + DuplexSetting::TwoSidedLongEdge => DuplexMode::TwoSidedPortrait, + DuplexSetting::TwoSidedShortEdge => DuplexMode::TwoSidedLandscape, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QualitySetting { + Draft, + Normal, + High, +} + +impl Default for QualitySetting { + fn default() -> Self { + QualitySetting::Normal + } +} + +impl From for PrintQuality { + fn from(value: QualitySetting) -> Self { + match value { + QualitySetting::Draft => PrintQuality::Draft, + QualitySetting::Normal => PrintQuality::Normal, + QualitySetting::High => PrintQuality::High, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OrientationSetting { + Portrait, + Landscape, +} + +impl From for Orientation { + fn from(value: OrientationSetting) -> Self { + match value { + OrientationSetting::Portrait => Orientation::Portrait, + OrientationSetting::Landscape => Orientation::Landscape, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrintParams { + #[serde(default)] + pub copies: Option, + #[serde(default)] + pub duplex: Option, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub media: Option, + #[serde(default)] + pub quality: Option, + #[serde(default)] + pub orientation: Option, + #[serde(default)] + pub job_name: Option, + /// 可选,指定打印机名称;为空则自动选择 + #[serde(default)] + pub printer: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrintDefaults { + #[serde(default = "config::default_copies")] + pub copies: u32, + #[serde(default = "config::default_duplex")] + pub duplex: DuplexSetting, + #[serde(default = "config::default_color")] + pub color: ColorModeSetting, + #[serde(default)] + pub media: Option, + #[serde(default = "config::default_quality")] + pub quality: QualitySetting, + #[serde(default)] + pub orientation: Option, +} + +impl Default for PrintDefaults { + fn default() -> Self { + Self { + copies: config::default_copies(), + duplex: config::default_duplex(), + color: config::default_color(), + media: None, + quality: config::default_quality(), + orientation: None, + } + } +} + +impl PrintParams { + pub fn to_print_options(&self, defaults: &PrintDefaults) -> PrintOptions { + let mut opts = PrintOptions::new().copies(self.copies.unwrap_or(defaults.copies)); + + opts = opts.duplex(self.duplex.unwrap_or(defaults.duplex).into()); + opts = opts.color_mode(self.color.clone().unwrap_or(defaults.color).into()); + opts = opts.quality(self.quality.unwrap_or(defaults.quality).into()); + + if let Some(media) = self.media.as_ref().or(defaults.media.as_ref()) { + opts = opts.media(media); + } + + if let Some(orientation) = self.orientation.as_ref().or(defaults.orientation.as_ref()) { + opts = opts.orientation(orientation.clone().into()); + } + + opts + } + + pub fn effective_job_name(&self) -> String { + if let Some(name) = &self.job_name { + return name.clone(); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("print-job-{}", now) + } +} + +#[derive(Debug, Deserialize)] +pub struct JsonPrintRequest { + pub pdf_base64: String, + pub params: PrintParams, +} + +#[derive(Debug, Serialize)] +pub struct PrintJobResponse { + pub job_id: i32, + pub printer: String, + pub strategy: String, + pub retries_used: usize, +} + diff --git a/src/printer.rs b/src/printer.rs new file mode 100644 index 0000000..73ca818 --- /dev/null +++ b/src/printer.rs @@ -0,0 +1,128 @@ +use std::{path::Path, sync::Arc}; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use cups_rs::{ + get_active_jobs, get_all_destinations, + job::{cancel_job, create_job_with_options, FORMAT_PDF}, + Destination, PrinterState, +}; +use tokio::task::spawn_blocking; +use tracing::{debug, warn}; + +use crate::models::{PrintDefaults, PrintParams}; + +#[derive(Debug, Clone)] +pub struct PrinterInfo { + pub dest: Destination, + pub state: PrinterState, + pub reasons: Vec, + pub active_jobs: usize, +} + +impl PrinterInfo { + pub fn healthy(&self) -> bool { + self.dest.is_accepting_jobs() && self.state.is_available() && !self.has_error_reason() + } + + fn has_error_reason(&self) -> bool { + self.reasons.iter().any(|r| { + let lower = r.to_ascii_lowercase(); + lower.contains("error") + || lower.contains("offline") + || lower.contains("jam") + || lower.contains("paper-out") + || lower.contains("paused") + }) + } +} + +#[derive(Debug, Clone)] +pub struct PrintJobResult { + pub job_id: i32, + pub printer: String, +} + +#[async_trait] +pub trait PrintAdapter: Send + Sync { + async fn list_printers(&self) -> Result>; + async fn submit_job( + &self, + printer: &Destination, + file_path: &Path, + params: &PrintParams, + defaults: &PrintDefaults, + ) -> Result; + async fn cancel(&self, job_id: i32) -> Result<()>; +} + +#[derive(Debug, Default)] +pub struct CupsClient; + +#[async_trait] +impl PrintAdapter for CupsClient { + async fn list_printers(&self) -> Result> { + spawn_blocking(|| { + let destinations = get_all_destinations().context("获取打印机列表失败")?; + + let mut infos = Vec::with_capacity(destinations.len()); + for dest in destinations { + let state = dest.state(); + let reasons = dest.state_reasons(); + let active_jobs = get_active_jobs(Some(dest.name.as_str())) + .map(|jobs| jobs.len()) + .unwrap_or(0); + + infos.push(PrinterInfo { + dest, + state, + reasons, + active_jobs, + }); + } + Ok::<_, anyhow::Error>(infos) + }) + .await + .context("获取打印机失败")? + } + + async fn submit_job( + &self, + printer: &Destination, + file_path: &Path, + params: &PrintParams, + defaults: &crate::models::PrintDefaults, + ) -> Result { + let dest = printer.clone(); + let path = file_path.to_path_buf(); + let options = params.to_print_options(defaults); + let job_name = params.effective_job_name(); + + spawn_blocking(move || { + let job = create_job_with_options(&dest, &job_name, &options) + .context("创建打印作业失败")?; + match job.submit_file(path.as_path(), FORMAT_PDF) { + Ok(_) => Ok(PrintJobResult { + job_id: job.id, + printer: dest.name.clone(), + }), + Err(e) => { + warn!("提交到打印机 {} 失败,尝试取消: {}", dest.name, e); + let _ = cancel_job(job.id); + Err(e).context("提交打印文件失败") + } + } + }) + .await + .context("提交打印任务失败")? + } + + async fn cancel(&self, job_id: i32) -> Result<()> { + spawn_blocking(move || cancel_job(job_id).context("取消打印作业失败")) + .await + .context("取消任务失败")? + } +} + +pub type SharedPrinterAdapter = Arc; + diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..342ca88 --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,502 @@ +use std::{io::Write, sync::Arc}; + +use axum::{ + extract::{Multipart, Path, Query, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use anyhow::anyhow; +use base64::engine::general_purpose::STANDARD as BASE64_STD; +use base64::Engine; +use serde::Serialize; +use tempfile::NamedTempFile; +use tracing::info; + +use crate::{ + config::AppConfig, + error::AppError, + models::{JsonPrintRequest, PrintJobResponse, PrintParams}, + scheduler::Scheduler, +}; + +#[derive(Clone)] +pub struct AppState { + pub scheduler: Arc, + pub config: AppConfig, +} + +pub fn build_router(state: AppState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/printers", get(printers)) + .route("/printers/:name/detail", get(printer_detail)) + .route("/print", post(print_base64)) + .route("/print/upload", post(print_multipart)) + .with_state(state) +} + +async fn health(State(state): State) -> Result, AppError> { + let printers = state.scheduler.healthy_snapshot().await?; + let details = printers + .into_iter() + .map(|p| { + let healthy = p.healthy(); + PrinterStatus { + name: p.dest.name.clone(), + state: format!("{}", p.state), + accepting_jobs: p.dest.is_accepting_jobs(), + active_jobs: p.active_jobs, + reasons: p.reasons, + healthy, + } + }) + .collect::>(); + Ok(Json(HealthResp { + status: "ok".to_string(), + available_printers: details.len(), + printers: details, + })) +} + +async fn printers(State(state): State) -> Result, AppError> { + let printers = state.scheduler.healthy_snapshot().await?; + let details = printers + .into_iter() + .map(|p| { + let healthy = p.healthy(); + PrinterStatus { + name: p.dest.name.clone(), + state: format!("{}", p.state), + accepting_jobs: p.dest.is_accepting_jobs(), + active_jobs: p.active_jobs, + reasons: p.reasons, + healthy, + } + }) + .collect::>(); + + Ok(Json(PrintersResp { printers: details })) +} + +async fn print_base64( + State(state): State, + Json(payload): Json, +) -> Result<(StatusCode, Json), AppError> { + let data = BASE64_STD + .decode(payload.pdf_base64) + .map_err(|e| AppError::BadRequest(format!("PDF base64 解码失败: {}", e)))?; + + let response = process_print(&state, data, payload.params).await?; + Ok((StatusCode::ACCEPTED, Json(response))) +} + +async fn print_multipart( + State(state): State, + mut multipart: Multipart, +) -> Result<(StatusCode, Json), AppError> { + let mut pdf: Option> = None; + let mut params: Option = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("解析表单失败: {}", e)))? + { + match field.name() { + Some("file") => { + pdf = Some( + field + .bytes() + .await + .map_err(|e| AppError::BadRequest(format!("读取文件失败: {}", e)))? + .to_vec(), + ); + } + Some("params") => { + let text = field + .text() + .await + .map_err(|e| AppError::BadRequest(format!("读取参数失败: {}", e)))?; + params = Some( + serde_json::from_str(&text) + .map_err(|e| AppError::BadRequest(format!("参数 JSON 无效: {}", e)))?, + ); + } + _ => {} + } + } + + let pdf = pdf.ok_or_else(|| AppError::BadRequest("缺少 file 字段".into()))?; + let params = params.ok_or_else(|| AppError::BadRequest("缺少 params 字段".into()))?; + + let response = process_print(&state, pdf, params).await?; + Ok((StatusCode::ACCEPTED, Json(response))) +} + +async fn process_print( + state: &AppState, + pdf_data: Vec, + params: PrintParams, +) -> Result { + if !is_pdf(&pdf_data) { + return Err(AppError::BadRequest("仅支持 PDF 文件".into())); + } + + let temp = TempPdf::new(pdf_data).await?; + let (job, retries_used) = state + .scheduler + .dispatch( + temp.path(), + ¶ms, + &state.config.defaults, + &state.config.retry, + ) + .await?; + + info!( + printer = %job.printer, + job_id = job.job_id, + retries = retries_used, + "打印任务已提交" + ); + + Ok(PrintJobResponse { + job_id: job.job_id, + printer: job.printer, + strategy: format!("{:?}", state.config.scheduler.strategy), + retries_used, + }) +} + +struct TempPdf { + file: NamedTempFile, +} + +impl TempPdf { + async fn new(data: Vec) -> Result { + let file = tokio::task::spawn_blocking(move || -> Result { + let mut file = NamedTempFile::new() + .map_err(|e| AppError::Internal(anyhow!("创建临时文件失败: {}", e)))?; + file.write_all(&data) + .map_err(|e| AppError::Internal(anyhow!(e)))?; + file.flush() + .map_err(|e| AppError::Internal(anyhow!(e)))?; + Ok(file) + }) + .await + .map_err(|e| AppError::Internal(anyhow!(e)))??; + + Ok(Self { file }) + } + + fn path(&self) -> &std::path::Path { + self.file.path() + } +} + +#[derive(Serialize)] +struct HealthResp { + status: String, + available_printers: usize, + printers: Vec, +} + +#[derive(Serialize)] +struct PrinterStatus { + name: String, + state: String, + accepting_jobs: bool, + active_jobs: usize, + reasons: Vec, + healthy: bool, +} + +fn is_pdf(data: &[u8]) -> bool { + data.starts_with(b"%PDF") +} + +#[derive(Serialize)] +struct PrintersResp { + printers: Vec, +} + +#[derive(Serialize)] +struct PrinterDetailResp { + name: String, + state: String, + accepting_jobs: bool, + active_jobs: usize, + reasons: Vec, + healthy: bool, + info: Option, + location: Option, + make_and_model: Option, + uri: Option, + device_uri: Option, + options: std::collections::HashMap, + media: MediaInfo, + alerts: Vec, +} + +async fn printer_detail( + State(state): State, + Path(name): Path, + Query(query): Query>, +) -> Result, AppError> { + let refresh = query.get("refresh").map(|s| { + let lower = s.to_ascii_lowercase(); + lower == "1" || lower == "true" || lower == "yes" || lower == "y" + }).unwrap_or(false); + let p = state + .scheduler + .printer_detail(&name, refresh) + .await + .map_err(|e| AppError::Printer(e.to_string()))?; + + let healthy = p.healthy(); + let (media_opt, alerts) = parse_media_and_alerts(p.dest.get_options()); + let media = if let Some(m) = media_opt { + m + } else { + let from_ipptool = fetch_media_via_ipptool( + &state.config, + &p.dest.uri().cloned().unwrap_or_default(), + ) + .await; + match from_ipptool { + Some(m) => m, + None => fetch_media_via_lpoptions(&state.config, &p.dest.name) + .await + .unwrap_or_default(), + } + }; + let resp = PrinterDetailResp { + name: p.dest.name.clone(), + state: format!("{}", p.state), + accepting_jobs: p.dest.is_accepting_jobs(), + active_jobs: p.active_jobs, + reasons: p.reasons, + healthy, + info: p.dest.info().cloned(), + location: p.dest.location().cloned(), + make_and_model: p.dest.make_and_model().cloned(), + uri: p.dest.uri().cloned(), + device_uri: p.dest.device_uri().cloned(), + options: p.dest.get_options().clone(), + media, + alerts, + }; + + Ok(Json(resp)) +} + +#[derive(Serialize, Default, Clone)] +struct MediaInfo { + ready: Vec, + supported: Vec, + default: Option, + sources: Vec, + types: Vec, + raw_ready_col: Option, +} + +fn parse_media_and_alerts( + options: &std::collections::HashMap, +) -> (Option, Vec) { + let mut media = None; + let mut m = MediaInfo::default(); + + if let Some(r) = options.get("media-ready") { + m.ready = split_list(r); + media = Some(m.clone()); + } + if let Some(r) = options.get("media-supported") { + m.supported = split_list(r); + media = Some(m.clone()); + } + if let Some(r) = options.get("media-default") { + m.default = Some(r.clone()); + media = Some(m.clone()); + } + if let Some(r) = options.get("media-source-supported") { + m.sources = split_list(r); + media = Some(m.clone()); + } + if let Some(r) = options.get("media-type-supported") { + m.types = split_list(r); + media = Some(m.clone()); + } + if let Some(r) = options.get("media-col-ready") { + m.raw_ready_col = Some(r.clone()); + media = Some(m.clone()); + } + + let mut alerts = Vec::new(); + if let Some(a) = options.get("printer-alert") { + alerts.extend(split_list(a)); + } + if let Some(a) = options.get("printer-alert-description") { + alerts.extend(split_list(a)); + } + + (media, alerts) +} + +fn split_list(s: &str) -> Vec { + s.split(',') + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .collect() +} + +async fn fetch_media_via_lpoptions(config: &AppConfig, printer: &str) -> Option { + let server_env = std::env::var("CUPS_SERVER").ok(); + let server = config + .cups + .server + .as_ref() + .map(|s| s.as_str()) + .or_else(|| server_env.as_deref()) + .unwrap_or("localhost:631"); + + let server_arg = format!("-h{}", server); + let printer_arg = format!("-p{}", printer); + + let output_detail = tokio::task::spawn_blocking({ + let server_arg = server_arg.clone(); + let printer_arg = printer_arg.clone(); + move || { + std::process::Command::new("lpoptions") + .args([server_arg.as_str(), printer_arg.as_str(), "-l"]) + .output() + } + }) + .await; + + let output_defaults = tokio::task::spawn_blocking(move || { + std::process::Command::new("lpoptions") + .args([server_arg.as_str(), printer_arg.as_str()]) + .output() + }) + .await; + + let detail = match output_detail { + Ok(Ok(o)) => o, + _ => return None, + }; + if !detail.status.success() { + return None; + } + + let defaults_opt = match output_defaults { + Ok(Ok(o)) if o.status.success() => Some(o), + _ => None, + }; + + let text = String::from_utf8_lossy(&detail.stdout); + let mut media = MediaInfo::default(); + + for line in text.lines() { + if line.starts_with("PageSize/") { + let tokens: Vec<&str> = line.split(':').nth(1).unwrap_or("").split_whitespace().collect(); + let mut supported = Vec::new(); + let mut default = None; + for t in tokens { + if t.starts_with('*') { + default = Some(t.trim_start_matches('*').to_string()); + supported.push(default.as_ref().unwrap().clone()); + } else { + supported.push(t.to_string()); + } + } + media.supported = supported; + media.default = default; + } else if line.starts_with("InputSlot/") { + let tokens: Vec<&str> = line.split(':').nth(1).unwrap_or("").split_whitespace().collect(); + let mut sources = Vec::new(); + for t in tokens { + sources.push(t.trim_start_matches('*').to_string()); + } + media.sources = sources; + } else if line.starts_with("MediaType/") { + let tokens: Vec<&str> = line.split(':').nth(1).unwrap_or("").split_whitespace().collect(); + let mut types = Vec::new(); + for t in tokens { + types.push(t.trim_start_matches('*').to_string()); + } + media.types = types; + } + } + + if let Some(o) = defaults_opt { + let defaults_line = String::from_utf8_lossy(&o.stdout); + for token in defaults_line.split_whitespace() { + if let Some(rest) = token.strip_prefix("InputSlot=") { + media.ready = vec![rest.to_string()]; + } + } + } + + Some(media) +} + +async fn fetch_media_via_ipptool(_config: &AppConfig, uri: &str) -> Option { + if uri.is_empty() { + return None; + } + + let uri = uri.to_string(); + let output = tokio::task::spawn_blocking(move || { + std::process::Command::new("ipptool") + .args(["-v", "-t", uri.as_str(), "get-printer-attributes.test"]) + .output() + }) + .await + .ok()?; + + let output = match output { + Ok(o) => o, + Err(_) => return None, + }; + + if !output.status.success() { + return None; + } + + let mut text = String::new(); + text.push_str(&String::from_utf8_lossy(&output.stdout)); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + let mut media = MediaInfo::default(); + + for line in text.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("media-default (keyword) = ") { + media.default = Some(rest.to_string()); + } else if let Some(rest) = trimmed.strip_prefix("media-ready (1setOf keyword) = ") { + media.ready = split_list(rest); + } else if let Some(rest) = trimmed.strip_prefix("media-supported (1setOf keyword) = ") { + media.supported = split_list(rest); + } else if let Some(rest) = trimmed.strip_prefix("media-source-supported (1setOf keyword) = ") { + media.sources = split_list(rest); + } else if let Some(rest) = trimmed.strip_prefix("media-type-supported (1setOf keyword) = ") { + media.types = split_list(rest); + } else if let Some(rest) = trimmed.strip_prefix("media-col-ready (1setOf collection) = ") { + media.raw_ready_col = Some(rest.to_string()); + } + } + + if media.ready.is_empty() + && media.supported.is_empty() + && media.default.is_none() + && media.sources.is_empty() + && media.types.is_empty() + && media.raw_ready_col.is_none() + { + None + } else { + Some(media) + } +} + + diff --git a/src/scheduler.rs b/src/scheduler.rs new file mode 100644 index 0000000..a37de65 --- /dev/null +++ b/src/scheduler.rs @@ -0,0 +1,413 @@ +use std::{ + path::Path, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, +}; + +use anyhow::{anyhow, Result}; +use cups_rs::PrinterState; +use tokio::sync::RwLock; +use tokio::time::sleep; +use tracing::{info, warn}; + +use crate::{ + config::{LoadBalanceStrategy, RetryConfig}, + models::{PrintDefaults, PrintParams}, + printer::{PrintAdapter, PrintJobResult, PrinterInfo, SharedPrinterAdapter}, +}; + +pub struct Scheduler { + adapter: SharedPrinterAdapter, + strategy: LoadBalanceStrategy, + rr_cursor: AtomicUsize, + refresh_interval: Duration, + cache: std::sync::Arc>>, + default_ppm_bw: u32, + default_ppm_color: u32, + target_wait_minutes: Option, + printer_speeds: std::collections::HashMap, +} + +impl Scheduler { + pub fn new( + strategy: LoadBalanceStrategy, + refresh_interval_secs: u64, + adapter: SharedPrinterAdapter, + default_ppm_bw: u32, + default_ppm_color: u32, + target_wait_minutes: Option, + printer_speeds: std::collections::HashMap, + ) -> Self { + let cache = std::sync::Arc::new(RwLock::new(Vec::new())); + let refresh_interval = Duration::from_secs(refresh_interval_secs.max(1)); + let this = Self { + adapter: adapter.clone(), + strategy, + rr_cursor: AtomicUsize::new(0), + refresh_interval, + cache: cache.clone(), + default_ppm_bw, + default_ppm_color, + target_wait_minutes, + printer_speeds, + }; + + // 启动后台刷新循环 + tokio::spawn(Self::refresh_loop( + adapter, + cache, + refresh_interval, + )); + + // 启动时立即拉取一次,填充缓存 + let init_adapter = this.adapter.clone(); + let init_cache = this.cache.clone(); + tokio::spawn(async move { + match init_adapter.list_printers().await { + Ok(list) => { + *init_cache.write().await = list; + info!("启动时已获取打印机列表并写入缓存"); + } + Err(e) => warn!("启动时获取打印机列表失败: {}", e), + } + }); + + this + } + + async fn healthy_printers(&self) -> Result> { + let mut printers = self.cache.read().await.clone(); + + if printers.is_empty() { + match self.adapter.list_printers().await { + Ok(list) => { + *self.cache.write().await = list.clone(); + printers = list; + } + Err(e) => { + warn!("获取打印机失败: {}", e); + } + } + } + + Ok(printers + .into_iter() + .filter(|p| p.healthy()) + .collect::>()) + } + + fn order_printers(&self, printers: &[PrinterInfo]) -> Vec { + match self.strategy { + LoadBalanceStrategy::RoundRobin => { + if printers.is_empty() { + return Vec::new(); + } + let start = self.rr_cursor.fetch_add(1, Ordering::Relaxed) % printers.len(); + printers + .iter() + .cycle() + .skip(start) + .take(printers.len()) + .cloned() + .collect() + } + LoadBalanceStrategy::LeastQueued => { + let mut sorted = printers.to_vec(); + sorted.sort_by(|a, b| { + a.active_jobs + .cmp(&b.active_jobs) + .then(Self::state_rank(&a.state).cmp(&Self::state_rank(&b.state))) + }); + sorted + } + } +} + + pub async fn dispatch( + &self, + pdf_path: &Path, + params: &PrintParams, + defaults: &PrintDefaults, + retry: &RetryConfig, + ) -> Result<(PrintJobResult, usize)> { + self.dispatch_with_target(pdf_path, params, defaults, retry, None) + .await + } + + pub async fn dispatch_with_target( + &self, + pdf_path: &Path, + params: &PrintParams, + defaults: &PrintDefaults, + retry: &RetryConfig, + target_printer: Option<&str>, + ) -> Result<(PrintJobResult, usize)> { + let printers = self.healthy_printers().await?; + if printers.is_empty() { + return Err(anyhow!("没有可用的打印机")); + } + + let requested_color = params.color.unwrap_or(defaults.color); + + let ordered = if let Some(target) = target_printer { + let filtered: Vec = printers + .into_iter() + .filter(|p| p.dest.name == target) + .collect(); + if filtered.is_empty() { + return Err(anyhow!("指定的打印机不可用或不存在: {}", target)); + } + filtered + } else { + self.order_printers(&printers) + }; + + let ordered: Vec = ordered + .into_iter() + .filter(|p| match requested_color { + crate::models::ColorModeSetting::Color => p.color_supported, + _ => true, + }) + .collect(); + + if ordered.is_empty() { + return Err(anyhow!("没有满足色彩需求的打印机")); + }; + + let mut ordered = ordered; + ordered.sort_by(|a, b| { + let ca = self.cost_estimate(a, requested_color); + let cb = self.cost_estimate(b, requested_color); + ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal) + }); + + let max_attempts = retry.attempts.max(1).min(ordered.len()); + let mut last_err = None; + + for (idx, printer) in ordered.into_iter().enumerate().take(max_attempts) { + info!( + printer = %printer.dest.name, + strategy = ?self.strategy, + attempt = idx + 1, + "提交打印任务" + ); + + match self + .adapter + .submit_job(&printer.dest, pdf_path, params, defaults) + .await + { + Ok(job) => return Ok((job, idx)), + Err(e) => { + warn!( + printer = %printer.dest.name, + error = %e, + "提交失败,尝试下一个打印机" + ); + last_err = Some(e); + if idx + 1 < max_attempts { + sleep(retry.backoff()).await; + } + } + } + } + + Err(anyhow!( + "所有打印机提交失败: {}", + last_err + .map(|e| e.to_string()) + .unwrap_or_else(|| "未知原因".to_string()) + )) + } + + pub async fn healthy_snapshot(&self) -> Result> { + let printers = self.cache.read().await.clone(); + Ok(printers) + } + + pub async fn printer_detail(&self, name: &str, refresh: bool) -> Result { + if refresh { + if let Ok(list) = self.adapter.list_printers().await { + *self.cache.write().await = list; + } + } + + { + let cache = self.cache.read().await; + if let Some(p) = cache.iter().find(|p| p.dest.name == name).cloned() { + return Ok(p); + } + } + + let list = self.adapter.list_printers().await?; + *self.cache.write().await = list.clone(); + list.into_iter() + .find(|p| p.dest.name == name) + .ok_or_else(|| anyhow!("未找到打印机: {}", name)) + } + + fn state_rank(state: &PrinterState) -> u8 { + match state { + PrinterState::Idle => 0, + PrinterState::Processing => 1, + PrinterState::Stopped => 2, + PrinterState::Unknown => 3, + } + } + + async fn refresh_loop( + adapter: SharedPrinterAdapter, + cache: std::sync::Arc>>, + interval: Duration, + ) { + loop { + match adapter.list_printers().await { + Ok(list) => { + *cache.write().await = list; + } + Err(e) => warn!("刷新打印机列表失败: {}", e), + } + sleep(interval).await; + } + } + + fn effective_ppm(&self, printer: &PrinterInfo, color: crate::models::ColorModeSetting) -> f64 { + // override by name + if let Some(override_speed) = self.printer_speeds.get(&printer.dest.name) { + if matches!(color, crate::models::ColorModeSetting::Color) { + if let Some(ppm) = override_speed.ppm_color { + return ppm.max(1) as f64; + } + } + if let Some(ppm) = override_speed.ppm { + return ppm.max(1) as f64; + } + } + + if matches!(color, crate::models::ColorModeSetting::Color) { + if let Some(ppm) = printer.ppm_color { + return ppm.max(1) as f64; + } + } + + if let Some(ppm) = printer.ppm { + return ppm.max(1) as f64; + } + + // fall back to defaults + if matches!(color, crate::models::ColorModeSetting::Color) { + return self.default_ppm_color.max(1) as f64; + } + self.default_ppm_bw.max(1) as f64 + } + + fn cost_estimate(&self, printer: &PrinterInfo, color: crate::models::ColorModeSetting) -> f64 { + // 简化成本:活跃作业数 / 速度;假设每个作业页数相近 + let ppm = self.effective_ppm(printer, color); + (printer.active_jobs as f64 + 1.0) / ppm + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use async_trait::async_trait; + use cups_rs::{Destination, PrinterState}; + use std::sync::Arc; + use tokio::runtime::Runtime; + + #[derive(Clone)] + struct FakeAdapter; + + #[async_trait] + impl PrintAdapter for FakeAdapter { + async fn list_printers(&self) -> Result> { + Ok(vec![]) + } + + async fn submit_job( + &self, + _printer: &Destination, + _file_path: &Path, + _params: &PrintParams, + _defaults: &PrintDefaults, + ) -> Result { + unreachable!() + } + + async fn cancel(&self, _job_id: i32) -> Result<()> { + Ok(()) + } + } + + fn mock_printer(name: &str, active_jobs: usize) -> PrinterInfo { + PrinterInfo { + dest: Destination { + name: name.to_string(), + instance: None, + is_default: false, + options: Default::default(), + }, + state: PrinterState::Idle, + reasons: vec![], + active_jobs, + color_supported: true, + color_modes_supported: vec!["color".to_string(), "monochrome".to_string()], + ppm: Some(30), + ppm_color: Some(20), + } + } + + #[test] + fn test_least_queue_sorting() { + let rt = Runtime::new().unwrap(); + let ordered = rt.block_on(async { + let adapter: SharedPrinterAdapter = Arc::new(FakeAdapter); + let scheduler = Scheduler::new( + LoadBalanceStrategy::LeastQueued, + 60, + adapter, + 30, + 20, + None, + std::collections::HashMap::new(), + ); + scheduler.order_printers(&[ + mock_printer("b", 3), + mock_printer("a", 1), + mock_printer("c", 2), + ]) + }); + + let names: Vec = ordered.into_iter().map(|p| p.dest.name).collect(); + assert_eq!(names, vec!["a", "c", "b"]); + } + + #[test] + fn test_round_robin_rotation() { + let rt = Runtime::new().unwrap(); + let printers = vec![mock_printer("p1", 0), mock_printer("p2", 0)]; + + let (first, second) = rt.block_on(async { + let adapter: SharedPrinterAdapter = Arc::new(FakeAdapter); + let scheduler = Scheduler::new( + LoadBalanceStrategy::RoundRobin, + 60, + adapter, + 30, + 20, + None, + std::collections::HashMap::new(), + ); + + let first = scheduler.order_printers(&printers); + let second = scheduler.order_printers(&printers); + (first, second) + }); + + assert_eq!(first[0].dest.name, "p1"); + assert_eq!(second[0].dest.name, "p2"); + } +} +