feat(saas): Phase 1 — 基础框架与账号管理模块
- 新增 zclaw-saas crate 作为 workspace 成员 - 配置系统 (TOML + 环境变量覆盖) - 错误类型体系 (SaasError 16 变体, IntoResponse) - SQLite 数据库 (12 表 schema, 内存/文件双模式, 3 系统角色种子数据) - JWT 认证 (签发/验证/刷新) - Argon2id 密码哈希 - 认证中间件 (公开/受保护路由分层) - 账号管理 CRUD + API Token 管理 + 操作日志 - 7 单元测试 + 5 集成测试全部通过
This commit is contained in:
339
Cargo.lock
generated
339
Cargo.lock
generated
@@ -110,6 +110,18 @@ dependencies = [
|
|||||||
"derive_arbitrary",
|
"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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -315,6 +327,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core",
|
||||||
|
"axum-macros",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
@@ -335,7 +348,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -362,6 +375,47 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -410,6 +464,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -654,6 +717,12 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -1168,6 +1237,15 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
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]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -1894,6 +1972,30 @@ dependencies = [
|
|||||||
"hashbrown 0.14.5",
|
"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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -2433,6 +2535,21 @@ dependencies = [
|
|||||||
"serde_json",
|
"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]]
|
[[package]]
|
||||||
name = "keyboard-types"
|
name = "keyboard-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -2625,6 +2742,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "matches"
|
name = "matches"
|
||||||
version = "0.1.10"
|
version = "0.1.10"
|
||||||
@@ -2716,6 +2842,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
@@ -2785,6 +2928,25 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"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]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -3120,6 +3282,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@@ -3132,6 +3305,16 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
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]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -3334,6 +3517,26 @@ dependencies = [
|
|||||||
"siphasher 1.0.2",
|
"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]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -3860,7 +4063,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-http 0.6.8",
|
"tower-http 0.6.8",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
@@ -3895,7 +4098,7 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-http 0.6.8",
|
"tower-http 0.6.8",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
@@ -4399,6 +4602,15 @@ dependencies = [
|
|||||||
"digest",
|
"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]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -4431,6 +4643,18 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
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]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
@@ -5261,6 +5485,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.47"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
|
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]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -5535,6 +5796,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5550,7 +5812,7 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"iri-string",
|
"iri-string",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
@@ -5597,6 +5859,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"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]]
|
[[package]]
|
||||||
@@ -5814,6 +6106,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -7124,6 +7422,39 @@ dependencies = [
|
|||||||
"zclaw-types",
|
"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]]
|
[[package]]
|
||||||
name = "zclaw-skills"
|
name = "zclaw-skills"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
13
Cargo.toml
13
Cargo.toml
@@ -15,6 +15,8 @@ members = [
|
|||||||
"crates/zclaw-growth",
|
"crates/zclaw-growth",
|
||||||
# Desktop Application
|
# Desktop Application
|
||||||
"desktop/src-tauri",
|
"desktop/src-tauri",
|
||||||
|
# SaaS Backend
|
||||||
|
"crates/zclaw-saas",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -95,6 +97,16 @@ shlex = "1"
|
|||||||
# Testing
|
# Testing
|
||||||
tempfile = "3"
|
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
|
# Internal crates
|
||||||
zclaw-types = { path = "crates/zclaw-types" }
|
zclaw-types = { path = "crates/zclaw-types" }
|
||||||
zclaw-memory = { path = "crates/zclaw-memory" }
|
zclaw-memory = { path = "crates/zclaw-memory" }
|
||||||
@@ -106,6 +118,7 @@ zclaw-channels = { path = "crates/zclaw-channels" }
|
|||||||
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
||||||
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
|
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
|
||||||
zclaw-growth = { path = "crates/zclaw-growth" }
|
zclaw-growth = { path = "crates/zclaw-growth" }
|
||||||
|
zclaw-saas = { path = "crates/zclaw-saas" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
42
crates/zclaw-saas/Cargo.toml
Normal file
42
crates/zclaw-saas/Cargo.toml
Normal file
@@ -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 }
|
||||||
117
crates/zclaw-saas/src/account/handlers.rs
Normal file
117
crates/zclaw-saas/src/account/handlers.rs
Normal file
@@ -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<AppState>,
|
||||||
|
Query(query): Query<ListAccountsQuery>,
|
||||||
|
_ctx: Extension<AuthContext>,
|
||||||
|
) -> SaasResult<Json<PaginatedResponse<serde_json::Value>>> {
|
||||||
|
service::list_accounts(&state.db, &query).await.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/accounts/:id
|
||||||
|
pub async fn get_account(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
_ctx: Extension<AuthContext>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
service::get_account(&state.db, &id).await.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/accounts/:id
|
||||||
|
pub async fn update_account(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
Json(req): Json<UpdateAccountRequest>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
Json(req): Json<UpdateStatusRequest>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
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<AppState>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
) -> SaasResult<Json<Vec<TokenInfo>>> {
|
||||||
|
service::list_api_tokens(&state.db, &ctx.account_id).await.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/tokens
|
||||||
|
pub async fn create_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
Json(req): Json<CreateTokenRequest>,
|
||||||
|
) -> SaasResult<Json<TokenInfo>> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
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<AppState>,
|
||||||
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
|
_ctx: Extension<AuthContext>,
|
||||||
|
) -> SaasResult<Json<Vec<serde_json::Value>>> {
|
||||||
|
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>, String, Option<String>, Option<String>, Option<String>, Option<String>, 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<serde_json::Value> = 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::<serde_json::Value>(&d).ok()),
|
||||||
|
"ip_address": ip_address, "created_at": created_at,
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
19
crates/zclaw-saas/src/account/mod.rs
Normal file
19
crates/zclaw-saas/src/account/mod.rs
Normal file
@@ -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<crate::state::AppState> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
222
crates/zclaw-saas/src/account/service.rs
Normal file
222
crates/zclaw-saas/src/account/service.rs
Normal file
@@ -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<PaginatedResponse<serde_json::Value>> {
|
||||||
|
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<String> = 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>, 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<serde_json::Value> = 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<serde_json::Value> {
|
||||||
|
let row: Option<(String, String, String, String, String, String, bool, Option<String>, 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<serde_json::Value> {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
let mut params: Vec<String> = 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<TokenInfo> {
|
||||||
|
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<Vec<TokenInfo>> {
|
||||||
|
let rows: Vec<(String, String, String, String, Option<String>, Option<String>, 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<String> = 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(())
|
||||||
|
}
|
||||||
53
crates/zclaw-saas/src/account/types.rs
Normal file
53
crates/zclaw-saas/src/account/types.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//! 账号管理类型
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateAccountRequest {
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateStatusRequest {
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListAccountsQuery {
|
||||||
|
pub page: Option<u32>,
|
||||||
|
pub page_size: Option<u32>,
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub search: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct PaginatedResponse<T: Serialize> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: u32,
|
||||||
|
pub page_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateTokenRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
pub expires_days: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TokenInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub token_prefix: String,
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
180
crates/zclaw-saas/src/auth/handlers.rs
Normal file
180
crates/zclaw-saas/src/auth/handlers.rs
Normal file
@@ -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<AppState>,
|
||||||
|
Json(req): Json<RegisterRequest>,
|
||||||
|
) -> SaasResult<(StatusCode, Json<AccountPublic>)> {
|
||||||
|
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<AppState>,
|
||||||
|
Json(req): Json<LoginRequest>,
|
||||||
|
) -> SaasResult<Json<LoginResponse>> {
|
||||||
|
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<AppState>,
|
||||||
|
axum::extract::Extension(ctx): axum::extract::Extension<AuthContext>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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<String> = 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<serde_json::Value>,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
91
crates/zclaw-saas/src/auth/jwt.rs
Normal file
91
crates/zclaw-saas/src/auth/jwt.rs
Normal file
@@ -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<String>,
|
||||||
|
pub iat: i64,
|
||||||
|
pub exp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Claims {
|
||||||
|
pub fn new(account_id: &str, role: &str, permissions: Vec<String>, 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<String>,
|
||||||
|
secret: &str,
|
||||||
|
expiration_hours: i64,
|
||||||
|
) -> SaasResult<String> {
|
||||||
|
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<Claims> {
|
||||||
|
let token_data = decode::<Claims>(
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
69
crates/zclaw-saas/src/auth/mod.rs
Normal file
69
crates/zclaw-saas/src/auth/mod.rs
Normal file
@@ -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<AppState>,
|
||||||
|
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<AppState> {
|
||||||
|
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<AppState> {
|
||||||
|
use axum::routing::post;
|
||||||
|
|
||||||
|
axum::Router::new()
|
||||||
|
.route("/api/v1/auth/refresh", post(handlers::refresh))
|
||||||
|
}
|
||||||
48
crates/zclaw-saas/src/auth/password.rs
Normal file
48
crates/zclaw-saas/src/auth/password.rs
Normal file
@@ -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<String> {
|
||||||
|
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<bool> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
49
crates/zclaw-saas/src/auth/types.rs
Normal file
49
crates/zclaw-saas/src/auth/types.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//! 认证相关类型
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 登录请求
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub totp_code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录响应
|
||||||
|
#[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<String>,
|
||||||
|
pub role: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 公开账号信息 (无敏感数据)
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
144
crates/zclaw-saas/src/config.rs
Normal file
144
crates/zclaw-saas/src/config.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 数据库配置
|
||||||
|
#[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<Self> {
|
||||||
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
281
crates/zclaw-saas/src/db.rs
Normal file
281
crates/zclaw-saas/src/db.rs
Normal file
@@ -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<SqlitePool> {
|
||||||
|
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<SqlitePool> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
crates/zclaw-saas/src/error.rs
Normal file
119
crates/zclaw-saas/src/error.rs
Normal file
@@ -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<T> = std::result::Result<T, SaasError>;
|
||||||
14
crates/zclaw-saas/src/lib.rs
Normal file
14
crates/zclaw-saas/src/lib.rs
Normal file
@@ -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;
|
||||||
57
crates/zclaw-saas/src/main.rs
Normal file
57
crates/zclaw-saas/src/main.rs
Normal file
@@ -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)
|
||||||
|
}
|
||||||
1
crates/zclaw-saas/src/migration/mod.rs
Normal file
1
crates/zclaw-saas/src/migration/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//! 配置迁移模块
|
||||||
1
crates/zclaw-saas/src/model_config/mod.rs
Normal file
1
crates/zclaw-saas/src/model_config/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//! 模型配置模块
|
||||||
1
crates/zclaw-saas/src/relay/mod.rs
Normal file
1
crates/zclaw-saas/src/relay/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//! 请求中转模块
|
||||||
28
crates/zclaw-saas/src/state.rs
Normal file
28
crates/zclaw-saas/src/state.rs
Normal file
@@ -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<RwLock<SaaSConfig>>,
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
crates/zclaw-saas/tests/integration_test.rs
Normal file
222
crates/zclaw-saas/tests/integration_test.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
17
saas-config.toml
Normal file
17
saas-config.toml
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user