commit aacda0b66a854d651091823217a99db3f91e89f4 Author: shenjianZ Date: Fri Feb 13 15:57:29 2026 +0800 first commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6d86e2a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo check:*)", + "Bash(cargo update:*)", + "Bash(find:*)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bb5cfe8 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# 环境变量参考配置 +# +# ⚠️ 重要提示: +# - 本项目不支持 .env 文件 +# - 开发环境和生产环境都请使用 config/ 目录下的 toml 配置文件 +# - 环境变量仅用于 Docker/Kubernetes/systemd 等部署场景 +# +# ============================================ + +# ============================================ +# CLI 参数环境变量 +# ============================================ +# 运行环境:development, production +# ENV=development + +# 调试模式:true, false +# DEBUG=false + +# 配置文件路径 +# CONFIG=config/production.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4fe702 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Rust +/target/ +**/*.rs.bk +*.pdb + + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Config (不要提交敏感配置) +.env +db.sqlite* +data +API_FLOW* +SEAORM* diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..44e55af --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3613 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[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 = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "arc-swap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[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 2.0.115", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[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", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "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 = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23738e11972c7643e4ec947840fc463b6a571afcd3e735bdfce7d03c7a784aca" +dependencies = [ + "async-trait", + "json5", + "lazy_static", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.115", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[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 = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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 = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[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-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[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 = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +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 = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "inherent" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.7.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[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 = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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 = "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-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pgvector" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" +dependencies = [ + "serde", +] + +[[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 = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "version_check", + "yansi", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +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 = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "backon", + "bytes", + "combine", + "futures", + "futures-util", + "itertools", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ron" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" +dependencies = [ + "base64 0.13.1", + "bitflags 1.3.2", + "serde", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-bae" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25" +dependencies = [ + "heck 0.4.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "sea-orm" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d945f62558fac19e5988680d2fdf747b734c2dbc6ce2cb81ba33ed8dde5b103" +dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", + "chrono", + "derive_more", + "futures-util", + "log", + "ouroboros", + "pgvector", + "rust_decimal", + "sea-orm-macros", + "sea-query", + "sea-query-binder", + "serde", + "serde_json", + "sqlx", + "strum", + "thiserror 2.0.18", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sea-orm-macros" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c2e64a50a9cc8339f10a27577e10062c7f995488e469f2c95762c5ee847832" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.115", + "unicode-ident", +] + +[[package]] +name = "sea-query" +version = "0.32.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" +dependencies = [ + "bigdecimal", + "chrono", + "inherent", + "ordered-float", + "rust_decimal", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "sea-query-binder" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" +dependencies = [ + "bigdecimal", + "chrono", + "rust_decimal", + "sea-query", + "serde_json", + "sqlx", + "time", + "uuid", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[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 2.0.115", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +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" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bigdecimal", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.115", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.115", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bigdecimal", + "bitflags 2.10.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bigdecimal", + "bitflags 2.10.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "num-bigint", + "once_cell", + "rand", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +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 = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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 2.0.115", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[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 = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "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 2.0.115", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +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 2.10.0", + "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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +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 2.0.115", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +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 = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.115", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-rust-template" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "clap", + "config", + "jsonwebtoken", + "rand", + "redis", + "sea-orm", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tower 0.4.13", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[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 2.0.115", +] + +[[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 2.0.115", +] + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[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 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3b3bf76 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "web-rust-template" +version = "0.1.0" +edition = "2021" + +[dependencies] +# ===== Web 框架 ===== +axum = "0.7" +tokio = { version = "1", features = ["full"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "trace"] } + +# ===== 数据库(支持 MySQL、SQLite、PostgreSQL) ===== +# SeaORM - 数据库 ORM(替代 SQLX 直接使用) +sea-orm = { version = "1.1", features = [ + "runtime-tokio-rustls", + "sqlx-mysql", + "sqlx-sqlite", + "sqlx-postgres", + "macros", + "with-chrono", + "with-uuid", +] } +# ===== 序列化 ===== +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# ===== 认证与加密 ===== +jsonwebtoken = "9" +argon2 = "0.5" +sha2 = "0.10" +base64 = "0.22" + +# ===== Redis ===== +redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } + +# ===== 工具库 ===== +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +anyhow = "1" +thiserror = "1" +async-trait = "0.1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +config = "0.13" +rand = "0.8" +clap = { version = "4", features = ["derive", "env"] } + +# 优化发布版本 +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c7cac3 --- /dev/null +++ b/README.md @@ -0,0 +1,218 @@ +# Web Rust Template + +基于 Rust + Axum 0.7 的生产级 Web 服务器模板,采用 DDD 分层架构设计。 + +## 核心特性 + +### 架构特色 +- **DDD 分层架构**:领域层、基础设施层、应用层清晰分离 +- **生产就绪**:JWT 双 Token 认证、Argon2 密码哈希、结构化日志 +- **多数据库支持**:MySQL / PostgreSQL / SQLite 无缝切换 + +### 技术栈 +- **Web 框架**:Axum 0.7 + Tokio +- **数据库 ORM**:SeaORM 1.1(支持多数据库) +- **认证**:JWT (Access Token 15min + Refresh Token 7天) +- **缓存**:Redis 存储 Refresh Token +- **安全**:Argon2 密码哈希、CORS 支持 + +## 快速开始 + +### 1. 克隆并安装依赖 + +```bash +git clone +cd web-rust-template +cargo build +``` + +### 2. 配置项目 + +**使用默认配置(SQLite,最简单)**: + +无需配置,直接运行即可: +```bash +cargo run +``` + +**使用 MySQL/PostgreSQL**: + +复制对应的配置文件并修改: +```bash +# 使用 MySQL +cp config/development.mysql.toml config/local.toml + +# 或使用 PostgreSQL +cp config/development.postgresql.toml config/local.toml + +# 编辑 config/local.toml,修改数据库连接信息 +# 然后运行 +cargo run -- -c config/local.toml +``` + +### 3. 运行服务 + +```bash +# 使用默认配置(SQLite) +cargo run + +# 或使用指定配置文件 +cargo run -- -c config/local.toml +``` + +服务将在 http://localhost:3000 启动 + +## 快速测试 + +### 用户注册 + +```bash +curl -X POST http://localhost:3000/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "password": "password123"}' +``` + +响应: + +```json +{ + "code": 200, + "message": "success", + "data": { + "email": "user@example.com", + "created_at": "2026-02-13T12:00:00.000Z", + "access_token": "eyJ...", + "refresh_token": "eyJ..." + } +} +``` + +> 查看 [完整 API 文档](docs/api/api-overview.md) 了解所有接口 + +## 核心配置 + +### 配置方式 + +**开发环境**: + +使用 `config/` 目录下的配置文件: + +```bash +# SQLite(默认) +cargo run + +# MySQL +cp config/development.mysql.toml config/local.toml +# 编辑 config/local.toml +cargo run -- -c config/local.toml + +# PostgreSQL +cp config/development.postgresql.toml config/local.toml +# 编辑 config/local.toml +cargo run -- -c config/local.toml +``` + +**生产环境**: + +使用环境变量或配置文件: + +```bash +# 使用环境变量 +DATABASE_TYPE=postgresql DATABASE_HOST=localhost cargo run -- -e production + +# 或使用配置文件 +cargo run -- -e production -c config/production.toml +``` + +> 查看 [完整配置文档](docs/deployment/configuration.md) 或 [环境变量配置](docs/deployment/environment-variables.md) + +## API 接口概览 + +### 公开接口 + +- `GET /health` - 健康检查 +- `GET /info` - 服务器信息 +- `POST /auth/register` - 用户注册 +- `POST /auth/login` - 用户登录 +- `POST /auth/refresh` - 刷新 Token + +### 需要认证的接口 + +- `POST /auth/delete` - 删除账号 +- `POST /auth/delete-refresh-token` - 删除 Refresh Token + +> 查看 [完整 API 文档](docs/api/api-overview.md) + +## 项目结构 + +``` +src/ +├── main.rs # 入口文件 +├── config/ # 配置模块 +│ ├── app.rs +│ ├── auth.rs +│ ├── database.rs +│ └── redis.rs +├── domain/ # 领域层(DDD) +│ ├── dto/ # 数据传输对象 +│ ├── entities/ # 实体 +│ └── vo/ # 视图对象 +├── handlers/ # HTTP 处理器层 +├── services/ # 业务逻辑层 +├── repositories/ # 数据访问层 +└── infra/ # 基础设施层 + ├── middleware/ # 中间件 + └── redis/ # Redis 客户端 +``` + +> 查看 [完整项目结构文档](docs/development/project-structure.md) + +## 技术栈 + +| 组件 | 技术 | 版本 | +|------|------|------| +| Web 框架 | Axum | 0.7 | +| 异步运行时 | Tokio | 1.x | +| 数据库 ORM | SeaORM | 1.1 | +| 认证 | JWT | 9.x | +| 密码哈希 | Argon2 | 0.5 | +| 缓存 | Redis | 0.27 | +| 日志 | tracing | 0.1 | + +## 文档导航 + +- [API 接口文档](docs/api/api-overview.md) - 完整的 API 接口说明和示例 +- [快速开始指南](docs/development/getting-started.md) - 详细的安装和配置指南 +- [开发规范](docs/development/ddd-architecture.md) - DDD 架构和代码规范 +- [部署文档](docs/deployment/configuration.md) - 配置和部署指南 + +## 日志格式 + +日志采用三段式结构: + +1. 📥 **请求开始**:显示请求方法和路径 +2. 🔧 **请求处理**:显示请求参数和响应内容 +3. ✅ **请求完成**:显示状态码和耗时 + +示例: + +``` +================================================================================ +GET /health +================================================================================ +[uuid-...] 📥 查询参数: 无 | 时间: 2026-02-12 13:30:45.123 +[uuid-...] ✅ 状态码: 200 | 耗时: 5ms +================================================================================ +``` + +## 安全特性 + +- ✅ 密码使用 Argon2 哈希 +- ✅ JWT Token 认证 +- ✅ Refresh Token 轮换机制 +- ✅ Token 过期时间可配置 +- ✅ 密码验证后才删除账号 + +## 许可证 + +MIT diff --git a/config/development.toml b/config/development.toml new file mode 100644 index 0000000..5b0be82 --- /dev/null +++ b/config/development.toml @@ -0,0 +1,33 @@ +# 开发环境配置 - SQLite 数据库 +[server] +host = "0.0.0.0" +port = 3000 + +[database] +# 数据库类型: mysql, sqlite, postgresql +database_type = "sqlite" + +# MySQL/PostgreSQL 配置 +# host = "localhost" +# port = 3306 +# user = "root" +# password = "root" +# database = "web_template" + +# SQLite 配置(当 database_type = "sqlite" 时使用) +path = "data/app.db" + +# 连接池配置 +max_connections = 10 + +[auth] +jwt_secret = "9f7d3c7a564dfkopp26smb2644nqzfvbsao9f7d3c7a1a8f28544b5e6d7a" +# 分开配置两个 token 的过期时间 +access_token_expiration_minutes = 15 # access_token 15 分钟 +refresh_token_expiration_days = 7 # refresh_token 7 天 + +[redis] +host = "localhost" +port = 6379 +password = "" # 可选 +db = 0 diff --git a/config/production.toml b/config/production.toml new file mode 100644 index 0000000..e67f1b5 --- /dev/null +++ b/config/production.toml @@ -0,0 +1,33 @@ +# 生产环境配置 - PostgreSQL 数据库 + +[server] +host = "0.0.0.0" # 服务器监听地址(0.0.0.0=允许所有网络访问) +port = 3000 # 服务器监听端口(确保防火墙已开放) + +[database] +database_type = "postgresql" # 数据库类型:sqlite/mysql/postgresql +host = "localhost" # PostgreSQL 服务器地址 +port = 5432 # PostgreSQL 端口(默认 5432) +user = "postgres" # PostgreSQL 用户名(请创建专用用户) +password = "postgres" # PostgreSQL 密码(请修改为强密码) +database = "web_template" # 数据库名称(不存在会自动创建) +max_connections = 20 # 最大连接数(生产环境建议 20-100) + +[auth] +jwt_secret = "9f7d3c7a564dfkopp26smb2644nqzfvbsao9f7d3c7a1a8f28544b5e6d7a" # JWT 签名密钥(必须修改为强随机字符串!) +access_token_expiration_minutes = 15 # Access Token 过期时间(分钟) +refresh_token_expiration_days = 7 # Refresh Token 过期时间(天) + +[redis] +host = "localhost" # Redis 服务器地址 +port = 6379 # Redis 端口(默认 6379) +password = "" # Redis 密码(强烈建议设置密码) +db = 0 # Redis 数据库编号(0-15) + +# 安全检查清单:部署前请确认 +# ✅ 1. 已修改 jwt_secret 为强随机字符串 +# ✅ 2. 已修改数据库密码为强密码 +# ✅ 3. 已设置 Redis 密码 +# ✅ 4. 已配置防火墙规则 +# ✅ 5. 已启用 HTTPS(使用 Nginx/Caddy 等反向代理) +# ✅ 6. 已设置数据库定期备份 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..9129523 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,116 @@ +# Web Rust Template 文档中心 + +欢迎使用 Web Rust Template 文档!本模板项目提供了生产级的 Rust Web 服务器基础架构,采用 DDD 分层设计,包含完整的认证、数据库、缓存等功能。 + +## 快速导航 + +### 按角色查找文档 + +#### 前端开发者 +- [API 接口概览](api/api-overview.md) - 快速了解所有可用的 API 接口 +- [公开接口文档](api/endpoints/public.md) - 注册、登录等公开接口的详细说明 +- [前端集成示例](api/examples/frontend-integration.md) - JavaScript/TypeScript/React/Vue 集成代码示例 +- [认证机制详解](api/authentication.md) - JWT 认证流程和最佳实践 + +#### 后端开发者 +- [快速开始指南](development/getting-started.md) - 安装、配置和运行项目 +- [项目结构详解](development/project-structure.md) - DDD 分层架构说明 +- [DDD 架构规范](development/ddd-architecture.md) - 各层设计原则和开发规范 +- [代码风格规范](development/code-style.md) - Rust 代码风格和命名规范 +- [Git 提交规范](development/git-workflow.md) - 提交信息规范和分支策略 +- [测试规范](development/testing.md) - 单元测试和集成测试指南 + +#### 运维人员 +- [环境变量配置](deployment/environment-variables.md) - 完整的环境变量列表和说明 +- [配置文件详解](deployment/configuration.md) - 多环境配置文件组织 +- [生产环境部署指南](deployment/production-guide.md) - 安全配置和部署最佳实践 + +## 文档结构 + +``` +docs/ +├── README.md # 本文档 +├── api/ # API 接口文档 +│ ├── api-overview.md # API 概览和快速参考 +│ ├── authentication.md # 认证机制详解 +│ ├── endpoints/ +│ │ ├── public.md # 公开接口 +│ │ └── protected.md # 需要认证的接口 +│ └── examples/ +│ └── frontend-integration.md # 前端集成代码示例 +├── development/ # 开发指南 +│ ├── getting-started.md # 快速开始 +│ ├── project-structure.md # 项目结构详解 +│ ├── ddd-architecture.md # DDD 分层架构规范 +│ ├── code-style.md # 代码风格和命名规范 +│ ├── git-workflow.md # Git 提交规范 +│ └── testing.md # 测试规范 +└── deployment/ # 部署文档 + ├── environment-variables.md # 环境变量配置说明 + ├── configuration.md # 配置文件详解 + └── production-guide.md # 生产环境部署指南 +``` + +## 核心概念 + +### DDD 分层架构 + +本项目采用领域驱动设计(DDD)分层架构: + +``` +┌─────────────────────────────────────┐ +│ Interface Layer (handlers) │ HTTP 处理器层 +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Application Layer (services) │ 业务逻辑层 +└──────────────┬──────────────────────┘ + │ + ┌───────┴────────┐ + │ │ +┌──────▼──────┐ ┌─────▼──────────┐ +│ Domain │ │ Infrastructure│ +│ Layer │ │ Layer │ +└─────────────┘ └────────────────┘ +``` + +### 双 Token 认证机制 + +- **Access Token**:15 分钟有效期,用于 API 请求认证 +- **Refresh Token**:7 天有效期,存储在 Redis,用于获取新的 Access Token +- **Token 轮换**:每次刷新会生成新的 Refresh Token,旧 Token 自动失效 + +### 多数据库支持 + +支持 MySQL、PostgreSQL、SQLite 三种数据库,通过简单的环境变量配置即可切换。 + +## 技术栈 + +| 组件 | 技术 | 版本 | +|------|------|------| +| Web 框架 | Axum | 0.7 | +| 异步运行时 | Tokio | 1.x | +| 数据库 ORM | SeaORM | 1.1 | +| 认证 | JWT | 9.x | +| 密码哈希 | Argon2 | 0.5 | +| 缓存 | Redis | 0.27 | +| 日志 | tracing | 0.1 | + +## 快速链接 + +- [项目 README](../README.md) - 返回项目主页 +- [API 接口文档](api/api-overview.md) - 完整的 API 接口说明 +- [快速开始指南](development/getting-started.md) - 安装和配置指南 +- [开发规范](development/ddd-architecture.md) - DDD 架构和代码规范 +- [部署文档](deployment/configuration.md) - 配置和部署指南 + +## 获取帮助 + +如果您在阅读文档时有任何疑问,请: +1. 查看相关主题的详细文档 +2. 检查 [常见问题](deployment/production-guide.md#常见问题) +3. 提交 Issue 到项目仓库 + +--- + +**提示**:建议按照"快速开始指南"→"API 接口文档"→"开发规范"的顺序阅读文档。 diff --git a/docs/api/api-overview.md b/docs/api/api-overview.md new file mode 100644 index 0000000..2e76b0c --- /dev/null +++ b/docs/api/api-overview.md @@ -0,0 +1,185 @@ +# API 接口概览 + +本文档提供所有 API 接口的快速参考。 + +## 基础信息 + +### Base URL + +``` +开发环境: http://localhost:3000 +生产环境: https://api.yourdomain.com +``` + +### 认证方式 + +本 API 使用 JWT(JSON Web Token)进行认证: + +- **Access Token**:有效期 15 分钟,用于 API 请求认证 +- **Refresh Token**:有效期 7 天,用于获取新的 Access Token + +### 认证 Header 格式 + +```http +Authorization: Bearer +``` + +### 响应格式 + +所有接口返回统一的 JSON 格式: + +**成功响应**: +```json +{ + "code": 200, + "message": "Success", + "data": { } +} +``` + +**错误响应**: +```json +{ + "code": 400, + "message": "错误信息", + "data": null +} +``` + +### 通用错误码 + +| 错误码 | 说明 | +|-------|------| +| 200 | 成功 | +| 400 | 请求参数错误 | +| 401 | 未授权(Token 无效或过期) | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | + +## 接口列表 + +### 公开接口(无需认证) + +| 方法 | 路径 | 说明 | 详细文档 | +|------|------|------|----------| +| GET | `/health` | 健康检查 | [查看详情](endpoints/public.md#get-health) | +| GET | `/info` | 服务器信息 | [查看详情](endpoints/public.md#get-info) | +| POST | `/auth/register` | 用户注册 | [查看详情](endpoints/public.md#post-authregister) | +| POST | `/auth/login` | 用户登录 | [查看详情](endpoints/public.md#post-authlogin) | +| POST | `/auth/refresh` | 刷新 Token | [查看详情](endpoints/public.md#post-authrefresh) | + +### 需要认证的接口 + +| 方法 | 路径 | 说明 | 详细文档 | +|------|------|------|----------| +| POST | `/auth/delete` | 删除账号 | [查看详情](endpoints/protected.md#post-authdelete) | +| POST | `/auth/delete-refresh-token` | 删除 Refresh Token | [查看详情](endpoints/protected.md#post-authdelete-refresh-token) | + +## 认证流程简述 + +### 1. 注册/登录 + +用户注册或登录成功后,会返回 Access Token 和 Refresh Token: + +```json +{ + "code": 200, + "message": "Success", + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs..." + } +} +``` + +### 2. 使用 Access Token + +将 Access Token 添加到请求头: + +```http +GET /auth/delete +Authorization: Bearer eyJhbGciOiJIUzI1NiIs... +``` + +### 3. 刷新 Token + +当 Access Token 过期时,使用 Refresh Token 获取新的 Token: + +```bash +POST /auth/refresh +Content-Type: application/json + +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +> 查看 [认证机制详解](authentication.md) 了解完整流程 + +## 快速示例 + +### 注册用户 + +```bash +curl -X POST http://localhost:3000/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "password123" + }' +``` + +### 用户登录 + +```bash +curl -X POST http://localhost:3000/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "password123" + }' +``` + +### 访问受保护接口 + +```bash +curl -X POST http://localhost:3000/auth/delete \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "user_id": "1234567890", + "password": "password123" + }' +``` + +### 健康检查 + +```bash +curl http://localhost:3000/health +``` + +## 详细文档 + +- [公开接口详细文档](endpoints/public.md) - 所有公开接口的详细说明 +- [受保护接口详细文档](endpoints/protected.md) - 需要认证的接口详细说明 +- [认证机制详解](authentication.md) - JWT 认证流程和安全最佳实践 +- [前端集成示例](examples/frontend-integration.md) - JavaScript/TypeScript/React/Vue 集成代码示例 + +## 相关文档 + +- [快速开始指南](../development/getting-started.md) - 安装和运行项目 +- [环境变量配置](../deployment/environment-variables.md) - 配置 API 服务器 +- [前端集成指南](examples/frontend-integration.md) - 前端开发集成示例 + +## 获取帮助 + +如果您在使用 API 时遇到问题: + +1. 检查请求格式是否正确 +2. 确认 Token 是否有效(未过期) +3. 查看日志输出获取详细错误信息 +4. 参考 [认证机制详解](authentication.md) 了解认证流程 + +--- + +**提示**:建议使用 Postman、Insomnia 或类似工具测试 API 接口。 diff --git a/docs/api/authentication.md b/docs/api/authentication.md new file mode 100644 index 0000000..88f505d --- /dev/null +++ b/docs/api/authentication.md @@ -0,0 +1,608 @@ +# 认证机制详解 + +本文档详细说明 Web Rust Template 的 JWT 认证机制、安全特性和最佳实践。 + +## 目录 + +- [认证架构概述](#认证架构概述) +- [双 Token 机制](#双-token-机制) +- [认证流程](#认证流程) +- [Token 管理](#token-管理) +- [安全特性](#安全特性) +- [最佳实践](#最佳实践) + +--- + +## 认证架构概述 + +本系统采用 **JWT (JSON Web Token)** 进行用户认证,使用 **双 Token 机制**: + +1. **Access Token**:短期有效,用于 API 请求认证 +2. **Refresh Token**:长期有效,用于获取新的 Access Token + +### 架构特点 + +- ✅ **无状态认证**:服务器不存储会话信息,易于扩展 +- ✅ **安全性**:Token 泄露影响可控,自动过期 +- ✅ **用户体验**:Refresh Token 可减少用户登录次数 +- ✅ **可撤销性**:通过 Redis 存储 Refresh Token,支持主动撤销 + +--- + +## 双 Token 机制 + +### Access Token + +**用途**:访问需要认证的 API 接口 + +**特点**: +- 有效期:15 分钟(可配置) +- 包含用户 ID 和 Token 类型信息 +- 不存储在服务器端(无状态) +- 每次请求都通过 HTTP Header 传递 + +**格式**: +```http +Authorization: Bearer +``` + +### Refresh Token + +**用途**:获取新的 Access Token + +**特点**: +- 有效期:7 天(可配置) +- 存储在 Redis 中 +- 支持撤销和主动登出 +- 每次刷新会生成新的 Refresh Token + +**存储位置**: +- 前端:localStorage 或 sessionStorage +- 后端:Redis(Key:`auth:refresh_token:`) + +--- + +## 认证流程 + +### 1. 用户注册流程 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Frontend as 前端应用 + participant API as API 服务器 + participant DB as 数据库 + participant Redis as Redis + + User->>Frontend: 输入邮箱和密码 + Frontend->>API: POST /auth/register + API->>API: 验证邮箱格式 + API->>API: 生成用户 ID + API->>API: 哈希密码(Argon2) + API->>DB: 创建用户记录 + DB-->>API: 用户创建成功 + API->>API: 生成 Access Token (15min) + API->>API: 生成 Refresh Token (7days) + API->>Redis: 存储 Refresh Token + Redis-->>API: 存储成功 + API-->>Frontend: 返回 Access Token + Refresh Token + Frontend->>Frontend: 存储 Token 到 localStorage + Frontend-->>User: 注册成功,自动登录 +``` + +**关键点**: +- 密码使用 Argon2 算法哈希,不可逆 +- Refresh Token 存储在 Redis,支持撤销 +- 注册成功后自动登录,返回 Token + +### 2. 用户登录流程 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Frontend as 前端应用 + participant API as API 服务器 + participant DB as 数据库 + participant Redis as Redis + + User->>Frontend: 输入邮箱和密码 + Frontend->>API: POST /auth/login + API->>DB: 查询用户记录 + DB-->>API: 返回用户信息 + API->>API: 验证密码(Argon2) + API->>API: 生成 Access Token (15min) + API->>API: 生成 Refresh Token (7days) + API->>Redis: 存储/更新 Refresh Token + Redis-->>API: 存储成功 + API-->>Frontend: 返回 Access Token + Refresh Token + Frontend->>Frontend: 存储 Token 到 localStorage + Frontend-->>User: 登录成功 +``` + +**安全特性**: +- 登录失败不返回具体错误信息(防止账号枚举) +- 密码错误会记录日志用于风控 +- Refresh Token 每次登录都会更新 + +### 3. 访问受保护接口流程 + +```mermaid +sequenceDiagram + participant Frontend as 前端应用 + participant API as API 服务器 + participant Redis as Redis + + Frontend->>API: GET /protected
Authorization: Bearer + API->>API: 验证 JWT 签名 + API->>API: 检查 Token 类型 + API->>API: 检查 Token 过期时间 + alt Token 有效 + API-->>Frontend: 200 OK 返回数据 + else Token 无效或过期 + API-->>Frontend: 401 Unauthorized + Frontend->>API: POST /auth/refresh + API->>Redis: 获取 Refresh Token + Redis-->>API: 返回 Refresh Token + API->>API: 验证 Refresh Token + API->>API: 生成新的 Token 对 + API->>Redis: 更新 Refresh Token + API-->>Frontend: 返回新的 Token + Frontend->>API: 重试原请求 + API-->>Frontend: 200 OK 返回数据 + end +``` + +**关键点**: +- 所有受保护接口都需要在 Header 中携带 Access Token +- Token 过期时前端自动刷新并重试请求 +- 刷新成功后旧 Refresh Token 立即失效 + +### 4. Token 刷新流程 + +```mermaid +sequenceDiagram + participant Frontend as 前端应用 + participant API as API 服务器 + participant Redis as Redis + + Frontend->>API: POST /auth/refresh
{"refresh_token": "..."} + API->>API: 验证 Refresh Token 签名 + API->>API: 检查 Token 类型(必须是 Refresh Token) + API->>API: 检查 Token 过期时间 + API->>Redis: 检查 Refresh Token 是否存在 + alt Token 有效 + API->>API: 生成新的 Access Token (15min) + API->>API: 生成新的 Refresh Token (7days) + API->>Redis: 删除旧的 Refresh Token + API->>Redis: 存储新的 Refresh Token + API-->>Frontend: 返回新的 Token 对 + else Token 无效或过期 + API-->>Frontend: 401 Unauthorized + Frontend->>Frontend: 清除 Token + Frontend->>Frontend: 跳转到登录页 + end +``` + +**Token 轮换**: +- 每次刷新都会生成新的 Refresh Token +- 旧的 Refresh Token 立即失效 +- 防止 Token 重放攻击 + +### 5. 用户登出流程 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Frontend as 前端应用 + participant API as API 服务器 + participant Redis as Redis + + User->>Frontend: 点击登出按钮 + Frontend->>API: POST /auth/delete-refresh-token
Authorization: Bearer + API->>API: 验证 Access Token + API->>API: 从 Token 中提取 user_id + API->>Redis: 删除 Refresh Token + Redis-->>API: 删除成功 + API-->>Frontend: 200 OK + Frontend->>Frontend: 清除本地 Token + Frontend->>Frontend: 跳转到登录页 + Frontend-->>User: 登出成功 +``` + +--- + +## Token 管理 + +### Token 生成 + +```rust +// src/utils/jwt.rs + +// 生成 Access Token +pub fn generate_access_token( + user_id: &str, + expiration_minutes: u64, + jwt_secret: &str, +) -> Result { + let expiration = Utc::now() + .checked_add_signed(Duration::minutes(expiration_minutes as i64)) + .expect("invalid expiration timestamp") + .timestamp() as usize; + + let claims = Claims { + sub: user_id.to_string(), + exp: expiration, + token_type: TokenType::Access, + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(jwt_secret.as_ref()), + )?; + + Ok(token) +} + +// 生成 Refresh Token +pub fn generate_refresh_token( + user_id: &str, + expiration_days: i64, + jwt_secret: &str, +) -> Result { + let expiration = Utc::now() + .checked_add_signed(Duration::days(expiration_days)) + .expect("invalid expiration timestamp") + .timestamp() as usize; + + let claims = Claims { + sub: user_id.to_string(), + exp: expiration, + token_type: TokenType::Refresh, + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(jwt_secret.as_ref()), + )?; + + Ok(token) +} +``` + +### Token 验证 + +```rust +// src/infra/middleware/auth.rs + +pub async fn auth_middleware( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + // 1. 提取 Authorization header + let auth_header = request + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .ok_or_else(|| ErrorResponse::new("缺少 Authorization header".to_string()))?; + + // 2. 验证 Bearer 格式 + if !auth_header.starts_with("Bearer ") { + return Err(ErrorResponse::new("Authorization header 格式错误".to_string())); + } + + let token = &auth_header[7..]; // 跳过 "Bearer " + + // 3. 验证 JWT 签名和过期时间 + let claims = decode_token(token, &state.config.auth.jwt_secret)?; + + // 4. 检查 Token 类型(必须是 Access Token) + if claims.token_type != TokenType::Access { + return Err(ErrorResponse::new("Token 类型错误".to_string())); + } + + // 5. 将 user_id 添加到请求扩展中 + let user_id = claims.sub; + request.extensions_mut().insert(user_id); + + // 6. 继续处理请求 + Ok(next.run(request).await) +} +``` + +### Refresh Token 存储 + +```rust +// src/services/auth_service.rs + +async fn save_refresh_token(&self, user_id: &str, refresh_token: &str, expiration_days: i64) -> Result<()> { + let key = RedisKey::new(BusinessType::Auth) + .add_identifier("refresh_token") + .add_identifier(user_id); + + let expiration_seconds = expiration_days * 24 * 3600; + + self.redis_client + .set_ex(&key.build(), refresh_token, expiration_seconds as u64) + .await + .map_err(|e| anyhow::anyhow!("Redis 保存失败: {}", e))?; + + Ok(()) +} +``` + +**Redis Key 设计**: +``` +auth:refresh_token: +``` + +**过期时间**:7 天(与 Refresh Token 有效期一致) + +--- + +## 安全特性 + +### 1. 密码安全 + +**Argon2 哈希**: +- 使用 Argon2 算法(内存 hard,抗 GPU/ASIC 破解) +- 自动生成随机盐值 +- 哈希结果不可逆 + +```rust +// src/services/auth_service.rs + +pub fn hash_password(&self, password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!("密码哈希失败: {}", e))? + .to_string(); + Ok(password_hash) +} +``` + +### 2. JWT 安全 + +**签名算法**:HS256 (HMAC-SHA256) + +**Claims 结构**: +```rust +pub struct Claims { + pub sub: String, // 用户 ID + pub exp: usize, // 过期时间(Unix 时间戳) + pub token_type: TokenType, // Token 类型(Access/Refresh) +} +``` + +**安全措施**: +- 使用强密钥(至少 32 位随机字符串) +- Token 包含过期时间 +- Token 类型区分(防止混用) +- 签名验证防止篡改 + +### 3. Refresh Token 安全 + +**存储安全**: +- 存储在 Redis 中,支持快速撤销 +- 每次刷新生成新 Token,旧 Token 失效 +- 支持主动登出,删除 Refresh Token + +**使用限制**: +- Refresh Token 只能使用一次 +- 过期后无法续期 +- 需要重新登录 + +### 4. 防护措施 + +**防重放攻击**: +- Refresh Token 单次使用 +- 刷新后立即失效 + +**防 Token 泄露**: +- Access Token 短期有效(15 分钟) +- 只通过 HTTPS 传输 +- 不在 URL 中传递 + +**防暴力破解**: +- 限制登录频率(可选实现) +- 记录失败尝试(日志) +- 密码哈希使用 Argon2 + +--- + +## 最佳实践 + +### 前端集成 + +#### 1. Token 存储 + +**推荐方案**: +```typescript +// 存储 Token +localStorage.setItem('access_token', access_token); +localStorage.setItem('refresh_token', refresh_token); + +// 读取 Token +const accessToken = localStorage.getItem('access_token'); +const refreshToken = localStorage.getItem('refresh_token'); + +// 清除 Token +localStorage.removeItem('access_token'); +localStorage.removeItem('refresh_token'); +``` + +#### 2. 请求拦截器 + +```typescript +// 添加 Token 到请求头 +api.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('access_token'); + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; +}); +``` + +#### 3. 响应拦截器(自动刷新 Token) + +```typescript +// 处理 401 错误并自动刷新 +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = localStorage.getItem('refresh_token'); + const response = await axios.post('/auth/refresh', { + refresh_token: refreshToken, + }); + + const { access_token, refresh_token } = response.data.data; + + localStorage.setItem('access_token', access_token); + localStorage.setItem('refresh_token', refresh_token); + + // 重试原请求 + originalRequest.headers.Authorization = `Bearer ${access_token}`; + return axios(originalRequest); + } catch (refreshError) { + // 刷新失败,跳转登录页 + localStorage.clear(); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + } +); +``` + +### 后端开发 + +#### 1. 密码强度要求 + +```rust +// 验证密码强度 +fn validate_password(password: &str) -> Result<()> { + if password.len() < 8 { + return Err(anyhow!("密码长度至少 8 位")); + } + if password.len() > 100 { + return Err(anyhow!("密码长度最多 100 位")); + } + // 可添加更多规则(如必须包含大小写、数字等) + Ok(()) +} +``` + +#### 2. JWT 密钥管理 + +**开发环境**: + +使用 `config/` 目录下的配置文件: + +```bash +# 方式1:使用默认配置(推荐) +# JWT 密钥已在 config/default.toml 中配置 + +# 方式2:创建本地配置文件 +cp config/default.toml config/local.toml +# 编辑 config/local.toml,修改 jwt_secret +nano config/local.toml + +# 运行 +cargo run -- -c config/local.toml +``` + +**生产环境**: +```bash +# 使用强随机密钥 +AUTH_JWT_SECRET=$(openssl rand -base64 32) +``` + +#### 3. Token 过期时间配置 + +```bash +# Access Token:15 分钟(推荐) +AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=15 + +# Refresh Token:7 天(推荐) +AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=7 +``` + +**建议**: +- Access Token:5-30 分钟(权衡安全性和用户体验) +- Refresh Token:7-30 天(根据应用安全要求) + +### 生产部署 + +#### 1. HTTPS 强制 + +```nginx +server { + listen 80; + server_name api.yourdomain.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl; + server_name api.yourdomain.com; + # SSL 配置... +} +``` + +#### 2. CORS 配置 + +开发环境可以允许所有来源: + +```rust +CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any) +``` + +生产环境应该限制允许的来源: + +```rust +CorsLayer::new() + .allow_origin("https://yourdomain.com".parse::().unwrap()) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) + .allow_headers([HeaderName::from_static("content-type"), HeaderName::from_static("authorization")]) +``` + +#### 3. 速率限制 + +防止暴力破解和 DDoS 攻击(需要额外实现): + +```rust +// 使用 governor 库实现速率限制 +use governor::{Quota, RateLimiter}; + +let limiter = RateLimiter::direct(Quota::per_second(5)); +// 每秒最多 5 个请求 +``` + +--- + +## 相关文档 + +- [公开接口文档](endpoints/public.md) - 注册、登录、刷新 Token 接口 +- [受保护接口文档](endpoints/protected.md) - 需要认证的接口 +- [前端集成示例](examples/frontend-integration.md) - 完整的前端集成代码 +- [环境变量配置](../deployment/environment-variables.md) - 认证相关配置说明 + +--- + +**提示**:生产环境部署前务必修改 JWT 密钥为强随机字符串! diff --git a/docs/api/endpoints/public.md b/docs/api/endpoints/public.md new file mode 100644 index 0000000..ef4fcd2 --- /dev/null +++ b/docs/api/endpoints/public.md @@ -0,0 +1,368 @@ +# 公开接口文档 + +本文档详细说明所有无需认证即可访问的 API 接口。 + +## 目录 + +- [GET /health - 健康检查](#get-health) +- [GET /info - 服务器信息](#get-info) +- [POST /auth/register - 用户注册](#post-authregister) +- [POST /auth/login - 用户登录](#post-authlogin) +- [POST /auth/refresh - 刷新 Token](#post-authrefresh) + +--- + +## GET /health + +健康检查端点,用于检查服务是否正常运行。 + +### 请求 + +```http +GET /health +``` + +**请求参数**:无 + +**请求头**:无特殊要求 + +### 响应 + +**成功响应 (200)**: + +```json +{ + "status": "ok" +} +``` + +或服务不可用时: + +```json +{ + "status": "unavailable" +} +``` + +### 示例 + +```bash +curl http://localhost:3000/health +``` + +### 错误码 + +| 错误码 | 说明 | +|-------|------| +| 500 | 服务器内部错误 | + +--- + +## GET /info + +获取服务器基本信息,包括应用名称、版本、状态等。 + +### 请求 + +```http +GET /info +``` + +**请求参数**:无 + +**请求头**:无特殊要求 + +### 响应 + +**成功响应 (200)**: + +```json +{ + "name": "web-rust-template", + "version": "0.1.0", + "status": "running", + "timestamp": 1704112800 +} +``` + +### 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| name | string | 应用名称 | +| version | string | 应用版本 | +| status | string | 运行状态 | +| timestamp | number | 当前时间戳(Unix 时间戳) | + +### 示例 + +```bash +curl http://localhost:3000/info +``` + +### 错误码 + +| 错误码 | 说明 | +|-------|------| +| 500 | 服务器内部错误 | + +--- + +## POST /auth/register + +创建新用户账户。注册成功后自动登录,返回 Access Token 和 Refresh Token。 + +### 请求 + +```http +POST /auth/register +Content-Type: application/json +``` + +**请求参数**: + +```json +{ + "email": "user@example.com", + "password": "password123" +} +``` + +### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| email | string | 是 | 用户邮箱,必须是有效的邮箱格式 | +| password | string | 是 | 用户密码,建议长度至少 8 位 | + +### 响应 + +**成功响应 (200)**: + +```json +{ + "code": 200, + "message": "Success", + "data": { + "email": "user@example.com", + "created_at": "2026-02-13T12:00:00.000Z", + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } +} +``` + +### 响应字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| email | string | 用户邮箱 | +| created_at | string | 账号创建时间(ISO 8601 格式) | +| access_token | string | Access Token,有效期 15 分钟 | +| refresh_token | string | Refresh Token,有效期 7 天 | + +### 示例 + +```bash +curl -X POST http://localhost:3000/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "password123" + }' +``` + +### 错误码 + +| 错误码 | 说明 | +|-------|------| +| 400 | 请求参数错误(邮箱格式错误、密码长度不够) | +| 409 | 邮箱已被注册 | +| 500 | 服务器内部错误 | + +### 注意事项 + +- 邮箱地址将作为用户的唯一标识符 +- 密码会使用 Argon2 算法进行哈希存储 +- 注册成功后会自动生成 Access Token 和 Refresh Token +- Refresh Token 会存储在 Redis 中,用于后续刷新 Token + +--- + +## POST /auth/login + +用户登录。验证成功后返回 Access Token 和 Refresh Token。 + +### 请求 + +```http +POST /auth/login +Content-Type: application/json +``` + +**请求参数**: + +```json +{ + "email": "user@example.com", + "password": "password123" +} +``` + +### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| email | string | 是 | 用户邮箱 | +| password | string | 是 | 用户密码 | + +### 响应 + +**成功响应 (200)**: + +```json +{ + "code": 200, + "message": "Success", + "data": { + "id": "1234567890", + "email": "user@example.com", + "created_at": "2026-02-13T12:00:00.000Z", + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } +} +``` + +### 响应字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 用户 ID(10 位数字) | +| email | string | 用户邮箱 | +| created_at | string | 账号创建时间(ISO 8601 格式) | +| access_token | string | Access Token,有效期 15 分钟 | +| refresh_token | string | Refresh Token,有效期 7 天 | + +### 示例 + +```bash +curl -X POST http://localhost:3000/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "password123" + }' +``` + +### 错误码 + +| 错误码 | 说明 | +|-------|------| +| 400 | 请求参数错误 | +| 401 | 邮箱或密码错误 | +| 500 | 服务器内部错误 | + +### 注意事项 + +- 登录失败不会返回具体的错误信息(如"邮箱不存在"或"密码错误"),统一返回"邮箱或密码错误" +- 密码错误次数过多可能会被临时限制(取决于具体实现) +- 登录成功后会生成新的 Token 对,旧的 Token 会失效 + +--- + +## POST /auth/refresh + +使用 Refresh Token 获取新的 Access Token 和 Refresh Token。 + +### 请求 + +```http +POST /auth/refresh +Content-Type: application/json +``` + +**请求参数**: + +```json +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| refresh_token | string | 是 | Refresh Token | + +### 响应 + +**成功响应 (200)**: + +```json +{ + "code": 200, + "message": "Success", + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } +} +``` + +### 响应字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| access_token | string | 新的 Access Token,有效期 15 分钟 | +| refresh_token | string | 新的 Refresh Token,有效期 7 天 | + +### 示例 + +```bash +curl -X POST http://localhost:3000/auth/refresh \ + -H "Content-Type: application/json" \ + -d '{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }' +``` + +### 错误码 + +| 错误码 | 说明 | +|-------|------| +| 400 | 请求参数错误 | +| 401 | Refresh Token 无效或已过期 | +| 500 | 服务器内部错误 | + +### 注意事项 + +- 每次刷新会生成新的 Refresh Token,旧的 Refresh Token 会立即失效 +- Refresh Token 只能使用一次,重复使用会返回错误 +- Refresh Token 有效期为 7 天,过期后需要重新登录 +- Refresh Token 存储在 Redis 中,服务器重启不会丢失(如果 Redis 持久化配置正确) + +### Token 刷新策略建议 + +**前端实现建议**: + +1. 在每次 API 请求失败(401 错误)时尝试刷新 Token +2. 刷新成功后重试原请求 +3. 刷新失败则跳转到登录页 +4. 不要在 Token 即将过期时主动刷新,而是在使用时检查有效性 + +查看 [前端集成示例](../examples/frontend-integration.md) 了解完整的实现代码。 + +--- + +## 相关文档 + +- [受保护接口文档](protected.md) - 需要认证的接口说明 +- [认证机制详解](../authentication.md) - 完整的认证流程说明 +- [API 概览](../api-overview.md) - 所有接口快速索引 +- [前端集成示例](../examples/frontend-integration.md) - 前端集成代码示例 + +--- + +**提示**:建议使用 Postman、Insomnia 或类似工具测试 API 接口。 diff --git a/docs/api/examples/frontend-integration.md b/docs/api/examples/frontend-integration.md new file mode 100644 index 0000000..f9186f5 --- /dev/null +++ b/docs/api/examples/frontend-integration.md @@ -0,0 +1,768 @@ +# 前端集成示例 + +本文档提供完整的前端集成代码示例,包括 JavaScript/TypeScript、React 和 Vue。 + +## 目录 + +- [TypeScript 基础示例](#typescript-基础示例) +- [React 集成示例](#react-集成示例) +- [Vue 集成示例](#vue-集成示例) +- [Token 存储建议](#token-存储建议) +- [错误处理](#错误处理) + +--- + +## TypeScript 基础示例 + +### 认证客户端类 + +以下是一个完整的 TypeScript 认证客户端实现,包含注册、登录、Token 刷新等功能: + +```typescript +interface ApiResponse { + code: number; + message: string; + data: T; +} + +interface RegisterData { + email: string; + password: string; +} + +interface LoginData { + email: string; + password: string; +} + +interface RegisterResponse { + email: string; + created_at: string; + access_token: string; + refresh_token: string; +} + +interface LoginResponse { + id: string; + email: string; + created_at: string; + access_token: string; + refresh_token: string; +} + +interface RefreshResponse { + access_token: string; + refresh_token: string; +} + +class AuthClient { + private baseURL: string; + private accessToken: string | null = null; + private refreshToken: string | null = null; + + constructor(baseURL: string = 'http://localhost:3000') { + this.baseURL = baseURL; + // 从 localStorage 加载 Token + this.accessToken = localStorage.getItem('access_token'); + this.refreshToken = localStorage.getItem('refresh_token'); + } + + /** + * 用户注册 + */ + async register(email: string, password: string): Promise { + const response = await fetch(`${this.baseURL}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const result: ApiResponse = await response.json(); + + if (result.code === 200) { + this.saveTokens(result.data.access_token, result.data.refresh_token); + return result.data; + } + + throw new Error(result.message); + } + + /** + * 用户登录 + */ + async login(email: string, password: string): Promise { + const response = await fetch(`${this.baseURL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const result: ApiResponse = await response.json(); + + if (result.code === 200) { + this.saveTokens(result.data.access_token, result.data.refresh_token); + return result.data; + } + + throw new Error(result.message); + } + + /** + * 刷新 Token + */ + async refreshTokens(): Promise { + if (!this.refreshToken) { + throw new Error('No refresh token available'); + } + + const response = await fetch(`${this.baseURL}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: this.refreshToken }), + }); + + const result: ApiResponse = await response.json(); + + if (result.code === 200) { + this.saveTokens(result.data.access_token, result.data.refresh_token); + } else { + this.clearTokens(); + throw new Error(result.message); + } + } + + /** + * 发起需要认证的请求 + */ + async authenticatedFetch(url: string, options?: RequestInit): Promise { + if (!this.accessToken) { + throw new Error('No access token available'); + } + + let response = await fetch(url, { + ...options, + headers: { + ...options?.headers, + 'Authorization': `Bearer ${this.accessToken}`, + }, + }); + + // Token 过期,尝试刷新 + if (response.status === 401) { + try { + await this.refreshTokens(); + // 重试原请求 + response = await fetch(url, { + ...options, + headers: { + ...options?.headers, + 'Authorization': `Bearer ${this.accessToken}`, + }, + }); + } catch (error) { + // 刷新失败,清除 Token 并抛出错误 + this.clearTokens(); + throw error; + } + } + + return response; + } + + /** + * 保存 Token 到 localStorage + */ + private saveTokens(accessToken: string, refreshToken: string): void { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + localStorage.setItem('access_token', accessToken); + localStorage.setItem('refresh_token', refreshToken); + } + + /** + * 清除 Token + */ + private clearTokens(): void { + this.accessToken = null; + this.refreshToken = null; + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + } + + /** + * 登出 + */ + logout(): void { + this.clearTokens(); + } + + /** + * 检查是否已登录 + */ + isAuthenticated(): boolean { + return this.accessToken !== null; + } +} + +// 使用示例 +const authClient = new AuthClient(); + +// 注册 +try { + const result = await authClient.register('user@example.com', 'password123'); + console.log('注册成功:', result); +} catch (error) { + console.error('注册失败:', error); +} + +// 登录 +try { + const result = await authClient.login('user@example.com', 'password123'); + console.log('登录成功:', result); +} catch (error) { + console.error('登录失败:', error); +} + +// 访问受保护接口 +try { + const response = await authClient.authenticatedFetch( + 'http://localhost:3000/auth/delete', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: '1234567890', password: 'password123' }), + } + ); + const data = await response.json(); + console.log('请求成功:', data); +} catch (error) { + console.error('请求失败:', error); +} + +// 登出 +authClient.logout(); +``` + +--- + +## React 集成示例 + +### AuthContext Provider + +```typescript +// AuthContext.tsx +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + +interface User { + id: string; + email: string; + created_at: string; +} + +interface AuthContextType { + user: User | null; + accessToken: string | null; + isAuthenticated: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + logout: () => void; + loading: boolean; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [accessToken, setAccessToken] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // 从 localStorage 加载 Token + const storedAccessToken = localStorage.getItem('access_token'); + const storedUser = localStorage.getItem('user'); + + if (storedAccessToken && storedUser) { + setAccessToken(storedAccessToken); + setUser(JSON.parse(storedUser)); + } + setLoading(false); + }, []); + + const login = async (email: string, password: string) => { + const response = await fetch('http://localhost:3000/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const result = await response.json(); + + if (result.code === 200) { + const userData: User = { + id: result.data.id, + email: result.data.email, + created_at: result.data.created_at, + }; + + setUser(userData); + setAccessToken(result.data.access_token); + + localStorage.setItem('access_token', result.data.access_token); + localStorage.setItem('refresh_token', result.data.refresh_token); + localStorage.setItem('user', JSON.stringify(userData)); + } else { + throw new Error(result.message); + } + }; + + const register = async (email: string, password: string) => { + const response = await fetch('http://localhost:3000/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const result = await response.json(); + + if (result.code === 200) { + const userData: User = { + id: result.data.id || '', + email: result.data.email, + created_at: result.data.created_at, + }; + + setUser(userData); + setAccessToken(result.data.access_token); + + localStorage.setItem('access_token', result.data.access_token); + localStorage.setItem('refresh_token', result.data.refresh_token); + localStorage.setItem('user', JSON.stringify(userData)); + } else { + throw new Error(result.message); + } + }; + + const logout = () => { + setUser(null); + setAccessToken(null); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user'); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +} +``` + +### API Hook(带 Token 刷新) + +```typescript +// useApi.ts +import { useCallback } from 'react'; +import { useAuth } from './AuthContext'; + +export function useApi() { + const { accessToken, setAccessToken, logout } = useAuth(); + + const fetchWithAuth = useCallback( + async (url: string, options?: RequestInit): Promise => { + if (!accessToken) { + throw new Error('Not authenticated'); + } + + let response = await fetch(url, { + ...options, + headers: { + ...options?.headers, + 'Authorization': `Bearer ${accessToken}`, + }, + }); + + // Token 过期,尝试刷新 + if (response.status === 401) { + const refreshToken = localStorage.getItem('refresh_token'); + + if (refreshToken) { + const refreshResponse = await fetch('http://localhost:3000/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + const refreshResult = await refreshResponse.json(); + + if (refreshResult.code === 200) { + setAccessToken(refreshResult.data.access_token); + localStorage.setItem('access_token', refreshResult.data.access_token); + localStorage.setItem('refresh_token', refreshResult.data.refresh_token); + + // 重试原请求 + response = await fetch(url, { + ...options, + headers: { + ...options?.headers, + 'Authorization': `Bearer ${refreshResult.data.access_token}`, + }, + }); + } else { + // 刷新失败,登出 + logout(); + throw new Error('Session expired'); + } + } else { + logout(); + throw new Error('Session expired'); + } + } + + return response; + }, + [accessToken, setAccessToken, logout] + ); + + return { fetchWithAuth }; +} +``` + +### 登录组件示例 + +```typescript +// Login.tsx +import React, { useState } from 'react'; +import { useAuth } from './AuthContext'; + +export function Login() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await login(email, password); + // 登录成功,路由跳转 + } catch (err) { + setError(err instanceof Error ? err.message : '登录失败'); + } finally { + setLoading(false); + } + }; + + return ( +
+

登录

+ {error &&
{error}
} + setEmail(e.target.value)} + placeholder="邮箱" + required + /> + setPassword(e.target.value)} + placeholder="密码" + required + /> + +
+ ); +} +``` + +--- + +## Vue 集成示例 + +### Auth Composable + +```typescript +// composables/useAuth.ts +import { ref, computed } from 'vue'; +import { useRouter } from 'vue-router'; + +interface User { + id: string; + email: string; + created_at: string; +} + +export function useAuth() { + const user = ref(null); + const accessToken = ref(null); + const router = useRouter(); + + const isAuthenticated = computed(() => !!accessToken.value); + + // 初始化:从 localStorage 加载 + const init = () => { + const storedAccessToken = localStorage.getItem('access_token'); + const storedUser = localStorage.getItem('user'); + + if (storedAccessToken && storedUser) { + accessToken.value = storedAccessToken; + user.value = JSON.parse(storedUser); + } + }; + + const login = async (email: string, password: string) => { + const response = await fetch('http://localhost:3000/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const result = await response.json(); + + if (result.code === 200) { + const userData: User = { + id: result.data.id, + email: result.data.email, + created_at: result.data.created_at, + }; + + user.value = userData; + accessToken.value = result.data.access_token; + + localStorage.setItem('access_token', result.data.access_token); + localStorage.setItem('refresh_token', result.data.refresh_token); + localStorage.setItem('user', JSON.stringify(userData)); + } else { + throw new Error(result.message); + } + }; + + const register = async (email: string, password: string) => { + const response = await fetch('http://localhost:3000/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const result = await response.json(); + + if (result.code === 200) { + const userData: User = { + id: result.data.id || '', + email: result.data.email, + created_at: result.data.created_at, + }; + + user.value = userData; + accessToken.value = result.data.access_token; + + localStorage.setItem('access_token', result.data.access_token); + localStorage.setItem('refresh_token', result.data.refresh_token); + localStorage.setItem('user', JSON.stringify(userData)); + } else { + throw new Error(result.message); + } + }; + + const logout = () => { + user.value = null; + accessToken.value = null; + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user'); + router.push('/login'); + }; + + return { + user, + accessToken, + isAuthenticated, + login, + register, + logout, + init, + }; +} +``` + +### Axios 拦截器示例 + +```typescript +// api/axios.ts +import axios from 'axios'; + +const api = axios.create({ + baseURL: 'http://localhost:3000', +}); + +// 请求拦截器:添加 Authorization header +api.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('access_token'); + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; +}); + +// 响应拦截器:处理 401 错误并刷新 Token +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + const refreshToken = localStorage.getItem('refresh_token'); + + if (refreshToken) { + try { + const response = await axios.post('/auth/refresh', { + refresh_token: refreshToken, + }); + + if (response.data.code === 200) { + const { access_token, refresh_token } = response.data.data; + + localStorage.setItem('access_token', access_token); + localStorage.setItem('refresh_token', refresh_token); + + // 重试原请求 + originalRequest.headers.Authorization = `Bearer ${access_token}`; + return axios(originalRequest); + } + } catch (refreshError) { + // 刷新失败,清除 Token + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } else { + // 没有 Refresh Token,跳转到登录页 + window.location.href = '/login'; + } + } + + return Promise.reject(error); + } +); + +export default api; +``` + +--- + +## Token 存储建议 + +### localStorage vs sessionStorage vs Cookie + +| 存储方式 | 优点 | 缺点 | 推荐场景 | +|---------|------|------|----------| +| localStorage | 数据持久化,刷新页面不丢失 | 容易受到 XSS 攻击 | Access Token、Refresh Token | +| sessionStorage | 关闭标签页自动清除 | 刷新页面会丢失 | 不推荐 | +| Cookie | 可设置 HttpOnly 防止 XSS | 容易受到 CSRF 攻击 | 服务器渲染场景 | + +### 推荐方案 + +**前端应用(SPA)**: +- Access Token:localStorage +- Refresh Token:localStorage +- 添加适当的 XSS 防护(内容安全策略、输入验证) + +**安全性要求高的场景**: +- Access Token:内存(React Context/Vue Reactive) +- Refresh Token:HttpOnly Cookie(需要后端支持) + +--- + +## 错误处理 + +### 通用错误处理 + +```typescript +async function handleApiCall( + apiCall: () => Promise, + onError?: (error: Error) => void +): Promise { + try { + return await apiCall(); + } catch (error) { + if (onError) { + onError(error as Error); + } else { + console.error('API 调用失败:', error); + } + return null; + } +} + +// 使用示例 +const result = await handleApiCall( + () => authClient.login('user@example.com', 'password123'), + (error) => { + alert(`登录失败: ${error.message}`); + } +); +``` + +### 网络错误重试 + +```typescript +async function fetchWithRetry( + url: string, + options?: RequestInit, + maxRetries: number = 3 +): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + return await fetch(url, options); + } catch (error) { + if (i === maxRetries - 1) { + throw error; + } + // 等待后重试 + await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); + } + } + throw new Error('Max retries reached'); +} +``` + +--- + +## 相关文档 + +- [公开接口文档](../endpoints/public.md) - API 接口详细说明 +- [认证机制详解](../authentication.md) - JWT 认证流程 +- [受保护接口文档](../endpoints/protected.md) - 需要认证的接口 + +--- + +**提示**:以上示例代码仅供参考,实际使用时请根据项目需求调整。 diff --git a/docs/deployment/environment-variables.md b/docs/deployment/environment-variables.md new file mode 100644 index 0000000..584d67a --- /dev/null +++ b/docs/deployment/environment-variables.md @@ -0,0 +1,536 @@ +# 环境变量配置说明 + +本文档提供所有可配置的环境变量说明。 + +## 目录 + +- [配置优先级](#配置优先级) +- [服务器配置](#服务器配置) +- [数据库配置](#数据库配置) +- [认证配置](#认证配置) +- [Redis 配置](#redis-配置) +- [配置示例](#配置示例) + +--- + +## 配置优先级 + +配置的加载优先级从高到低为: + +1. **环境变量**(最高优先级) +2. **配置文件**(config/ 目录) +3. **默认值**(代码中硬编码) + +这意味着: +- 环境变量会覆盖配置文件中的设置 +- 配置文件会覆盖代码中的默认值 + +--- + +## 服务器配置 + +### SERVER_HOST + +服务器监听地址。 + +| 属性 | 值 | +|------|-----| +| 类型 | 字符串 | +| 默认值 | `0.0.0.0` | +| 说明 | `0.0.0.0` 表示监听所有网络接口 | + +**示例**: +```bash +SERVER_HOST=127.0.0.1 # 仅本地访问 +SERVER_HOST=0.0.0.0 # 允许外部访问 +``` + +### SERVER_PORT + +服务器监听端口。 + +| 属性 | 值 | +|------|-----| +| 类型 | 整数 | +| 默认值 | `3000` | +| 说明 | 1-65535 之间的有效端口 | + +**示例**: +```bash +SERVER_PORT=3000 # 开发环境 +SERVER_PORT=8080 # 生产环境 +SERVER_PORT=80 # HTTP 标准端口 +``` + +--- + +## 数据库配置 + +### DATABASE_TYPE + +数据库类型,支持 MySQL、PostgreSQL、SQLite。 + +| 属性 | 值 | +|------|-----| +| 类型 | 字符串 | +| 默认值 | `sqlite` | +| 可选值 | `mysql`、`postgresql`、`sqlite` | + +**示例**: +```bash +DATABASE_TYPE=sqlite # SQLite 数据库 +DATABASE_TYPE=mysql # MySQL 数据库 +DATABASE_TYPE=postgresql # PostgreSQL 数据库 +``` + +### MySQL 配置 + +当 `DATABASE_TYPE=mysql` 时使用。 + +#### DATABASE_HOST + +MySQL 服务器地址。 + +| 属性 | 值 | +|------|-----| +| 类型 | 字符串 | +| 默认值 | `localhost` | + +**示例**: +```bash +DATABASE_HOST=localhost +DATABASE_HOST=192.168.1.100 +DATABASE_HOST=mysql.example.com +``` + +#### DATABASE_PORT + +MySQL 服务器端口。 + +| 属性 | 值 | +|------|-----| +| 类型 | 整数 | +| 默认值 | `3306` | + +**示例**: +```bash +DATABASE_PORT=3306 +``` + +#### DATABASE_USER + +MySQL 用户名。 + +| 属性 | 值 | +|------|-----| +| 类型 | 字符串 | +| 默认值 | - | +| 必填 | 是 | + +**示例**: +```bash +DATABASE_USER=root +DATABASE_USER=webapp +``` + +#### DATABASE_PASSWORD + +MySQL 密码。 + +| 属性 | 值 | +|------|-----| +| 类型 | 字符串 | +| 默认值 | - | +| 必填 | 是 | + +**示例**: +```bash +DATABASE_PASSWORD=your-password +``` + +#### DATABASE_DATABASE + +MySQL 数据库名称。 + +| 属性 | 值 | +|------|-----| +| 类型 | 字符串 | +| 默认值 | - | +| 必填 | 是 | + +**示例**: +```bash +DATABASE_DATABASE=web_template +``` + +### PostgreSQL 配置 + +当 `DATABASE_TYPE=postgresql` 时使用,配置项与 MySQL 相同。 + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| DATABASE_HOST | PostgreSQL 服务器地址 | localhost | +| DATABASE_PORT | PostgreSQL 服务器端口 | 5432 | +| DATABASE_USER | PostgreSQL 用户名 | - | +| DATABASE_PASSWORD | PostgreSQL 密码 | - | +| DATABASE_DATABASE | PostgreSQL 数据库名称 | - | + +### SQLite 配置 + +当 `DATABASE_TYPE=sqlite` 时使用。 + +#### DATABASE_PATH + +SQLite 数据库文件路径。 + +| 属性 | 值 | +|------|-----| +| 类型 | 字符串 | +| 默认值 | - | +| 必填 | 是 | + +**示例**: +```bash +DATABASE_PATH=data/app.db +DATABASE_PATH=/var/data/webapp.db +``` + +**注意**: +- 目录必须存在,程序不会自动创建目录 +- 文件不存在时会自动创建 + +### DATABASE_MAX_CONNECTIONS + +数据库连接池最大连接数。 + +| 属性 | 值 | +|------|-----| +| 类型 | 整数 | +| 默认值 | `10` | + +**示例**: +```bash +DATABASE_MAX_CONNECTIONS=10 # 开发环境 +DATABASE_MAX_CONNECTIONS=100 # 生产环境 +``` + +**建议**: +- 开发环境:5-10 +- 生产环境:根据应用负载调整(通常是 CPU 核心数的 2-4 倍) + +--- + +## 认证配置 + +### AUTH_JWT_SECRET + +JWT 签名密钥。 + +| 属性 | 值 | +|------|-----| +| 类型 | 字符串 | +| 默认值 | - | +| 必填 | 是 | + +**安全建议**: +- 生产环境使用至少 32 位的随机字符串 +- 定期更换密钥 +- 不要在代码中硬编码 + +**生成强密钥**: +```bash +# 使用 OpenSSL +openssl rand -base64 32 + +# 使用 Python +python -c "import secrets; print(secrets.token_urlsafe(32))" + +# 使用 Node.js +node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +``` + +**示例**: +```bash +# 开发环境(不安全) +AUTH_JWT_SECRET=dev-secret-key + +# 生产环境(安全) +AUTH_JWT_SECRET=Kx7Yn2Zp9qR8wF4tL6mN3vB5xC8zD1sE9aH2jK7 +``` + +### AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES + +Access Token 过期时间(分钟)。 + +| 属性 | 值 | +|------|-----| +| 类型 | 整数 | +| 默认值 | `15` | + +**示例**: +```bash +AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=15 # 15 分钟(推荐) +AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=30 # 30 分钟 +AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=60 # 1 小时 +``` + +**建议**: +- 安全性要求高:5-15 分钟 +- 用户体验优先:30-60 分钟 +- 权衡安全性和用户体验 + +### AUTH_REFRESH_TOKEN_EXPIRATION_DAYS + +Refresh Token 过期时间(天)。 + +| 属性 | 值 | +|------|-----| +| 类型 | 整数 | +| 默认值 | `7` | + +**示例**: +```bash +AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=7 # 7 天(推荐) +AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=30 # 30 天 +AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=90 # 90 天 +``` + +**建议**: +- Web 应用:7-30 天 +- 移动应用:30-90 天 +- 安全性要求高的应用:7 天或更短 + +--- + +## Redis 配置 + +### REDIS_HOST + +Redis 服务器地址。 + +| 属性 | 值 | +|------|-----| +| 类型 | 字符串 | +| 默认值 | `localhost` | + +**示例**: +```bash +REDIS_HOST=localhost +REDIS_HOST=192.168.1.100 +REDIS_HOST=redis.example.com +``` + +### REDIS_PORT + +Redis 服务器端口。 + +| 属性 | 值 | +|------|-----| +| 类型 | 整数 | +| 默认值 | `6379` | + +**示例**: +```bash +REDIS_PORT=6379 +``` + +### REDIS_PASSWORD + +Redis 密码(如果设置了密码)。 + +| 属性 | 值 | +|------|-----| +| 类型 | 字符串 | +| 默认值 | - | +| 必填 | 否 | + +**示例**: +```bash +REDIS_PASSWORD=your-redis-password +``` + +### REDIS_DB + +Redis 数据库编号。 + +| 属性 | 值 | +|------|-----| +| 类型 | 整数 | +| 默认值 | `0` | +| 范围 | 0-15 | + +**示例**: +```bash +REDIS_DB=0 # 默认数据库 +REDIS_DB=1 # 数据库 1 +``` + +--- + +## 配置示例 + +### 开发环境(SQLite) + +**重要**:本项目不支持 .env 文件。开发环境请使用 `config/` 目录下的 toml 配置文件。 + +**方式一:使用默认配置(最简单)** + +无需任何配置,直接运行即可: + +```bash +cargo run +``` + +**方式二:修改配置文件** + +如果需要修改配置,编辑 `config/default.toml` 或创建 `config/local.toml`: + +```bash +# 复制默认配置 +cp config/default.toml config/local.toml + +# 编辑配置文件 +nano config/local.toml # 或使用其他编辑器 + +# 运行 +cargo run -- -c config/local.toml +``` + +### 开发环境(MySQL) + +**重要**:本项目不支持 .env 文件。开发环境请使用 `config/` 目录下的 toml 配置文件。 + +复制并修改 MySQL 配置文件: + +```bash +# 复制 MySQL 配置模板 +cp config/development.mysql.toml config/local.toml + +# 编辑 config/local.toml,修改数据库连接信息 +nano config/local.toml + +# 运行 +cargo run -- -c config/local.toml +``` + +或使用环境变量(适用于 Docker/Kubernetes): + +```bash +DATABASE_TYPE=mysql \ +DATABASE_HOST=localhost \ +DATABASE_PORT=3306 \ +DATABASE_USER=root \ +DATABASE_PASSWORD=root \ +DATABASE_DATABASE=web_template_dev \ +cargo run +``` + +### 生产环境 + +**重要**:本项目不支持 .env 文件。生产环境请使用 `config/` 目录下的 toml 配置文件或环境变量。 + +**方式一:使用配置文件** + +修改 `config/production.toml` 中的配置: + +```bash +# 编辑生产环境配置文件 +nano config/production.toml + +# 运行 +cargo run -- -e production -c config/production.toml +``` + +**方式二:使用环境变量(Docker/Kubernetes 推荐)** + +```bash +DATABASE_TYPE=mysql \ +DATABASE_HOST=mysql.production.example.com \ +DATABASE_PORT=3306 \ +DATABASE_USER=webapp \ +DATABASE_PASSWORD=strong-password-here \ +DATABASE_DATABASE=web_template_prod \ +DATABASE_MAX_CONNECTIONS=100 \ +AUTH_JWT_SECRET=Kx7Yn2Zp9qR8wF4tL6mN3vB5xC8zD1sE9aH2jK7 \ +REDIS_HOST=redis.production.example.com \ +REDIS_PORT=6379 \ +REDIS_PASSWORD=strong-redis-password \ +REDIS_DB=0 \ +cargo run -- -e production +``` + +### Docker Compose 配置 + +```yaml +# docker-compose.yml +version: '3.8' + +services: + web: + image: web-rust-template:latest + ports: + - "3000:3000" + environment: + - SERVER_HOST=0.0.0.0 + - SERVER_PORT=3000 + - DATABASE_TYPE=postgresql + - DATABASE_HOST=db + - DATABASE_PORT=5432 + - DATABASE_USER=webapp + - DATABASE_PASSWORD=password + - DATABASE_DATABASE=web_template + - DATABASE_MAX_CONNECTIONS=10 + - AUTH_JWT_SECRET=${JWT_SECRET} + - AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=15 + - AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=7 + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_DB=0 + depends_on: + - db + - redis + + db: + image: postgres:15 + environment: + - POSTGRES_USER=webapp + - POSTGRES_PASSWORD=password + - POSTGRES_DB=web_template + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7 + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: +``` + +--- + +## 安全检查清单 + +生产环境部署前检查: + +- [ ] JWT 密钥使用强随机字符串(至少 32 位) +- [ ] 数据库密码使用强密码 +- [ ] Redis 设置密码(如果可从外部访问) +- [ ] 服务器监听地址根据需求配置(0.0.0.0 或 127.0.0.1) +- [ ] 数据库连接数根据负载调整 +- [ ] Token 过期时间根据安全要求配置 +- [ ] 环境变量文件不提交到版本控制 + +--- + +## 相关文档 + +- [配置文件详解](configuration.md) - 配置文件组织说明 +- [快速开始指南](../development/getting-started.md) - 安装和配置指南 +- [生产环境部署](production-guide.md) - 生产部署最佳实践 + +--- + +**提示**:使用 `.env.example` 作为模板,不要提交包含敏感信息的 `.env` 文件到版本控制。 diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md new file mode 100644 index 0000000..ebb576f --- /dev/null +++ b/docs/development/getting-started.md @@ -0,0 +1,459 @@ +# 快速开始指南 + +本文档将指导你完成 Web Rust Template 项目的安装、配置和运行。 + +## 目录 + +- [环境要求](#环境要求) +- [安装步骤](#安装步骤) +- [配置说明](#配置说明) +- [运行项目](#运行项目) +- [验证安装](#验证安装) +- [常见问题](#常见问题) + +--- + +## 环境要求 + +### 必需环境 + +- **Rust**:1.70 或更高版本 + - 安装方法:访问 [rustup.rs](https://rustup.rs/) 或使用 `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` +- **Git**:用于克隆项目 + +### 数据库(任选其一) + +- **SQLite**:默认选项,无需额外安装 +- **MySQL**:5.7 或更高版本 +- **PostgreSQL**:12 或更高版本 + +### 可选环境 + +- **Redis**:用于存储 Refresh Token(推荐) + - Windows:下载 [Redis for Windows](https://github.com/microsoftarchive/redis/releases) + - macOS:`brew install redis` + - Linux:`sudo apt-get install redis-server` + +### 检查环境 + +```bash +# 检查 Rust 版本 +rustc --version + +# 检查 Cargo 版本 +cargo --version + +# 检查 Git 版本 +git --version + +# 检查 MySQL(如果使用) +mysql --version + +# 检查 PostgreSQL(如果使用) +psql --version + +# 检查 Redis(如果使用) +redis-cli --version +``` + +--- + +## 安装步骤 + +### 1. 克隆项目 + +```bash +git clone +cd web-rust-template +``` + +### 2. 安装依赖 + +使用 Cargo 构建项目(会自动下载依赖): + +```bash +cargo build +``` + +### 3. 配置项目 + +#### 方式一:使用默认配置(SQLite,最简单) + +**无需任何配置!** 直接运行即可: + +```bash +cargo run +``` + +默认配置: +- 数据库:SQLite(自动创建 `db.sqlite3`) +- 服务器:`127.0.0.1:3000` +- Redis:`localhost:6379` + +#### 方式二:使用 MySQL/PostgreSQL + +**步骤 1**:复制对应的配置文件 + +```bash +# 使用 MySQL +cp config/development.mysql.toml config/local.toml + +# 或使用 PostgreSQL +cp config/development.postgresql.toml config/local.toml +``` + +**步骤 2**:修改配置文件 + +编辑 `config/local.toml`,修改数据库连接信息: + +```toml +[database] +# MySQL 配置 +host = "localhost" +port = 3306 +user = "root" +password = "your-password" +database = "web_template_dev" + +# 或 PostgreSQL 配置 +# host = "localhost" +# port = 5432 +# user = "postgres" +# password = "your-password" +# database = "web_template_dev" +``` + +**步骤 3**:运行项目 + +```bash +# 使用指定配置文件运行 +cargo run -- -c config/local.toml +``` + +#### 方式三:通过环境变量覆盖(适用于 Docker/Kubernetes) + +```bash +# 使用环境变量 +DATABASE_TYPE=postgresql \ +DATABASE_HOST=localhost \ +DATABASE_PORT=5432 \ +DATABASE_USER=postgres \ +DATABASE_PASSWORD=password \ +DATABASE_DATABASE=web_template_dev \ +cargo run +``` + +--- + +## 配置说明 + +### 数据库配置 + +#### SQLite(默认,推荐用于开发) + +**优点**:无需额外安装,文件存储,易于测试 + +**缺点**:不支持高并发写入 + +**适用场景**:开发环境、小型应用 + +**使用方法**:无需配置,直接运行 + +#### MySQL + +**优点**:成熟稳定,支持高并发 + +**缺点**:需要额外安装和配置 + +**适用场景**:生产环境、大型应用 + +**配置方法**: + +**选项 1**:修改配置文件 + +```bash +# 复制 MySQL 配置模板 +cp config/development.mysql.toml config/local.toml + +# 编辑 config/local.toml,修改数据库连接信息 +``` + +**选项 2**:使用环境变量 + +```bash +DATABASE_TYPE=mysql \ +DATABASE_HOST=localhost \ +DATABASE_PORT=3306 \ +DATABASE_USER=root \ +DATABASE_PASSWORD=your-password \ +DATABASE_DATABASE=web_template_dev \ +cargo run +``` + +#### PostgreSQL + +**优点**:功能强大,支持高级特性 + +**缺点**:资源占用较大 + +**适用场景**:需要高级数据库功能的应用 + +**配置方法**:与 MySQL 类似,使用 `config/development.postgresql.toml` 或环境变量 + +### 认证配置 + +**开发环境**:使用默认配置即可(JWT 密钥已在配置文件中) + +**生产环境**:必须修改配置文件中的 JWT 密钥 + +```toml +[auth] +# 生产环境必须使用强密钥 +jwt_secret = "Kx7Yn2Zp9qR8wF4tL6mN3vB5xC8zD1sE9aH2jK7" +``` + +生成强密钥: +```bash +openssl rand -base64 32 +``` + +### Redis 配置 + +**开发环境**:默认连接 `localhost:6379`,无需配置 + +**生产环境**:修改配置文件或设置环境变量 + +```bash +REDIS_HOST=your-redis-host \ +REDIS_PORT=6379 \ +REDIS_PASSWORD=your-password \ +cargo run +``` + +--- + +## 运行项目 + +### 开发模式 + +**使用默认配置(SQLite)**: +```bash +cargo run +``` + +**使用指定配置文件**: +```bash +cargo run -- -c config/development.mysql.toml +``` + +**使用环境变量**: +```bash +DATABASE_TYPE=mysql DATABASE_HOST=localhost cargo run +``` + +### 指定环境 + +```bash +# 开发环境 +cargo run -- -e development + +# 生产环境 +cargo run -- -e production +``` + +### 后台运行(生产环境) + +```bash +# 使用 nohup +nohup cargo run -- -e production > app.log 2>&1 & + +# 使用 screen +screen -S web-rust-template +cargo run -- -e production +# 按 Ctrl+A 然后 D 分离会话 +``` + +--- + +## 验证安装 + +### 1. 健康检查 + +```bash +curl http://localhost:3000/health +``` + +预期响应: +```json +{ + "status": "ok" +} +``` + +### 2. 服务器信息 + +```bash +curl http://localhost:3000/info +``` + +预期响应: +```json +{ + "name": "web-rust-template", + "version": "0.1.0", + "status": "running", + "timestamp": 1704112800 +} +``` + +### 3. 用户注册 + +```bash +curl -X POST http://localhost:3000/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "password123" + }' +``` + +预期响应: +```json +{ + "code": 200, + "message": "Success", + "data": { + "email": "test@example.com", + "created_at": "2026-02-13T12:00:00.000Z", + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs..." + } +} +``` + +### 4. 用户登录 + +```bash +curl -X POST http://localhost:3000/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "password123" + }' +``` + +预期响应: +```json +{ + "code": 200, + "message": "Success", + "data": { + "id": "1234567890", + "email": "test@example.com", + "created_at": "2026-02-13T12:00:00.000Z", + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs..." + } +} +``` + +--- + +## 常见问题 + +### 1. 端口被占用 + +**错误信息**:`Os { code: 10048, kind: AddrInUse }` 或 `Address already in use` + +**解决方案**: + +**选项 1**:修改配置文件中的端口 + +```toml +[server] +port = 3001 +``` + +**选项 2**:通过环境变量覆盖 + +```bash +SERVER_PORT=3001 cargo run +``` + +**选项 3**:停止占用端口的进程 + +```bash +# Windows +netstat -ano | findstr :3000 +taskkill /PID /F + +# macOS/Linux +lsof -ti:3000 | xargs kill -9 +``` + +### 2. 数据库连接失败 + +**错误信息**:`Database connection failed` + +**解决方案**: + +- 检查数据库服务是否启动 +- 检查配置文件中的数据库配置是否正确 +- 确认数据库用户权限 +- SQLite:检查是否有写入权限 + +### 3. Redis 连接失败 + +**错误信息**:`Redis 连接失败` + +**解决方案**: + +- 检查 Redis 服务是否启动:`redis-cli ping` +- 检查配置文件中的 Redis 配置是否正确 +- 如果不需要 Redis 功能,可以暂时禁用(需要修改代码) + +### 4. 编译错误 + +**错误信息**:`error: linking with link.exe failed` + +**解决方案**: + +- Windows 用户需要安装 [C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) +- 或使用 `cargo install cargo-vcpkg` 安装依赖 + +### 5. 权限错误 + +**错误信息**:`Permission denied` + +**解决方案**: + +```bash +# Linux/macOS +chmod +x target/debug/web-rust-template + +# 或使用 sudo 运行(不推荐生产环境) +sudo cargo run +``` + +--- + +## 下一步 + +安装成功后,你可以: + +1. 阅读 [API 接口文档](../api/api-overview.md) 了解所有可用的 API +2. 查看 [项目结构详解](project-structure.md) 了解代码组织 +3. 学习 [DDD 架构规范](ddd-architecture.md) 了解设计原则 +4. 参考 [前端集成示例](../api/examples/frontend-integration.md) 集成前端应用 + +--- + +## 相关文档 + +- [配置文件详解](../deployment/configuration.md) - 配置文件组织说明 +- [环境变量配置](../deployment/environment-variables.md) - 完整的环境变量列表 +- [API 接口文档](../api/api-overview.md) - 完整的 API 接口说明 + +--- + +**提示**:遇到问题?查看 [常见问题](#常见问题) 或提交 Issue 到项目仓库。 diff --git a/docs/development/project-structure.md b/docs/development/project-structure.md new file mode 100644 index 0000000..5cbe058 --- /dev/null +++ b/docs/development/project-structure.md @@ -0,0 +1,615 @@ +# 项目结构详解 + +本文档详细说明 Web Rust Template 的项目结构、DDD 分层架构和各层职责。 + +## 目录 + +- [DDD 分层架构](#ddd-分层架构) +- [项目目录结构](#项目目录结构) +- [各层职责说明](#各层职责说明) +- [数据流转](#数据流转) +- [核心组件](#核心组件) + +--- + +## DDD 分层架构 + +本系统采用**领域驱动设计(DDD)**的分层架构,将代码划分为不同的职责层次。 + +``` +┌─────────────────────────────────────────┐ +│ Interface Layer (handlers) │ HTTP 处理器层 +│ 路由定义、请求处理、响应封装 │ +└──────────────┬───────────────────────┘ + │ +┌──────────────▼───────────────────────┐ +│ Application Layer (services) │ 业务逻辑层 +│ 业务逻辑、Token 生成、认证 │ +└──────────────┬───────────────────────┘ + │ + ┌───────┴────────┐ + │ │ +┌──────▼──────┐ ┌─────▼──────────┐ +│ Domain │ │ Infrastructure│ +│ Layer │ │ Layer │ +│ │ │ │ +│ - DTO │ │ - Middleware │ +│ - Entities │ │ - Redis │ +│ - VO │ │ - Repositories│ +└─────────────┘ └────────────────┘ +``` + +### 分层优势 + +| 优势 | 说明 | +|------|------| +| 职责清晰 | 每层只关注自己的职责,降低耦合 | +| 易于测试 | 每层可独立测试,Mock 依赖 | +| 易于维护 | 修改某层不影响其他层 | +| 易于扩展 | 添加新功能只需扩展相应层 | + +--- + +## 项目目录结构 + +``` +web-rust-template/ +├── src/ # 源代码目录 +│ ├── main.rs # 应用入口 +│ ├── cli.rs # 命令行参数解析 +│ ├── config.rs # 配置模块导出 +│ ├── db.rs # 数据库连接池 +│ ├── error.rs # 错误处理 +│ │ +│ ├── config/ # 配置模块 +│ │ ├── app.rs # 主配置结构 +│ │ ├── auth.rs # 认证配置 +│ │ ├── database.rs # 数据库配置 +│ │ ├── redis.rs # Redis 配置 +│ │ └── server.rs # 服务器配置 +│ │ +│ ├── domain/ # 领域层(DDD) +│ │ ├── dto/ # 数据传输对象(Data Transfer Object) +│ │ │ └── auth.rs # 认证相关 DTO +│ │ ├── entities/ # 实体(数据库模型) +│ │ │ └── users.rs # 用户实体 +│ │ └── vo/ # 视图对象(View Object) +│ │ └── auth.rs # 认证相关 VO +│ │ +│ ├── handlers/ # HTTP 处理器层(接口层) +│ │ ├── auth.rs # 认证接口 +│ │ └── health.rs # 健康检查接口 +│ │ +│ ├── infra/ # 基础设施层 +│ │ ├── middleware/ # 中间件 +│ │ │ ├── auth.rs # JWT 认证中间件 +│ │ │ └── logging.rs # 日志中间件 +│ │ └── redis/ # Redis 客户端封装 +│ │ ├── redis_client.rs +│ │ └── redis_key.rs +│ │ +│ ├── repositories/ # 数据访问层 +│ │ └── user_repository.rs # 用户数据访问 +│ │ +│ ├── services/ # 业务逻辑层 +│ │ └── auth_service.rs # 认证业务逻辑 +│ │ +│ └── utils/ # 工具函数 +│ └── jwt.rs # JWT 工具类 +│ +├── config/ # 配置文件目录 +│ ├── default.toml # 默认配置 +│ ├── development.sqlite.toml # SQLite 开发环境配置 +│ ├── development.mysql.toml # MySQL 开发环境配置 +│ ├── development.postgresql.toml # PostgreSQL 开发环境配置 +│ └── production.toml # 生产环境配置 +│ +├── sql/ # SQL 脚本 +│ └── init.sql # 数据库初始化脚本 +│ +├── tests/ # 测试目录 +│ └── integration_test.rs # 集成测试 +│ +├── docs/ # 文档目录 +│ ├── README.md +│ ├── api/ +│ ├── development/ +│ └── deployment/ +│ +├── .env.example # 环境变量参考(仅用于 Docker/Kubernetes 等部署场景) +├── .gitignore # Git 忽略文件 +├── Cargo.toml # 项目依赖定义 +├── README.md # 项目说明 +└── rust-toolchain.toml # Rust 工具链配置 +``` + +--- + +## 各层职责说明 + +### 1. 接口层(handlers/) + +**职责**:处理 HTTP 请求和响应 + +**位置**:`src/handlers/` + +**关键文件**: +- `auth.rs`:认证相关接口(注册、登录、刷新 Token、删除账号) +- `health.rs`:健康检查和服务器信息接口 + +**示例**: +```rust +// src/handlers/auth.rs + +pub async fn register( + Extension(request_id): Extension, + State(state): State, + Json(payload): Json, +) -> Result>, ErrorResponse> { + // 1. 记录日志 + log_info(&request_id, "注册请求参数", &payload); + + // 2. 调用服务层处理业务逻辑 + let user_repo = UserRepository::new(state.pool.clone()); + let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone()); + + // 3. 调用业务逻辑 + match service.register(payload).await { + Ok((user_model, access_token, refresh_token)) => { + let data = RegisterResult::from((user_model, access_token, refresh_token)); + let response = ApiResponse::success(data); + log_info(&request_id, "注册成功", &response); + Ok(Json(response)) + } + Err(e) => { + log_info(&request_id, "注册失败", &e.to_string()); + Err(ErrorResponse::new(e.to_string())) + } + } +} +``` + +**职责边界**: +- ✅ 接收 HTTP 请求 +- ✅ 提取请求参数 +- ✅ 调用服务层处理业务逻辑 +- ✅ 封装响应数据 +- ❌ 不包含业务逻辑 +- ❌ 不直接访问数据库 + +### 2. 业务逻辑层(services/) + +**职责**:实现核心业务逻辑 + +**位置**:`src/services/` + +**关键文件**: +- `auth_service.rs`:认证业务逻辑(注册、登录、Token 刷新、密码哈希) + +**示例**: +```rust +// src/services/auth_service.rs + +pub struct AuthService { + user_repo: UserRepository, + redis_client: RedisClient, + auth_config: AuthConfig, +} + +impl AuthService { + /// 用户注册 + pub async fn register(&self, payload: RegisterRequest) -> Result<(Model, String, String)> { + // 1. 验证邮箱格式 + if !payload.email.contains('@') { + return Err(anyhow!("邮箱格式错误")); + } + + // 2. 生成唯一用户 ID + let user_id = self.generate_unique_user_id().await?; + + // 3. 哈希密码 + let password_hash = self.hash_password(&payload.password)?; + + // 4. 创建用户实体 + let user_model = users::Model { + id: user_id, + email: payload.email.clone(), + password_hash, + created_at: chrono::Utc::now().naive_utc(), + updated_at: chrono::Utc::now().naive_utc(), + }; + + // 5. 保存到数据库 + let created_user = self.user_repo.create(user_model).await?; + + // 6. 生成 Token + let (access_token, refresh_token) = TokenService::generate_token_pair( + &created_user.id, + self.auth_config.access_token_expiration_minutes, + self.auth_config.refresh_token_expiration_days, + &self.auth_config.jwt_secret, + )?; + + // 7. 保存 Refresh Token 到 Redis + self.save_refresh_token(&created_user.id, &refresh_token, self.auth_config.refresh_token_expiration_days).await?; + + Ok((created_user, access_token, refresh_token)) + } +} +``` + +**职责边界**: +- ✅ 实现业务逻辑 +- ✅ 协调 Repository 和基础设施 +- ✅ 事务管理 +- ❌ 不处理 HTTP 请求/响应 +- ❌ 不直接访问外部资源(通过 Repository) + +### 3. 数据访问层(repositories/) + +**职责**:封装数据库访问逻辑 + +**位置**:`src/repositories/` + +**关键文件**: +- `user_repository.rs`:用户数据访问(增删改查) + +**示例**: +```rust +// src/repositories/user_repository.rs + +pub struct UserRepository { + pool: DbPool, +} + +impl UserRepository { + pub fn new(pool: DbPool) -> Self { + Self { pool } + } + + /// 创建用户 + pub async fn create(&self, user_model: users::Model) -> Result { + let result = users::Entity::insert(user_model.into_active_model()) + .exec(&self.pool) + .await + .map_err(|e| anyhow!("创建用户失败: {}", e))?; + + Ok(users::Entity::find_by_id(result.last_insert_id)) + .one(&self.pool) + .await + .map_err(|e| anyhow!("查询用户失败: {}", e))? + .ok_or_else(|| anyhow!("用户不存在")) + } + + /// 根据邮箱查询用户 + pub async fn find_by_email(&self, email: &str) -> Result> { + Ok(users::Entity::find() + .filter(users::Column::Email.eq(email)) + .one(&self.pool) + .await?) + } + + /// 根据ID查询用户 + pub async fn find_by_id(&self, id: &str) -> Result> { + Ok(users::Entity::find_by_id(id.to_string()) + .one(&self.pool) + .await?) + } + + /// 统计相同ID的用户数量 + pub async fn count_by_id(&self, id: &str) -> Result { + Ok(users::Entity::find_by_id(id.to_string()) + .count(&self.pool) + .await?) + } +} +``` + +**职责边界**: +- ✅ 数据库 CRUD 操作 +- ✅ 封装 SeaORM 细节 +- ❌ 不包含业务逻辑 +- ❌ 不处理 HTTP 请求 + +### 4. 领域层(domain/) + +**职责**:定义核心业务模型 + +**位置**:`src/domain/` + +#### DTO(Data Transfer Object) + +**职责**:定义 API 请求和响应的数据结构 + +**位置**:`src/domain/dto/` + +**示例**: +```rust +// src/domain/dto/auth.rs + +#[derive(Deserialize)] +pub struct RegisterRequest { + pub email: String, + pub password: String, +} + +#[derive(Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} +``` + +#### Entities(实体) + +**职责**:定义数据库表模型 + +**位置**:`src/domain/entities/` + +**示例**: +```rust +// src/domain/entities/users.rs + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: String, + #[sea_orm(column_type = "Text", unique)] + pub email: String, + pub password_hash: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} +``` + +#### VO(View Object) + +**职责**:定义 API 响应的数据结构 + +**位置**:`src/domain/vo/` + +**示例**: +```rust +// src/domain/vo/auth.rs + +#[derive(Debug, Serialize)] +pub struct RegisterResult { + pub email: String, + pub created_at: String, + pub access_token: String, + pub refresh_token: String, +} + +#[derive(Debug, Serialize)] +pub struct LoginResult { + pub id: String, + pub email: String, + pub created_at: String, + pub access_token: String, + pub refresh_token: String, +} +``` + +### 5. 基础设施层(infra/) + +**职责**:提供技术基础设施 + +**位置**:`src/infra/` + +#### 中间件(middleware/) + +**职责**:请求拦截和处理 + +**位置**:`src/infra/middleware/` + +**关键文件**: +- `auth.rs`:JWT 认证中间件 +- `logging.rs`:日志中间件 + +**示例**: +```rust +// src/infra/middleware/auth.rs + +pub async fn auth_middleware( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + // 1. 提取 Authorization header + let auth_header = request + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .ok_or_else(|| ErrorResponse::new("缺少 Authorization header".to_string()))?; + + // 2. 验证 Bearer 格式 + if !auth_header.starts_with("Bearer ") { + return Err(ErrorResponse::new("Authorization header 格式错误".to_string())); + } + + let token = &auth_header[7..]; + + // 3. 验证 JWT + let claims = TokenService::decode_user_id(token, &state.config.auth.jwt_secret)?; + + // 4. 将 user_id 添加到请求扩展 + request.extensions_mut().insert(claims.sub); + + // 5. 继续处理请求 + Ok(next.run(request).await) +} +``` + +#### Redis 客户端(redis/) + +**职责**:封装 Redis 操作 + +**位置**:`src/infra/redis/` + +**关键文件**: +- `redis_client.rs`:Redis 客户端封装 +- `redis_key.rs`:Redis Key 命名规范 + +### 6. 工具层(utils/) + +**职责**:提供通用工具函数 + +**位置**:`src/utils/` + +**关键文件**: +- `jwt.rs`:JWT Token 生成和验证 + +**示例**: +```rust +// src/utils/jwt.rs + +pub struct TokenService; + +impl TokenService { + /// 生成 Access Token + pub fn generate_access_token( + user_id: &str, + expiration_minutes: u64, + jwt_secret: &str, + ) -> Result { + // ... 生成 JWT Token + } + + /// 验证 Token 并提取 user_id + pub fn decode_user_id(token: &str, jwt_secret: &str) -> Result { + // ... 验证并解码 JWT Token + } +} +``` + +--- + +## 数据流转 + +### 用户注册流程 + +``` +1. 客户端发起 POST /auth/register 请求 + ↓ +2. handlers/auth.rs::register() 接收请求 + - 提取请求参数(RegisterRequest) + ↓ +3. services/auth_service.rs::register() 处理业务逻辑 + - 验证邮箱格式 + - 生成唯一用户 ID + - 哈希密码 + - 创建用户实体 + ↓ +4. repositories/user_repository.rs::create() 保存到数据库 + - 使用 SeaORM 插入数据 + ↓ +5. services/auth_service.rs 生成 Token + - 生成 Access Token + - 生成 Refresh Token + ↓ +6. Redis 保存 Refresh Token + ↓ +7. handlers/auth.rs 封装响应(RegisterResult) + ↓ +8. 返回 JSON 响应给客户端 +``` + +### 访问受保护接口流程 + +``` +1. 客户端发起 POST /auth/delete 请求 + - 携带 Authorization: Bearer + ↓ +2. infra/middleware/auth.rs::auth_middleware() 拦截 + - 验证 Token 格式 + - 验证 JWT 签名 + - 检查 Token 过期时间 + - 提取 user_id 并添加到请求扩展 + ↓ +3. handlers/auth.rs::delete_account() 接收请求 + - 从扩展中提取 user_id + ↓ +4. services/auth_service.rs::delete_account() 处理业务逻辑 + - 验证密码 + - 调用 Repository 删除用户 + ↓ +5. repositories/user_repository.rs::delete() 删除数据库记录 + ↓ +6. 返回响应 +``` + +--- + +## 核心组件 + +### AppState + +**职责**:应用全局状态 + +**位置**:`src/main.rs` + +```rust +#[derive(Clone)] +pub struct AppState { + pub pool: db::DbPool, // 数据库连接池 + pub config: config::app::AppConfig, // 应用配置 + pub redis_client: infra::redis::redis_client::RedisClient, // Redis 客户端 +} +``` + +**用途**: +- 通过 Axum State 机制注入到所有处理器 +- 提供数据库访问 +- 提供配置信息 +- 提供 Redis 访问 + +### 路由配置 + +**位置**:`src/main.rs` + +```rust +// 公开路由 +let public_routes = Router::new() + .route("/health", get(handlers::health::health_check)) + .route("/info", get(handlers::health::server_info)) + .route("/auth/register", post(handlers::auth::register)) + .route("/auth/login", post(handlers::auth::login)) + .route("/auth/refresh", post(handlers::auth::refresh)); + +// 受保护路由 +let protected_routes = Router::new() + .route("/auth/delete", post(handlers::auth::delete_account)) + .route("//auth/delete-refresh-token", post(handlers::auth::delete_refresh_token)) + .route_layer(axum::middleware::from_fn_with_state( + app_state.clone(), + infra::middleware::auth::auth_middleware, + )); + +// 合并所有路由 +let app = public_routes + .merge(protected_routes) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) + .layer(axum::middleware::from_fn( + infra::middleware::logging::logging_middleware, + )); +``` + +--- + +## 相关文档 + +- [DDD 架构规范](ddd-architecture.md) - DDD 设计原则和最佳实践 +- [代码风格规范](code-style.md) - Rust 代码风格和命名规范 +- [快速开始指南](getting-started.md) - 安装和运行项目 + +--- + +**提示**:遵循 DDD 分层架构可以提高代码质量和可维护性。 diff --git a/docs/sql/init.sql b/docs/sql/init.sql new file mode 100644 index 0000000..7cdf221 --- /dev/null +++ b/docs/sql/init.sql @@ -0,0 +1,23 @@ +-- ============================================ +-- Web Template 数据库初始化脚本 +-- ============================================ + +CREATE DATABASE IF NOT EXISTS `web_template` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE `web_template`; + +-- ============================================ +-- 1. 用户表 +-- ============================================ +CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(10) PRIMARY KEY COMMENT '10位数字用户ID', + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL COMMENT '创建时间', + updated_at DATETIME NOT NULL COMMENT '更新时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================ +-- 初始化完成 +-- ============================================ +SELECT '✅ 数据库初始化完成' AS status; +SHOW TABLES; diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..0186986 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,234 @@ +/// 命令行参数和配置管理 +/// 支持优先级:CLI 参数 > 环境变量 > 配置文件 > 默认值 +use clap::{Parser, ValueEnum}; +use std::path::PathBuf; + +/// 运行环境(强类型) +#[derive(ValueEnum, Clone, Debug)] +pub enum Environment { + /// 开发环境 + Development, + /// 生产环境 + Production, +} + +impl Environment { + /// 转换为小写字符串 + pub fn as_str(&self) -> &'static str { + match self { + Environment::Development => "development", + Environment::Production => "production", + } + } +} + +/// 命令行参数 +#[derive(Parser, Debug)] +#[command(name = "web-rust-template")] +#[command(about = "Web Server Template", long_about = None)] +#[command(author = "Your Name ")] +#[command(version = "0.1.0")] +#[command(propagate_version = true)] +pub struct CliArgs { + /// 指定配置文件路径 + /// + /// 支持相对路径和绝对路径 + /// 例如:-c config/production.toml + #[arg(short, long, value_name = "FILE")] + pub config: Option, + + /// 指定运行环境 + /// + /// 自动加载对应环境的配置文件(如 config/development.toml) + /// 可通过环境变量 ENV 设置 + #[arg( + short = 'e', + long, + value_enum, + env = "ENV", + default_value = "development" + )] + pub env: Environment, + + /// 指定服务器监听端口 + /// + /// 覆盖配置文件中的 port 设置 + /// 可通过环境变量 SERVER_PORT 设置 + #[arg(short, long, global = true, env = "SERVER_PORT")] + pub port: Option, + + /// 指定服务器监听地址 + /// + /// 覆盖配置文件中的 host 设置 + /// 可通过环境变量 SERVER_HOST 设置 + #[arg(long, global = true, env = "SERVER_HOST")] + pub host: Option, + + /// 启用调试日志 + /// + /// 输出详细的日志信息,包括 SQL 查询 + /// 可通过环境变量 DEBUG 设置 + /// 注意:与 -v 冲突,推荐使用 -v/-vv/-vvv + #[arg(long, global = true, env = "DEBUG", conflicts_with = "verbose")] + pub debug: bool, + + /// 工作目录 + /// + /// 指定配置文件和数据库的基准目录 + #[arg(short, long, global = true)] + pub work_dir: Option, + + /// 显示详细日志(多级 verbose) + /// + /// -v : info 级别日志 + /// -vv : debug 级别日志(等同于 --debug) + /// -vvv : trace 级别日志(最详细) + #[arg(short, long, global = true, action = clap::ArgAction::Count)] + pub verbose: u8, +} + +impl CliArgs { + /// 获取是否启用调试 + pub fn is_debug_enabled(&self) -> bool { + self.debug || self.verbose >= 2 + } + + /// 获取日志级别 + pub fn get_log_level(&self) -> &'static str { + if self.debug { + return "debug"; + } + match self.verbose { + 0 => "info", + 1 => "debug", + _ => "trace", + } + } + + /// 获取环境变量的日志过滤器(工程化版本) + pub fn get_log_filter(&self) -> String { + let level = self.get_log_level(); + match level { + "trace" => "web_rust_template=trace,tower_http=trace,axum=trace,sqlx=debug".into(), + "debug" => "web_rust_template=debug,tower_http=debug,axum=debug,sqlx=debug".into(), + _ => "web_rust_template=info,tower_http=info,axum=info".into(), + } + } + + /// 获取配置文件路径 + /// + /// 优先级: + /// 1. CLI 参数 --config + /// 2. 环境变量 CONFIG + /// 3. {work_dir}/config/{env}.toml + /// 4. ./config/{env}.toml + /// 5. ./config/default.toml + /// + /// 如果找不到配置文件,返回 None(允许仅使用环境变量运行) + pub fn resolve_config_path(&self) -> Option { + use std::env; + + // 1. CLI 参数优先 + if let Some(ref config) = self.config { + if config.exists() { + return Some(config.clone()); + } + eprintln!("⚠ 警告:指定的配置文件不存在: {}", config.display()); + eprintln!(" 将仅使用环境变量运行"); + return None; + } + + // 2. 环境变量 + if let Ok(config_path) = env::var("CONFIG") { + let config = PathBuf::from(&config_path); + if config.exists() { + return Some(config); + } + eprintln!("⚠ 警告:环境变量 CONFIG 指定的配置文件不存在: {}", config_path); + eprintln!(" 将仅使用环境变量运行"); + return None; + } + + // 3-6. 查找配置文件 + let work_dir = self + .work_dir + .clone() + .or_else(|| env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + + let env_name = self.env.as_str(); + + // 按优先级尝试的位置 + let candidates = [ + // 工作目录下的环境配置 + work_dir.join("config").join(format!("{}.toml", env_name)), + // 当前目录的环境配置 + PathBuf::from(format!("config/{}.toml", env_name)), + // 工作目录下的默认配置 + work_dir.join("config").join("default.toml"), + // 当前目录的默认配置 + PathBuf::from("config/default.toml"), + ]; + + for candidate in &candidates { + if candidate.exists() { + // 使用 println! 而非 tracing::info! + println!("✓ Found configuration file: {}", candidate.display()); + return Some(candidate.clone()); + } + } + + // 所有候选路径都找不到配置文件,返回 None + eprintln!("ℹ 未找到配置文件,将仅使用环境变量和默认值"); + None + } + + /// 获取覆盖配置 + /// + /// CLI 参数可以覆盖配置文件中的值(仅 Web 服务器参数) + pub fn get_overrides(&self) -> ConfigOverrides { + ConfigOverrides { + host: self.host.clone(), + port: self.port, + } + } + + /// 显示启动信息(工程化版本:打印实际解析的配置) + /// + /// 使用 println! 而非 tracing::info!,因为 logger 可能尚未初始化 + pub fn print_startup_info(&self) { + let separator = "=".repeat(60); + println!("{}", separator); + println!("Web Rust Template Server v0.1.0"); + println!("Environment: {}", self.env.as_str()); + + // 打印实际解析的配置路径(而非 CLI 参数) + if let Some(config_path) = self.resolve_config_path() { + println!("Config file: {}", config_path.display()); + } else { + println!("Config file: None (using environment variables)"); + } + + if let Some(ref work_dir) = self.work_dir { + println!("Work directory: {}", work_dir.display()); + } + + // 打印实际的日志级别 + println!("Log level: {}", self.get_log_level()); + + if self.is_debug_enabled() { + println!("Debug mode: ENABLED"); + } + println!("{}", separator); + } +} + +/// CLI 参数覆盖的配置(仅 Web 服务器参数) +#[derive(Debug, Clone)] +pub struct ConfigOverrides { + /// Web 服务器主机覆盖 + pub host: Option, + + /// Web 服务器端口覆盖 + pub port: Option, +} diff --git a/src/config/app.rs b/src/config/app.rs new file mode 100644 index 0000000..f85ce4a --- /dev/null +++ b/src/config/app.rs @@ -0,0 +1,135 @@ +use super::{auth::AuthConfig, database::DatabaseConfig, redis::RedisConfig, server::ServerConfig}; +use config::{Config, ConfigError, Environment, File}; +use serde::Deserialize; +use std::path::PathBuf; + +// 导入 redis 默认值函数(使用完整路径) +use crate::config::redis::default_redis_host; + +#[derive(Debug, Deserialize, Clone)] +pub struct AppConfig { + pub server: ServerConfig, + pub database: DatabaseConfig, + pub auth: AuthConfig, + pub redis: RedisConfig, +} + +impl AppConfig { + /// 加载配置(支持 CLI 覆盖) + /// + /// 如果 config_path 为 None,则仅使用环境变量和默认值 + pub fn load_with_overrides( + cli_config_path: Option, + overrides: crate::cli::ConfigOverrides, + _environment: &str, + ) -> Result { + // 使用 ConfigBuilder 设置配置 + let mut builder = Config::builder(); + + // 如果提供了配置文件,先加载它 + if let Some(config_path) = cli_config_path { + if !config_path.exists() { + tracing::error!("Configuration file not found: {}", config_path.display()); + return Err(ConfigError::NotFound( + config_path.to_string_lossy().to_string(), + )); + } + tracing::info!("Loading configuration from: {}", config_path.display()); + builder = builder.add_source(File::from(config_path)); + } else { + tracing::info!("No configuration file found, using environment variables and defaults"); + tracing::warn!("⚠️ 没有找到配置文件,将使用 SQLite 作为默认数据库"); + tracing::warn!(" 默认数据库路径: db.sqlite3"); + tracing::warn!(" 如需使用其他数据库,请创建配置文件或设置环境变量"); + + // 直接使用 set_default 设置默认值 + // 注意:这些值会被环境变量覆盖 + builder = builder.set_default("server.host", default_server_host())?; + builder = builder.set_default("server.port", default_server_port())?; + + // 设置 database 默认值(使用 SQLite 作为默认数据库) + builder = builder.set_default("database.database_type", "sqlite")?; + builder = builder.set_default("database.path", "db.sqlite3")?; + builder = builder.set_default("database.max_connections", 10)?; + + // 设置 auth 默认值 + builder = builder.set_default("auth.jwt_secret", default_jwt_secret())?; + builder = builder.set_default("auth.access_token_expiration_minutes", 15)?; + builder = builder.set_default("auth.refresh_token_expiration_days", 7)?; + + // 设置 redis 默认值 + builder = builder.set_default("redis.host", default_redis_host())?; + builder = builder.set_default("redis.port", 6379)?; + builder = builder.set_default("redis.db", 0)?; + } + + // 添加环境变量源(会覆盖配置文件的值) + builder = builder.add_source(Environment::default().separator("_")); + + // 应用 CLI 覆盖(仅 Web 服务器参数) + if let Some(host) = overrides.host { + builder = builder.set_override("server.host", host)?; + } + if let Some(port) = overrides.port { + builder = builder.set_override("server.port", port)?; + } + + let settings = builder.build()?; + let config: AppConfig = settings.try_deserialize()?; + + // 安全警告:检查是否使用了默认的 JWT 密钥 + if config.auth.jwt_secret == "change-this-to-a-strong-secret-key-in-production" { + tracing::warn!("⚠️ 警告:正在使用不安全的默认 JWT 密钥!"); + tracing::warn!(" 请通过环境变量 AUTH_JWT_SECRET 或配置文件设置强密钥"); + tracing::warn!(" 示例:AUTH_JWT_SECRET=your-secure-random-string-here"); + } + + // 验证数据库配置 + if let Err(e) = config.database.validate() { + tracing::error!("数据库配置无效: {}", e); + return Err(ConfigError::Message(format!("数据库配置无效: {}", e))); + } + + Ok(config) + } + + /// 从指定路径加载配置 + pub fn load_from_path(path: &str) -> Result { + tracing::info!("Loading configuration from: {}", path); + + let settings = Config::builder() + .add_source(File::from(PathBuf::from(path))) + .add_source(Environment::default().separator("_")) + .build()?; + + let config: AppConfig = settings.try_deserialize()?; + + // 安全警告:检查是否使用了默认的 JWT 密钥 + if config.auth.jwt_secret == "change-this-to-a-strong-secret-key-in-production" { + tracing::warn!("⚠️ 警告:正在使用不安全的默认 JWT 密钥!"); + tracing::warn!(" 请通过环境变量 AUTH_JWT_SECRET 或配置文件设置强密钥"); + tracing::warn!(" 示例:AUTH_JWT_SECRET=your-secure-random-string-here"); + } + + // 验证数据库配置 + if let Err(e) = config.database.validate() { + tracing::error!("数据库配置无效: {}", e); + return Err(ConfigError::Message(format!("数据库配置无效: {}", e))); + } + + Ok(config) + } +} + +// 默认值函数(复用) +fn default_server_host() -> String { + "127.0.0.1".to_string() +} + +fn default_server_port() -> u16 { + 3000 +} + +fn default_jwt_secret() -> String { + "change-this-to-a-strong-secret-key-in-production".to_string() +} diff --git a/src/config/auth.rs b/src/config/auth.rs new file mode 100644 index 0000000..9a49347 --- /dev/null +++ b/src/config/auth.rs @@ -0,0 +1,25 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct AuthConfig { + #[serde(default = "default_jwt_secret")] + pub jwt_secret: String, + #[serde(default = "default_access_token_expiration_minutes")] + pub access_token_expiration_minutes: u64, + #[serde(default = "default_refresh_token_expiration_days")] + pub refresh_token_expiration_days: i64, +} + +fn default_jwt_secret() -> String { + // ⚠️ 警告:这是一个不安全的默认值,仅用于开发测试 + // 生产环境必须通过环境变量或配置文件设置强密钥 + "change-this-to-a-strong-secret-key-in-production".to_string() +} + +fn default_access_token_expiration_minutes() -> u64 { + 15 +} + +fn default_refresh_token_expiration_days() -> i64 { + 7 +} diff --git a/src/config/database.rs b/src/config/database.rs new file mode 100644 index 0000000..759228e --- /dev/null +++ b/src/config/database.rs @@ -0,0 +1,168 @@ +use serde::Deserialize; +use std::path::PathBuf; + +/// 数据库类型 +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum DatabaseType { + MySQL, + SQLite, + PostgreSQL, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct DatabaseConfig { + /// 数据库类型 + #[serde(default = "default_database_type")] + pub database_type: DatabaseType, + + /// 网络数据库配置(MySQL/PostgreSQL) + pub host: Option, + #[serde(default)] + pub port: Option, + pub user: Option, + pub password: Option, + pub database: Option, + + /// SQLite 文件路径 + pub path: Option, + + /// 连接池最大连接数 + #[serde(default = "default_max_connections")] + pub max_connections: u32, +} + +impl DatabaseConfig { + /// 获取端口号(根据数据库类型返回默认值) + pub fn get_port(&self) -> u16 { + self.port.unwrap_or_else(|| match self.database_type { + DatabaseType::MySQL => 3306, + DatabaseType::PostgreSQL => 5432, + DatabaseType::SQLite => 0, + }) + } + + /// 构建数据库连接 URL + /// + /// # 错误 + /// + /// 当缺少必需的配置字段时返回错误 + pub fn build_url(&self) -> Result { + match self.database_type { + DatabaseType::MySQL => { + let host = self + .host + .as_ref() + .ok_or_else(|| "MySQL 需要配置 database.host".to_string())?; + let user = self + .user + .as_ref() + .ok_or_else(|| "MySQL 需要配置 database.user".to_string())?; + let password = self + .password + .as_ref() + .ok_or_else(|| "MySQL 需要配置 database.password".to_string())?; + let database = self + .database + .as_ref() + .ok_or_else(|| "MySQL 需要配置 database.database".to_string())?; + + Ok(format!( + "mysql://{}:{}@{}:{}/{}", + user, + password, + host, + self.get_port(), + database + )) + } + DatabaseType::SQLite => { + let path = self + .path + .as_ref() + .ok_or_else(|| "SQLite 需要配置 database.path".to_string())?; + + // SQLite URL 格式 + // 相对路径:sqlite:./db.sqlite3 + // 绝对路径:sqlite:C:/path/to/db.sqlite3 + let path_str = path.to_string_lossy().replace('\\', "/"); + Ok(format!("sqlite:{}", path_str)) + } + DatabaseType::PostgreSQL => { + let host = self + .host + .as_ref() + .ok_or_else(|| "PostgreSQL 需要配置 database.host".to_string())?; + let user = self + .user + .as_ref() + .ok_or_else(|| "PostgreSQL 需要配置 database.user".to_string())?; + let password = self + .password + .as_ref() + .ok_or_else(|| "PostgreSQL 需要配置 database.password".to_string())?; + let database = self + .database + .as_ref() + .ok_or_else(|| "PostgreSQL 需要配置 database.database".to_string())?; + + Ok(format!( + "postgresql://{}:{}@{}:{}/{}", + user, + password, + host, + self.get_port(), + database + )) + } + } + } + + /// 验证配置是否完整 + pub fn validate(&self) -> Result<(), String> { + match self.database_type { + DatabaseType::MySQL => { + if self.host.is_none() { + return Err("MySQL 需要配置 database.host".to_string()); + } + if self.user.is_none() { + return Err("MySQL 需要配置 database.user".to_string()); + } + if self.password.is_none() { + return Err("MySQL 需要配置 database.password".to_string()); + } + if self.database.is_none() { + return Err("MySQL 需要配置 database.database".to_string()); + } + } + DatabaseType::SQLite => { + if self.path.is_none() { + return Err("SQLite 需要配置 database.path".to_string()); + } + } + DatabaseType::PostgreSQL => { + if self.host.is_none() { + return Err("PostgreSQL 需要配置 database.host".to_string()); + } + if self.user.is_none() { + return Err("PostgreSQL 需要配置 database.user".to_string()); + } + if self.password.is_none() { + return Err("PostgreSQL 需要配置 database.password".to_string()); + } + if self.database.is_none() { + return Err("PostgreSQL 需要配置 database.database".to_string()); + } + } + } + Ok(()) + } +} + +fn default_database_type() -> DatabaseType { + DatabaseType::MySQL +} + +fn default_max_connections() -> u32 { + 10 +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..69d1e76 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,5 @@ +pub mod app; +pub mod auth; +pub mod database; +pub mod redis; +pub mod server; diff --git a/src/config/redis.rs b/src/config/redis.rs new file mode 100644 index 0000000..c9291bb --- /dev/null +++ b/src/config/redis.rs @@ -0,0 +1,52 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct RedisConfig { + /// Redis 主机地址 + #[serde(default = "default_redis_host")] + pub host: String, + + /// Redis 端口 + #[serde(default = "default_redis_port")] + pub port: u16, + + /// Redis 密码(可选) + #[serde(default)] + pub password: Option, + + /// Redis 数据库编号(可选) + #[serde(default = "default_redis_db")] + pub db: u8, +} + +pub fn default_redis_host() -> String { + "localhost".to_string() +} + +pub fn default_redis_port() -> u16 { + 6379 +} + +pub fn default_redis_db() -> u8 { + 0 +} + +impl RedisConfig { + /// 构建 Redis 连接 URL + pub fn build_url(&self) -> String { + // 判断密码是否存在且非空 + match &self.password { + Some(password) if !password.is_empty() => { + // 有密码:redis://:password@host:port/db + format!( + "redis://:{}@{}:{}/{}", + password, self.host, self.port, self.db + ) + } + _ => { + // 无密码(None 或空字符串):redis://host:port/db + format!("redis://{}:{}/{}", self.host, self.port, self.db) + } + } + } +} diff --git a/src/config/server.rs b/src/config/server.rs new file mode 100644 index 0000000..3bf66fe --- /dev/null +++ b/src/config/server.rs @@ -0,0 +1,17 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct ServerConfig { + #[serde(default = "default_server_host")] + pub host: String, + #[serde(default = "default_server_port")] + pub port: u16, +} + +fn default_server_host() -> String { + "127.0.0.1".to_string() +} + +fn default_server_port() -> u16 { + 3000 +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..dcaac57 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,329 @@ +use crate::config::database::{DatabaseConfig, DatabaseType}; +use sea_orm::{ + ConnectionTrait, Database, DatabaseConnection, DbBackend, EntityName, EntityTrait, ConnectOptions, Schema, + Statement, +}; +use std::time::Duration; + +/// 数据库连接池(SeaORM 统一接口) +pub type DbPool = DatabaseConnection; + +/// 创建数据库连接池 +pub async fn create_pool(config: &DatabaseConfig) -> anyhow::Result { + let url = config + .build_url() + .map_err(|e| anyhow::anyhow!("数据库配置错误: {}", e))?; + + tracing::debug!("数据库连接 URL: {}", url); + + let mut opt = ConnectOptions::new(&url); + opt.max_connections(config.max_connections) + .min_connections(1) + .connect_timeout(Duration::from_secs(8)) + .idle_timeout(Duration::from_secs(8)) + .max_lifetime(Duration::from_secs(7200)) + .sqlx_logging(true); + + let pool = Database::connect(opt) + .await + .map_err(|e| anyhow::anyhow!("数据库连接失败: {}", e))?; + + tracing::info!("已连接到数据库: {}", sanitize_url(&url)); + + Ok(pool) +} + +/// 隐藏 URL 中的敏感信息(用于日志输出) +fn sanitize_url(url: &str) -> String { + // 隐藏密码:mysql://user:password@host -> mysql://user:***@host + if let Some(at_pos) = url.find('@') { + if let Some(scheme_end) = url.find("://") { + if scheme_end < at_pos { + return format!("{}***@{}", &url[..scheme_end + 3], &url[at_pos + 1..]); + } + } + } + url.to_string() +} + +/// 健康检查(保持向后兼容) +pub async fn health_check(pool: &DbPool) -> anyhow::Result<()> { + // 使用官方推荐的 ping 方法 + pool.ping() + .await + .map_err(|e| anyhow::anyhow!("数据库健康检查失败: {}", e)) +} + +/// 初始化数据库和表结构 +/// 每次启动时检查数据库和表是否存在,不存在则创建 +pub async fn init_database(config: &DatabaseConfig) -> anyhow::Result { + match config.database_type { + DatabaseType::MySQL => { + init_mysql_database(config).await?; + } + DatabaseType::PostgreSQL => { + init_postgresql_database(config).await?; + } + DatabaseType::SQLite => { + // 确保 SQLite 数据库文件的目录存在 + init_sqlite_database(config).await?; + } + } + + // 连接到数据库 + let pool = create_pool(config).await?; + + // 创建表 + create_tables(&pool).await?; + + Ok(pool) +} + +/// 获取端口号(根据数据库类型返回默认值) +fn get_database_port(config: &DatabaseConfig) -> u16 { + config.port.unwrap_or_else(|| match config.database_type { + DatabaseType::MySQL => 3306, + DatabaseType::PostgreSQL => 5432, + DatabaseType::SQLite => 0, + }) +} + +/// 为 MySQL 创建数据库(如果不存在) +async fn init_mysql_database(config: &DatabaseConfig) -> anyhow::Result<()> { + let database_name = config + .database + .as_ref() + .ok_or_else(|| anyhow::anyhow!("MySQL 需要配置 database.database"))?; + + let host = config + .host + .as_ref() + .ok_or_else(|| anyhow::anyhow!("MySQL 需要配置 database.host"))?; + let user = config + .user + .as_ref() + .ok_or_else(|| anyhow::anyhow!("MySQL 需要配置 database.user"))?; + let password = config + .password + .as_ref() + .ok_or_else(|| anyhow::anyhow!("MySQL 需要配置 database.password"))?; + + // 连接到 MySQL 服务器(不指定数据库) + let url = format!( + "mysql://{}:{}@{}:{}", + user, + password, + host, + get_database_port(config) + ); + + let mut opt = ConnectOptions::new(&url); + opt.max_connections(1) + .connect_timeout(Duration::from_secs(8)) + .sqlx_logging(true); + + let conn = Database::connect(opt) + .await + .map_err(|e| anyhow::anyhow!("连接 MySQL 服务器失败: {}", e))?; + + // 检查数据库是否存在,不存在则创建 + let query = format!( + "CREATE DATABASE IF NOT EXISTS `{}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + database_name + ); + + conn.execute(Statement::from_string( + sea_orm::DatabaseBackend::MySql, + query, + )) + .await + .map_err(|e| anyhow::anyhow!("创建 MySQL 数据库失败: {}", e))?; + + tracing::info!("✅ MySQL 数据库 '{}' 检查完成", database_name); + + Ok(()) +} + +/// 为 PostgreSQL 创建数据库(如果不存在) +async fn init_postgresql_database(config: &DatabaseConfig) -> anyhow::Result<()> { + let database_name = config + .database + .as_ref() + .ok_or_else(|| anyhow::anyhow!("PostgreSQL 需要配置 database.database"))?; + + let host = config + .host + .as_ref() + .ok_or_else(|| anyhow::anyhow!("PostgreSQL 需要配置 database.host"))?; + let user = config + .user + .as_ref() + .ok_or_else(|| anyhow::anyhow!("PostgreSQL 需要配置 database.user"))?; + let password = config + .password + .as_ref() + .ok_or_else(|| anyhow::anyhow!("PostgreSQL 需要配置 database.password"))?; + + // 连接到 PostgreSQL 默认数据库(postgres) + let url = format!( + "postgresql://{}:{}@{}:{}/postgres", + user, + password, + host, + get_database_port(config) + ); + + let mut opt = ConnectOptions::new(&url); + opt.max_connections(1) + .connect_timeout(Duration::from_secs(8)) + .sqlx_logging(true); + + let conn = Database::connect(opt) + .await + .map_err(|e| anyhow::anyhow!("连接 PostgreSQL 服务器失败: {}", e))?; + + // 检查数据库是否存在,不存在则创建 + // PostgreSQL 不支持 CREATE DATABASE IF NOT EXISTS,需要先查询 + let check_query = format!( + "SELECT 1 FROM pg_database WHERE datname='{}'", + database_name + ); + + let result = conn + .execute(Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + check_query, + )) + .await; + + match result { + Ok(_) => { + tracing::info!("PostgreSQL 数据库 '{}' 已存在", database_name); + } + Err(_) => { + // 数据库不存在,创建它 + let create_query = format!( + "CREATE DATABASE {} WITH ENCODING 'UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8'", + database_name + ); + + conn.execute(Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + create_query, + )) + .await + .map_err(|e| anyhow::anyhow!("创建 PostgreSQL 数据库失败: {}", e))?; + + tracing::info!("✅ PostgreSQL 数据库 '{}' 创建成功", database_name); + } + } + + Ok(()) +} + +/// 为 SQLite 确保数据库文件目录存在 +async fn init_sqlite_database(config: &DatabaseConfig) -> anyhow::Result<()> { + let path = config + .path + .as_ref() + .ok_or_else(|| anyhow::anyhow!("SQLite 需要配置 database.path"))?; + + // 如果是相对路径,转换为绝对路径 + let absolute_path = if path.is_absolute() { + path.clone() + } else { + std::env::current_dir() + .map_err(|e| anyhow::anyhow!("获取当前目录失败: {}", e))? + .join(path) + }; + + tracing::info!("SQLite 数据库路径: {}", absolute_path.display()); + + // 获取数据库文件的父目录 + if let Some(parent) = absolute_path.parent() { + // 如果父目录不存在,则创建 + if !parent.exists() { + std::fs::create_dir_all(parent) + .map_err(|e| anyhow::anyhow!("创建 SQLite 数据库目录失败: {}", e))?; + tracing::info!("✅ SQLite 数据库目录创建成功: {}", parent.display()); + } else { + tracing::info!("SQLite 数据库目录已存在: {}", parent.display()); + } + } + + // 如果数据库文件不存在,创建空文件 + if !absolute_path.exists() { + std::fs::File::create(&absolute_path) + .map_err(|e| anyhow::anyhow!("创建 SQLite 数据库文件失败: {}", e))?; + tracing::info!("✅ SQLite 数据库文件创建成功: {}", absolute_path.display()); + } else { + tracing::info!("SQLite 数据库文件已存在: {}", absolute_path.display()); + } + + Ok(()) +} + +/// 辅助函数:创建单个表(如果不存在) +async fn create_single_table( + db: &DatabaseConnection, + schema: &Schema, + builder: &DbBackend, + entity: E, + table_name: &str, +) -> anyhow::Result<()> +where + E: EntityName + EntityTrait, +{ + let create_table = schema.create_table_from_entity(entity); + + let sql = match builder { + DbBackend::MySql => { + use sea_orm::sea_query::MysqlQueryBuilder; + create_table.to_string(MysqlQueryBuilder {}) + } + DbBackend::Postgres => { + use sea_orm::sea_query::PostgresQueryBuilder; + create_table.to_string(PostgresQueryBuilder {}) + } + DbBackend::Sqlite => { + use sea_orm::sea_query::SqliteQueryBuilder; + create_table.to_string(SqliteQueryBuilder {}) + } + }; + + let sql = sql.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS"); + + match db.execute(Statement::from_string(*builder, sql)).await { + Ok(_) => { + tracing::info!("✅ {}检查完成", table_name); + } + Err(e) => { + let err_msg = e.to_string(); + if err_msg.contains("already exists") || (err_msg.contains("table") && err_msg.contains("exists")) { + tracing::info!("✅ {}已存在", table_name); + } else { + return Err(anyhow::anyhow!("创建{}失败: {}", table_name, e)); + } + } + } + + Ok(()) +} + +/// 创建数据库表结构 +async fn create_tables(db: &DatabaseConnection) -> anyhow::Result<()> { + tracing::info!("检查数据库表结构..."); + + let builder = db.get_database_backend(); + let schema = Schema::new(builder); + + // 导入所有 entities + use crate::domain::entities::users; + + // 创建所有表(添加新表只需一行!) + create_single_table(db, &schema, &builder, users::Entity, "用户表").await?; + + tracing::info!("✅ 数据库表结构检查完成"); + + Ok(()) +} diff --git a/src/domain/dto/auth.rs b/src/domain/dto/auth.rs new file mode 100644 index 0000000..f5d040d --- /dev/null +++ b/src/domain/dto/auth.rs @@ -0,0 +1,57 @@ +use serde::Deserialize; +use std::fmt; + +/// 注册请求 +#[derive(Deserialize)] +pub struct RegisterRequest { + pub email: String, + pub password: String, +} + +// 实现 Debug trait,对密码进行脱敏 +impl fmt::Debug for RegisterRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "RegisterRequest {{ email: {}, password: *** }}", self.email) + } +} + +/// 登录请求 +#[derive(Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +// 实现 Debug trait +impl fmt::Debug for LoginRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "LoginRequest {{ email: {}, password: *** }}", self.email) + } +} + +/// 删除用户请求 +#[derive(Deserialize)] +pub struct DeleteUserRequest { + pub user_id: String, + pub password: String, +} + +// 实现 Debug trait +impl fmt::Debug for DeleteUserRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "DeleteUserRequest {{ user_id: {}, password: *** }}", self.user_id) + } +} + +/// 刷新令牌请求 +#[derive(Deserialize)] +pub struct RefreshRequest { + pub refresh_token: String, +} + +// RefreshRequest 的 refresh_token 是敏感字段,需要脱敏 +impl fmt::Debug for RefreshRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "RefreshRequest {{ refresh_token: *** }}") + } +} diff --git a/src/domain/dto/mod.rs b/src/domain/dto/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/src/domain/dto/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/src/domain/dto/user.rs b/src/domain/dto/user.rs new file mode 100644 index 0000000..ffb2e1b --- /dev/null +++ b/src/domain/dto/user.rs @@ -0,0 +1 @@ +// 用户相关 DTO(预留) diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs new file mode 100644 index 0000000..995a558 --- /dev/null +++ b/src/domain/entities/mod.rs @@ -0,0 +1,2 @@ +pub mod users; + diff --git a/src/domain/entities/users.rs b/src/domain/entities/users.rs new file mode 100644 index 0000000..0bb7e6d --- /dev/null +++ b/src/domain/entities/users.rs @@ -0,0 +1,41 @@ +use sea_orm::entity::prelude::*; +use sea_orm::Set; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + #[sea_orm(unique)] + pub email: String, + pub password_hash: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + /// 在保存前自动填充时间戳 + async fn before_save(self, _db: &C, insert: bool) -> Result + where + C: ConnectionTrait, + { + let mut this = self; + let now = chrono::Utc::now().naive_utc(); + + if insert { + // 插入时:设置创建时间和更新时间 + this.created_at = Set(now); + this.updated_at = Set(now); + } else { + // 更新时:只更新更新时间 + this.updated_at = Set(now); + } + + Ok(this) + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..09561d9 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod vo; +pub mod entities; diff --git a/src/domain/vo/auth.rs b/src/domain/vo/auth.rs new file mode 100644 index 0000000..f51f29e --- /dev/null +++ b/src/domain/vo/auth.rs @@ -0,0 +1,50 @@ +use serde::Serialize; + +/// 注册结果 +#[derive(Debug, Serialize)] +pub struct RegisterResult { + pub email: String, + pub created_at: String, // ISO 8601 格式 + pub access_token: String, + pub refresh_token: String, +} + +impl From<(crate::domain::entities::users::Model, String, String)> for RegisterResult { + fn from((user_model, access_token, refresh_token): (crate::domain::entities::users::Model, String, String)) -> Self { + Self { + email: user_model.email, + created_at: user_model.created_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(), + access_token, + refresh_token, + } + } +} + +/// 登录结果 +#[derive(Debug, Serialize)] +pub struct LoginResult { + pub id: String, + pub email: String, + pub created_at: String, // ISO 8601 格式 + pub access_token: String, + pub refresh_token: String, +} + +impl From<(crate::domain::entities::users::Model, String, String)> for LoginResult { + fn from((user_model, access_token, refresh_token): (crate::domain::entities::users::Model, String, String)) -> Self { + Self { + id: user_model.id, + email: user_model.email, + created_at: user_model.created_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(), + access_token, + refresh_token, + } + } +} + +/// 刷新 Token 结果 +#[derive(Debug, Serialize)] +pub struct RefreshResult { + pub access_token: String, + pub refresh_token: String, +} diff --git a/src/domain/vo/mod.rs b/src/domain/vo/mod.rs new file mode 100644 index 0000000..31fb983 --- /dev/null +++ b/src/domain/vo/mod.rs @@ -0,0 +1,56 @@ +pub mod auth; +pub mod user; + +/// 统一的 API 响应结构 +use serde::Serialize; +use axum::http::StatusCode; + +#[derive(Debug, Serialize)] +pub struct ApiResponse { + /// HTTP 状态码 + pub code: u16, + /// 响应消息 + pub message: String, + /// 响应数据 + pub data: Option, +} + +impl ApiResponse { + /// 成功响应(200) + pub fn success(data: T) -> Self { + Self { + code: 200, + message: "Success".to_string(), + data: Some(data), + } + } + + /// 成功响应(自定义消息) + pub fn success_with_message(data: T, message: &str) -> Self { + Self { + code: 200, + message: message.to_string(), + data: Some(data), + } + } + + /// 错误响应 + #[allow(dead_code)] + pub fn error(status_code: StatusCode, message: &str) -> ApiResponse<()> { + ApiResponse { + code: status_code.as_u16(), + message: message.to_string(), + data: None, + } + } + + /// 错误响应(带数据) + #[allow(dead_code)] + pub fn error_with_data(status_code: StatusCode, message: &str, data: T) -> ApiResponse { + ApiResponse { + code: status_code.as_u16(), + message: message.to_string(), + data: Some(data), + } + } +} diff --git a/src/domain/vo/user.rs b/src/domain/vo/user.rs new file mode 100644 index 0000000..14637a3 --- /dev/null +++ b/src/domain/vo/user.rs @@ -0,0 +1 @@ +// 用户相关 VO(预留) diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..4373ebb --- /dev/null +++ b/src/error.rs @@ -0,0 +1,89 @@ +use crate::domain::vo::ApiResponse; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; + +/// 应用错误类型 +#[allow(dead_code)] +pub struct AppError(pub anyhow::Error); + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + tracing::error!("Application error: {:?}", self.0); + + let (status, message) = match self.0.downcast_ref::<&str>() { + Some(&"not_found") => (StatusCode::NOT_FOUND, "Resource not found"), + Some(&"unauthorized") => (StatusCode::UNAUTHORIZED, "Unauthorized"), + _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"), + }; + + let body = ApiResponse::<()> { + code: status.as_u16(), + message: message.to_string(), + data: None, + }; + + (status, Json(body)).into_response() + } +} + +// 为具体类型实现 From +impl From for AppError { + fn from(err: anyhow::Error) -> Self { + Self(err) + } +} + +/// 统一的 API 错误响应结构 +#[derive(Debug)] +pub struct ErrorResponse { + pub status: StatusCode, + pub message: String, +} + +impl ErrorResponse { + pub fn new(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } + } + + #[allow(dead_code)] + pub fn not_found(message: impl Into) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: message.into(), + } + } + + #[allow(dead_code)] + pub fn unauthorized(message: impl Into) -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + message: message.into(), + } + } + + #[allow(dead_code)] + pub fn internal(message: impl Into) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: message.into(), + } + } +} + +impl IntoResponse for ErrorResponse { + fn into_response(self) -> Response { + let body = ApiResponse::<()> { + code: self.status.as_u16(), + message: self.message, + data: None, + }; + + (self.status, Json(body)).into_response() + } +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs new file mode 100644 index 0000000..d6753a6 --- /dev/null +++ b/src/handlers/auth.rs @@ -0,0 +1,157 @@ +use crate::error::ErrorResponse; +use crate::infra::middleware::logging::{log_info, RequestId}; +use crate::domain::dto::auth::{RegisterRequest, LoginRequest, RefreshRequest, DeleteUserRequest}; +use crate::domain::vo::auth::{RegisterResult, LoginResult, RefreshResult}; +use crate::domain::vo::ApiResponse; +use crate::repositories::user_repository::UserRepository; +use crate::services::auth_service::AuthService; +use crate::AppState; +use axum::{ + extract::{Extension, State}, + Json, +}; +use serde_json::json; + +/// 注册 +pub async fn register( + Extension(request_id): Extension, + State(state): State, + Json(payload): Json, +) -> Result>, ErrorResponse> { + log_info(&request_id, "注册请求参数", &payload); + + let user_repo = UserRepository::new(state.pool.clone()); + let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone()); + + match service.register(payload).await { + Ok((user_model, access_token, refresh_token)) => { + let data = RegisterResult::from((user_model, access_token, refresh_token)); + let response = ApiResponse::success(data); + log_info(&request_id, "注册成功", &response); + Ok(Json(response)) + } + Err(e) => { + log_info(&request_id, "注册失败", &e.to_string()); + Err(ErrorResponse::new(e.to_string())) + } + } +} + +/// 登录 +pub async fn login( + Extension(request_id): Extension, + State(state): State, + Json(payload): Json, +) -> Result>, ErrorResponse> { + log_info(&request_id, "登录请求参数", &payload); + + let user_repo = UserRepository::new(state.pool.clone()); + let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone()); + + match service.login(payload).await { + Ok((user_model, access_token, refresh_token)) => { + let data = LoginResult::from((user_model, access_token, refresh_token)); + let response = ApiResponse::success(data); + log_info(&request_id, "登录成功", &response); + Ok(Json(response)) + } + Err(e) => { + log_info(&request_id, "登录失败", &e.to_string()); + Err(ErrorResponse::new(e.to_string())) + } + } +} + +/// 刷新 Token +pub async fn refresh( + Extension(request_id): Extension, + State(state): State, + Json(payload): Json, +) -> Result>, ErrorResponse> { + log_info( + &request_id, + "刷新 token 请求", + &json!({"device_id": "default"}), + ); + + let user_repo = UserRepository::new(state.pool.clone()); + let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone()); + + match service + .refresh_access_token(&payload.refresh_token) + .await + { + Ok((access_token, refresh_token)) => { + let data = RefreshResult { + access_token, + refresh_token, + }; + let response = ApiResponse::success(data); + + log_info( + &request_id, + "刷新成功", + &json!({"access_token": "***"}), + ); + Ok(Json(response)) + } + Err(e) => { + log_info(&request_id, "刷新失败", &e.to_string()); + Err(ErrorResponse::new(e.to_string())) + } + } +} + +/// 删除账号 +pub async fn delete_account( + Extension(request_id): Extension, + State(state): State, + Extension(user_id): Extension, + Json(payload): Json, +) -> Result>, ErrorResponse> { + log_info(&request_id, "删除账号请求", &format!("user_id={}", user_id)); + + let user_repo = UserRepository::new(state.pool.clone()); + let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone()); + + let delete_request = DeleteUserRequest { + user_id: user_id.clone(), + password: payload.password, + }; + + match service.delete_user(delete_request).await { + Ok(_) => { + log_info(&request_id, "账号删除成功", &format!("user_id={}", user_id)); + let response = ApiResponse::success_with_message((), "账号删除成功"); + Ok(Json(response)) + } + Err(e) => { + log_info(&request_id, "账号删除失败", &e.to_string()); + Err(ErrorResponse::new(e.to_string())) + } + } +} + +/// 刷新令牌 +pub async fn delete_refresh_token( + Extension(request_id): Extension, + State(state): State, + Extension(user_id): Extension, +) -> Result>, ErrorResponse> { + log_info(&request_id, "删除刷新令牌请求", &format!("user_id={}", user_id)); + + let user_repo = UserRepository::new(state.pool.clone()); + let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone()); + + match service.delete_refresh_token(&user_id).await { + Ok(_) => { + log_info(&request_id, "刷新令牌删除成功", &format!("user_id={}", user_id)); + let response = ApiResponse::success_with_message((), "刷新令牌删除成功"); + Ok(Json(response)) + } + Err(e) => { + log_info(&request_id, "刷新令牌删除失败", &e.to_string()); + Err(ErrorResponse::new(e.to_string())) + } + } +} diff --git a/src/handlers/health.rs b/src/handlers/health.rs new file mode 100644 index 0000000..9180d3b --- /dev/null +++ b/src/handlers/health.rs @@ -0,0 +1,25 @@ +use crate::AppState; +use crate::db; +use axum::{ + extract::State, + response::{IntoResponse, Json}, +}; +use serde_json::json; + +/// 健康检查端点 +pub async fn health_check(State(state): State) -> impl IntoResponse { + match db::health_check(&state.pool).await { + Ok(_) => Json(json!({"status": "ok"})), + Err(_) => Json(json!({"status": "unavailable"})), + } +} + +/// 获取服务器信息 +pub async fn server_info() -> impl IntoResponse { + Json(json!({ + "name": "web-rust-template", + "version": "0.1.0", + "status": "running", + "timestamp": chrono::Utc::now().timestamp() + })) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..a923656 --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod health; diff --git a/src/infra/middleware/auth.rs b/src/infra/middleware/auth.rs new file mode 100644 index 0000000..b5b118c --- /dev/null +++ b/src/infra/middleware/auth.rs @@ -0,0 +1,51 @@ +use crate::AppState; +use axum::{ + extract::{Request, State}, + http::{HeaderMap, StatusCode}, + middleware::Next, + response::Response, +}; +use jsonwebtoken::{decode, DecodingKey, Validation}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Claims { + pub sub: String, // user_id + #[allow(dead_code)] + pub exp: usize, +} + +/// JWT 认证中间件 +pub async fn auth_middleware( + State(state): State, + headers: HeaderMap, + mut req: Request, + next: Next, +) -> Result { + // 1. 提取 Authorization header + let auth_header = headers + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if !auth_header.starts_with("Bearer ") { + return Err(StatusCode::UNAUTHORIZED); + } + + let token = &auth_header[7..]; + + // 2. 验证 JWT + let jwt_secret = &state.config.auth.jwt_secret; + + let token_data = decode::( + token, + &DecodingKey::from_secret(jwt_secret.as_ref()), + &Validation::default(), + ) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + // 3. 将 user_id 添加到请求扩展 + req.extensions_mut().insert(token_data.claims.sub); + + Ok(next.run(req).await) +} diff --git a/src/infra/middleware/logging.rs b/src/infra/middleware/logging.rs new file mode 100644 index 0000000..b13d9bd --- /dev/null +++ b/src/infra/middleware/logging.rs @@ -0,0 +1,71 @@ +use axum::{extract::Request, response::Response}; +use std::time::Instant; + +/// Request ID 标记 +#[derive(Clone)] +pub struct RequestId(pub String); + +/// 请求日志中间件 +pub async fn request_logging_middleware( + mut req: Request, + next: axum::middleware::Next, +) -> Response { + let start = Instant::now(); + + // 提取请求信息 + let method = req.method().clone(); + let path = req.uri().path().to_string(); + let query = req.uri().query().map(|s| s.to_string()); + + // 生成请求 ID + let request_id = uuid::Uuid::new_v4().to_string(); + + // 将 request_id 存储到请求扩展中 + req.extensions_mut().insert(RequestId(request_id.clone())); + + // 第1条日志:请求开始 + let separator = "=".repeat(80); + let header = format!("{} {}", method, path); + + tracing::info!("{}", separator); + tracing::info!("{}", header); + tracing::info!("{}", separator); + + let now_beijing = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + let query_str = query.as_deref().unwrap_or("无"); + tracing::info!( + "[{}] 📥 查询参数: {} | 时间: {}", + request_id, + query_str, + now_beijing + ); + + // 调用下一个处理器 + let response = next.run(req).await; + + // 第3条日志:请求完成 + let duration = start.elapsed(); + let status = response.status(); + tracing::info!( + "[{}] ✅ 状态码: {} | 耗时: {}ms", + request_id, + status.as_u16(), + duration.as_millis() + ); + + tracing::info!("{}", separator); + + response +} + +/// 请求日志辅助工具 +pub fn log_info(request_id: &RequestId, label: &str, data: T) { + let data_str = format!("{:?}", data); + let truncated = if data_str.len() > 300 { + format!("{}...", &data_str[..300]) + } else { + data_str + }; + + tracing::info!("[{}] 🔧 {} | {}", request_id.0, label, truncated); +} diff --git a/src/infra/middleware/mod.rs b/src/infra/middleware/mod.rs new file mode 100644 index 0000000..7eaa040 --- /dev/null +++ b/src/infra/middleware/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod logging; diff --git a/src/infra/mod.rs b/src/infra/mod.rs new file mode 100644 index 0000000..fa7f0f6 --- /dev/null +++ b/src/infra/mod.rs @@ -0,0 +1,2 @@ +pub mod middleware; +pub mod redis; diff --git a/src/infra/redis/errors.rs b/src/infra/redis/errors.rs new file mode 100644 index 0000000..5fd2f71 --- /dev/null +++ b/src/infra/redis/errors.rs @@ -0,0 +1,20 @@ +use thiserror::Error; + +/// Redis 错误类型 +#[derive(Error, Debug)] +pub enum RedisError { + #[error("Redis 连接失败: {0}")] + ConnectionError(#[from] redis::RedisError), + + #[error("Redis 序列化失败: {0}")] + SerializationError(#[from] serde_json::Error), + + #[error("Redis 数据不存在: {key}")] + NotFound { key: String }, + + #[error("Redis 操作失败: {message}")] + OperationError { message: String }, + + #[error("Failed to create redis pool: {0}")] + PoolCreation(#[from] deadpool_redis::CreatePoolError), +} diff --git a/src/infra/redis/mod.rs b/src/infra/redis/mod.rs new file mode 100644 index 0000000..749cbc6 --- /dev/null +++ b/src/infra/redis/mod.rs @@ -0,0 +1,2 @@ +pub mod redis_client; +pub mod redis_key; diff --git a/src/infra/redis/redis_client.rs b/src/infra/redis/redis_client.rs new file mode 100644 index 0000000..4ed8493 --- /dev/null +++ b/src/infra/redis/redis_client.rs @@ -0,0 +1,135 @@ +use super::redis_key::RedisKey; +use redis::aio::MultiplexedConnection; +use redis::{AsyncCommands, Client}; +use serde::Serialize; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Redis 客户端(使用 MultiplexedConnection) +#[derive(Clone)] +pub struct RedisClient { + conn: Arc>, +} + +impl RedisClient { + /// 创建新的 Redis 客户端 + pub async fn new(url: &str) -> redis::RedisResult { + let client = Client::open(url)?; + let conn = client.get_multiplexed_async_connection().await?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// 设置字符串值 + pub async fn set(&self, k: &str, v: &str) -> redis::RedisResult<()> { + let mut c = self.conn.lock().await; + c.set(k, v).await + } + + /// 获取字符串值 + pub async fn get(&self, k: &str) -> redis::RedisResult> { + let mut c = self.conn.lock().await; + c.get(k).await + } + + /// 设置字符串值并指定过期时间(秒) + pub async fn set_ex(&self, k: &str, v: &str, seconds: u64) -> redis::RedisResult<()> { + let mut c = self.conn.lock().await; + c.set_ex(k, v, seconds).await + } + + /// 删除键 + pub async fn del(&self, k: &str) -> redis::RedisResult<()> { + let mut c = self.conn.lock().await; + c.del(k).await + } + + /// 设置键的过期时间(秒) + pub async fn expire(&self, k: &str, seconds: u64) -> redis::RedisResult<()> { + let mut c = self.conn.lock().await; + c.expire(k, seconds as i64).await + } + + /// 使用 RedisKey 设置 JSON 值 + pub async fn set_key( + &self, + key: &RedisKey, + value: &T, + ) -> redis::RedisResult<()> { + let json = serde_json::to_string(value).map_err(|e| { + redis::RedisError::from(( + redis::ErrorKind::TypeError, + "JSON serialization failed", + e.to_string(), + )) + })?; + let mut c = self.conn.lock().await; + c.set(key.build(), json).await + } + + /// 使用 RedisKey 设置 JSON 值并指定过期时间(秒) + pub async fn set_key_ex( + &self, + key: &RedisKey, + value: &T, + expiration_seconds: u64, + ) -> redis::RedisResult<()> { + let json = serde_json::to_string(value).map_err(|e| { + redis::RedisError::from(( + redis::ErrorKind::TypeError, + "JSON serialization failed", + e.to_string(), + )) + })?; + let mut c = self.conn.lock().await; + c.set_ex(key.build(), json, expiration_seconds).await + } + + /// 使用 RedisKey 获取字符串值 + pub async fn get_key(&self, key: &RedisKey) -> redis::RedisResult> { + let mut c = self.conn.lock().await; + let json: Option = c.get(key.build()).await?; + Ok(json) + } + + /// 使用 RedisKey 获取并反序列化 JSON 值 + pub async fn get_key_json serde::Deserialize<'de>>( + &self, + key: &RedisKey, + ) -> redis::RedisResult> { + let mut c = self.conn.lock().await; + let json: Option = c.get(key.build()).await?; + match json { + Some(data) => { + let value = serde_json::from_str(&data).map_err(|e| { + redis::RedisError::from(( + redis::ErrorKind::TypeError, + "JSON deserialization failed", + e.to_string(), + )) + })?; + Ok(Some(value)) + } + None => Ok(None), + } + } + + /// 使用 RedisKey 删除键 + pub async fn delete_key(&self, key: &RedisKey) -> redis::RedisResult<()> { + let mut c = self.conn.lock().await; + c.del(key.build()).await + } + + /// 使用 RedisKey 检查键是否存在 + pub async fn exists_key(&self, key: &RedisKey) -> redis::RedisResult { + let mut c = self.conn.lock().await; + c.exists(key.build()).await + } + + /// 使用 RedisKey 设置键的过期时间(秒) + pub async fn expire_key(&self, key: &RedisKey, seconds: u64) -> redis::RedisResult<()> { + let mut c = self.conn.lock().await; + c.expire(key.build(), seconds as i64).await + } +} diff --git a/src/infra/redis/redis_key.rs b/src/infra/redis/redis_key.rs new file mode 100644 index 0000000..8977808 --- /dev/null +++ b/src/infra/redis/redis_key.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// 业务类型枚举 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum BusinessType { + #[serde(rename = "auth")] + Auth, + #[serde(rename = "user")] + User, + #[serde(rename = "cache")] + Cache, + #[serde(rename = "session")] + Session, + #[serde(rename = "rate_limit")] + RateLimit, +} + +impl BusinessType { + pub fn prefix(self) -> &'static str { + match self { + BusinessType::Auth => "auth", + BusinessType::User => "user", + BusinessType::Cache => "cache", + BusinessType::Session => "session", + BusinessType::RateLimit => "rate_limit", + } + } +} + +/// Redis 键构建器 +#[derive(Debug, Clone)] +pub struct RedisKey { + business: BusinessType, + identifiers: Vec, +} + +impl RedisKey { + pub fn new(business: BusinessType) -> Self { + Self { + business, + identifiers: Vec::new(), + } + } + + pub fn add_identifier(mut self, id: impl Into) -> Self { + self.identifiers.push(id.into()); + self + } + + pub fn build(&self) -> String { + format!("{}:{}", self.business.prefix(), self.identifiers.join(":")) + } +} + +// 兼容现有格式 +impl fmt::Display for RedisKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.build()) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..affb886 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,131 @@ +mod cli; +mod config; +mod db; +mod domain; +mod error; +mod handlers; +mod infra; +mod repositories; +mod services; +mod utils; + +use axum::{ + routing::{get, post}, + Router, +}; +use clap::Parser; +use cli::CliArgs; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +/// 应用状态 +#[derive(Clone)] +pub struct AppState { + pub pool: db::DbPool, + pub config: config::app::AppConfig, + pub redis_client: infra::redis::redis_client::RedisClient, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // 解析命令行参数 + let args = CliArgs::parse(); + + // 初始化日志 + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| args.get_log_filter().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // 打印启动信息 + args.print_startup_info(); + + // 设置工作目录(如果指定) + if let Some(ref work_dir) = args.work_dir { + std::env::set_current_dir(work_dir).ok(); + println!("Working directory set to: {}", work_dir.display()); + } + + // 解析配置文件路径(可选) + let config_path = args.resolve_config_path(); + + // 加载配置(支持 CLI 覆盖) + // 如果没有配置文件,将仅使用环境变量和默认值 + let config = config::app::AppConfig::load_with_overrides( + config_path, + args.get_overrides(), + args.env.as_str(), + )?; + + tracing::info!("Configuration loaded successfully"); + tracing::info!("Environment: {}", args.env.as_str()); + tracing::info!("Debug mode: {}", args.is_debug_enabled()); + + // 初始化数据库(自动创建数据库和表) + let pool = db::init_database(&config.database).await?; + + // 初始化 Redis 客户端 + let redis_client = infra::redis::redis_client::RedisClient::new(&config.redis.build_url()) + .await + .map_err(|e| anyhow::anyhow!("Redis 初始化失败: {}", e))?; + + tracing::info!("Redis 连接池初始化成功"); + + // 创建应用状态 + let app_state = AppState { + pool: pool.clone(), + config: config.clone(), + redis_client, + }; + + // ========== 公开路由 ========== + let public_routes = Router::new() + .route("/health", get(handlers::health::health_check)) + .route("/info", get(handlers::health::server_info)) + .route("/auth/register", post(handlers::auth::register)) + .route("/auth/login", post(handlers::auth::login)) + .route("/auth/refresh", post(handlers::auth::refresh)); + + // ========== 受保护路由 ========== + let protected_routes = Router::new() + .route("/auth/delete", post(handlers::auth::delete_account)) + .route( + "/auth/delete-refresh-token", + post(handlers::auth::delete_refresh_token), + ) + // JWT 认证中间件(仅应用于受保护路由) + .route_layer(axum::middleware::from_fn_with_state( + app_state.clone(), + infra::middleware::auth::auth_middleware, + )); + + // ========== 合并路由 ========== + let app = public_routes + .merge(protected_routes) + // CORS(应用于所有路由) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any) + ) + // 日志中间件(应用于所有路由) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + infra::middleware::logging::request_logging_middleware, + )) + .with_state(app_state); + + // 启动服务器 + let addr = format!("{}:{}", config.server.host, config.server.port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!("Server listening on {}", addr); + tracing::info!("Press Ctrl+C to stop"); + + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs new file mode 100644 index 0000000..15ee656 --- /dev/null +++ b/src/repositories/mod.rs @@ -0,0 +1,2 @@ +pub mod user_repository; + diff --git a/src/repositories/user_repository.rs b/src/repositories/user_repository.rs new file mode 100644 index 0000000..1eea54e --- /dev/null +++ b/src/repositories/user_repository.rs @@ -0,0 +1,85 @@ +use sea_orm::{EntityTrait, QueryFilter, ColumnTrait, DatabaseConnection, Set, ActiveModelTrait, PaginatorTrait}; +use crate::domain::entities::users; +use anyhow::Result; + +/// 用户数据访问仓库 +pub struct UserRepository { + db: DatabaseConnection, +} + +impl UserRepository { + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } + + /// 根据 email 查询用户 + pub async fn find_by_email(&self, email: &str) -> Result> { + let user = users::Entity::find() + .filter(users::Column::Email.eq(email)) + .one(&self.db) + .await + .map_err(|e| anyhow::anyhow!("查询失败: {}", e))?; + + Ok(user) + } + + /// 统计邮箱数量 + pub async fn count_by_email(&self, email: &str) -> Result { + let count = users::Entity::find() + .filter(users::Column::Email.eq(email)) + .count(&self.db) + .await + .map_err(|e| anyhow::anyhow!("查询失败: {}", e))?; + + Ok(count as i64) + } + + /// 统计用户 ID 数量 + pub async fn count_by_id(&self, id: &str) -> Result { + let count = users::Entity::find() + .filter(users::Column::Id.eq(id)) + .count(&self.db) + .await + .map_err(|e| anyhow::anyhow!("查询失败: {}", e))?; + + Ok(count as i64) + } + + /// 获取密码哈希 + pub async fn get_password_hash(&self, email: &str) -> Result> { + let user = users::Entity::find() + .filter(users::Column::Email.eq(email)) + .one(&self.db) + .await + .map_err(|e| anyhow::anyhow!("查询失败: {}", e))?; + + Ok(user.map(|u| u.password_hash)) + } + + /// 插入用户(created_at 和 updated_at 会自动填充),返回插入后的用户对象 + pub async fn insert(&self, id: String, email: String, password_hash: String) -> Result { + let user_model = users::ActiveModel { + id: Set(id), + email: Set(email), + password_hash: Set(password_hash), + // created_at 和 updated_at 由 ActiveModelBehavior 自动填充 + ..Default::default() + }; + + let inserted_user = user_model.insert(&self.db) + .await + .map_err(|e| anyhow::anyhow!("插入失败: {}", e))?; + + Ok(inserted_user) + } + + /// 根据 ID 删除用户 + pub async fn delete_by_id(&self, id: &str) -> Result<()> { + users::Entity::delete_by_id(id) + .exec(&self.db) + .await + .map_err(|e| anyhow::anyhow!("删除失败: {}", e))?; + + Ok(()) + } +} diff --git a/src/services/auth_service.rs b/src/services/auth_service.rs new file mode 100644 index 0000000..0e704a0 --- /dev/null +++ b/src/services/auth_service.rs @@ -0,0 +1,232 @@ +use anyhow::Result; +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use rand::Rng; + +use crate::utils::jwt::TokenService; +use crate::domain::dto::auth::{RegisterRequest, LoginRequest, DeleteUserRequest}; +use crate::domain::entities::users; +use crate::config::auth::AuthConfig; +use crate::infra::redis::{redis_client::RedisClient, redis_key::{BusinessType, RedisKey}}; +use crate::repositories::user_repository::UserRepository; + +pub struct AuthService { + user_repo: UserRepository, + redis_client: RedisClient, + auth_config: AuthConfig, +} + +impl AuthService { + pub fn new(user_repo: UserRepository, redis_client: RedisClient, auth_config: AuthConfig) -> Self { + Self { user_repo, redis_client, auth_config } + } + + /// 哈希密码 + pub fn hash_password(&self, password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!("密码哈希失败: {}", e))? + .to_string(); + Ok(password_hash) + } + + /// 生成用户 ID + pub fn generate_user_id(&self) -> String { + let mut rng = rand::thread_rng(); + rng.gen_range(1_000_000_000i64..10_000_000_000i64) + .to_string() + } + + /// 生成唯一的用户 ID + pub async fn generate_unique_user_id(&self) -> Result { + let mut attempts = 0; + const MAX_ATTEMPTS: u32 = 10; + + loop { + let candidate_id = self.generate_user_id(); + + let existing = self.user_repo.count_by_id(&candidate_id).await?; + if existing == 0 { + return Ok(candidate_id); + } + + attempts += 1; + if attempts >= MAX_ATTEMPTS { + return Err(anyhow::anyhow!("生成唯一用户 ID 失败")); + } + } + } + + /// 保存 refresh_token 到 Redis + async fn save_refresh_token(&self, user_id: &str, refresh_token: &str, expiration_days: i64) -> Result<()> { + let key = RedisKey::new(BusinessType::Auth) + .add_identifier("refresh_token") + .add_identifier(user_id); + + let expiration_seconds = expiration_days * 24 * 3600; + + self.redis_client + .set_ex(&key.build(), refresh_token, expiration_seconds as u64) + .await + .map_err(|e| anyhow::anyhow!("Redis 保存失败: {}", e))?; + + Ok(()) + } + + /// 获取并删除 refresh_token + async fn get_and_delete_refresh_token(&self, user_id: &str) -> Result { + let key = RedisKey::new(BusinessType::Auth) + .add_identifier("refresh_token") + .add_identifier(user_id); + + let token: Option = self.redis_client + .get(&key.build()) + .await + .map_err(|e| anyhow::anyhow!("Redis 查询失败: {}", e))?; + + if token.is_some() { + self.redis_client + .delete_key(&key) + .await + .map_err(|e| anyhow::anyhow!("Redis 删除失败: {}", e))?; + } + + token.ok_or_else(|| anyhow::anyhow!("刷新令牌无效或已过期")) + } + + /// 删除用户的 refresh_token + pub async fn delete_refresh_token(&self, user_id: &str) -> Result<()> { + let key = RedisKey::new(BusinessType::Auth) + .add_identifier("refresh_token") + .add_identifier(user_id); + + self.redis_client + .delete_key(&key) + .await + .map_err(|e| anyhow::anyhow!("Redis 删除失败: {}", e))?; + + Ok(()) + } + + /// 注册用户 + pub async fn register( + &self, + request: RegisterRequest, + ) -> Result<(users::Model, String, String)> { + // 1. 检查邮箱是否已存在 + let existing = self.user_repo.count_by_email(&request.email).await?; + + if existing > 0 { + return Err(anyhow::anyhow!("邮箱已注册")); + } + + // 2. 哈希密码 + let password_hash = self.hash_password(&request.password)?; + + // 3. 生成用户 ID + let user_id = self.generate_unique_user_id().await?; + + // 4. 插入数据库并获取包含真实 created_at 的用户对象 + let user = self.user_repo.insert(user_id.clone(), request.email, password_hash).await?; + + // 5. 生成 token + let (access_token, refresh_token) = TokenService::generate_token_pair( + &user_id, + self.auth_config.access_token_expiration_minutes, + self.auth_config.refresh_token_expiration_days, + &self.auth_config.jwt_secret, + )?; + + // 6. 保存 refresh_token + self.save_refresh_token(&user_id, &refresh_token, self.auth_config.refresh_token_expiration_days as i64).await?; + + Ok((user, access_token, refresh_token)) + } + + /// 登录 + pub async fn login( + &self, + request: LoginRequest, + ) -> Result<(users::Model, String, String)> { + // 1. 查询用户 + let user = self.user_repo.find_by_email(&request.email).await? + .ok_or_else(|| anyhow::anyhow!("邮箱或密码错误"))?; + + // 2. 验证密码 + let password_hash = self.user_repo.get_password_hash(&request.email).await? + .ok_or_else(|| anyhow::anyhow!("邮箱或密码错误"))?; + + let parsed_hash = PasswordHash::new(&password_hash) + .map_err(|e| anyhow::anyhow!("解析密码哈希失败: {}", e))?; + let argon2 = Argon2::default(); + + argon2 + .verify_password(request.password.as_bytes(), &parsed_hash) + .map_err(|_| anyhow::anyhow!("邮箱或密码错误"))?; + + // 3. 生成 token + let (access_token, refresh_token) = TokenService::generate_token_pair( + &user.id, + self.auth_config.access_token_expiration_minutes, + self.auth_config.refresh_token_expiration_days, + &self.auth_config.jwt_secret, + )?; + + // 4. 保存 refresh_token + self.save_refresh_token(&user.id, &refresh_token, self.auth_config.refresh_token_expiration_days as i64).await?; + + Ok((user, access_token, refresh_token)) + } + + /// 使用 refresh_token 刷新 access_token + pub async fn refresh_access_token( + &self, + refresh_token: &str, + ) -> Result<(String, String)> { + // 1. 从 refresh_token 中解码出 user_id + let user_id = TokenService::decode_user_id(refresh_token, &self.auth_config.jwt_secret)?; + + // 2. 从 Redis 获取存储的 token 并删除 + let stored_token = self.get_and_delete_refresh_token(&user_id).await?; + + // 3. 验证 token 是否匹配 + if stored_token != refresh_token { + return Err(anyhow::anyhow!("刷新令牌无效")); + } + + // 4. 生成新的 token 对 + let (new_access_token, new_refresh_token) = TokenService::generate_token_pair( + &user_id, + self.auth_config.access_token_expiration_minutes, + self.auth_config.refresh_token_expiration_days, + &self.auth_config.jwt_secret, + )?; + + // 5. 保存新的 refresh_token + self.save_refresh_token(&user_id, &new_refresh_token, self.auth_config.refresh_token_expiration_days as i64).await?; + + Ok((new_access_token, new_refresh_token)) + } + + /// 删除用户 + pub async fn delete_user(&self, request: DeleteUserRequest) -> Result<()> { + let password_hash = self.user_repo.get_password_hash(&request.user_id).await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + let parsed_hash = PasswordHash::new(&password_hash) + .map_err(|e| anyhow::anyhow!("解析密码哈希失败: {}", e))?; + let argon2 = Argon2::default(); + + argon2 + .verify_password(request.password.as_bytes(), &parsed_hash) + .map_err(|_| anyhow::anyhow!("密码错误"))?; + + self.user_repo.delete_by_id(&request.user_id).await?; + + Ok(()) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..3fe88a6 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1 @@ +pub mod auth_service; diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs new file mode 100644 index 0000000..8739815 --- /dev/null +++ b/src/utils/jwt.rs @@ -0,0 +1,101 @@ +use anyhow::Result; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +/// JWT 工具类,负责生成和验证 JWT token +pub struct TokenService; + +impl TokenService { + /// 生成 JWT access token + pub fn generate_access_token( + user_id: &str, + expiration_minutes: u64, + jwt_secret: &str, + ) -> Result { + let expiration = Utc::now() + .checked_add_signed(Duration::minutes(expiration_minutes as i64)) + .expect("invalid expiration timestamp") + .timestamp() as usize; + + let claims = Claims { + sub: user_id.to_string(), + exp: expiration, + token_type: TokenType::Access, + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(jwt_secret.as_ref()), + )?; + + Ok(token) + } + + /// 生成 refresh token + pub fn generate_refresh_token( + user_id: &str, + expiration_days: i64, + jwt_secret: &str, + ) -> Result { + let expiration = Utc::now() + .checked_add_signed(Duration::days(expiration_days)) + .expect("invalid expiration timestamp") + .timestamp() as usize; + + let claims = Claims { + sub: user_id.to_string(), + exp: expiration, + token_type: TokenType::Refresh, + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(jwt_secret.as_ref()), + )?; + + Ok(token) + } + + /// 生成 access token 和 refresh token + pub fn generate_token_pair( + user_id: &str, + access_token_expiration_minutes: u64, + refresh_token_expiration_days: i64, + jwt_secret: &str, + ) -> Result<(String, String)> { + let access_token = + Self::generate_access_token(user_id, access_token_expiration_minutes, jwt_secret)?; + let refresh_token = + Self::generate_refresh_token(user_id, refresh_token_expiration_days, jwt_secret)?; + + Ok((access_token, refresh_token)) + } + + /// 从 token 中解码出 user_id + pub fn decode_user_id(token: &str, jwt_secret: &str) -> Result { + let token_data = decode::( + token, + &DecodingKey::from_secret(jwt_secret.as_ref()), + &Validation::default(), + ) + .map_err(|e| anyhow::anyhow!("Token 解码失败: {}", e))?; + + Ok(token_data.claims.sub) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, // user_id + pub exp: usize, // 过期时间 + pub token_type: TokenType, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum TokenType { + Access, + Refresh, +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..417233c --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod jwt;