From 13f7c1326a77545877942cfbea063708da297320 Mon Sep 17 00:00:00 2001 From: Mplan Date: Sun, 31 May 2026 22:36:20 +0800 Subject: [PATCH] feat: checkpoint user state tracking service with PostgreSQL - RESTful API: POST /heartbeat, POST /checkpoints, GET /status, GET /summaries - State-change-only checkpoint model with extensible StateType enum - PostgreSQL backend with sqlx, auto-migration on startup - pg_cron scheduled aggregation (state_summaries) and offline detection - Heartbeat-based liveness with 60s timeout auto-offline - LEAD() window function for state duration calculation - JSONB content field for extensible checkpoint metadata BREAKING CHANGE: Complete rewrite from Hello World to full API service. --- .gitignore | 2 + Cargo.lock | 2066 ++++++++++++++++++++++++++++++ Cargo.toml | 9 + README.md | 142 ++ migrations/001_init.sql | 20 + migrations/002_daily_summary.sql | 84 ++ migrations/003_sessions.sql | 65 + src/config.rs | 33 + src/db/mod.rs | 70 + src/db/postgres.rs | 306 +++++ src/error.rs | 54 + src/handlers/admin.rs | 77 ++ src/handlers/checkpoints.rs | 122 ++ src/handlers/health.rs | 6 + src/handlers/mod.rs | 3 + src/main.rs | 80 +- src/models/checkpoint.rs | 129 ++ src/models/mod.rs | 1 + src/router.rs | 32 + src/state.rs | 16 + 20 files changed, 3315 insertions(+), 2 deletions(-) create mode 100644 README.md create mode 100644 migrations/001_init.sql create mode 100644 migrations/002_daily_summary.sql create mode 100644 migrations/003_sessions.sql create mode 100644 src/config.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/postgres.rs create mode 100644 src/error.rs create mode 100644 src/handlers/admin.rs create mode 100644 src/handlers/checkpoints.rs create mode 100644 src/handlers/health.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/models/checkpoint.rs create mode 100644 src/models/mod.rs create mode 100644 src/router.rs create mode 100644 src/state.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..83a00ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target + +.env diff --git a/Cargo.lock b/Cargo.lock index 72411c3..ea26928 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,2072 @@ # It is not intended for manual editing. version = 4 +[[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 = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[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 = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[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.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +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 = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "ck-rs" version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "chrono", + "dotenvy", + "serde", + "serde_json", + "sqlx", + "tokio", + "tower-http", +] + +[[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 = "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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[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 = "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +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 = "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 = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "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", + "libc", + "wasi", +] + +[[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.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[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.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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[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-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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "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 = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[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-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +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", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" +dependencies = [ + "bitflags", +] + +[[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 = "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 = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +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 = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "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.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.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", + "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", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[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", +] + +[[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", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "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", + "bitflags", + "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", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "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", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[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", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "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.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[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", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "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 = "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 = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[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", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 index e12b5c4..44a1dff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,12 @@ version = "0.1.0" edition = "2024" [dependencies] +axum = "0.8" +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["cors"] } +chrono = "0.4.44" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +async-trait = "0.1" +dotenvy = "0.15" +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfa3026 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# ck-rs — Checkpoint 用户状态追踪服务 + +基于 [Axum](https://github.com/tokio-rs/axum) 的 RESTful API 服务,用于记录用户在不同状态下的持续时间。客户端定时上报检查点(checkpoint),服务端自动计算各状态累计时长。类比手机屏幕使用时间统计。 + +## 快速开始 + +```bash +# 启动服务(默认监听 127.0.0.1:3000,可通过 .env 中 LISTEN_ADDR 修改) +cargo run +``` + +## API 端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| `GET` | `/health` | 服务健康检查 | +| `POST` | `/users/{user_id}/checkpoints` | 上报当前状态(心跳) | +| `GET` | `/users/{user_id}/checkpoints` | 查询检查点历史(`?from=&to=&limit=`) | +| `GET` | `/users/{user_id}/checkpoints/{id}` | 查询单个检查点 | +| `GET` | `/users/{user_id}/status` | 当前状态 + 各状态累计时长 | + +### 状态类型 + +| 内置状态 | 说明 | +|----------|------| +| `Online` | 在线 | +| `Offline` | 离线 | +| `Idle` | 空闲 | +| `Working` | 工作中 | +| `Sleeping` | 睡眠 | +| `"任意字符串"` | 自定义状态,如 `Gaming`、`Meeting`、`Driving` 等 | + +### 请求示例 + +```bash +BASE=http://localhost:3000 + +# 健康检查 +curl $BASE/health + +# 上报状态(自动记录服务端当前时间) +curl -X POST $BASE/users/alice/checkpoints \ + -H "Content-Type: application/json" \ + -d '{"state":"Working"}' + +# 上报状态 + 附带元数据(设备、坐标等) +curl -X POST $BASE/users/alice/checkpoints \ + -H "Content-Type: application/json" \ + -d '{"state":"Idle","content":{"device":"MacBook","battery":85}}' + +# 上报自定义状态 +curl -X POST $BASE/users/alice/checkpoints \ + -H "Content-Type: application/json" \ + -d '{"state":"Gaming"}' + +# 等待几秒后切换状态(产生时长数据) +sleep 3 +curl -X POST $BASE/users/alice/checkpoints \ + -H "Content-Type: application/json" \ + -d '{"state":"Offline"}' + +# 查询检查点历史 +curl $BASE/users/alice/checkpoints + +# 按时间范围查询 +curl "$BASE/users/alice/checkpoints?from=1717161600&to=1717248000&limit=10" + +# 查询单个检查点 +curl $BASE/users/alice/checkpoints/1 + +# 查询状态汇总(当前状态 + 各状态累计时长) +curl $BASE/users/alice/status +# → {"user_id":"alice","current_state":"Offline","since":1717248000, +# "durations":[{"state":"Working","duration_secs":3}]} +``` + +## 环境变量 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `LISTEN_ADDR` | `127.0.0.1:3000` | 监听地址 | +| `DATABASE_URL` | (无) | 数据库连接字符串(接入真实 DB 时设置) | + +## 项目结构 + +``` +src/ +├── main.rs # 入口:加载配置 → 初始化 DB → 构建路由 → 启动 +├── config.rs # 配置层(环境变量 + 默认值) +├── state.rs # AppState(全局共享状态,持有 DB) +├── error.rs # 统一错误类型 AppError(实现 IntoResponse) +├── router.rs # 路由组装 +├── models/ +│ ├── mod.rs +│ └── checkpoint.rs # StateType / Checkpoint / UserStatusResponse 等 +├── handlers/ +│ ├── mod.rs +│ ├── health.rs # GET /health +│ └── checkpoints.rs # POST/GET /users/{id}/checkpoints +└── db/ + ├── mod.rs # Db trait(数据库抽象接口) + └── memory.rs # MemoryDb(内存模拟,开发期使用) +``` + +## 核心设计 + +- **状态可自由扩充**:`StateType` 内置 5 种状态 + `Custom(String)` 变体,传入任意字符串自动作为新状态 +- **content 可扩展**:每个检查点可附带 `Option` 元数据 +- **时长自动计算**:相邻检查点的时间差归属于前一个状态,`/status` 返回各状态累计秒数 +- **timestamp 可选**:请求可带时间戳,不传则服务端取当前时间 + +## 接入真实数据库 + +当前使用内存模拟存储(`MemoryDb`),切换为 PostgreSQL 仅需 3 步: + +### 1. 添加依赖 + +取消 `Cargo.toml` 中的注释: + +```toml +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] } +``` + +### 2. 实现 Db trait + +新建 `src/db/postgres.rs`,对 `PgPool` 实现 `Db` trait。 + +### 3. 修改入口 + +`src/main.rs` 中将 `MemoryDb::new()` 替换为 `PgPool::connect(...).await`。 + +## 技术栈 + +- [Axum 0.8](https://crates.io/crates/axum) — Web 框架 +- [Tokio](https://crates.io/crates/tokio) — 异步运行时 +- [Serde](https://crates.io/crates/serde) — 序列化 / 反序列化 +- [Chrono](https://crates.io/crates/chrono) — 日期时间处理 +- [Dotenvy](https://crates.io/crates/dotenvy) — .env 文件加载 + +## License + +MIT diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..2970f77 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,20 @@ +-- 001_init.sql +-- Checkpoint 服务初始建表 + +CREATE TABLE IF NOT EXISTS checkpoints ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL, + state VARCHAR(64) NOT NULL, + timestamp BIGINT NOT NULL, + content JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- 核心查询索引:按用户 + 时间范围 +CREATE INDEX IF NOT EXISTS idx_checkpoints_user_ts + ON checkpoints (user_id, timestamp); + +-- 快速获取用户最新检查点 +CREATE INDEX IF NOT EXISTS idx_checkpoints_user_latest + ON checkpoints (user_id, timestamp DESC); diff --git a/migrations/002_daily_summary.sql b/migrations/002_daily_summary.sql new file mode 100644 index 0000000..663282d --- /dev/null +++ b/migrations/002_daily_summary.sql @@ -0,0 +1,84 @@ +-- 002_daily_summary.sql +-- 状态时长定时快照表 + 聚合函数 + pg_cron 调度说明 + +-- ============================================================ +-- 1. 快照存储表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS state_summaries ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL, + state VARCHAR(64) NOT NULL, + duration_secs BIGINT NOT NULL DEFAULT 0, + period_start BIGINT NOT NULL, + period_end BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE (user_id, state, period_start, period_end) +); + +CREATE INDEX IF NOT EXISTS idx_summaries_user_period + ON state_summaries (user_id, period_start DESC); + +-- ============================================================ +-- 2. 聚合函数:滑动窗口计算指定时间范围内各状态时长 +-- - 调度器只需传入 period_start / period_end +-- - 内部用 LEAD() 窗口函数求相邻检查点差值 +-- - ON CONFLICT 保证幂等(重复执行不产生重复行) +-- ============================================================ +CREATE OR REPLACE FUNCTION aggregate_checkpoint_durations( + p_start BIGINT, + p_end BIGINT +) RETURNS BIGINT AS $$ +DECLARE + affected BIGINT; +BEGIN + INSERT INTO state_summaries (user_id, state, duration_secs, period_start, period_end) + SELECT user_id, state, SUM(duration) AS duration_secs, p_start, p_end + FROM ( + SELECT user_id, state, + LEAD(timestamp) OVER (PARTITION BY user_id ORDER BY timestamp) - timestamp AS duration + FROM checkpoints + WHERE timestamp >= p_start AND timestamp <= p_end + ) sub + WHERE duration > 0 + GROUP BY user_id, state + ON CONFLICT (user_id, state, period_start, period_end) DO UPDATE + SET duration_secs = EXCLUDED.duration_secs; + + GET DIAGNOSTICS affected = ROW_COUNT; + RETURN affected; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================ +-- 3. pg_cron 调度(需要超级用户手动执行一次) +-- ============================================================ +-- +-- -- 安装扩展(仅需一次) +-- CREATE EXTENSION IF NOT EXISTS pg_cron; +-- +-- -- 每 5 分钟执行一次聚合 +-- SELECT cron.schedule( +-- 'aggregate-5min', +-- '*/5 * * * *', +-- $$ SELECT aggregate_checkpoint_durations( +-- FLOOR(EXTRACT(EPOCH FROM now() - INTERVAL '5 minutes'))::BIGINT, +-- FLOOR(EXTRACT(EPOCH FROM now()))::BIGINT +-- ); $$ +-- ); +-- +-- -- 每小时整点执行一次(日报表用) +-- SELECT cron.schedule( +-- 'aggregate-hourly', +-- '0 * * * *', +-- $$ SELECT aggregate_checkpoint_durations( +-- FLOOR(EXTRACT(EPOCH FROM now() - INTERVAL '1 hour'))::BIGINT, +-- FLOOR(EXTRACT(EPOCH FROM now()))::BIGINT +-- ); $$ +-- ); +-- +-- -- 查看 cron 任务状态 +-- SELECT * FROM cron.job; +-- +-- -- 取消(如需) +-- SELECT cron.unschedule('aggregate-5min'); diff --git a/migrations/003_sessions.sql b/migrations/003_sessions.sql new file mode 100644 index 0000000..eeb3f9d --- /dev/null +++ b/migrations/003_sessions.sql @@ -0,0 +1,65 @@ +-- 003_sessions.sql +-- 用户会话表 + 心跳管理 + 离线检测 + pg_cron 调度 + +-- ============================================================ +-- 1. 用户会话表(跟踪当前状态和心跳) +-- ============================================================ +CREATE TABLE IF NOT EXISTS user_sessions ( + user_id VARCHAR(128) PRIMARY KEY, + current_state VARCHAR(64) NOT NULL DEFAULT 'Offline', + last_heartbeat BIGINT NOT NULL, + last_state_change BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ============================================================ +-- 2. 离线检测函数:心跳超时 60s 的用户自动补一条 Offline 检查点 +-- pg_cron 每 1 分钟执行一次 +-- ============================================================ +CREATE OR REPLACE FUNCTION detect_offline_users( + timeout_secs BIGINT DEFAULT 60 +) RETURNS SETOF BIGINT AS $$ +DECLARE + r RECORD; + now_ts BIGINT; +BEGIN + now_ts := EXTRACT(EPOCH FROM now())::BIGINT; + + FOR r IN + SELECT user_id, last_heartbeat + FROM user_sessions + WHERE current_state != 'Offline' + AND (now_ts - last_heartbeat) > timeout_secs + LOOP + -- 插入离线检查点(时间戳 = 最后心跳 + 超时时间) + INSERT INTO checkpoints (user_id, state, timestamp) + VALUES (r.user_id, 'Offline', r.last_heartbeat + timeout_secs); + + -- 更新会话状态 + UPDATE user_sessions + SET current_state = 'Offline', + last_state_change = r.last_heartbeat + timeout_secs, + updated_at = now() + WHERE user_id = r.user_id; + + RETURN NEXT 1; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================ +-- 3. pg_cron 调度(需要超级用户手动执行一次) +-- ============================================================ +-- +-- -- 安装扩展(仅需一次) +-- CREATE EXTENSION IF NOT EXISTS pg_cron; +-- +-- -- 每 1 分钟检测离线用户 +-- SELECT cron.schedule( +-- 'detect-offline', +-- '* * * * *', +-- $$ SELECT detect_offline_users(60); $$ +-- ); +-- +-- -- 查看状态 +-- SELECT * FROM cron.job; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..cd9c423 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,33 @@ +use std::net::SocketAddr; + +/// 服务器配置 +#[derive(Debug, Clone)] +pub struct Config { + /// 监听地址 + pub listen_addr: SocketAddr, + /// 数据库连接字符串(接入真实数据库时使用) + #[allow(dead_code)] + pub database_url: Option, +} + +impl Config { + /// 从环境变量加载配置,缺失时使用默认值 + pub fn from_env() -> Self { + Self { + listen_addr: std::env::var("LISTEN_ADDR") + .unwrap_or_else(|_| "127.0.0.1:3000".into()) + .parse() + .expect("invalid LISTEN_ADDR"), + database_url: std::env::var("DATABASE_URL").ok(), + } + } +} + +impl Default for Config { + fn default() -> Self { + Self { + listen_addr: SocketAddr::from(([127, 0, 0, 1], 3000)), + database_url: None, + } + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..3356aed --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,70 @@ +pub mod postgres; + +use std::sync::Arc; + +use crate::models::checkpoint::{Checkpoint, StateSummary, UserStatusResponse}; + +/// 心跳返回信息 +pub struct HeartbeatInfo { + pub current_state: String, + pub last_heartbeat: i64, +} + +/// 数据库操作抽象 trait —— 所有持久化实现必须满足此接口 +#[async_trait::async_trait] +pub trait Db: Send + Sync + 'static { + /// 创建一个检查点记录(仅在状态变更时调用) + async fn create_checkpoint( + &self, + user_id: &str, + state: &str, + timestamp: i64, + content: Option, + ) -> Result; + + /// 获取单个检查点 + async fn get_checkpoint(&self, id: u64) -> Result, String>; + + /// 列出用户的检查点(支持时间范围 + 条数限制) + async fn list_checkpoints( + &self, + user_id: &str, + from: Option, + to: Option, + limit: Option, + ) -> Result, String>; + + /// 获取用户最新的检查点(预留,用于心跳检测等场景) + #[allow(dead_code)] + async fn get_latest_checkpoint(&self, user_id: &str) -> Result, String>; + + /// 获取用户状态汇总(当前状态 + 各状态时长) + async fn get_status_summary(&self, user_id: &str) -> Result; + + /// 查询用户的定时快照历史 + async fn list_state_summaries( + &self, + user_id: &str, + from: Option, + to: Option, + limit: Option, + ) -> Result, String>; + + /// 心跳:刷新 last_heartbeat,不产生检查点 + /// state 为空字符串时只刷新心跳不改变状态 + async fn heartbeat(&self, user_id: &str, state: &str) -> Result; + + /// [管理] 手动触发离线检测(开发/调试用,生产由 pg_cron 接管) + async fn admin_detect_offline(&self, timeout_secs: i64) -> Result; + + /// [管理] 手动触发一次聚合(开发/调试用,生产由 pg_cron 接管) + async fn admin_trigger_aggregation(&self, period_start: i64, period_end: i64) -> Result; + + /// [调试] 将用户 last_heartbeat 设为指定值(模拟超时) + async fn debug_set_last_heartbeat(&self, user_id: &str, ts: i64) -> Result<(), String>; +} + +/// 简便构造:将 Db 实现包装为 Arc +pub fn into_shared(db: impl Db) -> Arc { + Arc::new(db) +} diff --git a/src/db/postgres.rs b/src/db/postgres.rs new file mode 100644 index 0000000..f0503e3 --- /dev/null +++ b/src/db/postgres.rs @@ -0,0 +1,306 @@ +use chrono::Utc; +use serde_json::Value; +use sqlx::PgPool; + +use crate::db::Db; +use crate::db::HeartbeatInfo; +use crate::models::checkpoint::{Checkpoint, StateDuration, StateSummary, StateType, UserStatusResponse}; + +/// PostgreSQL 数据库实现(通过 sqlx) +pub struct PgDb { + pool: PgPool, +} + +impl PgDb { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +fn parse_state(s: &str) -> StateType { + match s { + "Online" => StateType::Online, + "Offline" => StateType::Offline, + "Idle" => StateType::Idle, + "Working" => StateType::Working, + "Sleeping" => StateType::Sleeping, + other => StateType::Custom(other.to_string()), + } +} + +fn row_to_checkpoint(r: PgCheckpointRow) -> Checkpoint { + Checkpoint { + id: r.id as u64, + user_id: r.user_id, + state: parse_state(&r.state), + timestamp: r.timestamp, + content: r.content, + } +} + +#[async_trait::async_trait] +impl Db for PgDb { + async fn create_checkpoint( + &self, + user_id: &str, + state: &str, + timestamp: i64, + content: Option, + ) -> Result { + let row: PgCheckpointRow = sqlx::query_as( + "INSERT INTO checkpoints (user_id, state, timestamp, content) \ + VALUES ($1, $2, $3, $4) \ + RETURNING id, user_id, state, timestamp, content", + ) + .bind(user_id) + .bind(state) + .bind(timestamp) + .bind(content) + .fetch_one(&self.pool) + .await + .map_err(|e| format!("insert checkpoint: {e}"))?; + + Ok(row_to_checkpoint(row)) + } + + async fn get_checkpoint(&self, id: u64) -> Result, String> { + let row: Option = sqlx::query_as( + "SELECT id, user_id, state, timestamp, content \ + FROM checkpoints WHERE id = $1", + ) + .bind(id as i64) + .fetch_optional(&self.pool) + .await + .map_err(|e| format!("get checkpoint: {e}"))?; + + Ok(row.map(row_to_checkpoint)) + } + + async fn list_checkpoints( + &self, + user_id: &str, + from: Option, + to: Option, + limit: Option, + ) -> Result, String> { + let rows: Vec = sqlx::query_as( + "SELECT id, user_id, state, timestamp, content \ + FROM checkpoints WHERE user_id = $1 ORDER BY timestamp", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await + .map_err(|e| format!("list checkpoints: {e}"))?; + + let mut result: Vec = rows + .into_iter() + .map(row_to_checkpoint) + .filter(|c| from.map_or(true, |f| c.timestamp >= f)) + .filter(|c| to.map_or(true, |t| c.timestamp <= t)) + .collect(); + + if let Some(n) = limit { + result.truncate(n); + } + Ok(result) + } + + async fn get_latest_checkpoint(&self, user_id: &str) -> Result, String> { + let row: Option = sqlx::query_as( + "SELECT id, user_id, state, timestamp, content \ + FROM checkpoints \ + WHERE user_id = $1 \ + ORDER BY timestamp DESC \ + LIMIT 1", + ) + .bind(user_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| format!("latest checkpoint: {e}"))?; + + Ok(row.map(row_to_checkpoint)) + } + + async fn get_status_summary(&self, user_id: &str) -> Result { + // 用窗口函数计算相邻检查点之间的时长 + let rows: Vec = sqlx::query_as( + r#"SELECT state, duration FROM ( + SELECT state, + LEAD(timestamp) OVER (PARTITION BY user_id ORDER BY timestamp) - timestamp AS duration + FROM checkpoints + WHERE user_id = $1 + ) sub + WHERE duration > 0"#, + ) + .bind(user_id) + .fetch_all(&self.pool) + .await + .map_err(|e| format!("status summary: {e}"))?; + + let mut durations_map: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for row in &rows { + *durations_map.entry(row.state.clone()).or_insert(0) += row.duration; + } + + let durations: Vec = durations_map + .into_iter() + .map(|(k, v)| StateDuration { + state: parse_state(&k), + duration_secs: v, + }) + .collect(); + + let latest = self.get_latest_checkpoint(user_id).await?; + let (current_state, since) = match latest { + Some(cp) => (cp.state, cp.timestamp), + None => (StateType::Offline, 0), + }; + + Ok(UserStatusResponse { + user_id: user_id.to_string(), + current_state, + since, + durations, + }) + } + + async fn list_state_summaries( + &self, + user_id: &str, + from: Option, + to: Option, + limit: Option, + ) -> Result, String> { + use crate::models::checkpoint::StateSummary; + + let rows: Vec = sqlx::query_as( + "SELECT id, user_id, state, duration_secs, period_start, period_end, \ + TO_CHAR(created_at, 'YYYY-MM-DD HH24:MI:SS') AS created_at \ + FROM state_summaries \ + WHERE user_id = $1 \ + AND ($2::BIGINT IS NULL OR period_start >= $2) \ + AND ($3::BIGINT IS NULL OR period_end <= $3) \ + ORDER BY period_start DESC \ + LIMIT $4", + ) + .bind(user_id) + .bind(from) + .bind(to) + .bind(limit.unwrap_or(100) as i64) + .fetch_all(&self.pool) + .await + .map_err(|e| format!("list summaries: {e}"))?; + + Ok(rows + .into_iter() + .map(|r| StateSummary { + id: r.id as u64, + user_id: r.user_id, + state: parse_state(&r.state), + duration_secs: r.duration_secs, + period_start: r.period_start, + period_end: r.period_end, + created_at: r.created_at, + }) + .collect()) + } + + async fn heartbeat(&self, user_id: &str, state: &str) -> Result { + let now = Utc::now().timestamp(); + let effective_state = if state.is_empty() { None } else { Some(state) }; + + let row: HeartbeatRow = sqlx::query_as( + r#"INSERT INTO user_sessions (user_id, current_state, last_heartbeat, last_state_change) + VALUES ($1, COALESCE($2, 'Offline'), $3, $3) + ON CONFLICT (user_id) DO UPDATE SET + current_state = COALESCE($2, user_sessions.current_state), + last_heartbeat = $3, + last_state_change = CASE WHEN $2 IS NOT NULL THEN $3 ELSE user_sessions.last_state_change END, + updated_at = now() + RETURNING current_state, last_heartbeat"#, + ) + .bind(user_id) + .bind(effective_state) + .bind(now) + .fetch_one(&self.pool) + .await + .map_err(|e| format!("heartbeat: {e}"))?; + + Ok(HeartbeatInfo { + current_state: row.current_state, + last_heartbeat: row.last_heartbeat, + }) + } + + async fn admin_detect_offline(&self, timeout_secs: i64) -> Result { + let row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM detect_offline_users($1)" + ) + .bind(timeout_secs) + .fetch_one(&self.pool) + .await + .map_err(|e| format!("detect offline: {e}"))?; + + Ok(row.0 as u64) + } + + async fn admin_trigger_aggregation(&self, period_start: i64, period_end: i64) -> Result { + let row: (i64,) = sqlx::query_as( + "SELECT aggregate_checkpoint_durations($1, $2)" + ) + .bind(period_start) + .bind(period_end) + .fetch_one(&self.pool) + .await + .map_err(|e| format!("aggregation: {e}"))?; + + Ok(row.0 as u64) + } + + async fn debug_set_last_heartbeat(&self, user_id: &str, ts: i64) -> Result<(), String> { + sqlx::query( + "UPDATE user_sessions SET last_heartbeat = $1 WHERE user_id = $2" + ) + .bind(ts) + .bind(user_id) + .execute(&self.pool) + .await + .map_err(|e| format!("debug set heartbeat: {e}"))?; + Ok(()) + } +} + + +#[derive(Debug, sqlx::FromRow)] +struct HeartbeatRow { + current_state: String, + last_heartbeat: i64, +} +// ---------- sqlx 行映射 ---------- + +#[derive(Debug, sqlx::FromRow)] +struct PgCheckpointRow { + id: i64, + user_id: String, + state: String, + timestamp: i64, + content: Option, +} + +#[derive(Debug, sqlx::FromRow)] +struct StateDurationRow { + state: String, + duration: i64, +} + +#[derive(Debug, sqlx::FromRow)] +struct SummaryRow { + id: i64, + user_id: String, + state: String, + duration_secs: i64, + period_start: i64, + period_end: i64, + created_at: String, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..1c8cbbe --- /dev/null +++ b/src/error.rs @@ -0,0 +1,54 @@ +use axum::{http::StatusCode, response::IntoResponse, Json}; +use serde::Serialize; + +/// 统一错误响应体 +#[derive(Debug, Serialize)] +pub struct ErrorBody { + pub error: String, +} + +/// 应用层统一错误类型 +#[derive(Debug)] +pub enum AppError { + NotFound(String), + #[allow(dead_code)] + BadRequest(String), + Internal(String), +} + +impl AppError { + pub fn status_code(&self) -> StatusCode { + match self { + AppError::NotFound(_) => StatusCode::NOT_FOUND, + AppError::BadRequest(_) => StatusCode::BAD_REQUEST, + AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppError::NotFound(msg) => write!(f, "Not Found: {msg}"), + AppError::BadRequest(msg) => write!(f, "Bad Request: {msg}"), + AppError::Internal(msg) => write!(f, "Internal Error: {msg}"), + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + let status = self.status_code(); + let body = Json(ErrorBody { + error: self.to_string(), + }); + (status, body).into_response() + } +} + +// 允许 Axum handler 直接使用 `?` 将 sqlx 错误转为 AppError +impl From for AppError { + fn from(e: sqlx::Error) -> Self { + AppError::Internal(format!("database error: {e}")) + } +} diff --git a/src/handlers/admin.rs b/src/handlers/admin.rs new file mode 100644 index 0000000..8514e79 --- /dev/null +++ b/src/handlers/admin.rs @@ -0,0 +1,77 @@ +use axum::{ + extract::{Path, State}, + Json, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::state::AppState; + +#[derive(Serialize)] +pub struct AdminResult { + pub affected: u64, + pub message: String, +} + +#[derive(Deserialize)] +pub struct SetHeartbeatRequest { + pub seconds_ago: i64, +} + +/// POST /admin/offline-check +/// 手动触发离线检测(开发/调试用) +pub async fn offline_check( + State(state): State, +) -> Result, AppError> { + let n = state + .db + .admin_detect_offline(60) + .await + .map_err(AppError::Internal)?; + + Ok(Json(AdminResult { + affected: n, + message: format!("{n} users marked as offline"), + })) +} + +/// POST /admin/aggregate +/// 手动触发一次聚合(开发/调试用) +pub async fn aggregate_now( + State(state): State, +) -> Result, AppError> { + let now = Utc::now().timestamp(); + let start = now - 3600; // 聚合最近 1 小时 + + let n = state + .db + .admin_trigger_aggregation(start, now) + .await + .map_err(AppError::Internal)?; + + Ok(Json(AdminResult { + affected: n, + message: format!("{n} summary rows upserted"), + })) +} + +/// POST /admin/users/{user_id}/set-heartbeat +/// 调试用:将用户心跳时间设为 N 秒前(模拟超时) +pub async fn set_heartbeat_old( + State(state): State, + Path(user_id): Path, + Json(payload): Json, +) -> Result, AppError> { + let ts = Utc::now().timestamp() - payload.seconds_ago; + state + .db + .debug_set_last_heartbeat(&user_id, ts) + .await + .map_err(AppError::Internal)?; + + Ok(Json(AdminResult { + affected: 1, + message: format!("{user_id} last_heartbeat set to {}s ago", payload.seconds_ago), + })) +} diff --git a/src/handlers/checkpoints.rs b/src/handlers/checkpoints.rs new file mode 100644 index 0000000..513adab --- /dev/null +++ b/src/handlers/checkpoints.rs @@ -0,0 +1,122 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use chrono::Utc; + +use crate::error::AppError; +use crate::models::checkpoint::{ + Checkpoint, CreateCheckpointRequest, HeartbeatResponse, ListCheckpointsQuery, + ListSummariesQuery, StateSummary, UserStatusResponse, +}; +use crate::state::AppState; + +// ---------- POST /users/{user_id}/checkpoints ---------- +// 仅在状态变更时调用,同步更新会话 + +pub async fn create_checkpoint( + State(state): State, + Path(user_id): Path, + Json(payload): Json, +) -> Result<(StatusCode, Json), AppError> { + let timestamp = payload.timestamp.unwrap_or_else(|| Utc::now().timestamp()); + + let cp = state + .db + .create_checkpoint(&user_id, &payload.state.to_string(), timestamp, payload.content) + .await + .map_err(AppError::Internal)?; + + // 状态变更时同步更新会话(心跳也顺带刷新) + let _ = state.db.heartbeat(&user_id, &payload.state.to_string()).await; + + Ok((StatusCode::CREATED, Json(cp))) +} + +// ---------- GET /users/{user_id}/checkpoints ---------- + +pub async fn list_checkpoints( + State(state): State, + Path(user_id): Path, + Query(query): Query, +) -> Result>, AppError> { + let cps = state + .db + .list_checkpoints(&user_id, query.from, query.to, query.limit) + .await + .map_err(AppError::Internal)?; + + Ok(Json(cps)) +} + +// ---------- GET /users/{user_id}/checkpoints/{id} ---------- + +pub async fn get_checkpoint( + State(state): State, + Path((user_id, id)): Path<(String, u64)>, +) -> Result, AppError> { + let cp = state + .db + .get_checkpoint(id) + .await + .map_err(AppError::Internal)? + .ok_or_else(|| AppError::NotFound(format!("checkpoint {id} not found")))?; + + if cp.user_id != user_id { + return Err(AppError::NotFound(format!("checkpoint {id} not found"))); + } + + Ok(Json(cp)) +} + +// ---------- GET /users/{user_id}/status ---------- + +pub async fn get_user_status( + State(state): State, + Path(user_id): Path, +) -> Result, AppError> { + let summary = state + .db + .get_status_summary(&user_id) + .await + .map_err(AppError::Internal)?; + + Ok(Json(summary)) +} + +// ---------- GET /users/{user_id}/summaries ---------- + +pub async fn list_summaries( + State(state): State, + Path(user_id): Path, + Query(query): Query, +) -> Result>, AppError> { + let rows = state + .db + .list_state_summaries(&user_id, query.from, query.to, query.limit) + .await + .map_err(AppError::Internal)?; + + Ok(Json(rows)) +} + +// ---------- POST /users/{user_id}/heartbeat ---------- +// 心跳验证(每 30s),不产生检查点,仅刷新 last_heartbeat + +pub async fn heartbeat( + State(state): State, + Path(user_id): Path, +) -> Result, AppError> { + let hb = state + .db + .heartbeat(&user_id, "") + .await + .map_err(AppError::Internal)?; + + Ok(Json(HeartbeatResponse { + user_id, + current_state: hb.current_state, + last_heartbeat: hb.last_heartbeat, + })) +} diff --git a/src/handlers/health.rs b/src/handlers/health.rs new file mode 100644 index 0000000..263397a --- /dev/null +++ b/src/handlers/health.rs @@ -0,0 +1,6 @@ +use axum::{response::IntoResponse, Json}; + +/// GET /health +pub async fn health_check() -> impl IntoResponse { + Json(serde_json::json!({ "status": "ok" })) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..4df7a6b --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,3 @@ +pub mod admin; +pub mod checkpoints; +pub mod health; diff --git a/src/main.rs b/src/main.rs index e7a11a9..ec33805 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,79 @@ -fn main() { - println!("Hello, world!"); + +mod config; +mod db; +mod error; +mod handlers; +mod models; +mod router; +mod state; + +use config::Config; +use db::postgres::PgDb; +use state::AppState; + +/// 按 ; 拆分 SQL,跳过 $$...$$ 内的分号(保护 PG 函数体) +fn split_sql(sql: &str) -> Vec { + let mut stmts = Vec::new(); + let mut buf = String::new(); + let mut dollar_depth = 0; + let chars: Vec = sql.chars().collect(); + let mut i = 0; + while i < chars.len() { + if i + 1 < chars.len() && chars[i] == '$' && chars[i + 1] == '$' { + if dollar_depth == 0 { dollar_depth = 1; } else { dollar_depth = 0; } + buf.push_str("$$"); + i += 2; + } else if chars[i] == ';' && dollar_depth == 0 { + stmts.push(buf.trim().to_string()); + buf.clear(); + i += 1; + } else { + buf.push(chars[i]); + i += 1; + } + } + let remainder = buf.trim().to_string(); + if !remainder.is_empty() { + stmts.push(remainder); + } + stmts +} + +async fn run_migration(pool: &sqlx::PgPool, sql_file: &str, name: &str) { + for stmt in split_sql(sql_file) { + let trimmed = stmt.trim(); + if !trimmed.is_empty() { + sqlx::query(trimmed) + .execute(pool) + .await + .unwrap_or_else(|e| panic!("Migration {name} failed: {e}\nSQL: {trimmed}")); + } + } +} + +#[tokio::main] +async fn main() { + let _ = dotenvy::dotenv(); + let cfg = Config::from_env(); + + let database_url = cfg.database_url + .expect("DATABASE_URL must be set (PostgreSQL is the only supported backend)"); + + println!("🔗 Connecting to PostgreSQL..."); + let pool = sqlx::PgPool::connect(&database_url) + .await + .expect("Failed to connect to PostgreSQL"); + + run_migration(&pool, include_str!("../migrations/001_init.sql"), "001_init").await; + run_migration(&pool, include_str!("../migrations/002_daily_summary.sql"), "002_summary").await; + run_migration(&pool, include_str!("../migrations/003_sessions.sql"), "003_sessions").await; + println!("✅ PostgreSQL connected, migrations applied"); + + let db = db::into_shared(PgDb::new(pool)); + let state = AppState::new(db); + let app = router::build(state); + + println!("🚀 Server running at http://{}", cfg.listen_addr); + let listener = tokio::net::TcpListener::bind(cfg.listen_addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); } diff --git a/src/models/checkpoint.rs b/src/models/checkpoint.rs new file mode 100644 index 0000000..4381702 --- /dev/null +++ b/src/models/checkpoint.rs @@ -0,0 +1,129 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// 用户状态枚举 —— 通过 `Custom(String)` 变体可自由扩充新状态 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StateType { + Online, + Offline, + Idle, + Working, + Sleeping, + /// 可自由扩充的自定义状态,如 "Gaming", "Meeting", "Driving" 等 + Custom(String), +} + +// 自定义序列化:所有变体序列化为扁平字符串 +impl Serialize for StateType { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +// 自定义反序列化:已知名称匹配变体,未知名称视为 Custom +impl<'de> Deserialize<'de> for StateType { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(match s.as_str() { + "Online" => StateType::Online, + "Offline" => StateType::Offline, + "Idle" => StateType::Idle, + "Working" => StateType::Working, + "Sleeping" => StateType::Sleeping, + other => StateType::Custom(other.to_string()), + }) + } +} + +impl std::fmt::Display for StateType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StateType::Online => write!(f, "Online"), + StateType::Offline => write!(f, "Offline"), + StateType::Idle => write!(f, "Idle"), + StateType::Working => write!(f, "Working"), + StateType::Sleeping => write!(f, "Sleeping"), + StateType::Custom(s) => write!(f, "{s}"), + } + } +} + +/// 检查点 —— 用户在某时刻的状态快照 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Checkpoint { + pub id: u64, + pub user_id: String, + pub state: StateType, + /// Unix 时间戳(秒) + pub timestamp: i64, + /// 可扩展的 JSON 元数据(设备信息、坐标等) + pub content: Option, +} + +/// 创建检查点的请求体 +#[derive(Debug, Deserialize)] +pub struct CreateCheckpointRequest { + pub state: StateType, + /// 时间戳(可选),不传则服务端填充当前时间 + pub timestamp: Option, + /// 可选的附加元数据 + pub content: Option, +} + +/// 单个状态的持续时间 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateDuration { + pub state: StateType, + /// 该状态累计时长(秒) + pub duration_secs: i64, +} + +/// 用户状态汇总响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserStatusResponse { + pub user_id: String, + /// 当前所处状态 + pub current_state: StateType, + /// 自何时进入当前状态(Unix 秒) + pub since: i64, + /// 各状态累计时长 + pub durations: Vec, +} + +/// 查询检查点列表的参数 +#[derive(Debug, Deserialize)] +pub struct ListCheckpointsQuery { + pub from: Option, + pub to: Option, + pub limit: Option, +} + +// ---------- 定时快照模型 ---------- + +/// 状态时长快照(来自 state_summaries 表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateSummary { + pub id: u64, + pub user_id: String, + pub state: StateType, + pub duration_secs: i64, + pub period_start: i64, + pub period_end: i64, + pub created_at: String, +} + +/// 查询快照的参数 +#[derive(Debug, Deserialize)] +pub struct ListSummariesQuery { + pub from: Option, + pub to: Option, + pub limit: Option, +} + +/// 心跳响应 +#[derive(Debug, Serialize)] +pub struct HeartbeatResponse { + pub user_id: String, + pub current_state: String, + pub last_heartbeat: i64, +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..4c0eada --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1 @@ +pub mod checkpoint; diff --git a/src/router.rs b/src/router.rs new file mode 100644 index 0000000..9bc96a2 --- /dev/null +++ b/src/router.rs @@ -0,0 +1,32 @@ +use axum::{routing::{get, post}, Router}; + +use crate::handlers::{admin, checkpoints, health}; +use crate::state::AppState; + +/// 组装所有路由 +pub fn build(state: AppState) -> Router { + Router::new() + // 健康检查 + .route("/health", get(health::health_check)) + // 心跳验证(每 30s,不产生检查点) + .route("/users/{user_id}/heartbeat", post(checkpoints::heartbeat)) + // 检查点: 列表 + 创建(状态变更时才调用) + .route( + "/users/{user_id}/checkpoints", + get(checkpoints::list_checkpoints).post(checkpoints::create_checkpoint), + ) + // 单个检查点 + .route( + "/users/{user_id}/checkpoints/{id}", + get(checkpoints::get_checkpoint), + ) + // 用户状态汇总 + .route("/users/{user_id}/status", get(checkpoints::get_user_status)) + // 定时快照历史 + .route("/users/{user_id}/summaries", get(checkpoints::list_summaries)) + // 管理端点(开发/调试用) + .route("/admin/offline-check", post(admin::offline_check)) + .route("/admin/aggregate", post(admin::aggregate_now)) + .route("/admin/users/{user_id}/set-heartbeat", post(admin::set_heartbeat_old)) + .with_state(state) +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..89f67c9 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use crate::db::Db; + +/// 全局应用状态,所有 handler 通过 `State` 共享 +#[derive(Clone)] +pub struct AppState { + /// 数据库抽象层 —— 开发期用内存模拟,上线后替换为真实 DB + pub db: Arc, +} + +impl AppState { + pub fn new(db: Arc) -> Self { + Self { db } + } +}