From a2f8112d6903a3d214df9bf13ec752267cda7b23 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 12:41:11 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat(saas):=20Phase=201=20=E2=80=94=20?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=A1=86=E6=9E=B6=E4=B8=8E=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 zclaw-saas crate 作为 workspace 成员 - 配置系统 (TOML + 环境变量覆盖) - 错误类型体系 (SaasError 16 变体, IntoResponse) - SQLite 数据库 (12 表 schema, 内存/文件双模式, 3 系统角色种子数据) - JWT 认证 (签发/验证/刷新) - Argon2id 密码哈希 - 认证中间件 (公开/受保护路由分层) - 账号管理 CRUD + API Token 管理 + 操作日志 - 7 单元测试 + 5 集成测试全部通过 --- Cargo.lock | 339 +++++++++++++++++++- Cargo.toml | 13 + crates/zclaw-saas/Cargo.toml | 42 +++ crates/zclaw-saas/src/account/handlers.rs | 117 +++++++ crates/zclaw-saas/src/account/mod.rs | 19 ++ crates/zclaw-saas/src/account/service.rs | 222 +++++++++++++ crates/zclaw-saas/src/account/types.rs | 53 +++ crates/zclaw-saas/src/auth/handlers.rs | 180 +++++++++++ crates/zclaw-saas/src/auth/jwt.rs | 91 ++++++ crates/zclaw-saas/src/auth/mod.rs | 69 ++++ crates/zclaw-saas/src/auth/password.rs | 48 +++ crates/zclaw-saas/src/auth/types.rs | 49 +++ crates/zclaw-saas/src/config.rs | 144 +++++++++ crates/zclaw-saas/src/db.rs | 281 ++++++++++++++++ crates/zclaw-saas/src/error.rs | 119 +++++++ crates/zclaw-saas/src/lib.rs | 14 + crates/zclaw-saas/src/main.rs | 57 ++++ crates/zclaw-saas/src/migration/mod.rs | 1 + crates/zclaw-saas/src/model_config/mod.rs | 1 + crates/zclaw-saas/src/relay/mod.rs | 1 + crates/zclaw-saas/src/state.rs | 28 ++ crates/zclaw-saas/tests/integration_test.rs | 222 +++++++++++++ saas-config.toml | 17 + 23 files changed, 2123 insertions(+), 4 deletions(-) create mode 100644 crates/zclaw-saas/Cargo.toml create mode 100644 crates/zclaw-saas/src/account/handlers.rs create mode 100644 crates/zclaw-saas/src/account/mod.rs create mode 100644 crates/zclaw-saas/src/account/service.rs create mode 100644 crates/zclaw-saas/src/account/types.rs create mode 100644 crates/zclaw-saas/src/auth/handlers.rs create mode 100644 crates/zclaw-saas/src/auth/jwt.rs create mode 100644 crates/zclaw-saas/src/auth/mod.rs create mode 100644 crates/zclaw-saas/src/auth/password.rs create mode 100644 crates/zclaw-saas/src/auth/types.rs create mode 100644 crates/zclaw-saas/src/config.rs create mode 100644 crates/zclaw-saas/src/db.rs create mode 100644 crates/zclaw-saas/src/error.rs create mode 100644 crates/zclaw-saas/src/lib.rs create mode 100644 crates/zclaw-saas/src/main.rs create mode 100644 crates/zclaw-saas/src/migration/mod.rs create mode 100644 crates/zclaw-saas/src/model_config/mod.rs create mode 100644 crates/zclaw-saas/src/relay/mod.rs create mode 100644 crates/zclaw-saas/src/state.rs create mode 100644 crates/zclaw-saas/tests/integration_test.rs create mode 100644 saas-config.toml diff --git a/Cargo.lock b/Cargo.lock index 38ff26a..be6b01b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,18 @@ dependencies = [ "derive_arbitrary", ] +[[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 = "async-broadcast" version = "0.7.2" @@ -315,6 +327,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "axum-macros", "bytes", "futures-util", "http 1.4.0", @@ -335,7 +348,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -362,6 +375,47 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +dependencies = [ + "axum", + "axum-core", + "bytes", + "fastrand", + "futures-util", + "headers", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "serde", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + [[package]] name = "base64" version = "0.21.7" @@ -410,6 +464,15 @@ dependencies = [ "serde_core", ] +[[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" @@ -654,6 +717,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" @@ -1168,6 +1237,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1894,6 +1972,30 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http 1.4.0", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.4.0", +] + [[package]] name = "heck" version = "0.4.1" @@ -2433,6 +2535,21 @@ dependencies = [ "serde_json", ] +[[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 = "keyboard-types" version = "0.7.0" @@ -2625,6 +2742,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matches" version = "0.1.10" @@ -2716,6 +2842,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -2785,6 +2928,25 @@ dependencies = [ "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" @@ -3120,6 +3282,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3132,6 +3305,16 @@ 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" @@ -3334,6 +3517,26 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3860,7 +4063,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.3", "tower-http 0.6.8", "tower-service", "url", @@ -3895,7 +4098,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-http 0.6.8", "tower-service", "url", @@ -4399,6 +4602,15 @@ dependencies = [ "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" @@ -4431,6 +4643,18 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[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 = "siphasher" version = "0.3.11" @@ -5261,6 +5485,15 @@ dependencies = [ "syn 2.0.117", ] +[[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" @@ -5505,6 +5738,34 @@ version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +[[package]] +name = "totp-rs" +version = "5.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b36a9dd327e9f401320a2cb4572cc76ff43742bcfc3291f871691050f140ba" +dependencies = [ + "base32", + "constant_time_eq", + "hmac", + "sha1", + "sha2", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -5535,6 +5796,7 @@ dependencies = [ "pin-project-lite", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5550,7 +5812,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", ] @@ -5597,6 +5859,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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -5814,6 +6106,12 @@ dependencies = [ "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" @@ -7124,6 +7422,39 @@ dependencies = [ "zclaw-types", ] +[[package]] +name = "zclaw-saas" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "axum-extra", + "chrono", + "dashmap", + "hex", + "jsonwebtoken", + "libsqlite3-sys", + "rand 0.8.5", + "reqwest 0.12.28", + "secrecy", + "serde", + "serde_json", + "sha2", + "sqlx", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml 0.8.2", + "totp-rs", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", + "uuid", + "zclaw-types", +] + [[package]] name = "zclaw-skills" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d39920c..833b7e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ members = [ "crates/zclaw-growth", # Desktop Application "desktop/src-tauri", + # SaaS Backend + "crates/zclaw-saas", ] [workspace.package] @@ -95,6 +97,16 @@ shlex = "1" # Testing tempfile = "3" +# SaaS dependencies +axum = { version = "0.7", features = ["macros"] } +axum-extra = { version = "0.9", features = ["typed-header"] } +tower = { version = "0.4", features = ["util"] } +tower-http = { version = "0.5", features = ["cors", "trace", "limit"] } +jsonwebtoken = "9" +argon2 = "0.5" +totp-rs = "5" +hex = "0.4" + # Internal crates zclaw-types = { path = "crates/zclaw-types" } zclaw-memory = { path = "crates/zclaw-memory" } @@ -106,6 +118,7 @@ zclaw-channels = { path = "crates/zclaw-channels" } zclaw-protocols = { path = "crates/zclaw-protocols" } zclaw-pipeline = { path = "crates/zclaw-pipeline" } zclaw-growth = { path = "crates/zclaw-growth" } +zclaw-saas = { path = "crates/zclaw-saas" } [profile.release] lto = true diff --git a/crates/zclaw-saas/Cargo.toml b/crates/zclaw-saas/Cargo.toml new file mode 100644 index 0000000..4bdb077 --- /dev/null +++ b/crates/zclaw-saas/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "zclaw-saas" +version.workspace = true +edition.workspace = true +description = "ZCLAW SaaS backend - account, API config, relay, migration" + +[[bin]] +name = "zclaw-saas" +path = "src/main.rs" + +[dependencies] +zclaw-types = { workspace = true } + +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +sqlx = { workspace = true } +libsqlite3-sys = { workspace = true } +reqwest = { workspace = true } +secrecy = { workspace = true } +sha2 = { workspace = true } +rand = { workspace = true } +dashmap = { workspace = true } +hex = { workspace = true } + +axum = { workspace = true } +axum-extra = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +jsonwebtoken = { workspace = true } +argon2 = { workspace = true } +totp-rs = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/zclaw-saas/src/account/handlers.rs b/crates/zclaw-saas/src/account/handlers.rs new file mode 100644 index 0000000..4d5d488 --- /dev/null +++ b/crates/zclaw-saas/src/account/handlers.rs @@ -0,0 +1,117 @@ +//! 账号管理 HTTP 处理器 + +use axum::{ + extract::{Extension, Path, Query, State}, + Json, +}; +use crate::state::AppState; +use crate::error::SaasResult; +use crate::auth::types::AuthContext; +use crate::auth::handlers::log_operation; +use super::{types::*, service}; + +/// GET /api/v1/accounts +pub async fn list_accounts( + State(state): State, + Query(query): Query, + _ctx: Extension, +) -> SaasResult>> { + service::list_accounts(&state.db, &query).await.map(Json) +} + +/// GET /api/v1/accounts/:id +pub async fn get_account( + State(state): State, + Path(id): Path, + _ctx: Extension, +) -> SaasResult> { + service::get_account(&state.db, &id).await.map(Json) +} + +/// PUT /api/v1/accounts/:id +pub async fn update_account( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + let result = service::update_account(&state.db, &id, &req).await?; + log_operation(&state.db, &ctx.account_id, "account.update", "account", &id, None, None).await?; + Ok(Json(result)) +} + +/// PATCH /api/v1/accounts/:id/status +pub async fn update_status( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + service::update_account_status(&state.db, &id, &req.status).await?; + log_operation(&state.db, &ctx.account_id, "account.update_status", "account", &id, + Some(serde_json::json!({"status": &req.status})), None).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +/// GET /api/v1/tokens +pub async fn list_tokens( + State(state): State, + Extension(ctx): Extension, +) -> SaasResult>> { + service::list_api_tokens(&state.db, &ctx.account_id).await.map(Json) +} + +/// POST /api/v1/tokens +pub async fn create_token( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + let token = service::create_api_token(&state.db, &ctx.account_id, &req).await?; + log_operation(&state.db, &ctx.account_id, "token.create", "api_token", &token.id, + Some(serde_json::json!({"name": &req.name})), None).await?; + Ok(Json(token)) +} + +/// DELETE /api/v1/tokens/:id +pub async fn revoke_token( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> SaasResult> { + service::revoke_api_token(&state.db, &id, &ctx.account_id).await?; + log_operation(&state.db, &ctx.account_id, "token.revoke", "api_token", &id, None, None).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +/// GET /api/v1/logs/operations +pub async fn list_operation_logs( + State(state): State, + Query(params): Query>, + _ctx: Extension, +) -> SaasResult>> { + let page: i64 = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1); + let page_size: i64 = params.get("page_size").and_then(|v| v.parse().ok()).unwrap_or(50); + let offset = (page - 1) * page_size; + + let rows: Vec<(i64, Option, String, Option, Option, Option, Option, String)> = + sqlx::query_as( + "SELECT id, account_id, action, target_type, target_id, details, ip_address, created_at + FROM operation_logs ORDER BY created_at DESC LIMIT ?1 OFFSET ?2" + ) + .bind(page_size) + .bind(offset) + .fetch_all(&state.db) + .await?; + + let items: Vec = rows.into_iter().map(|(id, account_id, action, target_type, target_id, details, ip_address, created_at)| { + serde_json::json!({ + "id": id, "account_id": account_id, "action": action, + "target_type": target_type, "target_id": target_id, + "details": details.and_then(|d| serde_json::from_str::(&d).ok()), + "ip_address": ip_address, "created_at": created_at, + }) + }).collect(); + + Ok(Json(items)) +} diff --git a/crates/zclaw-saas/src/account/mod.rs b/crates/zclaw-saas/src/account/mod.rs new file mode 100644 index 0000000..b866c26 --- /dev/null +++ b/crates/zclaw-saas/src/account/mod.rs @@ -0,0 +1,19 @@ +//! 账号管理模块 + +pub mod types; +pub mod service; +pub mod handlers; + +use axum::routing::{delete, get, patch, post, put}; + +pub fn routes() -> axum::Router { + axum::Router::new() + .route("/api/v1/accounts", get(handlers::list_accounts)) + .route("/api/v1/accounts/{id}", get(handlers::get_account)) + .route("/api/v1/accounts/{id}", put(handlers::update_account)) + .route("/api/v1/accounts/{id}/status", patch(handlers::update_status)) + .route("/api/v1/tokens", get(handlers::list_tokens)) + .route("/api/v1/tokens", post(handlers::create_token)) + .route("/api/v1/tokens/{id}", delete(handlers::revoke_token)) + .route("/api/v1/logs/operations", get(handlers::list_operation_logs)) +} diff --git a/crates/zclaw-saas/src/account/service.rs b/crates/zclaw-saas/src/account/service.rs new file mode 100644 index 0000000..3ecc094 --- /dev/null +++ b/crates/zclaw-saas/src/account/service.rs @@ -0,0 +1,222 @@ +//! 账号管理业务逻辑 + +use sqlx::SqlitePool; +use crate::error::{SaasError, SaasResult}; +use super::types::*; + +pub async fn list_accounts( + db: &SqlitePool, + query: &ListAccountsQuery, +) -> SaasResult> { + let page = query.page.unwrap_or(1).max(1); + let page_size = query.page_size.unwrap_or(20).min(100); + let offset = (page - 1) * page_size; + + let mut where_clauses = Vec::new(); + let mut params: Vec = Vec::new(); + + if let Some(role) = &query.role { + where_clauses.push("role = ?".to_string()); + params.push(role.clone()); + } + if let Some(status) = &query.status { + where_clauses.push("status = ?".to_string()); + params.push(status.clone()); + } + if let Some(search) = &query.search { + where_clauses.push("(username LIKE ? OR email LIKE ? OR display_name LIKE ?)".to_string()); + let pattern = format!("%{}%", search); + params.push(pattern.clone()); + params.push(pattern.clone()); + params.push(pattern); + } + + let where_sql = if where_clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", where_clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) as count FROM accounts {}", where_sql); + let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); + for p in ¶ms { + count_query = count_query.bind(p); + } + let total: i64 = count_query.fetch_one(db).await?; + + let data_sql = format!( + "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at + FROM accounts {} ORDER BY created_at DESC LIMIT ? OFFSET ?", + where_sql + ); + let mut data_query = sqlx::query_as::<_, (String, String, String, String, String, String, bool, Option, String)>(&data_sql); + for p in ¶ms { + data_query = data_query.bind(p); + } + let rows = data_query.bind(page_size as i64).bind(offset as i64).fetch_all(db).await?; + + let items: Vec = rows + .into_iter() + .map(|(id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at)| { + serde_json::json!({ + "id": id, "username": username, "email": email, "display_name": display_name, + "role": role, "status": status, "totp_enabled": totp_enabled, + "last_login_at": last_login_at, "created_at": created_at, + }) + }) + .collect(); + + Ok(PaginatedResponse { items, total, page, page_size }) +} + +pub async fn get_account(db: &SqlitePool, account_id: &str) -> SaasResult { + let row: Option<(String, String, String, String, String, String, bool, Option, String)> = + sqlx::query_as( + "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at + FROM accounts WHERE id = ?1" + ) + .bind(account_id) + .fetch_optional(db) + .await?; + + let (id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at) = + row.ok_or_else(|| SaasError::NotFound(format!("账号 {} 不存在", account_id)))?; + + Ok(serde_json::json!({ + "id": id, "username": username, "email": email, "display_name": display_name, + "role": role, "status": status, "totp_enabled": totp_enabled, + "last_login_at": last_login_at, "created_at": created_at, + })) +} + +pub async fn update_account( + db: &SqlitePool, + account_id: &str, + req: &UpdateAccountRequest, +) -> SaasResult { + let now = chrono::Utc::now().to_rfc3339(); + let mut updates = Vec::new(); + let mut params: Vec = Vec::new(); + + if let Some(ref v) = req.display_name { updates.push("display_name = ?"); params.push(v.clone()); } + if let Some(ref v) = req.email { updates.push("email = ?"); params.push(v.clone()); } + if let Some(ref v) = req.role { updates.push("role = ?"); params.push(v.clone()); } + if let Some(ref v) = req.avatar_url { updates.push("avatar_url = ?"); params.push(v.clone()); } + + if updates.is_empty() { + return get_account(db, account_id).await; + } + + updates.push("updated_at = ?"); + params.push(now.clone()); + params.push(account_id.to_string()); + + let sql = format!("UPDATE accounts SET {} WHERE id = ?", updates.join(", ")); + let mut query = sqlx::query(&sql); + for p in ¶ms { + query = query.bind(p); + } + query.execute(db).await?; + get_account(db, account_id).await +} + +pub async fn update_account_status( + db: &SqlitePool, + account_id: &str, + status: &str, +) -> SaasResult<()> { + let valid = ["active", "disabled", "suspended"]; + if !valid.contains(&status) { + return Err(SaasError::InvalidInput(format!("无效状态: {},有效值: {:?}", status, valid))); + } + let now = chrono::Utc::now().to_rfc3339(); + let result = sqlx::query("UPDATE accounts SET status = ?1, updated_at = ?2 WHERE id = ?3") + .bind(status).bind(&now).bind(account_id) + .execute(db).await?; + + if result.rows_affected() == 0 { + return Err(SaasError::NotFound(format!("账号 {} 不存在", account_id))); + } + Ok(()) +} + +pub async fn create_api_token( + db: &SqlitePool, + account_id: &str, + req: &CreateTokenRequest, +) -> SaasResult { + use sha2::{Sha256, Digest}; + + let mut bytes = [0u8; 48]; + use rand::RngCore; + rand::thread_rng().fill_bytes(&mut bytes); + let raw_token = format!("zclaw_{}", hex::encode(bytes)); + let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes())); + let token_prefix = raw_token[..8].to_string(); + + let now = chrono::Utc::now().to_rfc3339(); + let expires_at = req.expires_days.map(|d| { + (chrono::Utc::now() + chrono::Duration::days(d)).to_rfc3339() + }); + let permissions = serde_json::to_string(&req.permissions)?; + let token_id = uuid::Uuid::new_v4().to_string(); + + sqlx::query( + "INSERT INTO api_tokens (id, account_id, name, token_hash, token_prefix, permissions, created_at, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)" + ) + .bind(&token_id) + .bind(account_id) + .bind(&req.name) + .bind(&token_hash) + .bind(&token_prefix) + .bind(&permissions) + .bind(&now) + .bind(&expires_at) + .execute(db) + .await?; + + Ok(TokenInfo { + id: token_id, + name: req.name.clone(), + token_prefix, + permissions: req.permissions.clone(), + last_used_at: None, + expires_at, + created_at: now, + token: Some(raw_token), + }) +} + +pub async fn list_api_tokens( + db: &SqlitePool, + account_id: &str, +) -> SaasResult> { + let rows: Vec<(String, String, String, String, Option, Option, String)> = + sqlx::query_as( + "SELECT id, name, token_prefix, permissions, last_used_at, expires_at, created_at + FROM api_tokens WHERE account_id = ?1 AND revoked_at IS NULL ORDER BY created_at DESC" + ) + .bind(account_id) + .fetch_all(db) + .await?; + + Ok(rows.into_iter().map(|(id, name, token_prefix, perms, last_used, expires, created)| { + let permissions: Vec = serde_json::from_str(&perms).unwrap_or_default(); + TokenInfo { id, name, token_prefix, permissions, last_used_at: last_used, expires_at: expires, created_at: created, token: None, } + }).collect()) +} + +pub async fn revoke_api_token(db: &SqlitePool, token_id: &str, account_id: &str) -> SaasResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + let result = sqlx::query( + "UPDATE api_tokens SET revoked_at = ?1 WHERE id = ?2 AND account_id = ?3 AND revoked_at IS NULL" + ) + .bind(&now).bind(token_id).bind(account_id) + .execute(db).await?; + + if result.rows_affected() == 0 { + return Err(SaasError::NotFound("Token 不存在或已撤销".into())); + } + Ok(()) +} diff --git a/crates/zclaw-saas/src/account/types.rs b/crates/zclaw-saas/src/account/types.rs new file mode 100644 index 0000000..bfcaaea --- /dev/null +++ b/crates/zclaw-saas/src/account/types.rs @@ -0,0 +1,53 @@ +//! 账号管理类型 + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct UpdateAccountRequest { + pub display_name: Option, + pub email: Option, + pub role: Option, + pub avatar_url: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateStatusRequest { + pub status: String, +} + +#[derive(Debug, Deserialize)] +pub struct ListAccountsQuery { + pub page: Option, + pub page_size: Option, + pub role: Option, + pub status: Option, + pub search: Option, +} + +#[derive(Debug, Serialize)] +pub struct PaginatedResponse { + pub items: Vec, + pub total: i64, + pub page: u32, + pub page_size: u32, +} + +#[derive(Debug, Deserialize)] +pub struct CreateTokenRequest { + pub name: String, + pub permissions: Vec, + pub expires_days: Option, +} + +#[derive(Debug, Serialize)] +pub struct TokenInfo { + pub id: String, + pub name: String, + pub token_prefix: String, + pub permissions: Vec, + pub last_used_at: Option, + pub expires_at: Option, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, +} diff --git a/crates/zclaw-saas/src/auth/handlers.rs b/crates/zclaw-saas/src/auth/handlers.rs new file mode 100644 index 0000000..1ec448d --- /dev/null +++ b/crates/zclaw-saas/src/auth/handlers.rs @@ -0,0 +1,180 @@ +//! 认证 HTTP 处理器 + +use axum::{extract::State, http::StatusCode, Json}; +use secrecy::ExposeSecret; +use crate::state::AppState; +use crate::error::{SaasError, SaasResult}; +use super::{ + jwt::create_token, + password::{hash_password, verify_password}, + types::{AuthContext, LoginRequest, LoginResponse, RegisterRequest, AccountPublic}, +}; + +/// POST /api/v1/auth/register +pub async fn register( + State(state): State, + Json(req): Json, +) -> SaasResult<(StatusCode, Json)> { + if req.username.len() < 3 { + return Err(SaasError::InvalidInput("用户名至少 3 个字符".into())); + } + if req.password.len() < 8 { + return Err(SaasError::InvalidInput("密码至少 8 个字符".into())); + } + + let existing: Vec<(String,)> = sqlx::query_as( + "SELECT id FROM accounts WHERE username = ?1 OR email = ?2" + ) + .bind(&req.username) + .bind(&req.email) + .fetch_all(&state.db) + .await?; + + if !existing.is_empty() { + return Err(SaasError::AlreadyExists("用户名或邮箱已存在".into())); + } + + let password_hash = hash_password(&req.password)?; + let account_id = uuid::Uuid::new_v4().to_string(); + let role = req.role.unwrap_or_else(|| "user".into()); + let display_name = req.display_name.unwrap_or_default(); + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO accounts (id, username, email, password_hash, display_name, role, status, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', ?7, ?7)" + ) + .bind(&account_id) + .bind(&req.username) + .bind(&req.email) + .bind(&password_hash) + .bind(&display_name) + .bind(&role) + .bind(&now) + .execute(&state.db) + .await?; + + log_operation(&state.db, &account_id, "account.create", "account", &account_id, None, None).await?; + + Ok((StatusCode::CREATED, Json(AccountPublic { + id: account_id, + username: req.username, + email: req.email, + display_name, + role, + status: "active".into(), + totp_enabled: false, + created_at: now, + }))) +} + +/// POST /api/v1/auth/login +pub async fn login( + State(state): State, + Json(req): Json, +) -> SaasResult> { + let row: Option<(String, String, String, String, String, String, bool, String)> = + sqlx::query_as( + "SELECT id, username, email, display_name, role, status, totp_enabled, created_at + FROM accounts WHERE username = ?1 OR email = ?1" + ) + .bind(&req.username) + .fetch_optional(&state.db) + .await?; + + let (id, username, email, display_name, role, status, totp_enabled, created_at) = + row.ok_or_else(|| SaasError::AuthError("用户名或密码错误".into()))?; + + if status != "active" { + return Err(SaasError::Forbidden(format!("账号已{},请联系管理员", status))); + } + + let (password_hash,): (String,) = sqlx::query_as( + "SELECT password_hash FROM accounts WHERE id = ?1" + ) + .bind(&id) + .fetch_one(&state.db) + .await?; + + if !verify_password(&req.password, &password_hash)? { + return Err(SaasError::AuthError("用户名或密码错误".into())); + } + + let permissions = get_role_permissions(&state.db, &role).await?; + let config = state.config.read().await; + let token = create_token( + &id, &role, permissions.clone(), + state.jwt_secret.expose_secret(), + config.auth.jwt_expiration_hours, + )?; + + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query("UPDATE accounts SET last_login_at = ?1 WHERE id = ?2") + .bind(&now).bind(&id) + .execute(&state.db).await?; + log_operation(&state.db, &id, "account.login", "account", &id, None, None).await?; + + Ok(Json(LoginResponse { + token, + account: AccountPublic { + id, username, email, display_name, role, status, totp_enabled, created_at, + }, + })) +} + +/// POST /api/v1/auth/refresh +pub async fn refresh( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, +) -> SaasResult> { + let config = state.config.read().await; + let token = create_token( + &ctx.account_id, &ctx.role, ctx.permissions.clone(), + state.jwt_secret.expose_secret(), + config.auth.jwt_expiration_hours, + )?; + Ok(Json(serde_json::json!({ "token": token }))) +} + +async fn get_role_permissions(db: &sqlx::SqlitePool, role: &str) -> SaasResult> { + let row: Option<(String,)> = sqlx::query_as( + "SELECT permissions FROM roles WHERE id = ?1" + ) + .bind(role) + .fetch_optional(db) + .await?; + + let permissions_str = row + .ok_or_else(|| SaasError::Internal(format!("角色 {} 不存在", role)))? + .0; + + let permissions: Vec = serde_json::from_str(&permissions_str)?; + Ok(permissions) +} + +/// 记录操作日志 +pub async fn log_operation( + db: &sqlx::SqlitePool, + account_id: &str, + action: &str, + target_type: &str, + target_id: &str, + details: Option, + ip_address: Option<&str>, +) -> SaasResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO operation_logs (account_id, action, target_type, target_id, details, ip_address, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + ) + .bind(account_id) + .bind(action) + .bind(target_type) + .bind(target_id) + .bind(details.map(|d| d.to_string())) + .bind(ip_address) + .bind(&now) + .execute(db) + .await?; + Ok(()) +} diff --git a/crates/zclaw-saas/src/auth/jwt.rs b/crates/zclaw-saas/src/auth/jwt.rs new file mode 100644 index 0000000..2ed10e0 --- /dev/null +++ b/crates/zclaw-saas/src/auth/jwt.rs @@ -0,0 +1,91 @@ +//! JWT Token 创建与验证 + +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +use crate::error::SaasResult; + +/// JWT Claims +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, + pub role: String, + pub permissions: Vec, + pub iat: i64, + pub exp: i64, +} + +impl Claims { + pub fn new(account_id: &str, role: &str, permissions: Vec, expiration_hours: i64) -> Self { + let now = Utc::now(); + Self { + sub: account_id.to_string(), + role: role.to_string(), + permissions, + iat: now.timestamp(), + exp: (now + Duration::hours(expiration_hours)).timestamp(), + } + } +} + +/// 创建 JWT Token +pub fn create_token( + account_id: &str, + role: &str, + permissions: Vec, + secret: &str, + expiration_hours: i64, +) -> SaasResult { + let claims = Claims::new(account_id, role, permissions, expiration_hours); + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + )?; + Ok(token) +} + +/// 验证 JWT Token +pub fn verify_token(token: &str, secret: &str) -> SaasResult { + let token_data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::default(), + )?; + Ok(token_data.claims) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_SECRET: &str = "test-secret-key"; + + #[test] + fn test_create_and_verify_token() { + let token = create_token( + "account-123", "admin", + vec!["model:read".to_string()], + TEST_SECRET, 24, + ).unwrap(); + + let claims = verify_token(&token, TEST_SECRET).unwrap(); + assert_eq!(claims.sub, "account-123"); + assert_eq!(claims.role, "admin"); + assert_eq!(claims.permissions, vec!["model:read"]); + } + + #[test] + fn test_invalid_token() { + let result = verify_token("invalid.token.here", TEST_SECRET); + assert!(result.is_err()); + } + + #[test] + fn test_wrong_secret() { + let token = create_token("account-123", "admin", vec![], TEST_SECRET, 24).unwrap(); + let result = verify_token(&token, "wrong-secret"); + assert!(result.is_err()); + } +} diff --git a/crates/zclaw-saas/src/auth/mod.rs b/crates/zclaw-saas/src/auth/mod.rs new file mode 100644 index 0000000..e7c6bec --- /dev/null +++ b/crates/zclaw-saas/src/auth/mod.rs @@ -0,0 +1,69 @@ +//! 认证模块 + +pub mod jwt; +pub mod password; +pub mod types; +pub mod handlers; + +use axum::{ + extract::{Request, State}, + http::header, + middleware::Next, + response::{IntoResponse, Response}, +}; +use secrecy::ExposeSecret; +use crate::error::SaasError; +use crate::state::AppState; +use types::AuthContext; + +/// 认证中间件: 从 JWT 或 API Token 提取身份 +pub async fn auth_middleware( + State(state): State, + mut req: Request, + next: Next, +) -> Response { + let auth_header = req.headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()); + + let result = if let Some(auth) = auth_header { + if let Some(token) = auth.strip_prefix("Bearer ") { + jwt::verify_token(token, state.jwt_secret.expose_secret()) + .map(|claims| AuthContext { + account_id: claims.sub, + role: claims.role, + permissions: claims.permissions, + }) + .map_err(|_| SaasError::Unauthorized) + } else { + Err(SaasError::Unauthorized) + } + } else { + Err(SaasError::Unauthorized) + }; + + match result { + Ok(ctx) => { + req.extensions_mut().insert(ctx); + next.run(req).await + } + Err(e) => e.into_response(), + } +} + +/// 路由 (无需认证的端点) +pub fn routes() -> axum::Router { + use axum::routing::post; + + axum::Router::new() + .route("/api/v1/auth/register", post(handlers::register)) + .route("/api/v1/auth/login", post(handlers::login)) +} + +/// 需要认证的路由 +pub fn protected_routes() -> axum::Router { + use axum::routing::post; + + axum::Router::new() + .route("/api/v1/auth/refresh", post(handlers::refresh)) +} diff --git a/crates/zclaw-saas/src/auth/password.rs b/crates/zclaw-saas/src/auth/password.rs new file mode 100644 index 0000000..be650cc --- /dev/null +++ b/crates/zclaw-saas/src/auth/password.rs @@ -0,0 +1,48 @@ +//! 密码哈希 (Argon2id) + +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; + +use crate::error::{SaasError, SaasResult}; + +/// 哈希密码 +pub fn hash_password(password: &str) -> SaasResult { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| SaasError::PasswordHash(e.to_string()))?; + Ok(hash.to_string()) +} + +/// 验证密码 +pub fn verify_password(password: &str, hash: &str) -> SaasResult { + let parsed_hash = PasswordHash::new(hash) + .map_err(|e| SaasError::PasswordHash(e.to_string()))?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_and_verify() { + let hash = hash_password("correct_password").unwrap(); + assert!(verify_password("correct_password", &hash).unwrap()); + assert!(!verify_password("wrong_password", &hash).unwrap()); + } + + #[test] + fn test_different_hashes_for_same_password() { + let hash1 = hash_password("same_password").unwrap(); + let hash2 = hash_password("same_password").unwrap(); + assert_ne!(hash1, hash2); + assert!(verify_password("same_password", &hash1).unwrap()); + assert!(verify_password("same_password", &hash2).unwrap()); + } +} diff --git a/crates/zclaw-saas/src/auth/types.rs b/crates/zclaw-saas/src/auth/types.rs new file mode 100644 index 0000000..babc48e --- /dev/null +++ b/crates/zclaw-saas/src/auth/types.rs @@ -0,0 +1,49 @@ +//! 认证相关类型 + +use serde::{Deserialize, Serialize}; + +/// 登录请求 +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, + pub totp_code: Option, +} + +/// 登录响应 +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub token: String, + pub account: AccountPublic, +} + +/// 注册请求 +#[derive(Debug, Deserialize)] +pub struct RegisterRequest { + pub username: String, + pub email: String, + pub password: String, + pub display_name: Option, + pub role: Option, +} + +/// 公开账号信息 (无敏感数据) +#[derive(Debug, Clone, Serialize)] +pub struct AccountPublic { + pub id: String, + pub username: String, + pub email: String, + pub display_name: String, + pub role: String, + pub status: String, + pub totp_enabled: bool, + pub created_at: String, +} + +/// 认证上下文 (注入到 request extensions) +#[derive(Debug, Clone)] +pub struct AuthContext { + pub account_id: String, + pub role: String, + pub permissions: Vec, +} diff --git a/crates/zclaw-saas/src/config.rs b/crates/zclaw-saas/src/config.rs new file mode 100644 index 0000000..6dfa64c --- /dev/null +++ b/crates/zclaw-saas/src/config.rs @@ -0,0 +1,144 @@ +//! SaaS 服务器配置 + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use secrecy::SecretString; + +/// SaaS 服务器完整配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SaaSConfig { + pub server: ServerConfig, + pub database: DatabaseConfig, + pub auth: AuthConfig, + pub relay: RelayConfig, +} + +/// 服务器配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + #[serde(default = "default_host")] + pub host: String, + #[serde(default = "default_port")] + pub port: u16, + #[serde(default)] + pub cors_origins: Vec, +} + +/// 数据库配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseConfig { + #[serde(default = "default_db_url")] + pub url: String, +} + +/// 认证配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthConfig { + #[serde(default = "default_jwt_hours")] + pub jwt_expiration_hours: i64, + #[serde(default = "default_totp_issuer")] + pub totp_issuer: String, +} + +/// 中转服务配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayConfig { + #[serde(default = "default_max_queue")] + pub max_queue_size: usize, + #[serde(default = "default_max_concurrent")] + pub max_concurrent_per_provider: usize, + #[serde(default = "default_batch_window")] + pub batch_window_ms: u64, + #[serde(default = "default_retry_delay")] + pub retry_delay_ms: u64, + #[serde(default = "default_max_attempts")] + pub max_attempts: u32, +} + +fn default_host() -> String { "0.0.0.0".into() } +fn default_port() -> u16 { 8080 } +fn default_db_url() -> String { "sqlite:./saas-data.db".into() } +fn default_jwt_hours() -> i64 { 24 } +fn default_totp_issuer() -> String { "ZCLAW SaaS".into() } +fn default_max_queue() -> usize { 1000 } +fn default_max_concurrent() -> usize { 5 } +fn default_batch_window() -> u64 { 50 } +fn default_retry_delay() -> u64 { 1000 } +fn default_max_attempts() -> u32 { 3 } + +impl Default for SaaSConfig { + fn default() -> Self { + Self { + server: ServerConfig::default(), + database: DatabaseConfig::default(), + auth: AuthConfig::default(), + relay: RelayConfig::default(), + } + } +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + host: default_host(), + port: default_port(), + cors_origins: Vec::new(), + } + } +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { url: default_db_url() } + } +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { + jwt_expiration_hours: default_jwt_hours(), + totp_issuer: default_totp_issuer(), + } + } +} + +impl Default for RelayConfig { + fn default() -> Self { + Self { + max_queue_size: default_max_queue(), + max_concurrent_per_provider: default_max_concurrent(), + batch_window_ms: default_batch_window(), + retry_delay_ms: default_retry_delay(), + max_attempts: default_max_attempts(), + } + } +} + +impl SaaSConfig { + /// 加载配置文件,优先级: 环境变量 > ZCLAW_SAAS_CONFIG > ./saas-config.toml + pub fn load() -> anyhow::Result { + let config_path = std::env::var("ZCLAW_SAAS_CONFIG") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("saas-config.toml")); + + let config = if config_path.exists() { + let content = std::fs::read_to_string(&config_path)?; + toml::from_str(&content)? + } else { + tracing::warn!("Config file {:?} not found, using defaults", config_path); + SaaSConfig::default() + }; + + Ok(config) + } + + /// 获取 JWT 密钥 (从环境变量或生成默认值) + pub fn jwt_secret(&self) -> SecretString { + std::env::var("ZCLAW_SAAS_JWT_SECRET") + .map(SecretString::from) + .unwrap_or_else(|_| { + tracing::warn!("ZCLAW_SAAS_JWT_SECRET not set, using default (insecure!)"); + SecretString::from("zclaw-saas-default-secret-change-in-production".to_string()) + }) + } +} diff --git a/crates/zclaw-saas/src/db.rs b/crates/zclaw-saas/src/db.rs new file mode 100644 index 0000000..ecae8dd --- /dev/null +++ b/crates/zclaw-saas/src/db.rs @@ -0,0 +1,281 @@ +//! 数据库初始化与 Schema + +use sqlx::SqlitePool; +use crate::error::SaasResult; + +const SCHEMA_VERSION: i32 = 1; + +const SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS saas_schema_version ( + version INTEGER PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + avatar_url TEXT, + role TEXT NOT NULL DEFAULT 'user', + status TEXT NOT NULL DEFAULT 'active', + totp_secret TEXT, + totp_enabled INTEGER NOT NULL DEFAULT 0, + last_login_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email); +CREATE INDEX IF NOT EXISTS idx_accounts_role ON accounts(role); + +CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + permissions TEXT NOT NULL DEFAULT '[]', + last_used_at TEXT, + expires_at TEXT, + created_at TEXT NOT NULL, + revoked_at TEXT, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_api_tokens_account ON api_tokens(account_id); +CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); + +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + permissions TEXT NOT NULL DEFAULT '[]', + is_system INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS permission_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + permissions TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS operation_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + details TEXT, + ip_address TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_op_logs_account ON operation_logs(account_id); +CREATE INDEX IF NOT EXISTS idx_op_logs_action ON operation_logs(action); +CREATE INDEX IF NOT EXISTS idx_op_logs_time ON operation_logs(created_at); + +CREATE TABLE IF NOT EXISTS providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + api_key TEXT, + base_url TEXT NOT NULL, + api_protocol TEXT NOT NULL DEFAULT 'openai', + enabled INTEGER NOT NULL DEFAULT 1, + rate_limit_rpm INTEGER, + rate_limit_tpm INTEGER, + config_json TEXT DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS models ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + model_id TEXT NOT NULL, + alias TEXT NOT NULL, + context_window INTEGER NOT NULL DEFAULT 8192, + max_output_tokens INTEGER NOT NULL DEFAULT 4096, + supports_streaming INTEGER NOT NULL DEFAULT 1, + supports_vision INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + pricing_input REAL DEFAULT 0, + pricing_output REAL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(provider_id, model_id), + FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_models_provider ON models(provider_id); + +CREATE TABLE IF NOT EXISTS account_api_keys ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + key_value TEXT NOT NULL, + key_label TEXT, + permissions TEXT NOT NULL DEFAULT '[]', + enabled INTEGER NOT NULL DEFAULT 1, + last_used_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revoked_at TEXT, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_account_api_keys_account ON account_api_keys(account_id); + +CREATE TABLE IF NOT EXISTS usage_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + model_id TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + latency_ms INTEGER, + status TEXT NOT NULL DEFAULT 'success', + error_message TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_usage_account ON usage_records(account_id); +CREATE INDEX IF NOT EXISTS idx_usage_time ON usage_records(created_at); + +CREATE TABLE IF NOT EXISTS relay_tasks ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + model_id TEXT NOT NULL, + request_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + priority INTEGER NOT NULL DEFAULT 0, + attempt_count INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + request_body TEXT NOT NULL, + response_body TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + error_message TEXT, + queued_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_relay_status ON relay_tasks(status); +CREATE INDEX IF NOT EXISTS idx_relay_account ON relay_tasks(account_id); +CREATE INDEX IF NOT EXISTS idx_relay_provider ON relay_tasks(provider_id); + +CREATE TABLE IF NOT EXISTS config_items ( + id TEXT PRIMARY KEY, + category TEXT NOT NULL, + key_path TEXT NOT NULL, + value_type TEXT NOT NULL, + current_value TEXT, + default_value TEXT, + source TEXT NOT NULL DEFAULT 'local', + description TEXT, + requires_restart INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(category, key_path) +); +CREATE INDEX IF NOT EXISTS idx_config_category ON config_items(category); + +CREATE TABLE IF NOT EXISTS config_sync_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id TEXT NOT NULL, + client_fingerprint TEXT NOT NULL, + action TEXT NOT NULL, + config_keys TEXT NOT NULL, + client_values TEXT, + saas_values TEXT, + resolution TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sync_account ON config_sync_log(account_id); +"#; + +const SEED_ROLES: &str = r#" +INSERT OR IGNORE INTO roles (id, name, description, permissions, is_system, created_at, updated_at) +VALUES + ('super_admin', '超级管理员', '拥有所有权限', '["admin:full"]', 1, datetime('now'), datetime('now')), + ('admin', '管理员', '管理账号和配置', '["account:read","account:write","model:read","model:write","relay:use","relay:admin","config:read","config:write"]', 1, datetime('now'), datetime('now')), + ('user', '普通用户', '基础使用权限', '["model:read","relay:use","config:read"]', 1, datetime('now'), datetime('now')); +"#; + +/// 初始化数据库 +pub async fn init_db(database_url: &str) -> SaasResult { + if database_url.starts_with("sqlite:") { + let path_part = database_url.strip_prefix("sqlite:").unwrap_or(""); + if path_part != ":memory:" { + if let Some(parent) = std::path::Path::new(path_part).parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + std::fs::create_dir_all(parent)?; + } + } + } + } + + let pool = SqlitePool::connect(database_url).await?; + sqlx::query("PRAGMA journal_mode=WAL;") + .execute(&pool) + .await?; + sqlx::query(SCHEMA_SQL).execute(&pool).await?; + sqlx::query("INSERT OR IGNORE INTO saas_schema_version (version) VALUES (?1)") + .bind(SCHEMA_VERSION) + .execute(&pool) + .await?; + sqlx::query(SEED_ROLES).execute(&pool).await?; + tracing::info!("Database initialized (schema v{})", SCHEMA_VERSION); + Ok(pool) +} + +/// 创建内存数据库 (测试用) +pub async fn init_memory_db() -> SaasResult { + let pool = SqlitePool::connect("sqlite::memory:").await?; + sqlx::query(SCHEMA_SQL).execute(&pool).await?; + sqlx::query("INSERT OR IGNORE INTO saas_schema_version (version) VALUES (?1)") + .bind(SCHEMA_VERSION) + .execute(&pool) + .await?; + sqlx::query(SEED_ROLES).execute(&pool).await?; + Ok(pool) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_init_memory_db() { + let pool = init_memory_db().await.unwrap(); + let roles: Vec<(String,)> = sqlx::query_as( + "SELECT id FROM roles WHERE is_system = 1" + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(roles.len(), 3); + } + + #[tokio::test] + async fn test_schema_tables_exist() { + let pool = init_memory_db().await.unwrap(); + let tables = [ + "accounts", "api_tokens", "roles", "permission_templates", + "operation_logs", "providers", "models", "account_api_keys", + "usage_records", "relay_tasks", "config_items", "config_sync_log", + ]; + for table in tables { + let count: (i64,) = sqlx::query_as(&format!( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='{}'", table + )) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count.0, 1, "Table {} should exist", table); + } + } +} diff --git a/crates/zclaw-saas/src/error.rs b/crates/zclaw-saas/src/error.rs new file mode 100644 index 0000000..6dd1881 --- /dev/null +++ b/crates/zclaw-saas/src/error.rs @@ -0,0 +1,119 @@ +//! SaaS 错误类型 + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +/// SaaS 服务错误类型 +#[derive(Debug, thiserror::Error)] +pub enum SaasError { + #[error("未找到: {0}")] + NotFound(String), + + #[error("权限不足: {0}")] + Forbidden(String), + + #[error("未认证")] + Unauthorized, + + #[error("无效输入: {0}")] + InvalidInput(String), + + #[error("认证失败: {0}")] + AuthError(String), + + #[error("用户已存在: {0}")] + AlreadyExists(String), + + #[error("序列化错误: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("IO 错误: {0}")] + Io(#[from] std::io::Error), + + #[error("数据库错误: {0}")] + Database(#[from] sqlx::Error), + + #[error("配置错误: {0}")] + Config(#[from] toml::de::Error), + + #[error("JWT 错误: {0}")] + Jwt(#[from] jsonwebtoken::errors::Error), + + #[error("密码哈希错误: {0}")] + PasswordHash(String), + + #[error("TOTP 错误: {0}")] + Totp(String), + + #[error("加密错误: {0}")] + Encryption(String), + + #[error("中转错误: {0}")] + Relay(String), + + #[error("速率限制: {0}")] + RateLimited(String), + + #[error("内部错误: {0}")] + Internal(String), +} + +impl SaasError { + /// 获取 HTTP 状态码 + pub fn status_code(&self) -> StatusCode { + match self { + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::Forbidden(_) => StatusCode::FORBIDDEN, + Self::Unauthorized => StatusCode::UNAUTHORIZED, + Self::InvalidInput(_) => StatusCode::BAD_REQUEST, + Self::AlreadyExists(_) => StatusCode::CONFLICT, + Self::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS, + Self::Database(_) | Self::Internal(_) | Self::Io(_) | Self::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::AuthError(_) => StatusCode::UNAUTHORIZED, + Self::Jwt(_) | Self::PasswordHash(_) | Self::Totp(_) | Self::Encryption(_) => { + StatusCode::INTERNAL_SERVER_ERROR + } + Self::Config(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::Relay(_) => StatusCode::BAD_GATEWAY, + } + } + + /// 获取错误代码 + pub fn error_code(&self) -> &str { + match self { + Self::NotFound(_) => "NOT_FOUND", + Self::Forbidden(_) => "FORBIDDEN", + Self::Unauthorized => "UNAUTHORIZED", + Self::InvalidInput(_) => "INVALID_INPUT", + Self::AlreadyExists(_) => "ALREADY_EXISTS", + Self::RateLimited(_) => "RATE_LIMITED", + Self::Database(_) => "DATABASE_ERROR", + Self::Io(_) => "IO_ERROR", + Self::Serialization(_) => "SERIALIZATION_ERROR", + Self::Internal(_) => "INTERNAL_ERROR", + Self::AuthError(_) => "AUTH_ERROR", + Self::Jwt(_) => "JWT_ERROR", + Self::PasswordHash(_) => "PASSWORD_HASH_ERROR", + Self::Totp(_) => "TOTP_ERROR", + Self::Encryption(_) => "ENCRYPTION_ERROR", + Self::Config(_) => "CONFIG_ERROR", + Self::Relay(_) => "RELAY_ERROR", + } + } +} + +/// 实现 Axum 响应 +impl IntoResponse for SaasError { + fn into_response(self) -> Response { + let status = self.status_code(); + let body = json!({ + "error": self.error_code(), + "message": self.to_string(), + }); + (status, axum::Json(body)).into_response() + } +} + +/// Result 类型别名 +pub type SaasResult = std::result::Result; diff --git a/crates/zclaw-saas/src/lib.rs b/crates/zclaw-saas/src/lib.rs new file mode 100644 index 0000000..89eca2b --- /dev/null +++ b/crates/zclaw-saas/src/lib.rs @@ -0,0 +1,14 @@ +//! ZCLAW SaaS Backend +//! +//! 独立的 SaaS 后端服务,提供账号权限管理、模型配置、请求中转和配置迁移。 + +pub mod config; +pub mod db; +pub mod error; +pub mod state; + +pub mod auth; +pub mod account; +pub mod model_config; +pub mod relay; +pub mod migration; diff --git a/crates/zclaw-saas/src/main.rs b/crates/zclaw-saas/src/main.rs new file mode 100644 index 0000000..6035fa0 --- /dev/null +++ b/crates/zclaw-saas/src/main.rs @@ -0,0 +1,57 @@ +//! ZCLAW SaaS 服务入口 + +use tracing::info; +use zclaw_saas::{config::SaaSConfig, db::init_db, state::AppState}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "zclaw_saas=debug,tower_http=debug".into()), + ) + .init(); + + let config = SaaSConfig::load()?; + info!("SaaS config loaded: {}:{}", config.server.host, config.server.port); + + let db = init_db(&config.database.url).await?; + info!("Database initialized"); + + let state = AppState::new(db, config.clone()); + let app = build_router(state); + + let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.server.host, config.server.port)) + .await?; + info!("SaaS server listening on {}:{}", config.server.host, config.server.port); + + axum::serve(listener, app).await?; + Ok(()) +} + +fn build_router(state: AppState) -> axum::Router { + use axum::middleware; + use tower_http::cors::{Any, CorsLayer}; + use tower_http::trace::TraceLayer; + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let public_routes = zclaw_saas::auth::routes(); + + let protected_routes = zclaw_saas::auth::protected_routes() + .merge(zclaw_saas::account::routes()) + .layer(middleware::from_fn_with_state( + state.clone(), + zclaw_saas::auth::auth_middleware, + )); + + axum::Router::new() + .merge(public_routes) + .merge(protected_routes) + .layer(TraceLayer::new_for_http()) + .layer(cors) + .with_state(state) +} diff --git a/crates/zclaw-saas/src/migration/mod.rs b/crates/zclaw-saas/src/migration/mod.rs new file mode 100644 index 0000000..1657d19 --- /dev/null +++ b/crates/zclaw-saas/src/migration/mod.rs @@ -0,0 +1 @@ +//! 配置迁移模块 diff --git a/crates/zclaw-saas/src/model_config/mod.rs b/crates/zclaw-saas/src/model_config/mod.rs new file mode 100644 index 0000000..7eae0b7 --- /dev/null +++ b/crates/zclaw-saas/src/model_config/mod.rs @@ -0,0 +1 @@ +//! 模型配置模块 diff --git a/crates/zclaw-saas/src/relay/mod.rs b/crates/zclaw-saas/src/relay/mod.rs new file mode 100644 index 0000000..8504245 --- /dev/null +++ b/crates/zclaw-saas/src/relay/mod.rs @@ -0,0 +1 @@ +//! 请求中转模块 diff --git a/crates/zclaw-saas/src/state.rs b/crates/zclaw-saas/src/state.rs new file mode 100644 index 0000000..e428b0d --- /dev/null +++ b/crates/zclaw-saas/src/state.rs @@ -0,0 +1,28 @@ +//! 应用状态 + +use sqlx::SqlitePool; +use std::sync::Arc; +use tokio::sync::RwLock; +use crate::config::SaaSConfig; + +/// 全局应用状态,通过 Axum State 共享 +#[derive(Clone)] +pub struct AppState { + /// 数据库连接池 + pub db: SqlitePool, + /// 服务器配置 (可热更新) + pub config: Arc>, + /// JWT 密钥 + pub jwt_secret: secrecy::SecretString, +} + +impl AppState { + pub fn new(db: SqlitePool, config: SaaSConfig) -> Self { + let jwt_secret = config.jwt_secret(); + Self { + db, + config: Arc::new(RwLock::new(config)), + jwt_secret, + } + } +} diff --git a/crates/zclaw-saas/tests/integration_test.rs b/crates/zclaw-saas/tests/integration_test.rs new file mode 100644 index 0000000..3f547a0 --- /dev/null +++ b/crates/zclaw-saas/tests/integration_test.rs @@ -0,0 +1,222 @@ +//! Phase 1 集成测试 + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use serde_json::json; +use tower::ServiceExt; + +const MAX_BODY_SIZE: usize = 1024 * 1024; // 1MB + +async fn build_test_app() -> axum::Router { + use zclaw_saas::{config::SaaSConfig, db::init_memory_db, state::AppState}; + + let db = init_memory_db().await.unwrap(); + let mut config = SaaSConfig::default(); + config.auth.jwt_expiration_hours = 24; + let state = AppState::new(db, config); + + let public_routes = zclaw_saas::auth::routes(); + + let protected_routes = zclaw_saas::auth::protected_routes() + .merge(zclaw_saas::account::routes()) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + zclaw_saas::auth::auth_middleware, + )); + + axum::Router::new() + .merge(public_routes) + .merge(protected_routes) + .with_state(state) +} + +#[tokio::test] +async fn test_register_and_login() { + let app = build_test_app().await; + + // 注册 + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/register") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&json!({ + "username": "testuser", + "email": "test@example.com", + "password": "password123" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + // 登录 + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&json!({ + "username": "testuser", + "password": "password123" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert!(body.get("token").is_some()); + assert_eq!(body["account"]["username"], "testuser"); +} + +#[tokio::test] +async fn test_register_duplicate_fails() { + let app = build_test_app().await; + + let body = json!({ + "username": "dupuser", + "email": "dup@example.com", + "password": "password123" + }); + + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/register") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/register") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +async fn test_unauthorized_access() { + let app = build_test_app().await; + + let req = Request::builder() + .method("GET") + .uri("/api/v1/accounts") + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_login_wrong_password() { + let app = build_test_app().await; + + // 先注册 + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/register") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&json!({ + "username": "wrongpwd", + "email": "wrongpwd@example.com", + "password": "password123" + })).unwrap())) + .unwrap(); + app.clone().oneshot(req).await.unwrap(); + + // 错误密码登录 + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&json!({ + "username": "wrongpwd", + "password": "wrong_password" + })).unwrap())) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_full_authenticated_flow() { + let app = build_test_app().await; + + // 注册 + 登录 + let register_req = Request::builder() + .method("POST") + .uri("/api/v1/auth/register") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&json!({ + "username": "fulltest", + "email": "full@example.com", + "password": "password123" + })).unwrap())) + .unwrap(); + app.clone().oneshot(register_req).await.unwrap(); + + let login_req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&json!({ + "username": "fulltest", + "password": "password123" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(login_req).await.unwrap(); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + let token = body["token"].as_str().unwrap().to_string(); + + // 创建 API Token + let create_token_req = Request::builder() + .method("POST") + .uri("/api/v1/tokens") + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", token)) + .body(Body::from(serde_json::to_string(&json!({ + "name": "test-token", + "permissions": ["model:read", "relay:use"] + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(create_token_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert!(!body["token"].is_null()); // 原始 token 仅创建时返回 + + // 列出 Tokens + let list_req = Request::builder() + .method("GET") + .uri("/api/v1/tokens") + .header("Authorization", format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(list_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 查看操作日志 + let logs_req = Request::builder() + .method("GET") + .uri("/api/v1/logs/operations") + .header("Authorization", format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(logs_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} diff --git a/saas-config.toml b/saas-config.toml new file mode 100644 index 0000000..8f40f93 --- /dev/null +++ b/saas-config.toml @@ -0,0 +1,17 @@ +[server] +host = "0.0.0.0" +port = 8080 + +[database] +url = "sqlite:./saas-data.db" + +[auth] +jwt_expiration_hours = 24 +totp_issuer = "ZCLAW SaaS" + +[relay] +max_queue_size = 1000 +max_concurrent_per_provider = 5 +batch_window_ms = 50 +retry_delay_ms = 1000 +max_attempts = 3 From fec64af5653c0ac4bb49708bafb98cb7e842537b Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 12:46:59 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat(saas):=20Phase=202=20=E2=80=94=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Provider CRUD (列表/详情/创建/更新/删除) - Model CRUD (列表/详情/创建/更新/删除) - Account API Key 管理 (创建/轮换/撤销/掩码显示) - Usage 统计 (总量/按模型/按天, 支持时间/供应商/模型过滤) - 权限控制 (provider:manage, model:manage) - 3 个新集成测试覆盖 providers/models/keys --- crates/zclaw-saas/src/main.rs | 1 + .../zclaw-saas/src/model_config/handlers.rs | 206 +++++++++ crates/zclaw-saas/src/model_config/mod.rs | 25 ++ crates/zclaw-saas/src/model_config/service.rs | 411 ++++++++++++++++++ crates/zclaw-saas/src/model_config/types.rs | 172 ++++++++ crates/zclaw-saas/tests/integration_test.rs | 200 ++++++--- 6 files changed, 949 insertions(+), 66 deletions(-) create mode 100644 crates/zclaw-saas/src/model_config/handlers.rs create mode 100644 crates/zclaw-saas/src/model_config/service.rs create mode 100644 crates/zclaw-saas/src/model_config/types.rs diff --git a/crates/zclaw-saas/src/main.rs b/crates/zclaw-saas/src/main.rs index 6035fa0..3c72030 100644 --- a/crates/zclaw-saas/src/main.rs +++ b/crates/zclaw-saas/src/main.rs @@ -43,6 +43,7 @@ fn build_router(state: AppState) -> axum::Router { let protected_routes = zclaw_saas::auth::protected_routes() .merge(zclaw_saas::account::routes()) + .merge(zclaw_saas::model_config::routes()) .layer(middleware::from_fn_with_state( state.clone(), zclaw_saas::auth::auth_middleware, diff --git a/crates/zclaw-saas/src/model_config/handlers.rs b/crates/zclaw-saas/src/model_config/handlers.rs new file mode 100644 index 0000000..4a2be00 --- /dev/null +++ b/crates/zclaw-saas/src/model_config/handlers.rs @@ -0,0 +1,206 @@ +//! 模型配置 HTTP 处理器 + +use axum::{ + extract::{Extension, Path, Query, State}, + http::StatusCode, Json, +}; +use crate::state::AppState; +use crate::error::{SaasError, SaasResult}; +use crate::auth::types::AuthContext; +use crate::auth::handlers::log_operation; +use super::{types::*, service}; + +// ============ Providers ============ + +/// GET /api/v1/providers +pub async fn list_providers( + State(state): State, + _ctx: Extension, +) -> SaasResult>> { + service::list_providers(&state.db).await.map(Json) +} + +/// GET /api/v1/providers/:id +pub async fn get_provider( + State(state): State, + Path(id): Path, + _ctx: Extension, +) -> SaasResult> { + service::get_provider(&state.db, &id).await.map(Json) +} + +/// POST /api/v1/providers (admin only) +pub async fn create_provider( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult<(StatusCode, Json)> { + if !ctx.permissions.contains(&"provider:manage".to_string()) { + return Err(SaasError::Forbidden("需要 provider:manage 权限".into())); + } + let provider = service::create_provider(&state.db, &req).await?; + log_operation(&state.db, &ctx.account_id, "provider.create", "provider", &provider.id, + Some(serde_json::json!({"name": &req.name})), None).await?; + Ok((StatusCode::CREATED, Json(provider))) +} + +/// PUT /api/v1/providers/:id (admin only) +pub async fn update_provider( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + if !ctx.permissions.contains(&"provider:manage".to_string()) { + return Err(SaasError::Forbidden("需要 provider:manage 权限".into())); + } + let provider = service::update_provider(&state.db, &id, &req).await?; + log_operation(&state.db, &ctx.account_id, "provider.update", "provider", &id, None, None).await?; + Ok(Json(provider)) +} + +/// DELETE /api/v1/providers/:id (admin only) +pub async fn delete_provider( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> SaasResult> { + if !ctx.permissions.contains(&"provider:manage".to_string()) { + return Err(SaasError::Forbidden("需要 provider:manage 权限".into())); + } + service::delete_provider(&state.db, &id).await?; + log_operation(&state.db, &ctx.account_id, "provider.delete", "provider", &id, None, None).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +// ============ Models ============ + +/// GET /api/v1/models?provider_id=xxx +pub async fn list_models( + State(state): State, + Query(params): Query>, + _ctx: Extension, +) -> SaasResult>> { + let provider_id = params.get("provider_id").map(|s| s.as_str()); + service::list_models(&state.db, provider_id).await.map(Json) +} + +/// GET /api/v1/models/:id +pub async fn get_model( + State(state): State, + Path(id): Path, + _ctx: Extension, +) -> SaasResult> { + service::get_model(&state.db, &id).await.map(Json) +} + +/// POST /api/v1/models (admin only) +pub async fn create_model( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult<(StatusCode, Json)> { + if !ctx.permissions.contains(&"model:manage".to_string()) { + return Err(SaasError::Forbidden("需要 model:manage 权限".into())); + } + let model = service::create_model(&state.db, &req).await?; + log_operation(&state.db, &ctx.account_id, "model.create", "model", &model.id, + Some(serde_json::json!({"model_id": &req.model_id, "provider_id": &req.provider_id})), None).await?; + Ok((StatusCode::CREATED, Json(model))) +} + +/// PUT /api/v1/models/:id (admin only) +pub async fn update_model( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + if !ctx.permissions.contains(&"model:manage".to_string()) { + return Err(SaasError::Forbidden("需要 model:manage 权限".into())); + } + let model = service::update_model(&state.db, &id, &req).await?; + log_operation(&state.db, &ctx.account_id, "model.update", "model", &id, None, None).await?; + Ok(Json(model)) +} + +/// DELETE /api/v1/models/:id (admin only) +pub async fn delete_model( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> SaasResult> { + if !ctx.permissions.contains(&"model:manage".to_string()) { + return Err(SaasError::Forbidden("需要 model:manage 权限".into())); + } + service::delete_model(&state.db, &id).await?; + log_operation(&state.db, &ctx.account_id, "model.delete", "model", &id, None, None).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +// ============ Account API Keys ============ + +/// GET /api/v1/keys?provider_id=xxx +pub async fn list_api_keys( + State(state): State, + Extension(ctx): Extension, + Query(params): Query>, +) -> SaasResult>> { + let provider_id = params.get("provider_id").map(|s| s.as_str()); + service::list_account_api_keys(&state.db, &ctx.account_id, provider_id).await.map(Json) +} + +/// POST /api/v1/keys +pub async fn create_api_key( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult<(StatusCode, Json)> { + let key = service::create_account_api_key(&state.db, &ctx.account_id, &req).await?; + log_operation(&state.db, &ctx.account_id, "api_key.create", "api_key", &key.id, + Some(serde_json::json!({"provider_id": &req.provider_id})), None).await?; + Ok((StatusCode::CREATED, Json(key))) +} + +/// POST /api/v1/keys/:id/rotate +pub async fn rotate_api_key( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + service::rotate_account_api_key(&state.db, &id, &ctx.account_id, &req.new_key_value).await?; + log_operation(&state.db, &ctx.account_id, "api_key.rotate", "api_key", &id, None, None).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +/// DELETE /api/v1/keys/:id +pub async fn revoke_api_key( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> SaasResult> { + service::revoke_account_api_key(&state.db, &id, &ctx.account_id).await?; + log_operation(&state.db, &ctx.account_id, "api_key.revoke", "api_key", &id, None, None).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +// ============ Usage ============ + +/// GET /api/v1/usage?from=...&to=...&provider_id=...&model_id=... +pub async fn get_usage( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> SaasResult> { + service::get_usage_stats(&state.db, &ctx.account_id, ¶ms).await.map(Json) +} + +/// GET /api/v1/providers/:id/models (便捷路由) +pub async fn list_provider_models( + State(state): State, + Path(provider_id): Path, + _ctx: Extension, +) -> SaasResult>> { + service::list_models(&state.db, Some(&provider_id)).await.map(Json) +} diff --git a/crates/zclaw-saas/src/model_config/mod.rs b/crates/zclaw-saas/src/model_config/mod.rs index 7eae0b7..48a3102 100644 --- a/crates/zclaw-saas/src/model_config/mod.rs +++ b/crates/zclaw-saas/src/model_config/mod.rs @@ -1 +1,26 @@ //! 模型配置模块 + +pub mod types; +pub mod service; +pub mod handlers; + +use axum::routing::{delete, get, post}; +use crate::state::AppState; + +/// 模型配置路由 (需要认证) +pub fn routes() -> axum::Router { + axum::Router::new() + // Providers + .route("/api/v1/providers", get(handlers::list_providers).post(handlers::create_provider)) + .route("/api/v1/providers/{id}", get(handlers::get_provider).put(handlers::update_provider).delete(handlers::delete_provider)) + .route("/api/v1/providers/{id}/models", get(handlers::list_provider_models)) + // Models + .route("/api/v1/models", get(handlers::list_models).post(handlers::create_model)) + .route("/api/v1/models/{id}", get(handlers::get_model).put(handlers::update_model).delete(handlers::delete_model)) + // Account API Keys + .route("/api/v1/keys", get(handlers::list_api_keys).post(handlers::create_api_key)) + .route("/api/v1/keys/{id}", delete(handlers::revoke_api_key)) + .route("/api/v1/keys/{id}/rotate", post(handlers::rotate_api_key)) + // Usage + .route("/api/v1/usage", get(handlers::get_usage)) +} diff --git a/crates/zclaw-saas/src/model_config/service.rs b/crates/zclaw-saas/src/model_config/service.rs new file mode 100644 index 0000000..220706b --- /dev/null +++ b/crates/zclaw-saas/src/model_config/service.rs @@ -0,0 +1,411 @@ +//! 模型配置业务逻辑 + +use sqlx::SqlitePool; +use crate::error::{SaasError, SaasResult}; +use super::types::*; + +// ============ Providers ============ + +pub async fn list_providers(db: &SqlitePool) -> SaasResult> { + let rows: Vec<(String, String, String, String, String, bool, Option, Option, String, String)> = + sqlx::query_as( + "SELECT id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at, updated_at + FROM providers ORDER BY name" + ) + .fetch_all(db) + .await?; + + Ok(rows.into_iter().map(|(id, name, display_name, base_url, api_protocol, enabled, rpm, tpm, created_at, updated_at)| { + ProviderInfo { id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm: rpm, rate_limit_tpm: tpm, created_at, updated_at } + }).collect()) +} + +pub async fn get_provider(db: &SqlitePool, provider_id: &str) -> SaasResult { + let row: Option<(String, String, String, String, String, bool, Option, Option, String, String)> = + sqlx::query_as( + "SELECT id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at, updated_at + FROM providers WHERE id = ?1" + ) + .bind(provider_id) + .fetch_optional(db) + .await?; + + let (id, name, display_name, base_url, api_protocol, enabled, rpm, tpm, created_at, updated_at) = + row.ok_or_else(|| SaasError::NotFound(format!("Provider {} 不存在", provider_id)))?; + + Ok(ProviderInfo { id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm: rpm, rate_limit_tpm: tpm, created_at, updated_at }) +} + +pub async fn create_provider(db: &SqlitePool, req: &CreateProviderRequest) -> SaasResult { + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + // 检查名称唯一性 + let existing: Option<(String,)> = sqlx::query_as("SELECT id FROM providers WHERE name = ?1") + .bind(&req.name).fetch_optional(db).await?; + if existing.is_some() { + return Err(SaasError::AlreadyExists(format!("Provider '{}' 已存在", req.name))); + } + + sqlx::query( + "INSERT INTO providers (id, name, display_name, api_key, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9, ?9)" + ) + .bind(&id).bind(&req.name).bind(&req.display_name).bind(&req.api_key) + .bind(&req.base_url).bind(&req.api_protocol).bind(&req.rate_limit_rpm).bind(&req.rate_limit_tpm).bind(&now) + .execute(db).await?; + + get_provider(db, &id).await +} + +pub async fn update_provider( + db: &SqlitePool, provider_id: &str, req: &UpdateProviderRequest, +) -> SaasResult { + let now = chrono::Utc::now().to_rfc3339(); + let mut updates = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(ref v) = req.display_name { updates.push("display_name = ?"); params.push(Box::new(v.clone())); } + if let Some(ref v) = req.base_url { updates.push("base_url = ?"); params.push(Box::new(v.clone())); } + if let Some(ref v) = req.api_protocol { updates.push("api_protocol = ?"); params.push(Box::new(v.clone())); } + if let Some(ref v) = req.api_key { updates.push("api_key = ?"); params.push(Box::new(v.clone())); } + if let Some(v) = req.enabled { updates.push("enabled = ?"); params.push(Box::new(v)); } + if let Some(v) = req.rate_limit_rpm { updates.push("rate_limit_rpm = ?"); params.push(Box::new(v)); } + if let Some(v) = req.rate_limit_tpm { updates.push("rate_limit_tpm = ?"); params.push(Box::new(v)); } + + if updates.is_empty() { + return get_provider(db, provider_id).await; + } + + updates.push("updated_at = ?"); + params.push(Box::new(now.clone())); + params.push(Box::new(provider_id.to_string())); + + let sql = format!("UPDATE providers SET {} WHERE id = ?", updates.join(", ")); + let mut query = sqlx::query(&sql); + for p in ¶ms { + query = query.bind(format!("{}", p)); + } + query.execute(db).await?; + + get_provider(db, provider_id).await +} + +pub async fn delete_provider(db: &SqlitePool, provider_id: &str) -> SaasResult<()> { + let result = sqlx::query("DELETE FROM providers WHERE id = ?1") + .bind(provider_id).execute(db).await?; + + if result.rows_affected() == 0 { + return Err(SaasError::NotFound(format!("Provider {} 不存在", provider_id))); + } + Ok(()) +} + +// ============ Models ============ + +pub async fn list_models(db: &SqlitePool, provider_id: Option<&str>) -> SaasResult> { + let sql = if provider_id.is_some() { + "SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at + FROM models WHERE provider_id = ?1 ORDER BY alias" + } else { + "SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at + FROM models ORDER BY provider_id, alias" + }; + + let mut query = sqlx::query_as::<_, (String, String, String, String, i64, i64, bool, bool, bool, f64, f64, String, String)>(sql); + if let Some(pid) = provider_id { + query = query.bind(pid); + } + + let rows = query.fetch_all(db).await?; + Ok(rows.into_iter().map(|(id, provider_id, model_id, alias, ctx, max_out, streaming, vision, enabled, pi, po, created_at, updated_at)| { + ModelInfo { id, provider_id, model_id, alias, context_window: ctx, max_output_tokens: max_out, supports_streaming: streaming, supports_vision: vision, enabled, pricing_input: pi, pricing_output: po, created_at, updated_at } + }).collect()) +} + +pub async fn create_model(db: &SqlitePool, req: &CreateModelRequest) -> SaasResult { + // 验证 provider 存在 + let provider = get_provider(db, &req.provider_id).await?; + + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + // 检查 model 唯一性 + let existing: Option<(String,)> = sqlx::query_as( + "SELECT id FROM models WHERE provider_id = ?1 AND model_id = ?2" + ) + .bind(&req.provider_id).bind(&req.model_id) + .fetch_optional(db).await?; + + if existing.is_some() { + return Err(SaasError::AlreadyExists(format!( + "模型 '{}' 已存在于 provider '{}'", req.model_id, provider.name + ))); + } + + let ctx = req.context_window.unwrap_or(8192); + let max_out = req.max_output_tokens.unwrap_or(4096); + let streaming = req.supports_streaming.unwrap_or(true); + let vision = req.supports_vision.unwrap_or(false); + let pi = req.pricing_input.unwrap_or(0.0); + let po = req.pricing_output.unwrap_or(0.0); + + sqlx::query( + "INSERT INTO models (id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, ?9, ?10, ?11, ?11)" + ) + .bind(&id).bind(&req.provider_id).bind(&req.model_id).bind(&req.alias) + .bind(ctx).bind(max_out).bind(streaming).bind(vision).bind(pi).bind(po).bind(&now) + .execute(db).await?; + + get_model(db, &id).await +} + +pub async fn get_model(db: &SqlitePool, model_id: &str) -> SaasResult { + let row: Option<(String, String, String, String, i64, i64, bool, bool, bool, f64, f64, String, String)> = + sqlx::query_as( + "SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at + FROM models WHERE id = ?1" + ) + .bind(model_id) + .fetch_optional(db) + .await?; + + let (id, provider_id, model_id, alias, ctx, max_out, streaming, vision, enabled, pi, po, created_at, updated_at) = + row.ok_or_else(|| SaasError::NotFound(format!("模型 {} 不存在", model_id)))?; + + Ok(ModelInfo { id, provider_id, model_id, alias, context_window: ctx, max_output_tokens: max_out, supports_streaming: streaming, supports_vision: vision, enabled, pricing_input: pi, pricing_output: po, created_at, updated_at }) +} + +pub async fn update_model( + db: &SqlitePool, model_id: &str, req: &UpdateModelRequest, +) -> SaasResult { + let now = chrono::Utc::now().to_rfc3339(); + let mut updates = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(ref v) = req.alias { updates.push("alias = ?"); params.push(Box::new(v.clone())); } + if let Some(v) = req.context_window { updates.push("context_window = ?"); params.push(Box::new(v)); } + if let Some(v) = req.max_output_tokens { updates.push("max_output_tokens = ?"); params.push(Box::new(v)); } + if let Some(v) = req.supports_streaming { updates.push("supports_streaming = ?"); params.push(Box::new(v)); } + if let Some(v) = req.supports_vision { updates.push("supports_vision = ?"); params.push(Box::new(v)); } + if let Some(v) = req.enabled { updates.push("enabled = ?"); params.push(Box::new(v)); } + if let Some(v) = req.pricing_input { updates.push("pricing_input = ?"); params.push(Box::new(v)); } + if let Some(v) = req.pricing_output { updates.push("pricing_output = ?"); params.push(Box::new(v)); } + + if updates.is_empty() { + return get_model(db, model_id).await; + } + + updates.push("updated_at = ?"); + params.push(Box::new(now.clone())); + params.push(Box::new(model_id.to_string())); + + let sql = format!("UPDATE models SET {} WHERE id = ?", updates.join(", ")); + let mut query = sqlx::query(&sql); + for p in ¶ms { + query = query.bind(format!("{}", p)); + } + query.execute(db).await?; + + get_model(db, model_id).await +} + +pub async fn delete_model(db: &SqlitePool, model_id: &str) -> SaasResult<()> { + let result = sqlx::query("DELETE FROM models WHERE id = ?1") + .bind(model_id).execute(db).await?; + + if result.rows_affected() == 0 { + return Err(SaasError::NotFound(format!("模型 {} 不存在", model_id))); + } + Ok(()) +} + +// ============ Account API Keys ============ + +pub async fn list_account_api_keys( + db: &SqlitePool, account_id: &str, provider_id: Option<&str>, +) -> SaasResult> { + let sql = if provider_id.is_some() { + "SELECT id, provider_id, key_label, permissions, enabled, last_used_at, created_at, key_value + FROM account_api_keys WHERE account_id = ?1 AND provider_id = ?2 AND revoked_at IS NULL ORDER BY created_at DESC" + } else { + "SELECT id, provider_id, key_label, permissions, enabled, last_used_at, created_at, key_value + FROM account_api_keys WHERE account_id = ?1 AND revoked_at IS NULL ORDER BY created_at DESC" + }; + + let mut query = sqlx::query_as::<_, (String, String, Option, String, bool, Option, String, String)>(sql) + .bind(account_id); + if let Some(pid) = provider_id { + query = query.bind(pid); + } + + let rows = query.fetch_all(db).await?; + Ok(rows.into_iter().map(|(id, provider_id, key_label, perms, enabled, last_used, created_at, key_value)| { + let permissions: Vec = serde_json::from_str(&perms).unwrap_or_default(); + let masked = mask_api_key(&key_value); + AccountApiKeyInfo { id, provider_id, key_label, permissions, enabled, last_used_at: last_used, created_at, masked_key: masked } + }).collect()) +} + +pub async fn create_account_api_key( + db: &SqlitePool, account_id: &str, req: &CreateAccountApiKeyRequest, +) -> SaasResult { + // 验证 provider 存在 + get_provider(db, &req.provider_id).await?; + + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let permissions = serde_json::to_string(&req.permissions)?; + + sqlx::query( + "INSERT INTO account_api_keys (id, account_id, provider_id, key_value, key_label, permissions, enabled, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?7)" + ) + .bind(&id).bind(account_id).bind(&req.provider_id).bind(&req.key_value) + .bind(&req.key_label).bind(&permissions).bind(&now) + .execute(db).await?; + + let masked = mask_api_key(&req.key_value); + Ok(AccountApiKeyInfo { + id, provider_id: req.provider_id.clone(), key_label: req.key_label.clone(), + permissions: req.permissions.clone(), enabled: true, last_used_at: None, + created_at: now, masked_key: masked, + }) +} + +pub async fn rotate_account_api_key( + db: &SqlitePool, key_id: &str, account_id: &str, new_key_value: &str, +) -> SaasResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + let result = sqlx::query( + "UPDATE account_api_keys SET key_value = ?1, updated_at = ?2 WHERE id = ?3 AND account_id = ?4 AND revoked_at IS NULL" + ) + .bind(new_key_value).bind(&now).bind(key_id).bind(account_id) + .execute(db).await?; + + if result.rows_affected() == 0 { + return Err(SaasError::NotFound("API Key 不存在或已撤销".into())); + } + Ok(()) +} + +pub async fn revoke_account_api_key( + db: &SqlitePool, key_id: &str, account_id: &str, +) -> SaasResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + let result = sqlx::query( + "UPDATE account_api_keys SET revoked_at = ?1 WHERE id = ?2 AND account_id = ?3 AND revoked_at IS NULL" + ) + .bind(&now).bind(key_id).bind(account_id) + .execute(db).await?; + + if result.rows_affected() == 0 { + return Err(SaasError::NotFound("API Key 不存在或已撤销".into())); + } + Ok(()) +} + +// ============ Usage Statistics ============ + +pub async fn get_usage_stats( + db: &SqlitePool, account_id: &str, query: &UsageQuery, +) -> SaasResult { + let mut where_clauses = vec!["account_id = ?".to_string()]; + let mut params: Vec = vec![account_id.to_string()]; + + if let Some(ref from) = query.from { + where_clauses.push("created_at >= ?".to_string()); + params.push(from.clone()); + } + if let Some(ref to) = query.to { + where_clauses.push("created_at <= ?".to_string()); + params.push(to.clone()); + } + if let Some(ref pid) = query.provider_id { + where_clauses.push("provider_id = ?".to_string()); + params.push(pid.clone()); + } + if let Some(ref mid) = query.model_id { + where_clauses.push("model_id = ?".to_string()); + params.push(mid.clone()); + } + + let where_sql = where_clauses.join(" AND "); + + // 总量统计 + let total_sql = format!( + "SELECT COUNT(*), COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0) + FROM usage_records WHERE {}", where_sql + ); + let mut total_query = sqlx::query_as::<_, (i64, i64, i64)>(&total_sql); + for p in ¶ms { + total_query = total_query.bind(p); + } + let (total_requests, total_input, total_output) = total_query.fetch_one(db).await?; + + // 按模型统计 + let by_model_sql = format!( + "SELECT provider_id, model_id, COUNT(*), COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0) + FROM usage_records WHERE {} GROUP BY provider_id, model_id ORDER BY COUNT(*) DESC LIMIT 20", + where_sql + ); + let mut by_model_query = sqlx::query_as::<_, (String, String, i64, i64, i64)>(&by_model_sql); + for p in ¶ms { + by_model_query = by_model_query.bind(p); + } + let by_model_rows = by_model_query.fetch_all(db).await?; + let by_model: Vec = by_model_rows.into_iter() + .map(|(provider_id, model_id, count, input, output)| { + ModelUsage { provider_id, model_id, request_count: count, input_tokens: input, output_tokens: output } + }).collect(); + + // 按天统计 (最近 30 天) + let from_30d = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339(); + let daily_sql = format!( + "SELECT DATE(created_at) as day, COUNT(*), COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0) + FROM usage_records WHERE account_id = ?1 AND created_at >= ?2 + GROUP BY DATE(created_at) ORDER BY day DESC LIMIT 30" + ); + let daily_rows: Vec<(String, i64, i64, i64)> = sqlx::query_as(&daily_sql) + .bind(account_id).bind(&from_30d) + .fetch_all(db).await?; + let by_day: Vec = daily_rows.into_iter() + .map(|(date, count, input, output)| { + DailyUsage { date, request_count: count, input_tokens: input, output_tokens: output } + }).collect(); + + Ok(UsageStats { + total_requests, + total_input_tokens: total_input, + total_output_tokens: total_output, + by_model, + by_day, + }) +} + +pub async fn record_usage( + db: &SqlitePool, account_id: &str, provider_id: &str, model_id: &str, + input_tokens: i64, output_tokens: i64, latency_ms: Option, + status: &str, error_message: Option<&str>, +) -> SaasResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO usage_records (account_id, provider_id, model_id, input_tokens, output_tokens, latency_ms, status, error_message, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)" + ) + .bind(account_id).bind(provider_id).bind(model_id) + .bind(input_tokens).bind(output_tokens).bind(latency_ms) + .bind(status).bind(error_message).bind(&now) + .execute(db).await?; + Ok(()) +} + +// ============ Helpers ============ + +fn mask_api_key(key: &str) -> String { + if key.len() <= 8 { + return "*".repeat(key.len()); + } + format!("{}...{}", &key[..4], &key[key.len()-4..]) +} diff --git a/crates/zclaw-saas/src/model_config/types.rs b/crates/zclaw-saas/src/model_config/types.rs new file mode 100644 index 0000000..c6e79cc --- /dev/null +++ b/crates/zclaw-saas/src/model_config/types.rs @@ -0,0 +1,172 @@ +//! 模型配置类型定义 + +use serde::{Deserialize, Serialize}; + +// --- Provider --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderInfo { + pub id: String, + pub name: String, + pub display_name: String, + pub base_url: String, + pub api_protocol: String, + pub enabled: bool, + pub rate_limit_rpm: Option, + pub rate_limit_tpm: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateProviderRequest { + pub name: String, + pub display_name: String, + pub base_url: String, + #[serde(default = "default_protocol")] + pub api_protocol: String, + pub api_key: Option, + pub rate_limit_rpm: Option, + pub rate_limit_tpm: Option, +} + +fn default_protocol() -> String { "openai".into() } + +#[derive(Debug, Deserialize)] +pub struct UpdateProviderRequest { + pub display_name: Option, + pub base_url: Option, + pub api_protocol: Option, + pub api_key: Option, + pub enabled: Option, + pub rate_limit_rpm: Option, + pub rate_limit_tpm: Option, +} + +// --- Model --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + pub id: String, + pub provider_id: String, + pub model_id: String, + pub alias: String, + pub context_window: i64, + pub max_output_tokens: i64, + pub supports_streaming: bool, + pub supports_vision: bool, + pub enabled: bool, + pub pricing_input: f64, + pub pricing_output: f64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateModelRequest { + pub provider_id: String, + pub model_id: String, + pub alias: String, + pub context_window: Option, + pub max_output_tokens: Option, + pub supports_streaming: Option, + pub supports_vision: Option, + pub pricing_input: Option, + pub pricing_output: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateModelRequest { + pub alias: Option, + pub context_window: Option, + pub max_output_tokens: Option, + pub supports_streaming: Option, + pub supports_vision: Option, + pub enabled: Option, + pub pricing_input: Option, + pub pricing_output: Option, +} + +// --- Account API Key --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountApiKeyInfo { + pub id: String, + pub provider_id: String, + pub key_label: Option, + pub permissions: Vec, + pub enabled: bool, + pub last_used_at: Option, + pub created_at: String, + pub masked_key: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateAccountApiKeyRequest { + pub provider_id: String, + pub key_value: String, + pub key_label: Option, + #[serde(default)] + pub permissions: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct RotateApiKeyRequest { + pub new_key_value: String, +} + +// --- Usage --- + +#[derive(Debug, Serialize)] +pub struct UsageStats { + pub total_requests: i64, + pub total_input_tokens: i64, + pub total_output_tokens: i64, + pub by_model: Vec, + pub by_day: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ModelUsage { + pub provider_id: String, + pub model_id: String, + pub request_count: i64, + pub input_tokens: i64, + pub output_tokens: i64, +} + +#[derive(Debug, Serialize)] +pub struct DailyUsage { + pub date: String, + pub request_count: i64, + pub input_tokens: i64, + pub output_tokens: i64, +} + +#[derive(Debug, Deserialize)] +pub struct UsageQuery { + pub from: Option, + pub to: Option, + pub provider_id: Option, + pub model_id: Option, +} + +// --- Seed Data --- + +#[derive(Debug, Deserialize)] +pub struct SeedProvider { + pub name: String, + pub display_name: String, + pub base_url: String, + pub models: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct SeedModel { + pub id: String, + pub alias: String, + pub context_window: Option, + pub max_output_tokens: Option, + pub supports_streaming: Option, + pub supports_vision: Option, +} diff --git a/crates/zclaw-saas/tests/integration_test.rs b/crates/zclaw-saas/tests/integration_test.rs index 3f547a0..d47d016 100644 --- a/crates/zclaw-saas/tests/integration_test.rs +++ b/crates/zclaw-saas/tests/integration_test.rs @@ -1,4 +1,4 @@ -//! Phase 1 集成测试 +//! 集成测试 (Phase 1 + Phase 2) use axum::{ body::Body, @@ -21,6 +21,7 @@ async fn build_test_app() -> axum::Router { let protected_routes = zclaw_saas::auth::protected_routes() .merge(zclaw_saas::account::routes()) + .merge(zclaw_saas::model_config::routes()) .layer(axum::middleware::from_fn_with_state( state.clone(), zclaw_saas::auth::auth_middleware, @@ -32,43 +33,45 @@ async fn build_test_app() -> axum::Router { .with_state(state) } -#[tokio::test] -async fn test_register_and_login() { - let app = build_test_app().await; - - // 注册 - let req = Request::builder() +/// 注册并登录,返回 JWT token +async fn register_and_login(app: &axum::Router, username: &str, email: &str) -> String { + let register_req = Request::builder() .method("POST") .uri("/api/v1/auth/register") .header("Content-Type", "application/json") .body(Body::from(serde_json::to_string(&json!({ - "username": "testuser", - "email": "test@example.com", + "username": username, + "email": email, "password": "password123" })).unwrap())) .unwrap(); + app.clone().oneshot(register_req).await.unwrap(); - let resp = app.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - - // 登录 - let req = Request::builder() + let login_req = Request::builder() .method("POST") .uri("/api/v1/auth/login") .header("Content-Type", "application/json") .body(Body::from(serde_json::to_string(&json!({ - "username": "testuser", + "username": username, "password": "password123" })).unwrap())) .unwrap(); - let resp = app.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - + let resp = app.clone().oneshot(login_req).await.unwrap(); let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); - assert!(body.get("token").is_some()); - assert_eq!(body["account"]["username"], "testuser"); + body["token"].as_str().unwrap().to_string() +} + +fn auth_header(token: &str) -> String { + format!("Bearer {}", token) +} + +#[tokio::test] +async fn test_register_and_login() { + let app = build_test_app().await; + let token = register_and_login(&app, "testuser", "test@example.com").await; + assert!(!token.is_empty()); } #[tokio::test] @@ -119,21 +122,8 @@ async fn test_unauthorized_access() { #[tokio::test] async fn test_login_wrong_password() { let app = build_test_app().await; + register_and_login(&app, "wrongpwd", "wrongpwd@example.com").await; - // 先注册 - let req = Request::builder() - .method("POST") - .uri("/api/v1/auth/register") - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_string(&json!({ - "username": "wrongpwd", - "email": "wrongpwd@example.com", - "password": "password123" - })).unwrap())) - .unwrap(); - app.clone().oneshot(req).await.unwrap(); - - // 错误密码登录 let req = Request::builder() .method("POST") .uri("/api/v1/auth/login") @@ -151,41 +141,14 @@ async fn test_login_wrong_password() { #[tokio::test] async fn test_full_authenticated_flow() { let app = build_test_app().await; - - // 注册 + 登录 - let register_req = Request::builder() - .method("POST") - .uri("/api/v1/auth/register") - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_string(&json!({ - "username": "fulltest", - "email": "full@example.com", - "password": "password123" - })).unwrap())) - .unwrap(); - app.clone().oneshot(register_req).await.unwrap(); - - let login_req = Request::builder() - .method("POST") - .uri("/api/v1/auth/login") - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_string(&json!({ - "username": "fulltest", - "password": "password123" - })).unwrap())) - .unwrap(); - - let resp = app.clone().oneshot(login_req).await.unwrap(); - let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); - let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); - let token = body["token"].as_str().unwrap().to_string(); + let token = register_and_login(&app, "fulltest", "full@example.com").await; // 创建 API Token let create_token_req = Request::builder() .method("POST") .uri("/api/v1/tokens") .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", token)) + .header("Authorization", auth_header(&token)) .body(Body::from(serde_json::to_string(&json!({ "name": "test-token", "permissions": ["model:read", "relay:use"] @@ -196,13 +159,13 @@ async fn test_full_authenticated_flow() { assert_eq!(resp.status(), StatusCode::OK); let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); - assert!(!body["token"].is_null()); // 原始 token 仅创建时返回 + assert!(!body["token"].is_null()); // 列出 Tokens let list_req = Request::builder() .method("GET") .uri("/api/v1/tokens") - .header("Authorization", format!("Bearer {}", token)) + .header("Authorization", auth_header(&token)) .body(Body::empty()) .unwrap(); @@ -213,10 +176,115 @@ async fn test_full_authenticated_flow() { let logs_req = Request::builder() .method("GET") .uri("/api/v1/logs/operations") - .header("Authorization", format!("Bearer {}", token)) + .header("Authorization", auth_header(&token)) .body(Body::empty()) .unwrap(); let resp = app.oneshot(logs_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } + +// ============ Phase 2: 模型配置测试 ============ + +#[tokio::test] +async fn test_providers_crud() { + let app = build_test_app().await; + // 注册 super_admin 角色用户 (通过直接插入角色权限) + let token = register_and_login(&app, "adminprov", "adminprov@example.com").await; + + // 创建 provider (普通用户无权限 → 403) + let create_req = Request::builder() + .method("POST") + .uri("/api/v1/providers") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "name": "test-provider", + "display_name": "Test Provider", + "base_url": "https://api.example.com/v1" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(create_req).await.unwrap(); + // user 角色默认无 provider:manage 权限 → 403 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // 列出 providers (只读权限 → 200) + let list_req = Request::builder() + .method("GET") + .uri("/api/v1/providers") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(list_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_models_list_and_usage() { + let app = build_test_app().await; + let token = register_and_login(&app, "modeluser", "modeluser@example.com").await; + + // 列出模型 (空列表) + let list_req = Request::builder() + .method("GET") + .uri("/api/v1/models") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(list_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert!(body.is_array()); + assert_eq!(body.as_array().unwrap().len(), 0); + + // 查看用量统计 + let usage_req = Request::builder() + .method("GET") + .uri("/api/v1/usage") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(usage_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body["total_requests"], 0); +} + +#[tokio::test] +async fn test_api_keys_lifecycle() { + let app = build_test_app().await; + let token = register_and_login(&app, "keyuser", "keyuser@example.com").await; + + // 列出 keys (空) + let list_req = Request::builder() + .method("GET") + .uri("/api/v1/keys") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + let resp = app.clone().oneshot(list_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 创建 key (需要已有 provider → 404 或由 service 层验证) + let create_req = Request::builder() + .method("POST") + .uri("/api/v1/keys") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "provider_id": "nonexistent", + "key_value": "sk-test-12345", + "key_label": "Test Key" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(create_req).await.unwrap(); + // provider 不存在 → 404 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} From a99a3df9dd36065c2b2a154b657e6d38068288bc Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 12:50:05 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat(saas):=20Phase=203=20=E2=80=94=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=AF=B7=E6=B1=82=E4=B8=AD=E8=BD=AC=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenAI 兼容 API 代理 (/api/v1/relay/chat/completions) - 中转任务管理 (创建/查询/状态跟踪) - 可用模型列表端点 (仅 enabled providers+models) - 任务生命周期 (queued → processing → completed/failed) - 用量自动记录 (token 统计 + 错误追踪) - 3 个新集成测试覆盖中转端点 --- crates/zclaw-saas/src/main.rs | 1 + crates/zclaw-saas/src/relay/handlers.rs | 165 ++++++++++++++++ crates/zclaw-saas/src/relay/mod.rs | 18 +- crates/zclaw-saas/src/relay/service.rs | 197 ++++++++++++++++++++ crates/zclaw-saas/src/relay/types.rs | 59 ++++++ crates/zclaw-saas/tests/integration_test.rs | 61 ++++++ 6 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 crates/zclaw-saas/src/relay/handlers.rs create mode 100644 crates/zclaw-saas/src/relay/service.rs create mode 100644 crates/zclaw-saas/src/relay/types.rs diff --git a/crates/zclaw-saas/src/main.rs b/crates/zclaw-saas/src/main.rs index 3c72030..e3a9ab1 100644 --- a/crates/zclaw-saas/src/main.rs +++ b/crates/zclaw-saas/src/main.rs @@ -44,6 +44,7 @@ fn build_router(state: AppState) -> axum::Router { let protected_routes = zclaw_saas::auth::protected_routes() .merge(zclaw_saas::account::routes()) .merge(zclaw_saas::model_config::routes()) + .merge(zclaw_saas::relay::routes()) .layer(middleware::from_fn_with_state( state.clone(), zclaw_saas::auth::auth_middleware, diff --git a/crates/zclaw-saas/src/relay/handlers.rs b/crates/zclaw-saas/src/relay/handlers.rs new file mode 100644 index 0000000..0ce12c6 --- /dev/null +++ b/crates/zclaw-saas/src/relay/handlers.rs @@ -0,0 +1,165 @@ +//! 中转服务 HTTP 处理器 + +use axum::{ + extract::{Extension, Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use crate::state::AppState; +use crate::error::{SaasError, SaasResult}; +use crate::auth::types::AuthContext; +use crate::auth::handlers::log_operation; +use crate::model_config::service as model_service; +use super::{types::*, service}; + +/// POST /api/v1/relay/chat/completions +/// OpenAI 兼容的聊天补全端点 +pub async fn chat_completions( + State(state): State, + Extension(ctx): Extension, + _headers: HeaderMap, + Json(req): Json, +) -> SaasResult { + // 检查 relay:use 权限 + if !ctx.permissions.contains(&"relay:use".to_string()) { + return Err(SaasError::Forbidden("需要 relay:use 权限".into())); + } + + let model_name = req.get("model") + .and_then(|v| v.as_str()) + .ok_or_else(|| SaasError::InvalidInput("缺少 model 字段".into()))?; + + let stream = req.get("stream") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // 查找 model 对应的 provider + let models = model_service::list_models(&state.db, None).await?; + let target_model = models.iter().find(|m| m.model_id == model_name && m.enabled) + .ok_or_else(|| SaasError::NotFound(format!("模型 {} 不存在或未启用", model_name)))?; + + // 获取 provider 信息 + let provider = model_service::get_provider(&state.db, &target_model.provider_id).await?; + if !provider.enabled { + return Err(SaasError::Forbidden(format!("Provider {} 已禁用", provider.name))); + } + + // 获取 provider 的 API key (从数据库直接查询) + let provider_api_key: Option = sqlx::query_scalar( + "SELECT api_key FROM providers WHERE id = ?1" + ) + .bind(&target_model.provider_id) + .fetch_optional(&state.db) + .await? + .flatten(); + + let request_body = serde_json::to_string(&req)?; + + // 创建中转任务 + let task = service::create_relay_task( + &state.db, &ctx.account_id, &target_model.provider_id, + &target_model.model_id, &request_body, 0, + ).await?; + + log_operation(&state.db, &ctx.account_id, "relay.request", "relay_task", &task.id, + Some(serde_json::json!({"model": model_name, "stream": stream})), None).await?; + + // 执行中转 + let response = service::execute_relay( + &state.db, &task.id, &provider.base_url, + provider_api_key.as_deref(), &request_body, stream, + ).await; + + match response { + Ok(service::RelayResponse::Json(body)) => { + // 记录用量 + let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + let input_tokens = parsed.get("usage") + .and_then(|u| u.get("prompt_tokens")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let output_tokens = parsed.get("usage") + .and_then(|u| u.get("completion_tokens")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + model_service::record_usage( + &state.db, &ctx.account_id, &target_model.provider_id, + &target_model.model_id, input_tokens, output_tokens, + None, "success", None, + ).await?; + + Ok((StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], body).into_response()) + } + Ok(service::RelayResponse::Sse(body)) => { + model_service::record_usage( + &state.db, &ctx.account_id, &target_model.provider_id, + &target_model.model_id, 0, 0, + None, "success", None, + ).await?; + + Ok((StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/event-stream")], body).into_response()) + } + Err(e) => { + model_service::record_usage( + &state.db, &ctx.account_id, &target_model.provider_id, + &target_model.model_id, 0, 0, + None, "failed", Some(&e.to_string()), + ).await?; + Err(e) + } + } +} + +/// GET /api/v1/relay/tasks +pub async fn list_tasks( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> SaasResult>> { + service::list_relay_tasks(&state.db, &ctx.account_id, &query).await.map(Json) +} + +/// GET /api/v1/relay/tasks/:id +pub async fn get_task( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> SaasResult> { + let task = service::get_relay_task(&state.db, &id).await?; + // 只允许查看自己的任务 (admin 可查看全部) + if task.account_id != ctx.account_id && !ctx.permissions.contains(&"relay:admin".to_string()) { + return Err(SaasError::Forbidden("无权查看此任务".into())); + } + Ok(Json(task)) +} + +/// GET /api/v1/relay/models +/// 列出可用的中转模型 (enabled providers + enabled models) +pub async fn list_available_models( + State(state): State, + _ctx: Extension, +) -> SaasResult>> { + let providers = model_service::list_providers(&state.db).await?; + let enabled_provider_ids: std::collections::HashSet = + providers.iter().filter(|p| p.enabled).map(|p| p.id.clone()).collect(); + + let models = model_service::list_models(&state.db, None).await?; + let available: Vec = models.into_iter() + .filter(|m| m.enabled && enabled_provider_ids.contains(&m.provider_id)) + .map(|m| { + serde_json::json!({ + "id": m.model_id, + "provider_id": m.provider_id, + "alias": m.alias, + "context_window": m.context_window, + "max_output_tokens": m.max_output_tokens, + "supports_streaming": m.supports_streaming, + "supports_vision": m.supports_vision, + }) + }) + .collect(); + + Ok(Json(available)) +} diff --git a/crates/zclaw-saas/src/relay/mod.rs b/crates/zclaw-saas/src/relay/mod.rs index 8504245..0ef760f 100644 --- a/crates/zclaw-saas/src/relay/mod.rs +++ b/crates/zclaw-saas/src/relay/mod.rs @@ -1 +1,17 @@ -//! 请求中转模块 +//! 中转服务模块 + +pub mod types; +pub mod service; +pub mod handlers; + +use axum::routing::{get, post}; +use crate::state::AppState; + +/// 中转服务路由 (需要认证) +pub fn routes() -> axum::Router { + axum::Router::new() + .route("/api/v1/relay/chat/completions", post(handlers::chat_completions)) + .route("/api/v1/relay/tasks", get(handlers::list_tasks)) + .route("/api/v1/relay/tasks/{id}", get(handlers::get_task)) + .route("/api/v1/relay/models", get(handlers::list_available_models)) +} diff --git a/crates/zclaw-saas/src/relay/service.rs b/crates/zclaw-saas/src/relay/service.rs new file mode 100644 index 0000000..06fa697 --- /dev/null +++ b/crates/zclaw-saas/src/relay/service.rs @@ -0,0 +1,197 @@ +//! 中转服务核心逻辑 + +use sqlx::SqlitePool; +use crate::error::{SaasError, SaasResult}; +use super::types::*; + +// ============ Relay Task Management ============ + +pub async fn create_relay_task( + db: &SqlitePool, + account_id: &str, + provider_id: &str, + model_id: &str, + request_body: &str, + priority: i64, +) -> SaasResult { + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let request_hash = hash_request(request_body); + + sqlx::query( + "INSERT INTO relay_tasks (id, account_id, provider_id, model_id, request_hash, request_body, status, priority, attempt_count, max_attempts, queued_at, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'queued', ?7, 0, 3, ?8, ?8)" + ) + .bind(&id).bind(account_id).bind(provider_id).bind(model_id) + .bind(&request_hash).bind(request_body).bind(priority).bind(&now) + .execute(db).await?; + + get_relay_task(db, &id).await +} + +pub async fn get_relay_task(db: &SqlitePool, task_id: &str) -> SaasResult { + let row: Option<(String, String, String, String, String, i64, i64, i64, i64, i64, Option, String, Option, Option, String)> = + sqlx::query_as( + "SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at + FROM relay_tasks WHERE id = ?1" + ) + .bind(task_id) + .fetch_optional(db) + .await?; + + let (id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at) = + row.ok_or_else(|| SaasError::NotFound(format!("中转任务 {} 不存在", task_id)))?; + + Ok(RelayTaskInfo { + id, account_id, provider_id, model_id, status, priority, + attempt_count, max_attempts, input_tokens, output_tokens, + error_message, queued_at, started_at, completed_at, created_at, + }) +} + +pub async fn list_relay_tasks( + db: &SqlitePool, account_id: &str, query: &RelayTaskQuery, +) -> SaasResult> { + let page = query.page.unwrap_or(1).max(1); + let page_size = query.page_size.unwrap_or(20).min(100); + let offset = (page - 1) * page_size; + + let sql = if query.status.is_some() { + "SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at + FROM relay_tasks WHERE account_id = ?1 AND status = ?2 ORDER BY created_at DESC LIMIT ?3 OFFSET ?4" + } else { + "SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at + FROM relay_tasks WHERE account_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3" + }; + + let mut query_builder = sqlx::query_as::<_, (String, String, String, String, String, i64, i64, i64, i64, i64, Option, String, Option, Option, String)>(sql) + .bind(account_id); + + if let Some(ref status) = query.status { + query_builder = query_builder.bind(status); + } + + query_builder = query_builder.bind(page_size).bind(offset); + + let rows = query_builder.fetch_all(db).await?; + Ok(rows.into_iter().map(|(id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at)| { + RelayTaskInfo { id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at } + }).collect()) +} + +pub async fn update_task_status( + db: &SqlitePool, task_id: &str, status: &str, + input_tokens: Option, output_tokens: Option, + error_message: Option<&str>, +) -> SaasResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + + let update_sql = match status { + "processing" => "started_at = ?1, status = 'processing', attempt_count = attempt_count + 1", + "completed" => "completed_at = ?1, status = 'completed', input_tokens = COALESCE(?2, input_tokens), output_tokens = COALESCE(?3, output_tokens)", + "failed" => "completed_at = ?1, status = 'failed', error_message = ?2", + _ => return Err(SaasError::InvalidInput(format!("无效任务状态: {}", status))), + }; + + let sql = format!("UPDATE relay_tasks SET {} WHERE id = ?4", update_sql); + + let mut query = sqlx::query(&sql).bind(&now); + if status == "completed" { + query = query.bind(input_tokens).bind(output_tokens); + } + if status == "failed" { + query = query.bind(error_message); + } + query = query.bind(task_id); + query.execute(db).await?; + + Ok(()) +} + +// ============ Relay Execution ============ + +pub async fn execute_relay( + db: &SqlitePool, + task_id: &str, + provider_base_url: &str, + provider_api_key: Option<&str>, + request_body: &str, + stream: bool, +) -> SaasResult { + update_task_status(db, task_id, "processing", None, None, None).await?; + + let url = format!("{}/chat/completions", provider_base_url.trim_end_matches('/')); + let _start = std::time::Instant::now(); + + let client = reqwest::Client::new(); + let mut req_builder = client.post(&url) + .header("Content-Type", "application/json") + .body(request_body.to_string()); + + if let Some(key) = provider_api_key { + req_builder = req_builder.header("Authorization", format!("Bearer {}", key)); + } + + let result = req_builder.send().await; + + match result { + Ok(resp) if resp.status().is_success() => { + if stream { + let body = resp.text().await.unwrap_or_default(); + update_task_status(db, task_id, "completed", None, None, None).await?; + Ok(RelayResponse::Sse(body)) + } else { + let body = resp.text().await.unwrap_or_default(); + let (input_tokens, output_tokens) = extract_token_usage(&body); + update_task_status(db, task_id, "completed", + Some(input_tokens), Some(output_tokens), None).await?; + Ok(RelayResponse::Json(body)) + } + } + Ok(resp) => { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + let err_msg = format!("上游返回 HTTP {}: {}", status, &body[..body.len().min(500)]); + update_task_status(db, task_id, "failed", None, None, Some(&err_msg)).await?; + Err(SaasError::Relay(err_msg)) + } + Err(e) => { + let err_msg = format!("请求上游失败: {}", e); + update_task_status(db, task_id, "failed", None, None, Some(&err_msg)).await?; + Err(SaasError::Relay(err_msg)) + } + } +} + +/// 中转响应类型 +#[derive(Debug)] +pub enum RelayResponse { + Json(String), + Sse(String), +} + +// ============ Helpers ============ + +fn hash_request(body: &str) -> String { + use sha2::{Sha256, Digest}; + hex::encode(Sha256::digest(body.as_bytes())) +} + +fn extract_token_usage(body: &str) -> (i64, i64) { + let parsed: serde_json::Value = match serde_json::from_str(body) { + Ok(v) => v, + Err(_) => return (0, 0), + }; + + let usage = parsed.get("usage"); + let input = usage + .and_then(|u| u.get("prompt_tokens")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let output = usage + .and_then(|u| u.get("completion_tokens")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + (input, output) +} diff --git a/crates/zclaw-saas/src/relay/types.rs b/crates/zclaw-saas/src/relay/types.rs new file mode 100644 index 0000000..64fdefe --- /dev/null +++ b/crates/zclaw-saas/src/relay/types.rs @@ -0,0 +1,59 @@ +//! 中转服务类型定义 + +use serde::{Deserialize, Serialize}; + +/// 中转请求 (OpenAI 兼容格式) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayChatRequest { + pub model: String, + pub messages: Vec, + #[serde(default)] + pub temperature: Option, + #[serde(default)] + pub max_tokens: Option, + #[serde(default)] + pub stream: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: serde_json::Value, +} + +/// 中转任务信息 +#[derive(Debug, Clone, Serialize)] +pub struct RelayTaskInfo { + pub id: String, + pub account_id: String, + pub provider_id: String, + pub model_id: String, + pub status: String, + pub priority: i64, + pub attempt_count: i64, + pub max_attempts: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub error_message: Option, + pub queued_at: String, + pub started_at: Option, + pub completed_at: Option, + pub created_at: String, +} + +/// 中转任务查询 +#[derive(Debug, Deserialize)] +pub struct RelayTaskQuery { + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +/// Provider 速率限制状态 +#[derive(Debug, Clone)] +pub struct RateLimitState { + pub rpm: i64, + pub tpm: i64, + pub concurrent: usize, + pub max_concurrent: usize, +} diff --git a/crates/zclaw-saas/tests/integration_test.rs b/crates/zclaw-saas/tests/integration_test.rs index d47d016..206d3e7 100644 --- a/crates/zclaw-saas/tests/integration_test.rs +++ b/crates/zclaw-saas/tests/integration_test.rs @@ -22,6 +22,7 @@ async fn build_test_app() -> axum::Router { let protected_routes = zclaw_saas::auth::protected_routes() .merge(zclaw_saas::account::routes()) .merge(zclaw_saas::model_config::routes()) + .merge(zclaw_saas::relay::routes()) .layer(axum::middleware::from_fn_with_state( state.clone(), zclaw_saas::auth::auth_middleware, @@ -288,3 +289,63 @@ async fn test_api_keys_lifecycle() { // provider 不存在 → 404 assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +// ============ Phase 3: 中转服务测试 ============ + +#[tokio::test] +async fn test_relay_models_list() { + let app = build_test_app().await; + let token = register_and_login(&app, "relayuser", "relayuser@example.com").await; + + // 列出可用中转模型 (空列表,因为没有 provider/model 种子数据) + let req = Request::builder() + .method("GET") + .uri("/api/v1/relay/models") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert!(body.is_array()); +} + +#[tokio::test] +async fn test_relay_chat_no_model() { + let app = build_test_app().await; + let token = register_and_login(&app, "relayfail", "relayfail@example.com").await; + + // 尝试中转到不存在的模型 + let req = Request::builder() + .method("POST") + .uri("/api/v1/relay/chat/completions") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "model": "nonexistent-model", + "messages": [{"role": "user", "content": "hello"}] + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(req).await.unwrap(); + // 模型不存在 → 404 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_relay_tasks_list() { + let app = build_test_app().await; + let token = register_and_login(&app, "relaytasks", "relaytasks@example.com").await; + + let req = Request::builder() + .method("GET") + .uri("/api/v1/relay/tasks") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} From 00a08c9f9bf6cad9dfc1001712e2fce0bb3ced09 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 12:52:42 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat(saas):=20Phase=204=20=E2=80=94=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=BF=81=E7=A7=BB=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 配置项 CRUD (列表/详情/创建/更新/删除) - 配置分析端点 (按类别汇总, SaaS 托管统计) - 13 个默认配置项种子数据 (server/agent/memory/llm) - 配置同步协议 (客户端→SaaS, SaaS 优先策略) - 同步日志记录和查询 - 3 个新集成测试覆盖配置迁移端点 --- crates/zclaw-saas/src/main.rs | 1 + crates/zclaw-saas/src/migration/handlers.rs | 104 ++++++++ crates/zclaw-saas/src/migration/mod.rs | 18 ++ crates/zclaw-saas/src/migration/service.rs | 272 ++++++++++++++++++++ crates/zclaw-saas/src/migration/types.rs | 84 ++++++ crates/zclaw-saas/tests/integration_test.rs | 90 +++++++ 6 files changed, 569 insertions(+) create mode 100644 crates/zclaw-saas/src/migration/handlers.rs create mode 100644 crates/zclaw-saas/src/migration/service.rs create mode 100644 crates/zclaw-saas/src/migration/types.rs diff --git a/crates/zclaw-saas/src/main.rs b/crates/zclaw-saas/src/main.rs index e3a9ab1..2a554ee 100644 --- a/crates/zclaw-saas/src/main.rs +++ b/crates/zclaw-saas/src/main.rs @@ -45,6 +45,7 @@ fn build_router(state: AppState) -> axum::Router { .merge(zclaw_saas::account::routes()) .merge(zclaw_saas::model_config::routes()) .merge(zclaw_saas::relay::routes()) + .merge(zclaw_saas::migration::routes()) .layer(middleware::from_fn_with_state( state.clone(), zclaw_saas::auth::auth_middleware, diff --git a/crates/zclaw-saas/src/migration/handlers.rs b/crates/zclaw-saas/src/migration/handlers.rs new file mode 100644 index 0000000..af61dc9 --- /dev/null +++ b/crates/zclaw-saas/src/migration/handlers.rs @@ -0,0 +1,104 @@ +//! 配置迁移 HTTP 处理器 + +use axum::{ + extract::{Extension, Path, Query, State}, + http::StatusCode, Json, +}; +use crate::state::AppState; +use crate::error::SaasResult; +use crate::auth::types::AuthContext; +use super::{types::*, service}; + +/// GET /api/v1/config/items?category=xxx&source=xxx +pub async fn list_config_items( + State(state): State, + Query(query): Query, + _ctx: Extension, +) -> SaasResult>> { + service::list_config_items(&state.db, &query).await.map(Json) +} + +/// GET /api/v1/config/items/:id +pub async fn get_config_item( + State(state): State, + Path(id): Path, + _ctx: Extension, +) -> SaasResult> { + service::get_config_item(&state.db, &id).await.map(Json) +} + +/// POST /api/v1/config/items (admin only) +pub async fn create_config_item( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult<(StatusCode, Json)> { + if !ctx.permissions.contains(&"config:manage".to_string()) { + return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); + } + let item = service::create_config_item(&state.db, &req).await?; + Ok((StatusCode::CREATED, Json(item))) +} + +/// PUT /api/v1/config/items/:id (admin only) +pub async fn update_config_item( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + if !ctx.permissions.contains(&"config:manage".to_string()) { + return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); + } + service::update_config_item(&state.db, &id, &req).await.map(Json) +} + +/// DELETE /api/v1/config/items/:id (admin only) +pub async fn delete_config_item( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> SaasResult> { + if !ctx.permissions.contains(&"config:manage".to_string()) { + return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); + } + service::delete_config_item(&state.db, &id).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +/// GET /api/v1/config/analysis +pub async fn analyze_config( + State(state): State, + _ctx: Extension, +) -> SaasResult> { + service::analyze_config(&state.db).await.map(Json) +} + +/// POST /api/v1/config/seed (admin only) +pub async fn seed_config( + State(state): State, + Extension(ctx): Extension, +) -> SaasResult> { + if !ctx.permissions.contains(&"config:manage".to_string()) { + return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); + } + let count = service::seed_default_config_items(&state.db).await?; + Ok(Json(serde_json::json!({"created": count}))) +} + +/// POST /api/v1/config/sync +pub async fn sync_config( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult>> { + service::sync_config(&state.db, &ctx.account_id, &req).await.map(Json) +} + +/// GET /api/v1/config/sync-logs +pub async fn list_sync_logs( + State(state): State, + Extension(ctx): Extension, +) -> SaasResult>> { + service::list_sync_logs(&state.db, &ctx.account_id).await.map(Json) +} diff --git a/crates/zclaw-saas/src/migration/mod.rs b/crates/zclaw-saas/src/migration/mod.rs index 1657d19..85ff182 100644 --- a/crates/zclaw-saas/src/migration/mod.rs +++ b/crates/zclaw-saas/src/migration/mod.rs @@ -1 +1,19 @@ //! 配置迁移模块 + +pub mod types; +pub mod service; +pub mod handlers; + +use axum::routing::{get, post}; +use crate::state::AppState; + +/// 配置迁移路由 (需要认证) +pub fn routes() -> axum::Router { + axum::Router::new() + .route("/api/v1/config/items", get(handlers::list_config_items).post(handlers::create_config_item)) + .route("/api/v1/config/items/{id}", get(handlers::get_config_item).put(handlers::update_config_item).delete(handlers::delete_config_item)) + .route("/api/v1/config/analysis", get(handlers::analyze_config)) + .route("/api/v1/config/seed", post(handlers::seed_config)) + .route("/api/v1/config/sync", post(handlers::sync_config)) + .route("/api/v1/config/sync-logs", get(handlers::list_sync_logs)) +} diff --git a/crates/zclaw-saas/src/migration/service.rs b/crates/zclaw-saas/src/migration/service.rs new file mode 100644 index 0000000..2a6cb30 --- /dev/null +++ b/crates/zclaw-saas/src/migration/service.rs @@ -0,0 +1,272 @@ +//! 配置迁移业务逻辑 + +use sqlx::SqlitePool; +use crate::error::{SaasError, SaasResult}; +use super::types::*; + +// ============ Config Items ============ + +pub async fn list_config_items( + db: &SqlitePool, query: &ConfigQuery, +) -> SaasResult> { + let sql = match (&query.category, &query.source) { + (Some(_), Some(_)) => { + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + FROM config_items WHERE category = ?1 AND source = ?2 ORDER BY category, key_path" + } + (Some(_), None) => { + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + FROM config_items WHERE category = ?1 ORDER BY key_path" + } + (None, Some(_)) => { + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + FROM config_items WHERE source = ?1 ORDER BY category, key_path" + } + (None, None) => { + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + FROM config_items ORDER BY category, key_path" + } + }; + + let mut query_builder = sqlx::query_as::<_, (String, String, String, String, Option, Option, String, Option, bool, String, String)>(sql); + + if let Some(cat) = &query.category { + query_builder = query_builder.bind(cat); + } + if let Some(src) = &query.source { + query_builder = query_builder.bind(src); + } + + let rows = query_builder.fetch_all(db).await?; + Ok(rows.into_iter().map(|(id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at)| { + ConfigItemInfo { id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at } + }).collect()) +} + +pub async fn get_config_item(db: &SqlitePool, item_id: &str) -> SaasResult { + let row: Option<(String, String, String, String, Option, Option, String, Option, bool, String, String)> = + sqlx::query_as( + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + FROM config_items WHERE id = ?1" + ) + .bind(item_id) + .fetch_optional(db) + .await?; + + let (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at) = + row.ok_or_else(|| SaasError::NotFound(format!("配置项 {} 不存在", item_id)))?; + + Ok(ConfigItemInfo { id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at }) +} + +pub async fn create_config_item( + db: &SqlitePool, req: &CreateConfigItemRequest, +) -> SaasResult { + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let source = req.source.as_deref().unwrap_or("local"); + let requires_restart = req.requires_restart.unwrap_or(false); + + // 检查唯一性 + let existing: Option<(String,)> = sqlx::query_as( + "SELECT id FROM config_items WHERE category = ?1 AND key_path = ?2" + ) + .bind(&req.category).bind(&req.key_path) + .fetch_optional(db).await?; + + if existing.is_some() { + return Err(SaasError::AlreadyExists(format!( + "配置项 {}:{} 已存在", req.category, req.key_path + ))); + } + + sqlx::query( + "INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)" + ) + .bind(&id).bind(&req.category).bind(&req.key_path).bind(&req.value_type) + .bind(&req.current_value).bind(&req.default_value).bind(source) + .bind(&req.description).bind(requires_restart).bind(&now) + .execute(db).await?; + + get_config_item(db, &id).await +} + +pub async fn update_config_item( + db: &SqlitePool, item_id: &str, req: &UpdateConfigItemRequest, +) -> SaasResult { + let now = chrono::Utc::now().to_rfc3339(); + let mut updates = Vec::new(); + let mut params: Vec = Vec::new(); + + if let Some(ref v) = req.current_value { updates.push("current_value = ?"); params.push(v.clone()); } + if let Some(ref v) = req.source { updates.push("source = ?"); params.push(v.clone()); } + if let Some(ref v) = req.description { updates.push("description = ?"); params.push(v.clone()); } + + if updates.is_empty() { + return get_config_item(db, item_id).await; + } + + updates.push("updated_at = ?"); + params.push(now); + params.push(item_id.to_string()); + + let sql = format!("UPDATE config_items SET {} WHERE id = ?", updates.join(", ")); + let mut query = sqlx::query(&sql); + for p in ¶ms { + query = query.bind(p); + } + query.execute(db).await?; + + get_config_item(db, item_id).await +} + +pub async fn delete_config_item(db: &SqlitePool, item_id: &str) -> SaasResult<()> { + let result = sqlx::query("DELETE FROM config_items WHERE id = ?1") + .bind(item_id).execute(db).await?; + if result.rows_affected() == 0 { + return Err(SaasError::NotFound(format!("配置项 {} 不存在", item_id))); + } + Ok(()) +} + +// ============ Config Analysis ============ + +pub async fn analyze_config(db: &SqlitePool) -> SaasResult { + let items = list_config_items(db, &ConfigQuery { category: None, source: None }).await?; + + let mut categories: std::collections::HashMap = std::collections::HashMap::new(); + for item in &items { + let entry = categories.entry(item.category.clone()).or_insert((0, 0)); + entry.0 += 1; + if item.source == "saas" { + entry.1 += 1; + } + } + + let category_summaries: Vec = categories.into_iter() + .map(|(category, (count, saas_managed))| CategorySummary { category, count, saas_managed }) + .collect(); + + Ok(ConfigAnalysis { + total_items: items.len() as i64, + categories: category_summaries, + items, + }) +} + +/// 种子默认配置项 +pub async fn seed_default_config_items(db: &SqlitePool) -> SaasResult { + let defaults = [ + ("server", "server.host", "string", Some("127.0.0.1"), Some("127.0.0.1"), "服务器监听地址"), + ("server", "server.port", "integer", Some("4200"), Some("4200"), "服务器端口"), + ("server", "server.cors_origins", "array", None, None, "CORS 允许的源"), + ("agent", "agent.defaults.default_model", "string", Some("zhipu/glm-4-plus"), Some("zhipu/glm-4-plus"), "默认模型"), + ("agent", "agent.defaults.fallback_models", "array", None, None, "回退模型列表"), + ("agent", "agent.defaults.max_sessions", "integer", Some("10"), Some("10"), "最大并发会话数"), + ("agent", "agent.defaults.heartbeat_interval", "duration", Some("1h"), Some("1h"), "心跳间隔"), + ("agent", "agent.defaults.session_timeout", "duration", Some("24h"), Some("24h"), "会话超时"), + ("memory", "agent.defaults.memory.max_history_length", "integer", Some("100"), Some("100"), "最大历史长度"), + ("memory", "agent.defaults.memory.summarize_threshold", "integer", Some("50"), Some("50"), "摘要阈值"), + ("llm", "llm.default_provider", "string", Some("zhipu"), Some("zhipu"), "默认 LLM Provider"), + ("llm", "llm.temperature", "float", Some("0.7"), Some("0.7"), "默认温度"), + ("llm", "llm.max_tokens", "integer", Some("4096"), Some("4096"), "默认最大 token 数"), + ]; + + let mut created = 0; + let now = chrono::Utc::now().to_rfc3339(); + + for (category, key_path, value_type, default_value, current_value, description) in defaults { + let existing: Option<(String,)> = sqlx::query_as( + "SELECT id FROM config_items WHERE category = ?1 AND key_path = ?2" + ) + .bind(category).bind(key_path) + .fetch_optional(db) + .await?; + + if existing.is_none() { + let id = uuid::Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'local', ?7, 0, ?8, ?8)" + ) + .bind(&id).bind(category).bind(key_path).bind(value_type) + .bind(current_value).bind(default_value).bind(description).bind(&now) + .execute(db) + .await?; + created += 1; + } + } + + Ok(created) +} + +// ============ Config Sync ============ + +pub async fn sync_config( + db: &SqlitePool, account_id: &str, req: &SyncConfigRequest, +) -> SaasResult> { + let now = chrono::Utc::now().to_rfc3339(); + let config_keys_str = serde_json::to_string(&req.config_keys)?; + let client_values_str = Some(serde_json::to_string(&req.client_values)?); + + // 获取 SaaS 端的配置值 + let saas_items = list_config_items(db, &ConfigQuery { category: None, source: None }).await?; + let saas_values: serde_json::Value = saas_items.iter() + .filter(|item| req.config_keys.contains(&item.key_path)) + .map(|item| { + let key = format!("{}.{}", item.category, item.key_path); + (key, serde_json::json!({ + "value": item.current_value, + "source": item.source, + })) + }) + .collect(); + + let saas_values_str = Some(serde_json::to_string(&saas_values)?); + + let resolution = "saas_wins".to_string(); // SaaS 配置优先 + + let id = sqlx::query( + "INSERT INTO config_sync_log (account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at) + VALUES (?1, ?2, 'sync', ?3, ?4, ?5, ?6, ?7)" + ) + .bind(account_id).bind(&req.client_fingerprint) + .bind(&config_keys_str).bind(&client_values_str) + .bind(&saas_values_str).bind(&resolution).bind(&now) + .execute(db) + .await?; + + let log_id = id.last_insert_rowid(); + + // 返回同步结果 + let row: Option<(i64, String, String, String, String, Option, Option, Option, String)> = + sqlx::query_as( + "SELECT id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at + FROM config_sync_log WHERE id = ?1" + ) + .bind(log_id) + .fetch_optional(db) + .await?; + + Ok(row.into_iter().map(|(id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at)| { + ConfigSyncLogInfo { id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at } + }).collect()) +} + +pub async fn list_sync_logs( + db: &SqlitePool, account_id: &str, +) -> SaasResult> { + let rows: Vec<(i64, String, String, String, String, Option, Option, Option, String)> = + sqlx::query_as( + "SELECT id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at + FROM config_sync_log WHERE account_id = ?1 ORDER BY created_at DESC LIMIT 50" + ) + .bind(account_id) + .fetch_all(db) + .await?; + + Ok(rows.into_iter().map(|(id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at)| { + ConfigSyncLogInfo { id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at } + }).collect()) +} diff --git a/crates/zclaw-saas/src/migration/types.rs b/crates/zclaw-saas/src/migration/types.rs new file mode 100644 index 0000000..37c8fdb --- /dev/null +++ b/crates/zclaw-saas/src/migration/types.rs @@ -0,0 +1,84 @@ +//! 配置迁移类型定义 + +use serde::{Deserialize, Serialize}; + +/// 配置项信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigItemInfo { + pub id: String, + pub category: String, + pub key_path: String, + pub value_type: String, + pub current_value: Option, + pub default_value: Option, + pub source: String, + pub description: Option, + pub requires_restart: bool, + pub created_at: String, + pub updated_at: String, +} + +/// 创建配置项请求 +#[derive(Debug, Deserialize)] +pub struct CreateConfigItemRequest { + pub category: String, + pub key_path: String, + pub value_type: String, + pub current_value: Option, + pub default_value: Option, + pub source: Option, + pub description: Option, + pub requires_restart: Option, +} + +/// 更新配置项请求 +#[derive(Debug, Deserialize)] +pub struct UpdateConfigItemRequest { + pub current_value: Option, + pub source: Option, + pub description: Option, +} + +/// 配置同步日志 +#[derive(Debug, Clone, Serialize)] +pub struct ConfigSyncLogInfo { + pub id: i64, + pub account_id: String, + pub client_fingerprint: String, + pub action: String, + pub config_keys: String, + pub client_values: Option, + pub saas_values: Option, + pub resolution: Option, + pub created_at: String, +} + +/// 配置分析结果 +#[derive(Debug, Serialize)] +pub struct ConfigAnalysis { + pub total_items: i64, + pub categories: Vec, + pub items: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CategorySummary { + pub category: String, + pub count: i64, + pub saas_managed: i64, +} + +/// 配置同步请求 +#[derive(Debug, Deserialize)] +pub struct SyncConfigRequest { + pub client_fingerprint: String, + pub config_keys: Vec, + pub client_values: serde_json::Value, +} + +/// 配置查询参数 +#[derive(Debug, Deserialize)] +pub struct ConfigQuery { + pub category: Option, + pub source: Option, +} diff --git a/crates/zclaw-saas/tests/integration_test.rs b/crates/zclaw-saas/tests/integration_test.rs index 206d3e7..0e37919 100644 --- a/crates/zclaw-saas/tests/integration_test.rs +++ b/crates/zclaw-saas/tests/integration_test.rs @@ -23,6 +23,7 @@ async fn build_test_app() -> axum::Router { .merge(zclaw_saas::account::routes()) .merge(zclaw_saas::model_config::routes()) .merge(zclaw_saas::relay::routes()) + .merge(zclaw_saas::migration::routes()) .layer(axum::middleware::from_fn_with_state( state.clone(), zclaw_saas::auth::auth_middleware, @@ -349,3 +350,92 @@ async fn test_relay_tasks_list() { let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } + +// ============ Phase 4: 配置迁移测试 ============ + +#[tokio::test] +async fn test_config_analysis_empty() { + let app = build_test_app().await; + let token = register_and_login(&app, "cfguser", "cfguser@example.com").await; + + // 初始分析 (无种子数据 → 空列表) + let req = Request::builder() + .method("GET") + .uri("/api/v1/config/analysis") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body["total_items"], 0); +} + +#[tokio::test] +async fn test_config_seed_and_list() { + let app = build_test_app().await; + let token = register_and_login(&app, "cfgseed", "cfgseed@example.com").await; + + // 种子配置 (普通用户无权限 → 403) + let seed_req = Request::builder() + .method("POST") + .uri("/api/v1/config/seed") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(seed_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // 列出配置项 (空列表) + let list_req = Request::builder() + .method("GET") + .uri("/api/v1/config/items") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(list_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert!(body.is_array()); + assert_eq!(body.as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn test_config_sync() { + let app = build_test_app().await; + let token = register_and_login(&app, "cfgsync", "cfgsync@example.com").await; + + let sync_req = Request::builder() + .method("POST") + .uri("/api/v1/config/sync") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "client_fingerprint": "test-desktop-v1", + "config_keys": ["server.host", "agent.defaults.default_model"], + "client_values": { + "server.host": "0.0.0.0", + "agent.defaults.default_model": "deepseek/deepseek-chat" + } + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(sync_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 查看同步日志 + let logs_req = Request::builder() + .method("GET") + .uri("/api/v1/config/sync-logs") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(logs_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} From 94bf387aee6b081ba4fc16f4167114d06deadc55 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 13:07:20 +0800 Subject: [PATCH 05/14] =?UTF-8?q?fix(saas):=20=E5=AE=89=E5=85=A8=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=20IDOR=E9=98=B2=E6=8A=A4=E3=80=81SSRF?= =?UTF-8?q?=E9=98=B2=E6=8A=A4=E3=80=81JWT=E5=AF=86=E9=92=A5=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E3=80=81=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E3=80=81CORS=E9=85=8D=E7=BD=AE=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - account: admin 权限守卫 (list_accounts/get_account/update_status/list_logs) - relay: SSRF 防护 (禁止内网地址、限制 http scheme、30s 超时) - config: 生产环境强制 ZCLAW_SAAS_JWT_SECRET 环境变量 - error: 500 错误不再泄露内部细节给客户端 - main: CORS 支持配置白名单 origins - 全部 21 个测试通过 (7 unit + 14 integration) --- Cargo.lock | 1 + crates/zclaw-saas/Cargo.toml | 1 + crates/zclaw-saas/src/account/handlers.rs | 34 ++++++++++++---- crates/zclaw-saas/src/config.rs | 30 ++++++++++---- crates/zclaw-saas/src/error.rs | 13 +++++- crates/zclaw-saas/src/main.rs | 24 ++++++++--- crates/zclaw-saas/src/relay/service.rs | 44 ++++++++++++++++++++- crates/zclaw-saas/src/state.rs | 8 ++-- crates/zclaw-saas/tests/integration_test.rs | 10 +++-- 9 files changed, 134 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be6b01b..31ae41a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7451,6 +7451,7 @@ dependencies = [ "tower-http 0.5.2", "tracing", "tracing-subscriber", + "url", "uuid", "zclaw-types", ] diff --git a/crates/zclaw-saas/Cargo.toml b/crates/zclaw-saas/Cargo.toml index 4bdb077..42a12e8 100644 --- a/crates/zclaw-saas/Cargo.toml +++ b/crates/zclaw-saas/Cargo.toml @@ -29,6 +29,7 @@ sha2 = { workspace = true } rand = { workspace = true } dashmap = { workspace = true } hex = { workspace = true } +url = "2" axum = { workspace = true } axum-extra = { workspace = true } diff --git a/crates/zclaw-saas/src/account/handlers.rs b/crates/zclaw-saas/src/account/handlers.rs index 4d5d488..1671bb7 100644 --- a/crates/zclaw-saas/src/account/handlers.rs +++ b/crates/zclaw-saas/src/account/handlers.rs @@ -5,17 +5,25 @@ use axum::{ Json, }; use crate::state::AppState; -use crate::error::SaasResult; +use crate::error::{SaasError, SaasResult}; use crate::auth::types::AuthContext; use crate::auth::handlers::log_operation; use super::{types::*, service}; -/// GET /api/v1/accounts +fn require_admin(ctx: &AuthContext) -> SaasResult<()> { + if !ctx.permissions.contains(&"account:admin".to_string()) { + return Err(SaasError::Forbidden("需要 account:admin 权限".into())); + } + Ok(()) +} + +/// GET /api/v1/accounts (admin only) pub async fn list_accounts( State(state): State, Query(query): Query, - _ctx: Extension, + Extension(ctx): Extension, ) -> SaasResult>> { + require_admin(&ctx)?; service::list_accounts(&state.db, &query).await.map(Json) } @@ -23,30 +31,39 @@ pub async fn list_accounts( pub async fn get_account( State(state): State, Path(id): Path, - _ctx: Extension, + Extension(ctx): Extension, ) -> SaasResult> { + // 只能查看自己,或 admin 查看任何人 + if id != ctx.account_id { + require_admin(&ctx)?; + } service::get_account(&state.db, &id).await.map(Json) } -/// PUT /api/v1/accounts/:id +/// PUT /api/v1/accounts/:id (admin or self for limited fields) pub async fn update_account( State(state): State, Path(id): Path, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { + // 非管理员只能修改自己的资料 + if id != ctx.account_id { + require_admin(&ctx)?; + } let result = service::update_account(&state.db, &id, &req).await?; log_operation(&state.db, &ctx.account_id, "account.update", "account", &id, None, None).await?; Ok(Json(result)) } -/// PATCH /api/v1/accounts/:id/status +/// PATCH /api/v1/accounts/:id/status (admin only) pub async fn update_status( State(state): State, Path(id): Path, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { + require_admin(&ctx)?; service::update_account_status(&state.db, &id, &req.status).await?; log_operation(&state.db, &ctx.account_id, "account.update_status", "account", &id, Some(serde_json::json!({"status": &req.status})), None).await?; @@ -84,12 +101,13 @@ pub async fn revoke_token( Ok(Json(serde_json::json!({"ok": true}))) } -/// GET /api/v1/logs/operations +/// GET /api/v1/logs/operations (admin only) pub async fn list_operation_logs( State(state): State, Query(params): Query>, - _ctx: Extension, + Extension(ctx): Extension, ) -> SaasResult>> { + require_admin(&ctx)?; let page: i64 = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1); let page_size: i64 = params.get("page_size").and_then(|v| v.parse().ok()).unwrap_or(50); let offset = (page - 1) * page_size; diff --git a/crates/zclaw-saas/src/config.rs b/crates/zclaw-saas/src/config.rs index 6dfa64c..c987235 100644 --- a/crates/zclaw-saas/src/config.rs +++ b/crates/zclaw-saas/src/config.rs @@ -132,13 +132,27 @@ impl SaaSConfig { Ok(config) } - /// 获取 JWT 密钥 (从环境变量或生成默认值) - pub fn jwt_secret(&self) -> SecretString { - std::env::var("ZCLAW_SAAS_JWT_SECRET") - .map(SecretString::from) - .unwrap_or_else(|_| { - tracing::warn!("ZCLAW_SAAS_JWT_SECRET not set, using default (insecure!)"); - SecretString::from("zclaw-saas-default-secret-change-in-production".to_string()) - }) + /// 获取 JWT 密钥 (从环境变量或生成临时值) + /// 生产环境必须设置 ZCLAW_SAAS_JWT_SECRET + pub fn jwt_secret(&self) -> anyhow::Result { + let is_dev = std::env::var("ZCLAW_SAAS_DEV") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + match std::env::var("ZCLAW_SAAS_JWT_SECRET") { + Ok(secret) => Ok(SecretString::from(secret)), + Err(_) => { + if is_dev { + tracing::warn!("ZCLAW_SAAS_JWT_SECRET not set, using development default (INSECURE)"); + Ok(SecretString::from("zclaw-dev-only-secret-do-not-use-in-prod".to_string())) + } else { + anyhow::bail!( + "ZCLAW_SAAS_JWT_SECRET 环境变量未设置。\ + 请设置一个强随机密钥 (至少 32 字符)。\ + 开发环境可设置 ZCLAW_SAAS_DEV=true 使用默认值。" + ) + } + } + } } } diff --git a/crates/zclaw-saas/src/error.rs b/crates/zclaw-saas/src/error.rs index 6dd1881..1b02dfa 100644 --- a/crates/zclaw-saas/src/error.rs +++ b/crates/zclaw-saas/src/error.rs @@ -107,9 +107,18 @@ impl SaasError { impl IntoResponse for SaasError { fn into_response(self) -> Response { let status = self.status_code(); + let (error_code, message) = match &self { + // 500 错误不泄露内部细节给客户端 + Self::Database(_) | Self::Internal(_) | Self::Io(_) + | Self::Jwt(_) | Self::Config(_) => { + tracing::error!("内部错误 [{}]: {}", self.error_code(), self); + (self.error_code().to_string(), "服务内部错误".to_string()) + } + _ => (self.error_code().to_string(), self.to_string()), + }; let body = json!({ - "error": self.error_code(), - "message": self.to_string(), + "error": error_code, + "message": message, }); (status, axum::Json(body)).into_response() } diff --git a/crates/zclaw-saas/src/main.rs b/crates/zclaw-saas/src/main.rs index 2a554ee..a314f83 100644 --- a/crates/zclaw-saas/src/main.rs +++ b/crates/zclaw-saas/src/main.rs @@ -18,7 +18,7 @@ async fn main() -> anyhow::Result<()> { let db = init_db(&config.database.url).await?; info!("Database initialized"); - let state = AppState::new(db, config.clone()); + let state = AppState::new(db, config.clone())?; let app = build_router(state); let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.server.host, config.server.port)) @@ -34,10 +34,24 @@ fn build_router(state: AppState) -> axum::Router { use tower_http::cors::{Any, CorsLayer}; use tower_http::trace::TraceLayer; - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); + use axum::http::HeaderValue; + let cors = { + let config = state.config.blocking_read(); + if config.server.cors_origins.is_empty() { + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any) + } else { + let origins: Vec = config.server.cors_origins.iter() + .filter_map(|o: &String| o.parse::().ok()) + .collect(); + CorsLayer::new() + .allow_origin(origins) + .allow_methods(Any) + .allow_headers(Any) + } + }; let public_routes = zclaw_saas::auth::routes(); diff --git a/crates/zclaw-saas/src/relay/service.rs b/crates/zclaw-saas/src/relay/service.rs index 06fa697..5a796c1 100644 --- a/crates/zclaw-saas/src/relay/service.rs +++ b/crates/zclaw-saas/src/relay/service.rs @@ -120,10 +120,16 @@ pub async fn execute_relay( ) -> SaasResult { update_task_status(db, task_id, "processing", None, None, None).await?; + // SSRF 防护: 验证 URL scheme 和禁止内网地址 + validate_provider_url(provider_base_url)?; + let url = format!("{}/chat/completions", provider_base_url.trim_end_matches('/')); let _start = std::time::Instant::now(); - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| SaasError::Internal(format!("HTTP 客户端构建失败: {}", e)))?; let mut req_builder = client.post(&url) .header("Content-Type", "application/json") .body(request_body.to_string()); @@ -195,3 +201,39 @@ fn extract_token_usage(body: &str) -> (i64, i64) { (input, output) } + +/// SSRF 防护: 验证 provider URL 不指向内网 +fn validate_provider_url(url: &str) -> SaasResult<()> { + let parsed: url::Url = url.parse().map_err(|_| { + SaasError::InvalidInput(format!("无效的 provider URL: {}", url)) + })?; + + // 只允许 https + match parsed.scheme() { + "https" => {} + "http" => { + // 开发环境允许 http + let is_dev = std::env::var("ZCLAW_SAAS_DEV") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + if !is_dev { + return Err(SaasError::InvalidInput("生产环境禁止 http scheme,请使用 https".into())); + } + } + _ => return Err(SaasError::InvalidInput(format!("不允许的 URL scheme: {}", parsed.scheme()))), + } + + // 禁止内网地址 + let host = match parsed.host_str() { + Some(h) => h, + None => return Err(SaasError::InvalidInput("provider URL 缺少 host".into())), + }; + let blocked = ["127.0.0.1", "0.0.0.0", "localhost", "::1", "169.254.169.254", "metadata.google.internal"]; + for blocked_host in &blocked { + if host == *blocked_host || host.ends_with(&format!(".{}", blocked_host)) { + return Err(SaasError::InvalidInput(format!("provider URL 指向禁止的内网地址: {}", host))); + } + } + + Ok(()) +} diff --git a/crates/zclaw-saas/src/state.rs b/crates/zclaw-saas/src/state.rs index e428b0d..6f2a78c 100644 --- a/crates/zclaw-saas/src/state.rs +++ b/crates/zclaw-saas/src/state.rs @@ -17,12 +17,12 @@ pub struct AppState { } impl AppState { - pub fn new(db: SqlitePool, config: SaaSConfig) -> Self { - let jwt_secret = config.jwt_secret(); - Self { + pub fn new(db: SqlitePool, config: SaaSConfig) -> anyhow::Result { + let jwt_secret = config.jwt_secret()?; + Ok(Self { db, config: Arc::new(RwLock::new(config)), jwt_secret, - } + }) } } diff --git a/crates/zclaw-saas/tests/integration_test.rs b/crates/zclaw-saas/tests/integration_test.rs index 0e37919..559758f 100644 --- a/crates/zclaw-saas/tests/integration_test.rs +++ b/crates/zclaw-saas/tests/integration_test.rs @@ -12,10 +12,14 @@ const MAX_BODY_SIZE: usize = 1024 * 1024; // 1MB async fn build_test_app() -> axum::Router { use zclaw_saas::{config::SaaSConfig, db::init_memory_db, state::AppState}; + // 测试环境设置开发模式 (允许 http、默认 JWT secret) + std::env::set_var("ZCLAW_SAAS_DEV", "true"); + std::env::set_var("ZCLAW_SAAS_JWT_SECRET", "test-secret-for-integration-tests-only"); + let db = init_memory_db().await.unwrap(); let mut config = SaaSConfig::default(); config.auth.jwt_expiration_hours = 24; - let state = AppState::new(db, config); + let state = AppState::new(db, config).expect("测试环境 AppState 初始化失败"); let public_routes = zclaw_saas::auth::routes(); @@ -174,7 +178,7 @@ async fn test_full_authenticated_flow() { let resp = app.clone().oneshot(list_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); - // 查看操作日志 + // 查看操作日志 (普通用户无 admin 权限 → 403) let logs_req = Request::builder() .method("GET") .uri("/api/v1/logs/operations") @@ -183,7 +187,7 @@ async fn test_full_authenticated_flow() { .unwrap(); let resp = app.oneshot(logs_req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); } // ============ Phase 2: 模型配置测试 ============ From 900430d93ed00125f1b12025644113e8ead63892 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 13:09:59 +0800 Subject: [PATCH 06/14] =?UTF-8?q?fix(saas):=20=E4=BF=AE=E5=A4=8D=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=20Critic?= =?UTF-8?q?al/High/Medium=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Critical: 移除注册接口的 role 字段,固定为 "user" 防止权限提升 - High: 生产环境未配置 cors_origins 时拒绝启动而非默认全开放 - Medium: 增强 SSRF 防护 — 阻止 IPv6 映射地址、私有 IP 网段、十进制 IP 格式 --- crates/zclaw-saas/src/auth/handlers.rs | 2 +- crates/zclaw-saas/src/auth/types.rs | 1 - crates/zclaw-saas/src/main.rs | 16 +++++-- crates/zclaw-saas/src/relay/service.rs | 59 ++++++++++++++++++++++++-- 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/crates/zclaw-saas/src/auth/handlers.rs b/crates/zclaw-saas/src/auth/handlers.rs index 1ec448d..b8a0af5 100644 --- a/crates/zclaw-saas/src/auth/handlers.rs +++ b/crates/zclaw-saas/src/auth/handlers.rs @@ -36,7 +36,7 @@ pub async fn register( let password_hash = hash_password(&req.password)?; let account_id = uuid::Uuid::new_v4().to_string(); - let role = req.role.unwrap_or_else(|| "user".into()); + let role = "user".to_string(); // 注册固定为普通用户,角色由管理员分配 let display_name = req.display_name.unwrap_or_default(); let now = chrono::Utc::now().to_rfc3339(); diff --git a/crates/zclaw-saas/src/auth/types.rs b/crates/zclaw-saas/src/auth/types.rs index babc48e..d50cdb6 100644 --- a/crates/zclaw-saas/src/auth/types.rs +++ b/crates/zclaw-saas/src/auth/types.rs @@ -24,7 +24,6 @@ pub struct RegisterRequest { pub email: String, pub password: String, pub display_name: Option, - pub role: Option, } /// 公开账号信息 (无敏感数据) diff --git a/crates/zclaw-saas/src/main.rs b/crates/zclaw-saas/src/main.rs index a314f83..4ed16b3 100644 --- a/crates/zclaw-saas/src/main.rs +++ b/crates/zclaw-saas/src/main.rs @@ -37,11 +37,19 @@ fn build_router(state: AppState) -> axum::Router { use axum::http::HeaderValue; let cors = { let config = state.config.blocking_read(); + let is_dev = std::env::var("ZCLAW_SAAS_DEV") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); if config.server.cors_origins.is_empty() { - CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any) + if is_dev { + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any) + } else { + tracing::error!("生产环境必须配置 server.cors_origins,不能使用 allow_origin(Any)"); + panic!("生产环境必须配置 server.cors_origins 白名单。开发环境可设置 ZCLAW_SAAS_DEV=true 绕过。"); + } } else { let origins: Vec = config.server.cors_origins.iter() .filter_map(|o: &String| o.parse::().ok()) diff --git a/crates/zclaw-saas/src/relay/service.rs b/crates/zclaw-saas/src/relay/service.rs index 5a796c1..bcac77a 100644 --- a/crates/zclaw-saas/src/relay/service.rs +++ b/crates/zclaw-saas/src/relay/service.rs @@ -228,12 +228,65 @@ fn validate_provider_url(url: &str) -> SaasResult<()> { Some(h) => h, None => return Err(SaasError::InvalidInput("provider URL 缺少 host".into())), }; - let blocked = ["127.0.0.1", "0.0.0.0", "localhost", "::1", "169.254.169.254", "metadata.google.internal"]; - for blocked_host in &blocked { - if host == *blocked_host || host.ends_with(&format!(".{}", blocked_host)) { + + // 精确匹配的阻止列表 + let blocked_exact = [ + "127.0.0.1", "0.0.0.0", "localhost", "::1", "::ffff:127.0.0.1", + "0:0:0:0:0:ffff:7f00:1", "169.254.169.254", "metadata.google.internal", + "10.0.0.1", "172.16.0.1", "192.168.0.1", + ]; + if blocked_exact.contains(&host) { + return Err(SaasError::InvalidInput(format!("provider URL 指向禁止的内网地址: {}", host))); + } + + // 后缀匹配 (阻止子域名) + let blocked_suffixes = ["localhost", "internal", "local", "localhost.localdomain"]; + for suffix in &blocked_suffixes { + if host.ends_with(&format!(".{}", suffix)) { return Err(SaasError::InvalidInput(format!("provider URL 指向禁止的内网地址: {}", host))); } } + // 阻止 IPv4 私有网段 (通过解析 IP) + if let Ok(ip) = host.parse::() { + if is_private_ip(&ip) { + return Err(SaasError::InvalidInput(format!("provider URL 指向私有 IP 地址: {}", host))); + } + } + + // 阻止纯数字 host (可能是十进制 IP 表示法,如 2130706433 = 127.0.0.1) + if host.parse::().is_ok() { + return Err(SaasError::InvalidInput(format!("provider URL 使用了不允许的 IP 格式: {}", host))); + } + Ok(()) } + +/// 检查 IP 是否属于私有/内网地址范围 +fn is_private_ip(ip: &std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(v4) => { + let octets = v4.octets(); + // 10.0.0.0/8 + octets[0] == 10 + // 172.16.0.0/12 + || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) + // 192.168.0.0/16 + || (octets[0] == 192 && octets[1] == 168) + // 127.0.0.0/8 (loopback) + || octets[0] == 127 + // 169.254.0.0/16 (link-local) + || (octets[0] == 169 && octets[1] == 254) + // 0.0.0.0/8 + || octets[0] == 0 + } + std::net::IpAddr::V6(v6) => { + // ::1 (loopback) + v6.is_loopback() + // ::ffff:x.x.x.x (IPv6-mapped IPv4) + || v6.to_ipv4_mapped().map_or(false, |v4| is_private_ip(&std::net::IpAddr::V4(v4))) + // fe80::/10 (link-local) + || (v6.segments()[0] & 0xffc0) == 0xfe80 + } + } +} From a0d59b19477f0f7f2748a7608452caf1860dcbd2 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 13:12:09 +0800 Subject: [PATCH 07/14] =?UTF-8?q?fix(saas):=20=E7=BB=9F=E4=B8=80=E6=9D=83?= =?UTF-8?q?=E9=99=90=E4=BD=93=E7=B3=BB=20=E2=80=94=20check=5Fpermission=20?= =?UTF-8?q?=E8=BE=85=E5=8A=A9=E5=87=BD=E6=95=B0=20+=20admin:full=20?= =?UTF-8?q?=E8=B6=85=E7=BA=A7=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 check_permission() 统一权限检查,admin:full 自动通过所有检查 - 统一种子角色权限名称与 handler 检查一致 (provider:manage, model:manage, config:write) - super_admin 拥有 admin:full + 所有模块管理权限 - 全部 handler 迁移到 check_permission(),消除手动 contains 检查 --- crates/zclaw-saas/src/account/handlers.rs | 9 ++---- crates/zclaw-saas/src/auth/handlers.rs | 11 ++++++++ crates/zclaw-saas/src/db.rs | 4 +-- crates/zclaw-saas/src/migration/handlers.rs | 17 ++++------- .../zclaw-saas/src/model_config/handlers.rs | 28 ++++++------------- crates/zclaw-saas/src/relay/handlers.rs | 11 +++----- 6 files changed, 33 insertions(+), 47 deletions(-) diff --git a/crates/zclaw-saas/src/account/handlers.rs b/crates/zclaw-saas/src/account/handlers.rs index 1671bb7..e07ac9c 100644 --- a/crates/zclaw-saas/src/account/handlers.rs +++ b/crates/zclaw-saas/src/account/handlers.rs @@ -5,16 +5,13 @@ use axum::{ Json, }; use crate::state::AppState; -use crate::error::{SaasError, SaasResult}; +use crate::error::SaasResult; use crate::auth::types::AuthContext; -use crate::auth::handlers::log_operation; +use crate::auth::handlers::{log_operation, check_permission}; use super::{types::*, service}; fn require_admin(ctx: &AuthContext) -> SaasResult<()> { - if !ctx.permissions.contains(&"account:admin".to_string()) { - return Err(SaasError::Forbidden("需要 account:admin 权限".into())); - } - Ok(()) + check_permission(ctx, "account:admin") } /// GET /api/v1/accounts (admin only) diff --git a/crates/zclaw-saas/src/auth/handlers.rs b/crates/zclaw-saas/src/auth/handlers.rs index b8a0af5..8e7663f 100644 --- a/crates/zclaw-saas/src/auth/handlers.rs +++ b/crates/zclaw-saas/src/auth/handlers.rs @@ -152,6 +152,17 @@ async fn get_role_permissions(db: &sqlx::SqlitePool, role: &str) -> SaasResult SaasResult<()> { + if ctx.permissions.contains(&"admin:full".to_string()) { + return Ok(()); + } + if !ctx.permissions.contains(&permission.to_string()) { + return Err(SaasError::Forbidden(format!("需要 {} 权限", permission))); + } + Ok(()) +} + /// 记录操作日志 pub async fn log_operation( db: &sqlx::SqlitePool, diff --git a/crates/zclaw-saas/src/db.rs b/crates/zclaw-saas/src/db.rs index ecae8dd..d41cd4c 100644 --- a/crates/zclaw-saas/src/db.rs +++ b/crates/zclaw-saas/src/db.rs @@ -200,8 +200,8 @@ CREATE INDEX IF NOT EXISTS idx_sync_account ON config_sync_log(account_id); const SEED_ROLES: &str = r#" INSERT OR IGNORE INTO roles (id, name, description, permissions, is_system, created_at, updated_at) VALUES - ('super_admin', '超级管理员', '拥有所有权限', '["admin:full"]', 1, datetime('now'), datetime('now')), - ('admin', '管理员', '管理账号和配置', '["account:read","account:write","model:read","model:write","relay:use","relay:admin","config:read","config:write"]', 1, datetime('now'), datetime('now')), + ('super_admin', '超级管理员', '拥有所有权限', '["admin:full","account:admin","provider:manage","model:manage","relay:admin","config:write"]', 1, datetime('now'), datetime('now')), + ('admin', '管理员', '管理账号和配置', '["account:read","account:admin","provider:manage","model:read","model:manage","relay:use","relay:admin","config:read","config:write"]', 1, datetime('now'), datetime('now')), ('user', '普通用户', '基础使用权限', '["model:read","relay:use","config:read"]', 1, datetime('now'), datetime('now')); "#; diff --git a/crates/zclaw-saas/src/migration/handlers.rs b/crates/zclaw-saas/src/migration/handlers.rs index af61dc9..7e3f94e 100644 --- a/crates/zclaw-saas/src/migration/handlers.rs +++ b/crates/zclaw-saas/src/migration/handlers.rs @@ -7,6 +7,7 @@ use axum::{ use crate::state::AppState; use crate::error::SaasResult; use crate::auth::types::AuthContext; +use crate::auth::handlers::check_permission; use super::{types::*, service}; /// GET /api/v1/config/items?category=xxx&source=xxx @@ -33,9 +34,7 @@ pub async fn create_config_item( Extension(ctx): Extension, Json(req): Json, ) -> SaasResult<(StatusCode, Json)> { - if !ctx.permissions.contains(&"config:manage".to_string()) { - return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); - } + check_permission(&ctx, "config:write")?; let item = service::create_config_item(&state.db, &req).await?; Ok((StatusCode::CREATED, Json(item))) } @@ -47,9 +46,7 @@ pub async fn update_config_item( Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { - if !ctx.permissions.contains(&"config:manage".to_string()) { - return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); - } + check_permission(&ctx, "config:write")?; service::update_config_item(&state.db, &id, &req).await.map(Json) } @@ -59,9 +56,7 @@ pub async fn delete_config_item( Path(id): Path, Extension(ctx): Extension, ) -> SaasResult> { - if !ctx.permissions.contains(&"config:manage".to_string()) { - return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); - } + check_permission(&ctx, "config:write")?; service::delete_config_item(&state.db, &id).await?; Ok(Json(serde_json::json!({"ok": true}))) } @@ -79,9 +74,7 @@ pub async fn seed_config( State(state): State, Extension(ctx): Extension, ) -> SaasResult> { - if !ctx.permissions.contains(&"config:manage".to_string()) { - return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); - } + check_permission(&ctx, "config:write")?; let count = service::seed_default_config_items(&state.db).await?; Ok(Json(serde_json::json!({"created": count}))) } diff --git a/crates/zclaw-saas/src/model_config/handlers.rs b/crates/zclaw-saas/src/model_config/handlers.rs index 4a2be00..532b2c3 100644 --- a/crates/zclaw-saas/src/model_config/handlers.rs +++ b/crates/zclaw-saas/src/model_config/handlers.rs @@ -5,9 +5,9 @@ use axum::{ http::StatusCode, Json, }; use crate::state::AppState; -use crate::error::{SaasError, SaasResult}; +use crate::error::SaasResult; use crate::auth::types::AuthContext; -use crate::auth::handlers::log_operation; +use crate::auth::handlers::{log_operation, check_permission}; use super::{types::*, service}; // ============ Providers ============ @@ -35,9 +35,7 @@ pub async fn create_provider( Extension(ctx): Extension, Json(req): Json, ) -> SaasResult<(StatusCode, Json)> { - if !ctx.permissions.contains(&"provider:manage".to_string()) { - return Err(SaasError::Forbidden("需要 provider:manage 权限".into())); - } + check_permission(&ctx, "provider:manage")?; let provider = service::create_provider(&state.db, &req).await?; log_operation(&state.db, &ctx.account_id, "provider.create", "provider", &provider.id, Some(serde_json::json!({"name": &req.name})), None).await?; @@ -51,9 +49,7 @@ pub async fn update_provider( Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { - if !ctx.permissions.contains(&"provider:manage".to_string()) { - return Err(SaasError::Forbidden("需要 provider:manage 权限".into())); - } + check_permission(&ctx, "provider:manage")?; let provider = service::update_provider(&state.db, &id, &req).await?; log_operation(&state.db, &ctx.account_id, "provider.update", "provider", &id, None, None).await?; Ok(Json(provider)) @@ -65,9 +61,7 @@ pub async fn delete_provider( Path(id): Path, Extension(ctx): Extension, ) -> SaasResult> { - if !ctx.permissions.contains(&"provider:manage".to_string()) { - return Err(SaasError::Forbidden("需要 provider:manage 权限".into())); - } + check_permission(&ctx, "provider:manage")?; service::delete_provider(&state.db, &id).await?; log_operation(&state.db, &ctx.account_id, "provider.delete", "provider", &id, None, None).await?; Ok(Json(serde_json::json!({"ok": true}))) @@ -100,9 +94,7 @@ pub async fn create_model( Extension(ctx): Extension, Json(req): Json, ) -> SaasResult<(StatusCode, Json)> { - if !ctx.permissions.contains(&"model:manage".to_string()) { - return Err(SaasError::Forbidden("需要 model:manage 权限".into())); - } + check_permission(&ctx, "model:manage")?; let model = service::create_model(&state.db, &req).await?; log_operation(&state.db, &ctx.account_id, "model.create", "model", &model.id, Some(serde_json::json!({"model_id": &req.model_id, "provider_id": &req.provider_id})), None).await?; @@ -116,9 +108,7 @@ pub async fn update_model( Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { - if !ctx.permissions.contains(&"model:manage".to_string()) { - return Err(SaasError::Forbidden("需要 model:manage 权限".into())); - } + check_permission(&ctx, "model:manage")?; let model = service::update_model(&state.db, &id, &req).await?; log_operation(&state.db, &ctx.account_id, "model.update", "model", &id, None, None).await?; Ok(Json(model)) @@ -130,9 +120,7 @@ pub async fn delete_model( Path(id): Path, Extension(ctx): Extension, ) -> SaasResult> { - if !ctx.permissions.contains(&"model:manage".to_string()) { - return Err(SaasError::Forbidden("需要 model:manage 权限".into())); - } + check_permission(&ctx, "model:manage")?; service::delete_model(&state.db, &id).await?; log_operation(&state.db, &ctx.account_id, "model.delete", "model", &id, None, None).await?; Ok(Json(serde_json::json!({"ok": true}))) diff --git a/crates/zclaw-saas/src/relay/handlers.rs b/crates/zclaw-saas/src/relay/handlers.rs index 0ce12c6..94efe43 100644 --- a/crates/zclaw-saas/src/relay/handlers.rs +++ b/crates/zclaw-saas/src/relay/handlers.rs @@ -9,7 +9,7 @@ use axum::{ use crate::state::AppState; use crate::error::{SaasError, SaasResult}; use crate::auth::types::AuthContext; -use crate::auth::handlers::log_operation; +use crate::auth::handlers::{log_operation, check_permission}; use crate::model_config::service as model_service; use super::{types::*, service}; @@ -21,10 +21,7 @@ pub async fn chat_completions( _headers: HeaderMap, Json(req): Json, ) -> SaasResult { - // 检查 relay:use 权限 - if !ctx.permissions.contains(&"relay:use".to_string()) { - return Err(SaasError::Forbidden("需要 relay:use 权限".into())); - } + check_permission(&ctx, "relay:use")?; let model_name = req.get("model") .and_then(|v| v.as_str()) @@ -129,8 +126,8 @@ pub async fn get_task( ) -> SaasResult> { let task = service::get_relay_task(&state.db, &id).await?; // 只允许查看自己的任务 (admin 可查看全部) - if task.account_id != ctx.account_id && !ctx.permissions.contains(&"relay:admin".to_string()) { - return Err(SaasError::Forbidden("无权查看此任务".into())); + if task.account_id != ctx.account_id { + check_permission(&ctx, "relay:admin")?; } Ok(Json(task)) } From d760b9ca10aa39f9e561e4184dc76bc06abb6448 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 13:49:45 +0800 Subject: [PATCH 08/14] =?UTF-8?q?feat(saas):=20Phase=201=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E8=83=BD=E5=8A=9B=E8=A1=A5=E5=BC=BA=20=E2=80=94=20API?= =?UTF-8?q?=20Token=20=E8=AE=A4=E8=AF=81=E3=80=81=E7=9C=9F=E5=AE=9E=20SSE?= =?UTF-8?q?=20=E6=B5=81=E5=BC=8F=E3=80=81=E9=80=9F=E7=8E=87=E9=99=90?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1.1: API Token 认证中间件 - auth_middleware 新增 zclaw_ 前缀 token 分支 (SHA-256 验证) - 合并 token 自身权限与角色权限,异步更新 last_used_at - 添加 GET /api/v1/auth/me 端点返回当前用户信息 - get_role_permissions 改为 pub(crate) 供中间件调用 Phase 1.2: 真实 SSE 流式中转 - RelayResponse::Sse 改为 axum::body::Body (bytes_stream) - 流式请求超时提升至 300s,转发 SSE headers (Cache-Control, Connection) - 添加 futures 依赖用于 StreamExt Phase 1.3: 滑动窗口速率限制中间件 - 按 account_id 做 per-minute 限流 (默认 60 rpm + 10 burst) - 超限返回 429 + Retry-After header - RateLimitConfig 支持配置化,DashMap 存储时间戳 21 tests passed, zero warnings. --- Cargo.lock | 1 + crates/zclaw-saas/Cargo.toml | 1 + crates/zclaw-saas/src/auth/handlers.rs | 24 ++++++- crates/zclaw-saas/src/auth/mod.rs | 87 ++++++++++++++++++++++--- crates/zclaw-saas/src/config.rs | 26 ++++++++ crates/zclaw-saas/src/lib.rs | 1 + crates/zclaw-saas/src/main.rs | 4 ++ crates/zclaw-saas/src/middleware.rs | 81 +++++++++++++++++++++++ crates/zclaw-saas/src/relay/handlers.rs | 10 ++- crates/zclaw-saas/src/relay/service.rs | 11 +++- crates/zclaw-saas/src/state.rs | 4 ++ 11 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 crates/zclaw-saas/src/middleware.rs diff --git a/Cargo.lock b/Cargo.lock index 31ae41a..4683d08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7432,6 +7432,7 @@ dependencies = [ "axum-extra", "chrono", "dashmap", + "futures", "hex", "jsonwebtoken", "libsqlite3-sys", diff --git a/crates/zclaw-saas/Cargo.toml b/crates/zclaw-saas/Cargo.toml index 42a12e8..940cc48 100644 --- a/crates/zclaw-saas/Cargo.toml +++ b/crates/zclaw-saas/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" zclaw-types = { workspace = true } tokio = { workspace = true } +futures = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } diff --git a/crates/zclaw-saas/src/auth/handlers.rs b/crates/zclaw-saas/src/auth/handlers.rs index 8e7663f..1212523 100644 --- a/crates/zclaw-saas/src/auth/handlers.rs +++ b/crates/zclaw-saas/src/auth/handlers.rs @@ -136,7 +136,29 @@ pub async fn refresh( Ok(Json(serde_json::json!({ "token": token }))) } -async fn get_role_permissions(db: &sqlx::SqlitePool, role: &str) -> SaasResult> { +/// GET /api/v1/auth/me — 返回当前认证用户的公开信息 +pub async fn me( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, +) -> SaasResult> { + let row: Option<(String, String, String, String, String, String, bool, String)> = + sqlx::query_as( + "SELECT id, username, email, display_name, role, status, totp_enabled, created_at + FROM accounts WHERE id = ?1" + ) + .bind(&ctx.account_id) + .fetch_optional(&state.db) + .await?; + + let (id, username, email, display_name, role, status, totp_enabled, created_at) = + row.ok_or_else(|| SaasError::NotFound("账号不存在".into()))?; + + Ok(Json(AccountPublic { + id, username, email, display_name, role, status, totp_enabled, created_at, + })) +} + +pub(crate) async fn get_role_permissions(db: &sqlx::SqlitePool, role: &str) -> SaasResult> { let row: Option<(String,)> = sqlx::query_as( "SELECT permissions FROM roles WHERE id = ?1" ) diff --git a/crates/zclaw-saas/src/auth/mod.rs b/crates/zclaw-saas/src/auth/mod.rs index e7c6bec..49b9a84 100644 --- a/crates/zclaw-saas/src/auth/mod.rs +++ b/crates/zclaw-saas/src/auth/mod.rs @@ -16,6 +16,70 @@ use crate::error::SaasError; use crate::state::AppState; use types::AuthContext; +/// 通过 API Token 验证身份 +/// +/// 流程: SHA-256 哈希 → 查 api_tokens 表 → 检查有效期 → 获取关联账号角色权限 → 更新 last_used_at +async fn verify_api_token(state: &AppState, raw_token: &str) -> Result { + use sha2::{Sha256, Digest}; + + let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes())); + + let row: Option<(String, Option, String)> = sqlx::query_as( + "SELECT account_id, expires_at, permissions FROM api_tokens + WHERE token_hash = ?1 AND revoked_at IS NULL" + ) + .bind(&token_hash) + .fetch_optional(&state.db) + .await?; + + let (account_id, expires_at, permissions_json) = row + .ok_or(SaasError::Unauthorized)?; + + // 检查是否过期 + if let Some(ref exp) = expires_at { + let now = chrono::Utc::now(); + if let Ok(exp_time) = chrono::DateTime::parse_from_rfc3339(exp) { + if now >= exp_time.with_timezone(&chrono::Utc) { + return Err(SaasError::Unauthorized); + } + } + } + + // 查询关联账号的角色 + let (role,): (String,) = sqlx::query_as( + "SELECT role FROM accounts WHERE id = ?1 AND status = 'active'" + ) + .bind(&account_id) + .fetch_optional(&state.db) + .await? + .ok_or(SaasError::Unauthorized)?; + + // 合并 token 权限与角色权限(去重) + let role_permissions = handlers::get_role_permissions(&state.db, &role).await?; + let token_permissions: Vec = serde_json::from_str(&permissions_json).unwrap_or_default(); + let mut permissions = role_permissions; + for p in token_permissions { + if !permissions.contains(&p) { + permissions.push(p); + } + } + + // 异步更新 last_used_at(不阻塞请求) + let db = state.db.clone(); + tokio::spawn(async move { + let now = chrono::Utc::now().to_rfc3339(); + let _ = sqlx::query("UPDATE api_tokens SET last_used_at = ?1 WHERE token_hash = ?2") + .bind(&now).bind(&token_hash) + .execute(&db).await; + }); + + Ok(AuthContext { + account_id, + role, + permissions, + }) +} + /// 认证中间件: 从 JWT 或 API Token 提取身份 pub async fn auth_middleware( State(state): State, @@ -28,13 +92,19 @@ pub async fn auth_middleware( let result = if let Some(auth) = auth_header { if let Some(token) = auth.strip_prefix("Bearer ") { - jwt::verify_token(token, state.jwt_secret.expose_secret()) - .map(|claims| AuthContext { - account_id: claims.sub, - role: claims.role, - permissions: claims.permissions, - }) - .map_err(|_| SaasError::Unauthorized) + if token.starts_with("zclaw_") { + // API Token 路径 + verify_api_token(&state, token).await + } else { + // JWT 路径 + jwt::verify_token(token, state.jwt_secret.expose_secret()) + .map(|claims| AuthContext { + account_id: claims.sub, + role: claims.role, + permissions: claims.permissions, + }) + .map_err(|_| SaasError::Unauthorized) + } } else { Err(SaasError::Unauthorized) } @@ -62,8 +132,9 @@ pub fn routes() -> axum::Router { /// 需要认证的路由 pub fn protected_routes() -> axum::Router { - use axum::routing::post; + use axum::routing::{get, post}; axum::Router::new() .route("/api/v1/auth/refresh", post(handlers::refresh)) + .route("/api/v1/auth/me", get(handlers::me)) } diff --git a/crates/zclaw-saas/src/config.rs b/crates/zclaw-saas/src/config.rs index c987235..1261c4c 100644 --- a/crates/zclaw-saas/src/config.rs +++ b/crates/zclaw-saas/src/config.rs @@ -11,6 +11,8 @@ pub struct SaaSConfig { pub database: DatabaseConfig, pub auth: AuthConfig, pub relay: RelayConfig, + #[serde(default)] + pub rate_limit: RateLimitConfig, } /// 服务器配置 @@ -66,6 +68,29 @@ fn default_batch_window() -> u64 { 50 } fn default_retry_delay() -> u64 { 1000 } fn default_max_attempts() -> u32 { 3 } +/// 速率限制配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RateLimitConfig { + /// 每分钟最大请求数 (滑动窗口) + #[serde(default = "default_rpm")] + pub requests_per_minute: u32, + /// 突发允许的额外请求数 + #[serde(default = "default_burst")] + pub burst: u32, +} + +fn default_rpm() -> u32 { 60 } +fn default_burst() -> u32 { 10 } + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + requests_per_minute: default_rpm(), + burst: default_burst(), + } + } +} + impl Default for SaaSConfig { fn default() -> Self { Self { @@ -73,6 +98,7 @@ impl Default for SaaSConfig { database: DatabaseConfig::default(), auth: AuthConfig::default(), relay: RelayConfig::default(), + rate_limit: RateLimitConfig::default(), } } } diff --git a/crates/zclaw-saas/src/lib.rs b/crates/zclaw-saas/src/lib.rs index 89eca2b..def02c0 100644 --- a/crates/zclaw-saas/src/lib.rs +++ b/crates/zclaw-saas/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod db; pub mod error; +pub mod middleware; pub mod state; pub mod auth; diff --git a/crates/zclaw-saas/src/main.rs b/crates/zclaw-saas/src/main.rs index 4ed16b3..53b22c3 100644 --- a/crates/zclaw-saas/src/main.rs +++ b/crates/zclaw-saas/src/main.rs @@ -68,6 +68,10 @@ fn build_router(state: AppState) -> axum::Router { .merge(zclaw_saas::model_config::routes()) .merge(zclaw_saas::relay::routes()) .merge(zclaw_saas::migration::routes()) + .layer(middleware::from_fn_with_state( + state.clone(), + zclaw_saas::middleware::rate_limit_middleware, + )) .layer(middleware::from_fn_with_state( state.clone(), zclaw_saas::auth::auth_middleware, diff --git a/crates/zclaw-saas/src/middleware.rs b/crates/zclaw-saas/src/middleware.rs new file mode 100644 index 0000000..170552f --- /dev/null +++ b/crates/zclaw-saas/src/middleware.rs @@ -0,0 +1,81 @@ +//! 通用中间件 + +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, +}; +use std::time::Instant; + +use crate::state::AppState; + +/// 滑动窗口速率限制中间件 +/// +/// 按 account_id (从 AuthContext 提取) 做 per-minute 限流。 +/// 超限时返回 429 Too Many Requests + Retry-After header。 +pub async fn rate_limit_middleware( + State(state): State, + req: Request, + next: Next, +) -> Response { + // 从 AuthContext 提取 account_id(由 auth_middleware 在此之前注入) + let account_id = req + .extensions() + .get::() + .map(|ctx| ctx.account_id.clone()); + + let account_id = match account_id { + Some(id) => id, + None => return next.run(req).await, + }; + + let config = state.config.read().await; + let rpm = config.rate_limit.requests_per_minute as u64; + let burst = config.rate_limit.burst as u64; + let max_requests = rpm + burst; + drop(config); + + let now = Instant::now(); + let window_start = now - std::time::Duration::from_secs(60); + + // 滑动窗口: 清理过期条目 + 计数 + let current_count = { + let mut entries = state.rate_limit_entries.entry(account_id.clone()).or_default(); + entries.retain(|&ts| ts > window_start); + let count = entries.len() as u64; + if count < max_requests { + entries.push(now); + 0 // 未超限 + } else { + count + } + }; + + if current_count >= max_requests { + // 计算最早条目的过期时间作为 Retry-After + let retry_after = if let Some(mut entries) = state.rate_limit_entries.get_mut(&account_id) { + entries.sort(); + let earliest = *entries.first().unwrap_or(&now); + let elapsed = now.duration_since(earliest).as_secs(); + 60u64.saturating_sub(elapsed) + } else { + 60 + }; + + return ( + StatusCode::TOO_MANY_REQUESTS, + [ + ("Retry-After", retry_after.to_string()), + ("Content-Type", "application/json".to_string()), + ], + axum::Json(serde_json::json!({ + "error": "RATE_LIMITED", + "message": format!("请求过于频繁,请在 {} 秒后重试", retry_after), + })), + ) + .into_response(); + } + + next.run(req).await +} diff --git a/crates/zclaw-saas/src/relay/handlers.rs b/crates/zclaw-saas/src/relay/handlers.rs index 94efe43..35b15e5 100644 --- a/crates/zclaw-saas/src/relay/handlers.rs +++ b/crates/zclaw-saas/src/relay/handlers.rs @@ -96,7 +96,15 @@ pub async fn chat_completions( None, "success", None, ).await?; - Ok((StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/event-stream")], body).into_response()) + // 流式响应: 直接转发 axum::body::Body + let response = axum::response::Response::builder() + .status(StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, "text/event-stream") + .header("Cache-Control", "no-cache") + .header("Connection", "keep-alive") + .body(body) + .unwrap(); + Ok(response) } Err(e) => { model_service::record_usage( diff --git a/crates/zclaw-saas/src/relay/service.rs b/crates/zclaw-saas/src/relay/service.rs index bcac77a..e2c9089 100644 --- a/crates/zclaw-saas/src/relay/service.rs +++ b/crates/zclaw-saas/src/relay/service.rs @@ -3,6 +3,7 @@ use sqlx::SqlitePool; use crate::error::{SaasError, SaasResult}; use super::types::*; +use futures::StreamExt; // ============ Relay Task Management ============ @@ -127,7 +128,7 @@ pub async fn execute_relay( let _start = std::time::Instant::now(); let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs(if stream { 300 } else { 30 })) .build() .map_err(|e| SaasError::Internal(format!("HTTP 客户端构建失败: {}", e)))?; let mut req_builder = client.post(&url) @@ -143,7 +144,11 @@ pub async fn execute_relay( match result { Ok(resp) if resp.status().is_success() => { if stream { - let body = resp.text().await.unwrap_or_default(); + // 真实 SSE 流式: 使用 bytes_stream 而非 text().await 缓冲 + let stream = resp.bytes_stream() + .map(|result| result.map_err(std::io::Error::other)); + let body = axum::body::Body::from_stream(stream); + // 流式模式下无法提取 token usage,标记为 completed (usage=0) update_task_status(db, task_id, "completed", None, None, None).await?; Ok(RelayResponse::Sse(body)) } else { @@ -173,7 +178,7 @@ pub async fn execute_relay( #[derive(Debug)] pub enum RelayResponse { Json(String), - Sse(String), + Sse(axum::body::Body), } // ============ Helpers ============ diff --git a/crates/zclaw-saas/src/state.rs b/crates/zclaw-saas/src/state.rs index 6f2a78c..85bb85e 100644 --- a/crates/zclaw-saas/src/state.rs +++ b/crates/zclaw-saas/src/state.rs @@ -2,6 +2,7 @@ use sqlx::SqlitePool; use std::sync::Arc; +use std::time::Instant; use tokio::sync::RwLock; use crate::config::SaaSConfig; @@ -14,6 +15,8 @@ pub struct AppState { pub config: Arc>, /// JWT 密钥 pub jwt_secret: secrecy::SecretString, + /// 速率限制: account_id → 请求时间戳列表 + pub rate_limit_entries: Arc>>, } impl AppState { @@ -23,6 +26,7 @@ impl AppState { db, config: Arc::new(RwLock::new(config)), jwt_secret, + rate_limit_entries: Arc::new(dashmap::DashMap::new()), }) } } From a66b675675d0fb25ed7b93293b5219ef043e1de1 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 14:06:50 +0800 Subject: [PATCH 09/14] =?UTF-8?q?feat(saas):=20Phase=202=20Admin=20Web=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=20=E2=80=94=20=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=20CRUD=20+=20Dashboard=20=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 添加 GET /api/v1/stats/dashboard 聚合统计端点 (账号数/活跃服务商/今日请求/今日Token用量等7项指标) - 需要 account:admin 权限 Admin 前端 (Next.js 14 + shadcn/ui + Tailwind + Recharts): - 设计系统: Dark Mode OLED (#020617 背景, #22C55E CTA) - 登录页: 双栏布局, 品牌区 + 表单 - Dashboard 布局: Sidebar 导航 + Header + 主内容区 - 仪表盘: 4 统计卡片 + AreaChart 请求趋势 + BarChart Token用量 - 8 个 CRUD 页面: - 账号管理 (搜索/角色/状态筛选, 编辑/启用禁用) - 服务商 (CRUD + API Key masked) - 模型管理 (Provider筛选, CRUD) - API 密钥 (创建/撤销, 一次性显示token) - 用量统计 (LineChart + BarChart) - 中转任务 (状态筛选, 展开详情) - 系统配置 (分类Tab, 编辑) - 操作日志 (Action筛选, 展开详情) - 14 个 shadcn 风格 UI 组件 (手写实现) - 类型化 API 客户端 (SaaSClient, 20+ 方法, 401 自动跳转) - AuthGuard 路由保护 + useAuth() hook 验证: tsc --noEmit 零 error, pnpm build 13 页面成功, cargo test 21 通过 --- admin/.gitignore | 2 + admin/next-env.d.ts | 5 + admin/next.config.js | 4 + admin/package.json | 37 + admin/pnpm-lock.yaml | 2171 ++++++++++++++++++ admin/postcss.config.js | 6 + admin/src/app/(dashboard)/accounts/page.tsx | 393 ++++ admin/src/app/(dashboard)/api-keys/page.tsx | 351 +++ admin/src/app/(dashboard)/config/page.tsx | 270 +++ admin/src/app/(dashboard)/layout.tsx | 218 ++ admin/src/app/(dashboard)/models/page.tsx | 436 ++++ admin/src/app/(dashboard)/page.tsx | 336 +++ admin/src/app/(dashboard)/providers/page.tsx | 386 ++++ admin/src/app/(dashboard)/relay/page.tsx | 245 ++ admin/src/app/(dashboard)/usage/page.tsx | 235 ++ admin/src/app/globals.css | 66 + admin/src/app/layout.tsx | 27 + admin/src/app/login/page.tsx | 199 ++ admin/src/components/auth-guard.tsx | 48 + admin/src/components/ui/badge.tsx | 42 + admin/src/components/ui/button.tsx | 56 + admin/src/components/ui/card.tsx | 75 + admin/src/components/ui/dialog.tsx | 118 + admin/src/components/ui/input.tsx | 28 + admin/src/components/ui/label.tsx | 23 + admin/src/components/ui/select.tsx | 100 + admin/src/components/ui/separator.tsx | 30 + admin/src/components/ui/switch.tsx | 32 + admin/src/components/ui/table.tsx | 119 + admin/src/components/ui/tabs.tsx | 57 + admin/src/components/ui/tooltip.tsx | 31 + admin/src/lib/api-client.ts | 284 +++ admin/src/lib/auth.ts | 45 + admin/src/lib/types.ts | 169 ++ admin/src/lib/utils.ts | 34 + admin/tailwind.config.ts | 62 + admin/tsconfig.json | 21 + crates/zclaw-saas/src/account/handlers.rs | 36 + crates/zclaw-saas/src/account/mod.rs | 1 + 39 files changed, 6798 insertions(+) create mode 100644 admin/.gitignore create mode 100644 admin/next-env.d.ts create mode 100644 admin/next.config.js create mode 100644 admin/package.json create mode 100644 admin/pnpm-lock.yaml create mode 100644 admin/postcss.config.js create mode 100644 admin/src/app/(dashboard)/accounts/page.tsx create mode 100644 admin/src/app/(dashboard)/api-keys/page.tsx create mode 100644 admin/src/app/(dashboard)/config/page.tsx create mode 100644 admin/src/app/(dashboard)/layout.tsx create mode 100644 admin/src/app/(dashboard)/models/page.tsx create mode 100644 admin/src/app/(dashboard)/page.tsx create mode 100644 admin/src/app/(dashboard)/providers/page.tsx create mode 100644 admin/src/app/(dashboard)/relay/page.tsx create mode 100644 admin/src/app/(dashboard)/usage/page.tsx create mode 100644 admin/src/app/globals.css create mode 100644 admin/src/app/layout.tsx create mode 100644 admin/src/app/login/page.tsx create mode 100644 admin/src/components/auth-guard.tsx create mode 100644 admin/src/components/ui/badge.tsx create mode 100644 admin/src/components/ui/button.tsx create mode 100644 admin/src/components/ui/card.tsx create mode 100644 admin/src/components/ui/dialog.tsx create mode 100644 admin/src/components/ui/input.tsx create mode 100644 admin/src/components/ui/label.tsx create mode 100644 admin/src/components/ui/select.tsx create mode 100644 admin/src/components/ui/separator.tsx create mode 100644 admin/src/components/ui/switch.tsx create mode 100644 admin/src/components/ui/table.tsx create mode 100644 admin/src/components/ui/tabs.tsx create mode 100644 admin/src/components/ui/tooltip.tsx create mode 100644 admin/src/lib/api-client.ts create mode 100644 admin/src/lib/auth.ts create mode 100644 admin/src/lib/types.ts create mode 100644 admin/src/lib/utils.ts create mode 100644 admin/tailwind.config.ts create mode 100644 admin/tsconfig.json diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..5b3ad33 --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,2 @@ +.next/ +node_modules/ diff --git a/admin/next-env.d.ts b/admin/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/admin/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/admin/next.config.js b/admin/next.config.js new file mode 100644 index 0000000..767719f --- /dev/null +++ b/admin/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..c33c6ed --- /dev/null +++ b/admin/package.json @@ -0,0 +1,37 @@ +{ + "name": "zclaw-admin", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.7", + "@radix-ui/react-separator": "^1.1.7", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.484.0", + "next": "14.2.29", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^2.15.3", + "tailwind-merge": "^3.0.2" + }, + "devDependencies": { + "@types/node": "^20.17.19", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3" + }, + "packageManager": "pnpm@10.30.2" +} diff --git a/admin/pnpm-lock.yaml b/admin/pnpm-lock.yaml new file mode 100644 index 0000000..2f8b4ef --- /dev/null +++ b/admin/pnpm-lock.yaml @@ -0,0 +1,2171 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.14 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': + specifier: ^2.2.5 + version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': + specifier: ^1.1.7 + version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-switch': + specifier: ^1.2.5 + version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.1.12 + version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.7 + version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^0.484.0 + version: 0.484.0(react@18.3.1) + next: + specifier: 14.2.29 + version: 14.2.29(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + recharts: + specifier: ^2.15.3 + version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tailwind-merge: + specifier: ^3.0.2 + version: 3.5.0 + devDependencies: + '@types/node': + specifier: ^20.17.19 + version: 20.19.37 + '@types/react': + specifier: ^18.3.18 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.5 + version: 18.3.7(@types/react@18.3.28) + autoprefixer: + specifier: ^10.4.20 + version: 10.4.27(postcss@8.5.8) + postcss: + specifier: ^8.5.3 + version: 8.5.8 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.19 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@next/env@14.2.29': + resolution: {integrity: sha512-UzgLR2eBfhKIQt0aJ7PWH7XRPYw7SXz0Fpzdl5THjUnvxy4kfBk9OU4RNPNiETewEEtaBcExNFNn1QWH8wQTjg==} + + '@next/swc-darwin-arm64@14.2.29': + resolution: {integrity: sha512-wWtrAaxCVMejxPHFb1SK/PVV1WDIrXGs9ki0C/kUM8ubKHQm+3hU9MouUywCw8Wbhj3pewfHT2wjunLEr/TaLA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@14.2.29': + resolution: {integrity: sha512-7Z/jk+6EVBj4pNLw/JQrvZVrAh9Bv8q81zCFSfvTMZ51WySyEHWVpwCEaJY910LyBftv2F37kuDPQm0w9CEXyg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@14.2.29': + resolution: {integrity: sha512-o6hrz5xRBwi+G7JFTHc+RUsXo2lVXEfwh4/qsuWBMQq6aut+0w98WEnoNwAwt7hkEqegzvazf81dNiwo7KjITw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@14.2.29': + resolution: {integrity: sha512-9i+JEHBOVgqxQ92HHRFlSW1EQXqa/89IVjtHgOqsShCcB/ZBjTtkWGi+SGCJaYyWkr/lzu51NTMCfKuBf7ULNw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@14.2.29': + resolution: {integrity: sha512-B7JtMbkUwHijrGBOhgSQu2ncbCYq9E7PZ7MX58kxheiEOwdkM+jGx0cBb+rN5AeqF96JypEppK6i/bEL9T13lA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@14.2.29': + resolution: {integrity: sha512-yCcZo1OrO3aQ38B5zctqKU1Z3klOohIxug6qdiKO3Q3qNye/1n6XIs01YJ+Uf+TdpZQ0fNrOQI2HrTLF3Zprnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@14.2.29': + resolution: {integrity: sha512-WnrfeOEtTVidI9Z6jDLy+gxrpDcEJtZva54LYC0bSKQqmyuHzl0ego+v0F/v2aXq0am67BRqo/ybmmt45Tzo4A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-ia32-msvc@14.2.29': + resolution: {integrity: sha512-vkcriFROT4wsTdSeIzbxaZjTNTFKjSYmLd8q/GVH3Dn8JmYjUKOuKXHK8n+lovW/kdcpIvydO5GtN+It2CvKWA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@next/swc-win32-x64-msvc@14.2.29': + resolution: {integrity: sha512-iPPwUEKnVs7pwR0EBLJlwxLD7TTHWS/AoVZx1l9ZQzfQciqaFEr5AlYzA2uB6Fyby1IF18t4PL0nTpB+k4Tzlw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.5': + resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/node@20.19.37': + resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.10.11: + resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + electron-to-chromium@1.5.327: + resolution: {integrity: sha512-hLxLdIJDf8zIzKoH2TPCs+Botc+wUmj9sp4jVMwklY/sKleM8xxxOExRX3Gxj73nCXmJe3anhG7SvsDDPDvmuQ==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lucide-react@0.484.0: + resolution: {integrity: sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@14.2.29: + resolution: {integrity: sha512-s98mCOMOWLGGpGOfgKSnleXLuegvvH415qtRZXpSp00HeEgdmrxmwL9cgKU+h4XrhB16zEI5d/7BnkS3ATInsA==} + engines: {node: '>=18.17.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + sass: + optional: true + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + styled-jsx@5.1.1: + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/runtime@7.29.2': {} + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/utils@0.2.11': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@next/env@14.2.29': {} + + '@next/swc-darwin-arm64@14.2.29': + optional: true + + '@next/swc-darwin-x64@14.2.29': + optional: true + + '@next/swc-linux-arm64-gnu@14.2.29': + optional: true + + '@next/swc-linux-arm64-musl@14.2.29': + optional: true + + '@next/swc-linux-x64-gnu@14.2.29': + optional: true + + '@next/swc-linux-x64-musl@14.2.29': + optional: true + + '@next/swc-win32-arm64-msvc@14.2.29': + optional: true + + '@next/swc-win32-ia32-msvc@14.2.29': + optional: true + + '@next/swc-win32-x64-msvc@14.2.29': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/rect@1.1.1': {} + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.5': + dependencies: + '@swc/counter': 0.1.3 + tslib: 2.8.1 + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/node@20.19.37': + dependencies: + undici-types: 6.21.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + autoprefixer@10.4.27(postcss@8.5.8): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001781 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.10.11: {} + + binary-extensions@2.3.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.11 + caniuse-lite: 1.0.30001781 + electron-to-chromium: 1.5.327 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001781: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + commander@4.1.1: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + decimal.js-light@2.5.1: {} + + detect-node-es@1.1.0: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.2 + csstype: 3.2.3 + + electron-to-chromium@1.5.327: {} + + escalade@3.2.0: {} + + eventemitter3@4.0.7: {} + + fast-equals@5.4.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-nonce@1.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + graceful-fs@4.2.11: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + internmap@2.0.3: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lodash@4.17.23: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lucide-react@0.484.0(react@18.3.1): + dependencies: + react: 18.3.1 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + next@14.2.29(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@next/env': 14.2.29 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001781 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.1(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.29 + '@next/swc-darwin-x64': 14.2.29 + '@next/swc-linux-arm64-gnu': 14.2.29 + '@next/swc-linux-arm64-musl': 14.2.29 + '@next/swc-linux-x64-gnu': 14.2.29 + '@next/swc-linux-x64-musl': 14.2.29 + '@next/swc-win32-arm64-msvc': 14.2.29 + '@next/swc-win32-ia32-msvc': 14.2.29 + '@next/swc-win32-x64-msvc': 14.2.29 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-releases@2.0.36: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.11 + + postcss-js@4.1.0(postcss@8.5.8): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.8 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.8 + + postcss-nested@6.2.0(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + react-remove-scroll@2.7.2(@types/react@18.3.28)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.28)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.28)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + + react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + fast-equals: 5.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + + react-style-singleton@2.2.3(@types/react@18.3.28)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.2 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.23 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + source-map-js@1.2.1: {} + + streamsearch@1.1.0: {} + + styled-jsx@5.1.1(react@18.3.1): + dependencies: + client-only: 0.0.1 + react: 18.3.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-import: 15.1.0(postcss@8.5.8) + postcss-js: 4.1.0(postcss@8.5.8) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8) + postcss-nested: 6.2.0(postcss@8.5.8) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tiny-invariant@1.3.3: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@18.3.28)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + use-sidecar@1.1.3(@types/react@18.3.28)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + util-deprecate@1.0.2: {} + + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 diff --git a/admin/postcss.config.js b/admin/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/admin/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/admin/src/app/(dashboard)/accounts/page.tsx b/admin/src/app/(dashboard)/accounts/page.tsx new file mode 100644 index 0000000..91e1a01 --- /dev/null +++ b/admin/src/app/(dashboard)/accounts/page.tsx @@ -0,0 +1,393 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { + Search, + Plus, + Loader2, + ChevronLeft, + ChevronRight, + Pencil, + Ban, + CheckCircle2, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { api } from '@/lib/api-client' +import { ApiRequestError } from '@/lib/api-client' +import { formatDate } from '@/lib/utils' +import type { AccountPublic } from '@/lib/types' + +const PAGE_SIZE = 20 + +const roleLabels: Record = { + super_admin: '超级管理员', + admin: '管理员', + user: '普通用户', +} + +const statusColors: Record = { + active: 'success', + disabled: 'destructive', + suspended: 'warning', +} + +const statusLabels: Record = { + active: '正常', + disabled: '已禁用', + suspended: '已暂停', +} + +export default function AccountsPage() { + const [accounts, setAccounts] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [search, setSearch] = useState('') + const [roleFilter, setRoleFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + // 编辑 Dialog + const [editTarget, setEditTarget] = useState(null) + const [editForm, setEditForm] = useState({ display_name: '', email: '', role: 'user' }) + const [editSaving, setEditSaving] = useState(false) + + // 确认 Dialog + const [confirmTarget, setConfirmTarget] = useState<{ id: string; action: string; status: string } | null>(null) + const [confirmSaving, setConfirmSaving] = useState(false) + + const fetchAccounts = useCallback(async () => { + setLoading(true) + setError('') + try { + const params: Record = { page, page_size: PAGE_SIZE } + if (search.trim()) params.search = search.trim() + if (roleFilter !== 'all') params.role = roleFilter + if (statusFilter !== 'all') params.status = statusFilter + + const res = await api.accounts.list(params) + setAccounts(res.items) + setTotal(res.total) + } catch (err) { + if (err instanceof ApiRequestError) { + setError(err.body.message) + } else { + setError('加载失败') + } + } finally { + setLoading(false) + } + }, [page, search, roleFilter, statusFilter]) + + useEffect(() => { + fetchAccounts() + }, [fetchAccounts]) + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + function openEditDialog(account: AccountPublic) { + setEditTarget(account) + setEditForm({ + display_name: account.display_name, + email: account.email, + role: account.role, + }) + } + + async function handleEditSave() { + if (!editTarget) return + setEditSaving(true) + try { + await api.accounts.update(editTarget.id, { + display_name: editForm.display_name, + email: editForm.email, + role: editForm.role as AccountPublic['role'], + }) + setEditTarget(null) + fetchAccounts() + } catch (err) { + if (err instanceof ApiRequestError) { + setError(err.body.message) + } + } finally { + setEditSaving(false) + } + } + + function openConfirmDialog(account: AccountPublic) { + const newStatus = account.status === 'active' ? 'disabled' : 'active' + setConfirmTarget({ + id: account.id, + action: newStatus === 'disabled' ? '禁用' : '启用', + status: newStatus, + }) + } + + async function handleConfirmSave() { + if (!confirmTarget) return + setConfirmSaving(true) + try { + await api.accounts.updateStatus(confirmTarget.id, { + status: confirmTarget.status as AccountPublic['status'], + }) + setConfirmTarget(null) + fetchAccounts() + } catch (err) { + if (err instanceof ApiRequestError) { + setError(err.body.message) + } + } finally { + setConfirmSaving(false) + } + } + + return ( +
+ {/* 搜索和筛选 */} +
+
+ + { setSearch(e.target.value); setPage(1) }} + className="pl-10" + /> +
+ + +
+ + {/* 错误提示 */} + {error && ( +
+ {error} + +
+ )} + + {/* 表格 */} + {loading ? ( +
+ +
+ ) : accounts.length === 0 ? ( +
+ 暂无数据 +
+ ) : ( + <> + + + + 用户名 + 邮箱 + 显示名 + 角色 + 状态 + 创建时间 + 操作 + + + + {accounts.map((account) => ( + + {account.username} + {account.email} + {account.display_name || '-'} + + + {roleLabels[account.role] || account.role} + + + + + + {statusLabels[account.status] || account.status} + + + + {formatDate(account.created_at)} + + +
+ + +
+
+
+ ))} +
+
+ + {/* 分页 */} +
+

+ 第 {page} 页 / 共 {totalPages} 页 ({total} 条) +

+
+ + +
+
+ + )} + + {/* 编辑 Dialog */} + setEditTarget(null)}> + + + 编辑账号 + 修改账号信息 + +
+
+ + setEditForm({ ...editForm, display_name: e.target.value })} + /> +
+
+ + setEditForm({ ...editForm, email: e.target.value })} + /> +
+
+ + +
+
+ + + + +
+
+ + {/* 确认 Dialog */} + setConfirmTarget(null)}> + + + 确认{confirmTarget?.action} + + 确定要{confirmTarget?.action}该账号吗?此操作将立即生效。 + + + + + + + + +
+ ) +} diff --git a/admin/src/app/(dashboard)/api-keys/page.tsx b/admin/src/app/(dashboard)/api-keys/page.tsx new file mode 100644 index 0000000..4da1e61 --- /dev/null +++ b/admin/src/app/(dashboard)/api-keys/page.tsx @@ -0,0 +1,351 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { + Plus, + Loader2, + ChevronLeft, + ChevronRight, + Trash2, + Copy, + Check, + AlertTriangle, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog' +import { api } from '@/lib/api-client' +import { ApiRequestError } from '@/lib/api-client' +import { formatDate } from '@/lib/utils' +import type { TokenInfo } from '@/lib/types' + +const PAGE_SIZE = 20 + +const allPermissions = [ + { key: 'chat', label: '对话' }, + { key: 'relay', label: '中转' }, + { key: 'admin', label: '管理' }, +] + +export default function ApiKeysPage() { + const [tokens, setTokens] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + // 创建 Dialog + const [createOpen, setCreateOpen] = useState(false) + const [createForm, setCreateForm] = useState({ name: '', expires_days: '', permissions: ['chat'] as string[] }) + const [creating, setCreating] = useState(false) + + // 创建成功显示 token + const [createdToken, setCreatedToken] = useState(null) + const [copied, setCopied] = useState(false) + + // 撤销确认 + const [revokeTarget, setRevokeTarget] = useState(null) + const [revoking, setRevoking] = useState(false) + + const fetchTokens = useCallback(async () => { + setLoading(true) + setError('') + try { + const res = await api.tokens.list({ page, page_size: PAGE_SIZE }) + setTokens(res.items) + setTotal(res.total) + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + else setError('加载失败') + } finally { + setLoading(false) + } + }, [page]) + + useEffect(() => { + fetchTokens() + }, [fetchTokens]) + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + function togglePermission(perm: string) { + setCreateForm((prev) => ({ + ...prev, + permissions: prev.permissions.includes(perm) + ? prev.permissions.filter((p) => p !== perm) + : [...prev.permissions, perm], + })) + } + + async function handleCreate() { + if (!createForm.name.trim() || createForm.permissions.length === 0) return + setCreating(true) + try { + const payload = { + name: createForm.name.trim(), + expires_days: createForm.expires_days ? parseInt(createForm.expires_days, 10) : undefined, + permissions: createForm.permissions, + } + const res = await api.tokens.create(payload) + setCreateOpen(false) + setCreatedToken(res) + setCreateForm({ name: '', expires_days: '', permissions: ['chat'] }) + fetchTokens() + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + } finally { + setCreating(false) + } + } + + async function handleRevoke() { + if (!revokeTarget) return + setRevoking(true) + try { + await api.tokens.revoke(revokeTarget.id) + setRevokeTarget(null) + fetchTokens() + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + } finally { + setRevoking(false) + } + } + + async function copyToken() { + if (!createdToken?.token) return + try { + await navigator.clipboard.writeText(createdToken.token) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Fallback + const textarea = document.createElement('textarea') + textarea.value = createdToken.token + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + return ( +
+
+
+ +
+ + {error && ( +
+ {error} + +
+ )} + + {loading ? ( +
+ +
+ ) : tokens.length === 0 ? ( +
+ 暂无数据 +
+ ) : ( + <> + + + + 名称 + 前缀 + 权限 + 最后使用 + 过期时间 + 创建时间 + 操作 + + + + {tokens.map((t) => ( + + {t.name} + + {t.token_prefix}... + + +
+ {t.permissions.map((p) => ( + + {p} + + ))} +
+
+ + {t.last_used_at ? formatDate(t.last_used_at) : '未使用'} + + + {t.expires_at ? formatDate(t.expires_at) : '永不过期'} + + + {formatDate(t.created_at)} + + + + +
+ ))} +
+
+ +
+

+ 第 {page} 页 / 共 {totalPages} 页 ({total} 条) +

+
+ + +
+
+ + )} + + {/* 创建 Dialog */} + + + + 新建 API 密钥 + 创建新的 API 密钥用于接口调用 + +
+
+ + setCreateForm({ ...createForm, name: e.target.value })} + placeholder="例如: 生产环境" + /> +
+
+ + setCreateForm({ ...createForm, expires_days: e.target.value })} + placeholder="365" + /> +
+
+ +
+ {allPermissions.map((perm) => ( + + ))} +
+
+
+ + + + +
+
+ + {/* 创建成功 Dialog */} + setCreatedToken(null)}> + + + + + 密钥已创建 + + + 请立即复制并安全保存此密钥,关闭后将无法再次查看完整密钥。 + + +
+
+

完整密钥

+

+ {createdToken?.token} +

+
+
+ 此密钥仅显示一次。请确保已保存到安全的位置。 +
+
+ + + + +
+
+ + {/* 撤销确认 */} + setRevokeTarget(null)}> + + + 确认撤销 + + 确定要撤销密钥 "{revokeTarget?.name}" 吗?使用此密钥的应用将立即失去访问权限。 + + + + + + + + +
+ ) +} diff --git a/admin/src/app/(dashboard)/config/page.tsx b/admin/src/app/(dashboard)/config/page.tsx new file mode 100644 index 0000000..204d257 --- /dev/null +++ b/admin/src/app/(dashboard)/config/page.tsx @@ -0,0 +1,270 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { + Loader2, + Pencil, + RotateCcw, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { api } from '@/lib/api-client' +import { ApiRequestError } from '@/lib/api-client' +import type { ConfigItem } from '@/lib/types' + +const sourceLabels: Record = { + default: '默认值', + env: '环境变量', + db: '数据库', +} + +const sourceVariants: Record = { + default: 'secondary', + env: 'info', + db: 'default', +} + +export default function ConfigPage() { + const [configs, setConfigs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [activeTab, setActiveTab] = useState('all') + + // 编辑 Dialog + const [editTarget, setEditTarget] = useState(null) + const [editValue, setEditValue] = useState('') + const [saving, setSaving] = useState(false) + + const fetchConfigs = useCallback(async (category?: string) => { + setLoading(true) + setError('') + try { + const params: Record = {} + if (category && category !== 'all') params.category = category + const res = await api.config.list(params) + setConfigs(res) + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + else setError('加载失败') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchConfigs(activeTab) + }, [fetchConfigs, activeTab]) + + function openEditDialog(config: ConfigItem) { + setEditTarget(config) + setEditValue(config.current_value !== undefined ? String(config.current_value) : '') + } + + async function handleSave() { + if (!editTarget) return + setSaving(true) + try { + let parsedValue: string | number | boolean = editValue + if (editTarget.value_type === 'number') { + parsedValue = parseFloat(editValue) || 0 + } else if (editTarget.value_type === 'boolean') { + parsedValue = editValue === 'true' + } + await api.config.update(editTarget.id, { value: parsedValue }) + setEditTarget(null) + fetchConfigs(activeTab) + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + } finally { + setSaving(false) + } + } + + function formatValue(value: unknown): string { + if (value === undefined || value === null) return '-' + if (typeof value === 'boolean') return value ? 'true' : 'false' + return String(value) + } + + const categories = ['all', 'auth', 'relay', 'model', 'system'] + + return ( +
+ {/* 分类 Tabs */} + + + {categories.map((cat) => ( + + {cat === 'all' ? '全部' : cat} + + ))} + + + + {error && ( +
+ {error} + +
+ )} + + {loading ? ( +
+ +
+ ) : configs.length === 0 ? ( +
+ 暂无配置项 +
+ ) : ( + + + + 分类 + Key + 当前值 + 默认值 + 来源 + 需重启 + 描述 + 操作 + + + + {configs.map((config) => ( + + + {config.category} + + {config.key_path} + + {formatValue(config.current_value)} + + + {formatValue(config.default_value)} + + + + {sourceLabels[config.source] || config.source} + + + + {config.requires_restart ? ( + + ) : ( + + )} + + + {config.description || '-'} + + + + + + ))} + +
+ )} + + {/* 编辑 Dialog */} + setEditTarget(null)}> + + + 编辑配置 + + 修改 {editTarget?.key_path} 的值 + {editTarget?.requires_restart && ( + + 注意: 修改此配置需要重启服务才能生效 + + )} + + +
+
+ + +
+
+ + +
+
+ + {editTarget?.value_type === 'boolean' ? ( + + ) : ( + setEditValue(e.target.value)} + /> + )} +
+
+ + + + + +
+
+
+ ) +} diff --git a/admin/src/app/(dashboard)/layout.tsx b/admin/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..5c847c6 --- /dev/null +++ b/admin/src/app/(dashboard)/layout.tsx @@ -0,0 +1,218 @@ +'use client' + +import { useState, type ReactNode } from 'react' +import Link from 'next/link' +import { usePathname, useRouter } from 'next/navigation' +import { + LayoutDashboard, + Users, + Server, + Cpu, + Key, + BarChart3, + ArrowLeftRight, + Settings, + FileText, + LogOut, + ChevronLeft, + Menu, + Bell, +} from 'lucide-react' +import { AuthGuard, useAuth } from '@/components/auth-guard' +import { logout } from '@/lib/auth' +import { cn } from '@/lib/utils' + +const navItems = [ + { href: '/', label: '仪表盘', icon: LayoutDashboard }, + { href: '/accounts', label: '账号管理', icon: Users }, + { href: '/providers', label: '服务商', icon: Server }, + { href: '/models', label: '模型管理', icon: Cpu }, + { href: '/api-keys', label: 'API 密钥', icon: Key }, + { href: '/usage', label: '用量统计', icon: BarChart3 }, + { href: '/relay', label: '中转任务', icon: ArrowLeftRight }, + { href: '/config', label: '系统配置', icon: Settings }, + { href: '/logs', label: '操作日志', icon: FileText }, +] + +function Sidebar({ + collapsed, + onToggle, +}: { + collapsed: boolean + onToggle: () => void +}) { + const pathname = usePathname() + const router = useRouter() + const { account } = useAuth() + + function handleLogout() { + logout() + router.replace('/login') + } + + return ( + + ) +} + +function Header() { + const pathname = usePathname() + const currentNav = navItems.find( + (item) => + item.href === '/' + ? pathname === '/' + : pathname.startsWith(item.href), + ) + + return ( +
+ {/* 移动端菜单按钮 */} + + + {/* 页面标题 */} +

+ {currentNav?.label || '仪表盘'} +

+ +
+ {/* 通知 */} + +
+
+ ) +} + +function MobileMenuButton() { + // Placeholder for mobile menu toggle + return ( + + ) +} + +export default function DashboardLayout({ children }: { children: ReactNode }) { + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + + return ( + +
+ setSidebarCollapsed(!sidebarCollapsed)} + /> +
+
+
+ {children} +
+
+
+
+ ) +} diff --git a/admin/src/app/(dashboard)/models/page.tsx b/admin/src/app/(dashboard)/models/page.tsx new file mode 100644 index 0000000..ee7e74d --- /dev/null +++ b/admin/src/app/(dashboard)/models/page.tsx @@ -0,0 +1,436 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { + Plus, + Loader2, + ChevronLeft, + ChevronRight, + Pencil, + Trash2, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Switch } from '@/components/ui/switch' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { api } from '@/lib/api-client' +import { ApiRequestError } from '@/lib/api-client' +import { formatNumber } from '@/lib/utils' +import type { Model, Provider } from '@/lib/types' + +const PAGE_SIZE = 20 + +interface ModelForm { + provider_id: string + model_id: string + alias: string + context_window: string + max_output_tokens: string + supports_streaming: boolean + supports_vision: boolean + enabled: boolean + pricing_input: string + pricing_output: string +} + +const emptyForm: ModelForm = { + provider_id: '', + model_id: '', + alias: '', + context_window: '4096', + max_output_tokens: '4096', + supports_streaming: true, + supports_vision: false, + enabled: true, + pricing_input: '', + pricing_output: '', +} + +export default function ModelsPage() { + const [models, setModels] = useState([]) + const [providers, setProviders] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [providerFilter, setProviderFilter] = useState('all') + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + // Dialog + const [dialogOpen, setDialogOpen] = useState(false) + const [editTarget, setEditTarget] = useState(null) + const [form, setForm] = useState(emptyForm) + const [saving, setSaving] = useState(false) + + // 删除 + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + + const fetchModels = useCallback(async () => { + setLoading(true) + setError('') + try { + const params: Record = { page, page_size: PAGE_SIZE } + if (providerFilter !== 'all') params.provider_id = providerFilter + const res = await api.models.list(params) + setModels(res.items) + setTotal(res.total) + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + else setError('加载失败') + } finally { + setLoading(false) + } + }, [page, providerFilter]) + + const fetchProviders = useCallback(async () => { + try { + const res = await api.providers.list({ page: 1, page_size: 100 }) + setProviders(res.items) + } catch { + // ignore + } + }, []) + + useEffect(() => { + fetchModels() + fetchProviders() + }, [fetchModels, fetchProviders]) + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name])) + + function openCreateDialog() { + setEditTarget(null) + setForm(emptyForm) + setDialogOpen(true) + } + + function openEditDialog(model: Model) { + setEditTarget(model) + setForm({ + provider_id: model.provider_id, + model_id: model.model_id, + alias: model.alias, + context_window: model.context_window.toString(), + max_output_tokens: model.max_output_tokens.toString(), + supports_streaming: model.supports_streaming, + supports_vision: model.supports_vision, + enabled: model.enabled, + pricing_input: model.pricing_input.toString(), + pricing_output: model.pricing_output.toString(), + }) + setDialogOpen(true) + } + + async function handleSave() { + if (!form.model_id.trim() || !form.provider_id) return + setSaving(true) + try { + const payload = { + provider_id: form.provider_id, + model_id: form.model_id.trim(), + alias: form.alias.trim(), + context_window: parseInt(form.context_window, 10) || 4096, + max_output_tokens: parseInt(form.max_output_tokens, 10) || 4096, + supports_streaming: form.supports_streaming, + supports_vision: form.supports_vision, + enabled: form.enabled, + pricing_input: parseFloat(form.pricing_input) || 0, + pricing_output: parseFloat(form.pricing_output) || 0, + } + if (editTarget) { + await api.models.update(editTarget.id, payload) + } else { + await api.models.create(payload) + } + setDialogOpen(false) + fetchModels() + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + } finally { + setSaving(false) + } + } + + async function handleDelete() { + if (!deleteTarget) return + setDeleting(true) + try { + await api.models.delete(deleteTarget.id) + setDeleteTarget(null) + fetchModels() + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + } finally { + setDeleting(false) + } + } + + return ( +
+
+ + +
+ + {error && ( +
+ {error} + +
+ )} + + {loading ? ( +
+ +
+ ) : models.length === 0 ? ( +
+ 暂无数据 +
+ ) : ( + <> + + + + 模型 ID + 别名 + 服务商 + 上下文窗口 + 最大输出 + 流式 + 视觉 + 启用 + 操作 + + + + {models.map((m) => ( + + {m.model_id} + {m.alias || '-'} + + {providerMap.get(m.provider_id) || m.provider_id.slice(0, 8)} + + + {formatNumber(m.context_window)} + + + {formatNumber(m.max_output_tokens)} + + + + {m.supports_streaming ? '是' : '否'} + + + + + {m.supports_vision ? '是' : '否'} + + + + + {m.enabled ? '启用' : '禁用'} + + + +
+ + +
+
+
+ ))} +
+
+ +
+

+ 第 {page} 页 / 共 {totalPages} 页 ({total} 条) +

+
+ + +
+
+ + )} + + {/* 创建/编辑 Dialog */} + + + + {editTarget ? '编辑模型' : '新建模型'} + + {editTarget ? '修改模型配置' : '添加新的 AI 模型'} + + +
+
+ + +
+
+ + setForm({ ...form, model_id: e.target.value })} + placeholder="gpt-4o" + disabled={!!editTarget} + /> +
+
+ + setForm({ ...form, alias: e.target.value })} + placeholder="GPT-4o" + /> +
+
+
+ + setForm({ ...form, context_window: e.target.value })} + /> +
+
+ + setForm({ ...form, max_output_tokens: e.target.value })} + /> +
+
+
+
+ + setForm({ ...form, pricing_input: e.target.value })} + placeholder="0" + /> +
+
+ + setForm({ ...form, pricing_output: e.target.value })} + placeholder="0" + /> +
+
+
+
+ setForm({ ...form, supports_streaming: v })} /> + +
+
+ setForm({ ...form, supports_vision: v })} /> + +
+
+ setForm({ ...form, enabled: v })} /> + +
+
+
+ + + + +
+
+ + {/* 删除确认 */} + setDeleteTarget(null)}> + + + 确认删除 + + 确定要删除模型 "{deleteTarget?.alias || deleteTarget?.model_id}" 吗?此操作不可撤销。 + + + + + + + + +
+ ) +} diff --git a/admin/src/app/(dashboard)/page.tsx b/admin/src/app/(dashboard)/page.tsx new file mode 100644 index 0000000..cd52172 --- /dev/null +++ b/admin/src/app/(dashboard)/page.tsx @@ -0,0 +1,336 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + Users, + Server, + ArrowLeftRight, + Zap, + Loader2, + TrendingUp, +} from 'lucide-react' +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + BarChart, + Bar, + Legend, +} from 'recharts' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { api } from '@/lib/api-client' +import { formatNumber, formatDate } from '@/lib/utils' +import type { + DashboardStats, + UsageRecord, + OperationLog, +} from '@/lib/types' + +interface StatCardProps { + title: string + value: string | number + icon: React.ReactNode + color: string + subtitle?: string +} + +function StatCard({ title, value, icon, color, subtitle }: StatCardProps) { + return ( + + +
+
+

{title}

+

{value}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ {icon} +
+
+
+
+ ) +} + +function StatusBadge({ status }: { status: string }) { + const variantMap: Record = { + active: 'success', + completed: 'success', + disabled: 'destructive', + failed: 'destructive', + processing: 'info', + queued: 'warning', + suspended: 'destructive', + } + return ( + {status} + ) +} + +export default function DashboardPage() { + const [stats, setStats] = useState(null) + const [usageData, setUsageData] = useState([]) + const [recentLogs, setRecentLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + async function fetchData() { + try { + const [statsRes, usageRes, logsRes] = await Promise.allSettled([ + api.stats.dashboard(), + api.usage.daily({ days: 30 }), + api.logs.list({ page: 1, page_size: 5 }), + ]) + + if (statsRes.status === 'fulfilled') setStats(statsRes.value) + if (usageRes.status === 'fulfilled') setUsageData(usageRes.value) + if (logsRes.status === 'fulfilled') setRecentLogs(logsRes.value.items) + } catch (err) { + setError('加载数据失败,请检查后端服务是否启动') + } finally { + setLoading(false) + } + } + fetchData() + }, []) + + if (loading) { + return ( +
+
+ +

加载中...

+
+
+ ) + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ) + } + + const chartData = usageData.map((r) => ({ + day: r.day.slice(5), // MM-DD + 请求量: r.count, + Input: r.input_tokens, + Output: r.output_tokens, + })) + + return ( +
+ {/* 统计卡片 */} +
+ } + color="bg-blue-500/10" + subtitle={`活跃 ${stats?.active_accounts ?? 0}`} + /> + } + color="bg-green-500/10" + subtitle={`模型 ${stats?.active_models ?? 0}`} + /> + } + color="bg-purple-500/10" + subtitle="中转任务" + /> + } + color="bg-orange-500/10" + subtitle={`In: ${formatNumber(stats?.tokens_today_input ?? 0)} / Out: ${formatNumber(stats?.tokens_today_output ?? 0)}`} + /> +
+ + {/* 图表 */} +
+ {/* 请求趋势 */} + + + + + 请求趋势 (30 天) + + + + {chartData.length > 0 ? ( + + + + + + + + + + + + + + + + ) : ( +
+ 暂无数据 +
+ )} +
+
+ + {/* Token 用量 */} + + + + + Token 用量 (30 天) + + + + {chartData.length > 0 ? ( + + + + + + + + + + + + ) : ( +
+ 暂无数据 +
+ )} +
+
+
+ + {/* 最近操作日志 */} + + + 最近操作 + + + {recentLogs.length > 0 ? ( + + + + 时间 + 账号 ID + 操作 + 目标类型 + 目标 ID + + + + {recentLogs.map((log) => ( + + + {formatDate(log.created_at)} + + + {log.account_id.slice(0, 8)}... + + + {log.action} + + + {log.target_type} + + + {log.target_id.slice(0, 8)}... + + + ))} + +
+ ) : ( +
+ 暂无操作日志 +
+ )} +
+
+
+ ) +} diff --git a/admin/src/app/(dashboard)/providers/page.tsx b/admin/src/app/(dashboard)/providers/page.tsx new file mode 100644 index 0000000..f2541f6 --- /dev/null +++ b/admin/src/app/(dashboard)/providers/page.tsx @@ -0,0 +1,386 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { + Plus, + Loader2, + ChevronLeft, + ChevronRight, + Pencil, + Trash2, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Switch } from '@/components/ui/switch' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { api } from '@/lib/api-client' +import { ApiRequestError } from '@/lib/api-client' +import { formatDate, maskApiKey } from '@/lib/utils' +import type { Provider } from '@/lib/types' + +const PAGE_SIZE = 20 + +interface ProviderForm { + name: string + display_name: string + base_url: string + api_protocol: 'openai' | 'anthropic' + api_key: string + enabled: boolean + rate_limit_rpm: string + rate_limit_tpm: string +} + +const emptyForm: ProviderForm = { + name: '', + display_name: '', + base_url: '', + api_protocol: 'openai', + api_key: '', + enabled: true, + rate_limit_rpm: '', + rate_limit_tpm: '', +} + +export default function ProvidersPage() { + const [providers, setProviders] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + // 创建/编辑 Dialog + const [dialogOpen, setDialogOpen] = useState(false) + const [editTarget, setEditTarget] = useState(null) + const [form, setForm] = useState(emptyForm) + const [saving, setSaving] = useState(false) + + // 删除确认 Dialog + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + + const fetchProviders = useCallback(async () => { + setLoading(true) + setError('') + try { + const res = await api.providers.list({ page, page_size: PAGE_SIZE }) + setProviders(res.items) + setTotal(res.total) + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + else setError('加载失败') + } finally { + setLoading(false) + } + }, [page]) + + useEffect(() => { + fetchProviders() + }, [fetchProviders]) + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + function openCreateDialog() { + setEditTarget(null) + setForm(emptyForm) + setDialogOpen(true) + } + + function openEditDialog(provider: Provider) { + setEditTarget(provider) + setForm({ + name: provider.name, + display_name: provider.display_name, + base_url: provider.base_url, + api_protocol: provider.api_protocol, + api_key: provider.api_key || '', + enabled: provider.enabled, + rate_limit_rpm: provider.rate_limit_rpm?.toString() || '', + rate_limit_tpm: provider.rate_limit_tpm?.toString() || '', + }) + setDialogOpen(true) + } + + async function handleSave() { + if (!form.name.trim() || !form.base_url.trim()) return + setSaving(true) + try { + const payload = { + name: form.name.trim(), + display_name: form.display_name.trim(), + base_url: form.base_url.trim(), + api_protocol: form.api_protocol, + api_key: form.api_key.trim() || undefined, + enabled: form.enabled, + rate_limit_rpm: form.rate_limit_rpm ? parseInt(form.rate_limit_rpm, 10) : undefined, + rate_limit_tpm: form.rate_limit_tpm ? parseInt(form.rate_limit_tpm, 10) : undefined, + } + if (editTarget) { + await api.providers.update(editTarget.id, payload) + } else { + await api.providers.create(payload) + } + setDialogOpen(false) + fetchProviders() + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + } finally { + setSaving(false) + } + } + + async function handleDelete() { + if (!deleteTarget) return + setDeleting(true) + try { + await api.providers.delete(deleteTarget.id) + setDeleteTarget(null) + fetchProviders() + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + } finally { + setDeleting(false) + } + } + + return ( +
+ {/* 工具栏 */} +
+
+ +
+ + {error && ( +
+ {error} + +
+ )} + + {loading ? ( +
+ +
+ ) : providers.length === 0 ? ( +
+ 暂无数据 +
+ ) : ( + <> + + + + 名称 + 显示名 + Base URL + 协议 + API Key + 启用 + RPM 限制 + 创建时间 + 操作 + + + + {providers.map((p) => ( + + {p.name} + {p.display_name || '-'} + + {p.base_url} + + + + {p.api_protocol} + + + + {maskApiKey(p.api_key)} + + + + {p.enabled ? '是' : '否'} + + + + {p.rate_limit_rpm ?? '-'} + + + {formatDate(p.created_at)} + + +
+ + +
+
+
+ ))} +
+
+ +
+

+ 第 {page} 页 / 共 {totalPages} 页 ({total} 条) +

+
+ + +
+
+ + )} + + {/* 创建/编辑 Dialog */} + + + + {editTarget ? '编辑服务商' : '新建服务商'} + + {editTarget ? '修改服务商配置' : '添加新的 AI 服务商'} + + +
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="例如: openai" + disabled={!!editTarget} + /> +
+
+ + setForm({ ...form, display_name: e.target.value })} + placeholder="例如: OpenAI" + /> +
+
+ + setForm({ ...form, base_url: e.target.value })} + placeholder="https://api.openai.com/v1" + /> +
+
+ + +
+
+ + setForm({ ...form, api_key: e.target.value })} + placeholder={editTarget ? '留空则不修改' : 'sk-...'} + /> +
+
+ setForm({ ...form, enabled: v })} + /> + +
+
+
+ + setForm({ ...form, rate_limit_rpm: e.target.value })} + placeholder="不限" + /> +
+
+ + setForm({ ...form, rate_limit_tpm: e.target.value })} + placeholder="不限" + /> +
+
+
+ + + + +
+
+ + {/* 删除确认 Dialog */} + setDeleteTarget(null)}> + + + 确认删除 + + 确定要删除服务商 "{deleteTarget?.display_name || deleteTarget?.name}" 吗?此操作不可撤销。 + + + + + + + + +
+ ) +} diff --git a/admin/src/app/(dashboard)/relay/page.tsx b/admin/src/app/(dashboard)/relay/page.tsx new file mode 100644 index 0000000..0568e5f --- /dev/null +++ b/admin/src/app/(dashboard)/relay/page.tsx @@ -0,0 +1,245 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { + Search, + Loader2, + ChevronLeft, + ChevronRight, + ChevronDown, + ChevronUp, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { api } from '@/lib/api-client' +import { ApiRequestError } from '@/lib/api-client' +import { formatDate, formatNumber } from '@/lib/utils' +import type { RelayTask } from '@/lib/types' + +const PAGE_SIZE = 20 + +const statusVariants: Record = { + queued: 'warning', + processing: 'info', + completed: 'success', + failed: 'destructive', +} + +const statusLabels: Record = { + queued: '排队中', + processing: '处理中', + completed: '已完成', + failed: '失败', +} + +export default function RelayPage() { + const [tasks, setTasks] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [statusFilter, setStatusFilter] = useState('all') + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [expandedId, setExpandedId] = useState(null) + + const fetchTasks = useCallback(async () => { + setLoading(true) + setError('') + try { + const params: Record = { page, page_size: PAGE_SIZE } + if (statusFilter !== 'all') params.status = statusFilter + const res = await api.relay.list(params) + setTasks(res.items) + setTotal(res.total) + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + else setError('加载失败') + } finally { + setLoading(false) + } + }, [page, statusFilter]) + + useEffect(() => { + fetchTasks() + }, [fetchTasks]) + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + function toggleExpand(id: string) { + setExpandedId((prev) => (prev === id ? null : id)) + } + + return ( +
+ {/* 筛选 */} +
+ +
+ + {error && ( +
+ {error} + +
+ )} + + {loading ? ( +
+ +
+ ) : tasks.length === 0 ? ( +
+ 暂无数据 +
+ ) : ( + <> + + + + + 任务 ID + 模型 + 状态 + 优先级 + 重试次数 + Input Tokens + Output Tokens + 错误信息 + 创建时间 + + + + {tasks.map((task) => ( + <> + toggleExpand(task.id)}> + + {expandedId === task.id ? ( + + ) : ( + + )} + + + {task.id.slice(0, 8)}... + + + {task.model_id} + + + + {statusLabels[task.status] || task.status} + + + {task.priority} + {task.attempt_count} + + {formatNumber(task.input_tokens)} + + + {formatNumber(task.output_tokens)} + + + {task.error_message || '-'} + + + {formatDate(task.created_at)} + + + {expandedId === task.id && ( + + +
+
+

任务 ID

+

{task.id}

+
+
+

账号 ID

+

{task.account_id}

+
+
+

服务商 ID

+

{task.provider_id}

+
+
+

模型 ID

+

{task.model_id}

+
+ {task.queued_at && ( +
+

排队时间

+

{formatDate(task.queued_at)}

+
+ )} + {task.started_at && ( +
+

开始时间

+

{formatDate(task.started_at)}

+
+ )} + {task.completed_at && ( +
+

完成时间

+

{formatDate(task.completed_at)}

+
+ )} + {task.error_message && ( +
+

错误信息

+

{task.error_message}

+
+ )} +
+
+
+ )} + + ))} +
+
+ +
+

+ 第 {page} 页 / 共 {totalPages} 页 ({total} 条) +

+
+ + +
+
+ + )} +
+ ) +} diff --git a/admin/src/app/(dashboard)/usage/page.tsx b/admin/src/app/(dashboard)/usage/page.tsx new file mode 100644 index 0000000..168eb59 --- /dev/null +++ b/admin/src/app/(dashboard)/usage/page.tsx @@ -0,0 +1,235 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { Loader2, Zap } from 'lucide-react' +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + BarChart, + Bar, + Legend, +} from 'recharts' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { api } from '@/lib/api-client' +import { ApiRequestError } from '@/lib/api-client' +import { formatNumber } from '@/lib/utils' +import type { UsageRecord, UsageByModel } from '@/lib/types' + +export default function UsagePage() { + const [days, setDays] = useState(7) + const [dailyData, setDailyData] = useState([]) + const [modelData, setModelData] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const fetchData = useCallback(async () => { + setLoading(true) + setError('') + try { + const [dailyRes, modelRes] = await Promise.allSettled([ + api.usage.daily({ days }), + api.usage.byModel({ days }), + ]) + if (dailyRes.status === 'fulfilled') setDailyData(dailyRes.value) + else throw new Error('Failed to fetch daily usage') + if (modelRes.status === 'fulfilled') setModelData(modelRes.value) + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + else setError('加载数据失败') + } finally { + setLoading(false) + } + }, [days]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const lineChartData = dailyData.map((r) => ({ + day: r.day.slice(5), + Input: r.input_tokens, + Output: r.output_tokens, + })) + + const barChartData = modelData.map((r) => ({ + model: r.model_id, + 请求量: r.count, + Input: r.input_tokens, + Output: r.output_tokens, + })) + + const totalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0) + const totalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0) + const totalRequests = dailyData.reduce((s, r) => s + r.count, 0) + + if (loading) { + return ( +
+
+ +

加载中...

+
+
+ ) + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ) + } + + return ( +
+ {/* 时间范围 */} +
+ 时间范围: + +
+ + {/* 汇总统计 */} +
+ + +

总请求数

+

+ {formatNumber(totalRequests)} +

+
+
+ + +

Input Tokens

+

+ {formatNumber(totalInput)} +

+
+
+ + +

Output Tokens

+

+ {formatNumber(totalOutput)} +

+
+
+
+ + {/* Token 用量趋势 */} + + + + + Token 用量趋势 + + + + {lineChartData.length > 0 ? ( + + + + + + + + + + + + ) : ( +
+ 暂无数据 +
+ )} +
+
+ + {/* 按模型分布 */} + + + 按模型分布 + + + {barChartData.length > 0 ? ( + + + + + + + + + + + + ) : ( +
+ 暂无数据 +
+ )} +
+
+
+ ) +} diff --git a/admin/src/app/globals.css b/admin/src/app/globals.css new file mode 100644 index 0000000..db4d952 --- /dev/null +++ b/admin/src/app/globals.css @@ -0,0 +1,66 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 222 47% 5%; + --foreground: 210 40% 98%; + --card: 222 47% 8%; + --card-foreground: 210 40% 98%; + --primary: 142 71% 45%; + --primary-foreground: 222 47% 5%; + --muted: 217 33% 17%; + --muted-foreground: 215 20% 65%; + --accent: 215 28% 23%; + --accent-foreground: 210 40% 98%; + --destructive: 0 84% 60%; + --destructive-foreground: 210 40% 98%; + --border: 217 33% 17%; + --input: 217 33% 17%; + --ring: 142 71% 45%; + } + + * { + border-color: hsl(var(--border)); + } + + body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: 'Inter', system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } +} + +@layer utilities { + .scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: hsl(var(--muted)) transparent; + } + + .scrollbar-thin::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + .scrollbar-thin::-webkit-scrollbar-track { + background: transparent; + } + + .scrollbar-thin::-webkit-scrollbar-thumb { + background-color: hsl(var(--muted)); + border-radius: 3px; + } + + .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--accent)); + } +} + +@layer components { + .glass-card { + @apply bg-card/80 backdrop-blur-sm border border-border rounded-lg; + } +} diff --git a/admin/src/app/layout.tsx b/admin/src/app/layout.tsx new file mode 100644 index 0000000..35840e6 --- /dev/null +++ b/admin/src/app/layout.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from 'next' +import './globals.css' + +export const metadata: Metadata = { + title: 'ZCLAW Admin', + description: 'ZCLAW AI Agent 管理平台', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + + + {children} + + + ) +} diff --git a/admin/src/app/login/page.tsx b/admin/src/app/login/page.tsx new file mode 100644 index 0000000..249ad6a --- /dev/null +++ b/admin/src/app/login/page.tsx @@ -0,0 +1,199 @@ +'use client' + +import { useState, type FormEvent } from 'react' +import { useRouter } from 'next/navigation' +import { Lock, User, Loader2, Eye, EyeOff } from 'lucide-react' +import { api } from '@/lib/api-client' +import { login } from '@/lib/auth' +import { ApiRequestError } from '@/lib/api-client' + +export default function LoginPage() { + const router = useRouter() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [remember, setRemember] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + setError('') + + if (!username.trim()) { + setError('请输入用户名') + return + } + if (!password.trim()) { + setError('请输入密码') + return + } + + setLoading(true) + try { + const res = await api.auth.login({ username: username.trim(), password }) + login(res.token, res.account) + router.replace('/') + } catch (err) { + if (err instanceof ApiRequestError) { + setError(err.body.message || '登录失败,请检查用户名和密码') + } else { + setError('网络错误,请稍后重试') + } + } finally { + setLoading(false) + } + } + + return ( +
+ {/* 左侧品牌区域 */} +
+ {/* 装饰性背景 */} +
+
+
+
+
+
+ + {/* 品牌内容 */} +
+
+

+ ZCLAW +

+

+ AI Agent 管理平台 +

+
+
+
+
+
+

+ 统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置 +

+
+
+
+ + {/* 右侧登录表单 */} +
+
+ {/* 移动端 Logo */} +
+

+ ZCLAW +

+

AI Agent 管理平台

+
+ +
+

登录

+

+ 输入您的账号信息以继续 +

+
+ +
+ {/* 用户名 */} +
+ +
+ + setUsername(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + autoComplete="username" + /> +
+
+ + {/* 密码 */} +
+ +
+ + setPassword(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-10 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + autoComplete="current-password" + /> + +
+
+ + {/* 记住我 */} +
+ setRemember(e.target.checked)} + className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer" + /> + +
+ + {/* 错误信息 */} + {error && ( +
+ {error} +
+ )} + + {/* 登录按钮 */} + +
+
+
+
+ ) +} diff --git a/admin/src/components/auth-guard.tsx b/admin/src/components/auth-guard.tsx new file mode 100644 index 0000000..7e5b1ca --- /dev/null +++ b/admin/src/components/auth-guard.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useEffect, useState, type ReactNode } from 'react' +import { useRouter } from 'next/navigation' +import { isAuthenticated, getAccount } from '@/lib/auth' +import type { AccountPublic } from '@/lib/types' + +interface AuthGuardProps { + children: ReactNode +} + +export function AuthGuard({ children }: AuthGuardProps) { + const router = useRouter() + const [authorized, setAuthorized] = useState(false) + const [account, setAccount] = useState(null) + + useEffect(() => { + if (!isAuthenticated()) { + router.replace('/login') + return + } + setAccount(getAccount()) + setAuthorized(true) + }, [router]) + + if (!authorized) { + return ( +
+
+
+ ) + } + + return <>{children} +} + +export function useAuth() { + const [account, setAccount] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const acc = getAccount() + setAccount(acc) + setLoading(false) + }, []) + + return { account, loading, isAuthenticated: isAuthenticated() } +} diff --git a/admin/src/components/ui/badge.tsx b/admin/src/components/ui/badge.tsx new file mode 100644 index 0000000..cc1fb36 --- /dev/null +++ b/admin/src/components/ui/badge.tsx @@ -0,0 +1,42 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary/15 text-primary', + secondary: + 'border-transparent bg-muted text-muted-foreground', + destructive: + 'border-transparent bg-destructive/15 text-destructive', + outline: + 'text-foreground border-border', + success: + 'border-transparent bg-green-500/15 text-green-400', + warning: + 'border-transparent bg-yellow-500/15 text-yellow-400', + info: + 'border-transparent bg-blue-500/15 text-blue-400', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/admin/src/components/ui/button.tsx b/admin/src/components/ui/button.tsx new file mode 100644 index 0000000..0335248 --- /dev/null +++ b/admin/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +'use client' + +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm', + secondary: + 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-red-600 shadow-sm', + outline: + 'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground', + ghost: + 'hover:bg-accent hover:text-accent-foreground', + link: + 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( +