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