chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -1,9 +1,10 @@
//! 数据库初始化与 Schema
//! 数据库初始化与 Schema (PostgreSQL)
use sqlx::SqlitePool;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use crate::error::SaasResult;
const SCHEMA_VERSION: i32 = 1;
const SCHEMA_VERSION: i32 = 4;
const SCHEMA_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS saas_schema_version (
@@ -20,7 +21,7 @@ CREATE TABLE IF NOT EXISTS accounts (
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
totp_enabled BOOLEAN NOT NULL DEFAULT FALSE,
last_login_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -49,7 +50,7 @@ CREATE TABLE IF NOT EXISTS roles (
name TEXT NOT NULL,
description TEXT,
permissions TEXT NOT NULL DEFAULT '[]',
is_system INTEGER NOT NULL DEFAULT 0,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -64,7 +65,7 @@ CREATE TABLE IF NOT EXISTS permission_templates (
);
CREATE TABLE IF NOT EXISTS operation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id BIGSERIAL PRIMARY KEY,
account_id TEXT,
action TEXT NOT NULL,
target_type TEXT,
@@ -84,7 +85,7 @@ CREATE TABLE IF NOT EXISTS providers (
api_key TEXT,
base_url TEXT NOT NULL,
api_protocol TEXT NOT NULL DEFAULT 'openai',
enabled INTEGER NOT NULL DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
rate_limit_rpm INTEGER,
rate_limit_tpm INTEGER,
config_json TEXT DEFAULT '{}',
@@ -97,13 +98,13 @@ CREATE TABLE IF NOT EXISTS models (
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,
context_window BIGINT NOT NULL DEFAULT 8192,
max_output_tokens BIGINT NOT NULL DEFAULT 4096,
supports_streaming BOOLEAN NOT NULL DEFAULT TRUE,
supports_vision BOOLEAN NOT NULL DEFAULT FALSE,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
pricing_input DOUBLE PRECISION DEFAULT 0,
pricing_output DOUBLE PRECISION DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(provider_id, model_id),
@@ -118,7 +119,7 @@ CREATE TABLE IF NOT EXISTS account_api_keys (
key_value TEXT NOT NULL,
key_label TEXT,
permissions TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_used_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -129,7 +130,7 @@ CREATE TABLE IF NOT EXISTS account_api_keys (
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,
id BIGSERIAL PRIMARY KEY,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
model_id TEXT NOT NULL,
@@ -176,7 +177,7 @@ CREATE TABLE IF NOT EXISTS config_items (
default_value TEXT,
source TEXT NOT NULL DEFAULT 'local',
description TEXT,
requires_restart INTEGER NOT NULL DEFAULT 0,
requires_restart BOOLEAN NOT NULL DEFAULT FALSE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(category, key_path)
@@ -184,7 +185,7 @@ CREATE TABLE IF NOT EXISTS config_items (
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,
id BIGSERIAL PRIMARY KEY,
account_id TEXT NOT NULL,
client_fingerprint TEXT NOT NULL,
action TEXT NOT NULL,
@@ -210,140 +211,318 @@ CREATE TABLE IF NOT EXISTS devices (
CREATE INDEX IF NOT EXISTS idx_devices_account ON devices(account_id);
CREATE INDEX IF NOT EXISTS idx_devices_device_id ON devices(device_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_unique ON devices(account_id, device_id);
-- 提示词模板主表
CREATE TABLE IF NOT EXISTS prompt_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
category TEXT NOT NULL,
description TEXT,
source TEXT NOT NULL DEFAULT 'builtin',
current_version INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_prompt_status ON prompt_templates(status);
-- 提示词版本表(不可变)
CREATE TABLE IF NOT EXISTS prompt_versions (
id TEXT PRIMARY KEY,
template_id TEXT NOT NULL,
version INTEGER NOT NULL,
system_prompt TEXT,
user_prompt_template TEXT,
variables TEXT NOT NULL DEFAULT '[]',
changelog TEXT,
min_app_version TEXT,
created_at TEXT NOT NULL,
UNIQUE(template_id, version)
);
CREATE INDEX IF NOT EXISTS idx_prompt_ver_template ON prompt_versions(template_id);
-- 客户端提示词同步状态
CREATE TABLE IF NOT EXISTS prompt_sync_status (
device_id TEXT NOT NULL,
template_id TEXT NOT NULL,
synced_version INTEGER NOT NULL,
synced_at TEXT NOT NULL,
PRIMARY KEY(device_id, template_id)
);
-- Provider Key Pool 表
CREATE TABLE IF NOT EXISTS provider_keys (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
key_label TEXT NOT NULL,
key_value TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
max_rpm INTEGER,
max_tpm INTEGER,
quota_reset_interval TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_429_at TEXT,
cooldown_until TEXT,
total_requests BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pkeys_provider ON provider_keys(provider_id);
CREATE INDEX IF NOT EXISTS idx_pkeys_active ON provider_keys(provider_id, is_active);
-- Key 使用量滑动窗口
CREATE TABLE IF NOT EXISTS key_usage_window (
key_id TEXT NOT NULL,
window_minute TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
token_count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY(key_id, window_minute)
);
-- Agent 配置模板表
CREATE TABLE IF NOT EXISTS agent_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'general',
source TEXT NOT NULL DEFAULT 'builtin',
model TEXT,
system_prompt TEXT,
tools TEXT NOT NULL DEFAULT '[]'::text,
capabilities TEXT NOT NULL DEFAULT '[]'::text,
temperature DOUBLE PRECISION,
max_tokens INTEGER,
visibility TEXT NOT NULL DEFAULT 'public',
status TEXT NOT NULL DEFAULT 'active',
current_version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_agent_tmpl_status ON agent_templates(status);
CREATE INDEX IF NOT EXISTS idx_agent_tmpl_visibility ON agent_templates(visibility);
-- 桌面端遥测上报表Token 用量统计,无内容)
CREATE TABLE IF NOT EXISTS telemetry_reports (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
device_id TEXT NOT NULL,
app_version TEXT,
model_id TEXT NOT NULL,
input_tokens BIGINT NOT NULL DEFAULT 0,
output_tokens BIGINT NOT NULL DEFAULT 0,
latency_ms INTEGER,
success BOOLEAN NOT NULL DEFAULT TRUE,
error_type TEXT,
connection_mode TEXT NOT NULL DEFAULT 'tauri',
reported_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_telemetry_account ON telemetry_reports(account_id);
CREATE INDEX IF NOT EXISTS idx_telemetry_time ON telemetry_reports(reported_at);
CREATE INDEX IF NOT EXISTS idx_telemetry_model ON telemetry_reports(model_id);
CREATE INDEX IF NOT EXISTS idx_telemetry_day ON telemetry_reports((SUBSTRING(reported_at, 1, 10)));
-- Refresh Token 存储 (一次性使用, JWT jti 追踪)
CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
jti TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_refresh_account ON refresh_tokens(account_id);
CREATE INDEX IF NOT EXISTS idx_refresh_jti ON refresh_tokens(jti);
CREATE INDEX IF NOT EXISTS idx_refresh_expires ON refresh_tokens(expires_at);
"#;
const SEED_ROLES: &str = r#"
INSERT OR IGNORE INTO roles (id, name, description, permissions, is_system, created_at, updated_at)
INSERT INTO roles (id, name, description, permissions, is_system, created_at, updated_at)
VALUES
('super_admin', '超级管理员', '拥有所有权限', '["admin:full","account:admin","provider:manage","model:manage","relay:admin","config:write"]', 1, datetime('now'), datetime('now')),
('admin', '管理员', '管理账号和配置', '["account:read","account:admin","provider:manage","model:read","model:manage","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'));
('super_admin', '超级管理员', '拥有所有权限', '["admin:full","account:admin","provider:manage","model:manage","relay:admin","config:write","prompt:read","prompt:write","prompt:publish","prompt:admin"]', TRUE, '2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00'),
('admin', '管理员', '管理账号和配置', '["account:read","account:admin","provider:manage","model:read","model:manage","relay:use","relay:admin","config:read","config:write","prompt:read","prompt:write","prompt:publish"]', TRUE, '2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00'),
('user', '普通用户', '基础使用权限', '["model:read","relay:use","config:read","prompt:read"]', TRUE, '2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')
ON CONFLICT (id) DO NOTHING;
"#;
/// 初始化数据库
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)?;
}
}
pub async fn init_db(database_url: &str) -> SaasResult<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(2)
.acquire_timeout(std::time::Duration::from_secs(5))
.idle_timeout(std::time::Duration::from_secs(600))
.connect(database_url)
.await?;
// PostgreSQL 不支持在一个 prepared statement 中执行多条 SQL
// 需要逐条执行
for stmt in SCHEMA_SQL.split(';') {
let trimmed = stmt.trim();
if !trimmed.is_empty() {
sqlx::query(trimmed).execute(&pool).await?;
}
}
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)")
sqlx::query("INSERT INTO saas_schema_version (version) VALUES ($1) ON CONFLICT DO NOTHING")
.bind(SCHEMA_VERSION)
.execute(&pool)
.await?;
sqlx::query(SEED_ROLES).execute(&pool).await?;
for stmt in SEED_ROLES.split(';') {
let trimmed = stmt.trim();
if !trimmed.is_empty() {
sqlx::query(trimmed).execute(&pool).await?;
}
}
seed_admin_account(&pool).await?;
seed_builtin_prompts(&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)
}
/// 如果 accounts 表为空且环境变量已设置,自动创建 super_admin 账号
async fn seed_admin_account(pool: &SqlitePool) -> SaasResult<()> {
let has_accounts: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM accounts LIMIT 1) as has"
)
.fetch_one(pool)
.await?;
if has_accounts.0 {
return Ok(());
}
/// 或者更新现有 admin 用户的角色为 super_admin
async fn seed_admin_account(pool: &PgPool) -> SaasResult<()> {
let admin_username = std::env::var("ZCLAW_ADMIN_USERNAME")
.unwrap_or_else(|_| "admin".to_string());
// 检查是否设置了管理员密码
let admin_password = match std::env::var("ZCLAW_ADMIN_PASSWORD") {
Ok(pwd) => pwd,
Err(_) => {
tracing::warn!(
"accounts 表为空但未设置 ZCLAW_ADMIN_PASSWORD 环境变量。\
请通过 POST /api/v1/auth/register 注册首个用户,然后手动将其 role 改为 super_admin\
或设置 ZCLAW_ADMIN_USERNAME 和 ZCLAW_ADMIN_PASSWORD 环境变量后重启服务。"
);
// 没有设置密码,尝试更新现有 admin 用户的角色
let result = sqlx::query(
"UPDATE accounts SET role = 'super_admin' WHERE username = $1 AND role != 'super_admin'"
)
.bind(&admin_username)
.execute(pool)
.await?;
if result.rows_affected() > 0 {
tracing::info!("已将用户 {} 的角色更新为 super_admin", admin_username);
}
return Ok(());
}
};
use crate::auth::password::hash_password;
let password_hash = hash_password(&admin_password)?;
let account_id = uuid::Uuid::new_v4().to_string();
let email = format!("{}@zclaw.local", admin_username);
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, 'super_admin', 'active', ?6, ?6)"
// 检查 admin 用户是否已存在
let existing: Option<(String,)> = sqlx::query_as(
"SELECT id FROM accounts WHERE username = $1"
)
.bind(&account_id)
.bind(&admin_username)
.bind(&email)
.bind(&password_hash)
.bind(&admin_username)
.bind(&now)
.execute(pool)
.fetch_optional(pool)
.await?;
tracing::info!(
"自动创建 super_admin 账号: username={}, email={}", admin_username, email
);
if let Some((account_id,)) = existing {
// 更新现有用户的密码和角色
use crate::auth::password::hash_password;
let password_hash = hash_password(&admin_password)?;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE accounts SET password_hash = $1, role = 'super_admin', updated_at = $2 WHERE id = $3"
)
.bind(&password_hash)
.bind(&now)
.bind(&account_id)
.execute(pool)
.await?;
tracing::info!("已更新用户 {} 的密码和角色为 super_admin", admin_username);
} else {
// 创建新的 super_admin 账号
use crate::auth::password::hash_password;
let password_hash = hash_password(&admin_password)?;
let account_id = uuid::Uuid::new_v4().to_string();
let email = format!("{}@zclaw.local", admin_username);
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, 'super_admin', 'active', $6, $6)"
)
.bind(&account_id)
.bind(&admin_username)
.bind(&email)
.bind(&password_hash)
.bind(&admin_username)
.bind(&now)
.execute(pool)
.await?;
tracing::info!("自动创建 super_admin 账号: username={}, email={}", admin_username, email);
}
Ok(())
}
/// 种子化内置提示词模板(仅当表为空时)
async fn seed_builtin_prompts(pool: &PgPool) -> SaasResult<()> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM prompt_templates")
.fetch_one(pool).await?;
if count.0 > 0 {
return Ok(());
}
let now = chrono::Utc::now().to_rfc3339();
// reflection 提示词
let reflection_id = uuid::Uuid::new_v4().to_string();
let reflection_ver_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO prompt_templates (id, name, category, description, source, current_version, status, created_at, updated_at)
VALUES ($1, 'reflection', 'builtin_system', 'Agent 自我反思引擎', 'builtin', 1, 'active', $2, $2)"
).bind(&reflection_id).bind(&now).execute(pool).await?;
sqlx::query(
"INSERT INTO prompt_versions (id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at)
VALUES ($1, $2, 1, $3, $4, '[]', '初始版本', NULL, $5)"
).bind(&reflection_ver_id).bind(&reflection_id)
.bind("你是一个 AI Agent 的自我反思引擎。分析最近的对话历史,识别行为模式,并生成改进建议。\n\n输出 JSON 格式:\n{\n \"patterns\": [\n {\n \"observation\": \"观察到的模式描述\",\n \"frequency\": 数字,\n \"sentiment\": \"positive/negative/neutral\",\n \"evidence\": [\"证据1\", \"证据2\"]\n }\n ],\n \"improvements\": [\n {\n \"area\": \"改进领域\",\n \"suggestion\": \"具体建议\",\n \"priority\": \"high/medium/low\"\n }\n ],\n \"identityProposals\": []\n}")
.bind("分析以下对话历史,进行自我反思:\n\n{{context}}\n\n请识别行为模式(积极和消极),并提供具体的改进建议。")
.bind(&now).execute(pool).await?;
// compaction 提示词
let compaction_id = uuid::Uuid::new_v4().to_string();
let compaction_ver_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO prompt_templates (id, name, category, description, source, current_version, status, created_at, updated_at)
VALUES ($1, 'compaction', 'builtin_compaction', '对话上下文压缩', 'builtin', 1, 'active', $2, $2)"
).bind(&compaction_id).bind(&now).execute(pool).await?;
sqlx::query(
"INSERT INTO prompt_versions (id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at)
VALUES ($1, $2, 1, $3, $4, '[]', '初始版本', NULL, $5)"
).bind(&compaction_ver_id).bind(&compaction_id)
.bind("你是一个对话摘要专家。将长对话压缩为简洁的摘要,保留关键信息。\n\n要求:\n1. 保留所有重要决策和结论\n2. 保留用户偏好和约束\n3. 保留未完成的任务\n4. 保持时间顺序\n5. 摘要应能在后续对话中替代原始内容")
.bind("请将以下对话压缩为简洁摘要,保留关键信息:\n\n{{messages}}")
.bind(&now).execute(pool).await?;
// extraction 提示词
let extraction_id = uuid::Uuid::new_v4().to_string();
let extraction_ver_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO prompt_templates (id, name, category, description, source, current_version, status, created_at, updated_at)
VALUES ($1, 'extraction', 'builtin_extraction', '记忆提取引擎', 'builtin', 1, 'active', $2, $2)"
).bind(&extraction_id).bind(&now).execute(pool).await?;
sqlx::query(
"INSERT INTO prompt_versions (id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at)
VALUES ($1, $2, 1, $3, $4, '[]', '初始版本', NULL, $5)"
).bind(&extraction_ver_id).bind(&extraction_id)
.bind("你是一个记忆提取专家。从对话中提取值得长期记住的信息。\n\n提取类型:\n- fact: 用户告知的事实(如\"我的公司叫XXX\"\n- preference: 用户的偏好(如\"我喜欢简洁的回答\"\n- lesson: 本次对话的经验教训\n- task: 未完成的任务或承诺\n\n输出 JSON 数组:\n[\n {\n \"content\": \"记忆内容\",\n \"type\": \"fact/preference/lesson/task\",\n \"importance\": 1-10,\n \"tags\": [\"标签1\", \"标签2\"]\n }\n]")
.bind("从以下对话中提取值得长期记住的信息:\n\n{{conversation}}\n\n如果没有值得记忆的内容,返回空数组 []。")
.bind(&now).execute(pool).await?;
tracing::info!("Seeded 3 builtin prompt templates (reflection, compaction, extraction)");
Ok(())
}
#[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", "devices",
];
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);
}
}
// PostgreSQL 单元测试需要真实数据库连接,此处保留接口兼容
// 集成测试见 tests/integration_test.rs
}