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:
iven
2026-03-27 12:41:11 +08:00
parent 80d98b35a5
commit a2f8112d69
23 changed files with 2123 additions and 4 deletions

View 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 }

View 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))
}

View 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))
}

View 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 &params {
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 &params {
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 &params {
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(())
}

View 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>,
}

View 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(())
}

View 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());
}
}

View 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))
}

View 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());
}
}

View 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>,
}

View 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
View 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);
}
}
}

View 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>;

View 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;

View 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)
}

View File

@@ -0,0 +1 @@
//! 配置迁移模块

View File

@@ -0,0 +1 @@
//! 模型配置模块

View File

@@ -0,0 +1 @@
//! 请求中转模块

View 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,
}
}
}

View 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);
}