chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user