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",
|
||||
]
|
||||
|
||||
[[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"
|
||||
|
||||
13
Cargo.toml
13
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
|
||||
|
||||
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