- 新增 zclaw-saas crate 作为 workspace 成员 - 配置系统 (TOML + 环境变量覆盖) - 错误类型体系 (SaasError 16 变体, IntoResponse) - SQLite 数据库 (12 表 schema, 内存/文件双模式, 3 系统角色种子数据) - JWT 认证 (签发/验证/刷新) - Argon2id 密码哈希 - 认证中间件 (公开/受保护路由分层) - 账号管理 CRUD + API Token 管理 + 操作日志 - 7 单元测试 + 5 集成测试全部通过
282 lines
9.2 KiB
Rust
282 lines
9.2 KiB
Rust
//! 数据库初始化与 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);
|
|
}
|
|
}
|
|
}
|