feat(saas): Phase 4 端到端完善 — 设备注册、离线支持、配置迁移、集成测试
- 后端: devices 表 + register/heartbeat/list 端点 (UPSERT 语义) - 桌面端: 设备 ID 持久化 + 5 分钟心跳 + 离线状态指示 - saas-client: 重试逻辑 (2 次指数退避) + isServerReachable 跟踪 - ConfigMigrationWizard: 3 步向导 (方向选择→冲突解决→结果) - SaaSSettings: 修改密码折叠面板 + 迁移向导入口 - 集成测试: 21 个测试全部通过 (含设备注册/UPSERT/心跳、密码修改、E2E 生命周期) - 修复 ConfigMigrationWizard merge 分支变量遮蔽 bug
This commit is contained in:
@@ -5,7 +5,7 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::error::SaasResult;
|
use crate::error::{SaasError, SaasResult};
|
||||||
use crate::auth::types::AuthContext;
|
use crate::auth::types::AuthContext;
|
||||||
use crate::auth::handlers::{log_operation, check_permission};
|
use crate::auth::handlers::{log_operation, check_permission};
|
||||||
use super::{types::*, service};
|
use super::{types::*, service};
|
||||||
@@ -179,3 +179,97 @@ pub async fn dashboard_stats(
|
|||||||
"tokens_today_output": tokens_today_output.0,
|
"tokens_today_output": tokens_today_output.0,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Devices ============
|
||||||
|
|
||||||
|
/// POST /api/v1/devices/register — 注册或更新设备
|
||||||
|
pub async fn register_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
Json(req): Json<serde_json::Value>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
let device_id = req.get("device_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| SaasError::InvalidInput("缺少 device_id".into()))?;
|
||||||
|
let device_name = req.get("device_name").and_then(|v| v.as_str()).unwrap_or("Unknown");
|
||||||
|
let platform = req.get("platform").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||||
|
let app_version = req.get("app_version").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let device_uuid = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// UPSERT: 已存在则更新 last_seen_at,不存在则插入
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO devices (id, account_id, device_id, device_name, platform, app_version, last_seen_at, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
|
||||||
|
ON CONFLICT(account_id, device_id) DO UPDATE SET
|
||||||
|
device_name = ?4, platform = ?5, app_version = ?6, last_seen_at = ?7"
|
||||||
|
)
|
||||||
|
.bind(&device_uuid)
|
||||||
|
.bind(&ctx.account_id)
|
||||||
|
.bind(device_id)
|
||||||
|
.bind(device_name)
|
||||||
|
.bind(platform)
|
||||||
|
.bind(app_version)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
log_operation(&state.db, &ctx.account_id, "device.register", "device", device_id,
|
||||||
|
Some(serde_json::json!({"device_name": device_name, "platform": platform})),
|
||||||
|
ctx.client_ip.as_deref()).await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"ok": true, "device_id": device_id})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/devices/heartbeat — 设备心跳
|
||||||
|
pub async fn device_heartbeat(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
Json(req): Json<serde_json::Value>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
let device_id = req.get("device_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| SaasError::InvalidInput("缺少 device_id".into()))?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE devices SET last_seen_at = ?1 WHERE account_id = ?2 AND device_id = ?3"
|
||||||
|
)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&ctx.account_id)
|
||||||
|
.bind(device_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(SaasError::NotFound("设备未注册".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/devices — 列出当前用户的设备
|
||||||
|
pub async fn list_devices(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
) -> SaasResult<Json<Vec<serde_json::Value>>> {
|
||||||
|
let rows: Vec<(String, String, Option<String>, Option<String>, Option<String>, String, String)> =
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT id, device_id, device_name, platform, app_version, last_seen_at, created_at
|
||||||
|
FROM devices WHERE account_id = ?1 ORDER BY last_seen_at DESC"
|
||||||
|
)
|
||||||
|
.bind(&ctx.account_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let items: Vec<serde_json::Value> = rows.into_iter().map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": r.0, "device_id": r.1,
|
||||||
|
"device_name": r.2, "platform": r.3, "app_version": r.4,
|
||||||
|
"last_seen_at": r.5, "created_at": r.6,
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,4 +17,7 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
|
|||||||
.route("/api/v1/tokens/{id}", delete(handlers::revoke_token))
|
.route("/api/v1/tokens/{id}", delete(handlers::revoke_token))
|
||||||
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
|
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
|
||||||
.route("/api/v1/stats/dashboard", get(handlers::dashboard_stats))
|
.route("/api/v1/stats/dashboard", get(handlers::dashboard_stats))
|
||||||
|
.route("/api/v1/devices", get(handlers::list_devices))
|
||||||
|
.route("/api/v1/devices/register", post(handlers::register_device))
|
||||||
|
.route("/api/v1/devices/heartbeat", post(handlers::device_heartbeat))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,21 @@ CREATE TABLE IF NOT EXISTS config_sync_log (
|
|||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sync_account ON config_sync_log(account_id);
|
CREATE INDEX IF NOT EXISTS idx_sync_account ON config_sync_log(account_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
device_name TEXT,
|
||||||
|
platform TEXT,
|
||||||
|
app_version TEXT,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
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);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
const SEED_ROLES: &str = r#"
|
const SEED_ROLES: &str = r#"
|
||||||
@@ -319,7 +334,7 @@ mod tests {
|
|||||||
let tables = [
|
let tables = [
|
||||||
"accounts", "api_tokens", "roles", "permission_templates",
|
"accounts", "api_tokens", "roles", "permission_templates",
|
||||||
"operation_logs", "providers", "models", "account_api_keys",
|
"operation_logs", "providers", "models", "account_api_keys",
|
||||||
"usage_records", "relay_tasks", "config_items", "config_sync_log",
|
"usage_records", "relay_tasks", "config_items", "config_sync_log", "devices",
|
||||||
];
|
];
|
||||||
for table in tables {
|
for table in tables {
|
||||||
let count: (i64,) = sqlx::query_as(&format!(
|
let count: (i64,) = sqlx::query_as(&format!(
|
||||||
|
|||||||
@@ -419,6 +419,356 @@ async fn test_config_seed_and_list() {
|
|||||||
assert_eq!(body.as_array().unwrap().len(), 0);
|
assert_eq!(body.as_array().unwrap().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Phase 4: 设备注册 + 密码修改 ============
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_device_register_and_list() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
let token = register_and_login(&app, "devuser", "devuser@example.com").await;
|
||||||
|
|
||||||
|
// 注册设备
|
||||||
|
let reg_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/devices/register")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"device_id": "desktop-test-001",
|
||||||
|
"device_name": "My Desktop",
|
||||||
|
"platform": "windows",
|
||||||
|
"app_version": "0.1.0"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(reg_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 列出设备 — 应该有 1 台
|
||||||
|
let list_req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/v1/devices")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(list_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.is_array());
|
||||||
|
assert_eq!(body.as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(body[0]["device_id"], "desktop-test-001");
|
||||||
|
assert_eq!(body[0]["platform"], "windows");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_device_upsert_on_reregister() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
let token = register_and_login(&app, "upsertdev", "upsertdev@example.com").await;
|
||||||
|
|
||||||
|
// 第一次注册
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/devices/register")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"device_id": "device-upsert-01",
|
||||||
|
"device_name": "Old Name",
|
||||||
|
"platform": "linux"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 同一 device_id 再次注册 (UPSERT → 更新 device_name 和 last_seen_at)
|
||||||
|
let req2 = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/devices/register")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"device_id": "device-upsert-01",
|
||||||
|
"device_name": "New Name",
|
||||||
|
"platform": "linux"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp2 = app.clone().oneshot(req2).await.unwrap();
|
||||||
|
assert_eq!(resp2.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 仍然只有 1 台设备,名称已更新
|
||||||
|
let list_req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/v1/devices")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(list_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_eq!(body.as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(body[0]["device_name"], "New Name");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_device_heartbeat() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
let token = register_and_login(&app, "hbuser", "hbuser@example.com").await;
|
||||||
|
|
||||||
|
// 先注册设备
|
||||||
|
let reg_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/devices/register")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"device_id": "hb-device-01",
|
||||||
|
"device_name": "Heartbeat Device"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
app.clone().oneshot(reg_req).await.unwrap();
|
||||||
|
|
||||||
|
// 发送心跳
|
||||||
|
let hb_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/devices/heartbeat")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"device_id": "hb-device-01"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(hb_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 心跳到未注册的设备 → 404
|
||||||
|
let bad_hb = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/devices/heartbeat")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"device_id": "nonexistent-device"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(bad_hb).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_device_register_missing_id() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
let token = register_and_login(&app, "baddev", "baddev@example.com").await;
|
||||||
|
|
||||||
|
// 缺少 device_id → 400
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/devices/register")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"device_name": "No ID Device"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_change_password() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
let token = register_and_login(&app, "pwduser", "pwduser@example.com").await;
|
||||||
|
|
||||||
|
// 修改密码
|
||||||
|
let change_req = Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri("/api/v1/auth/password")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"old_password": "password123",
|
||||||
|
"new_password": "newpassword456"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(change_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 用新密码重新登录
|
||||||
|
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": "pwduser",
|
||||||
|
"password": "newpassword456"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(login_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 用旧密码登录 → 401
|
||||||
|
let old_login = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"username": "pwduser",
|
||||||
|
"password": "password123"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(old_login).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_change_password_wrong_old() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
let token = register_and_login(&app, "wrongold", "wrongold@example.com").await;
|
||||||
|
|
||||||
|
// 旧密码错误 → 400
|
||||||
|
let change_req = Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri("/api/v1/auth/password")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"old_password": "wrong-old-password",
|
||||||
|
"new_password": "newpassword456"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(change_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Phase 4 E2E: 完整生命周期 ============
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_e2e_full_lifecycle() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
|
||||||
|
// 1. 注册
|
||||||
|
let token = register_and_login(&app, "e2euser", "e2e@example.com").await;
|
||||||
|
assert!(!token.is_empty());
|
||||||
|
|
||||||
|
// 2. 查看自己的账号信息
|
||||||
|
let me_req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/v1/auth/me")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(me_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 me: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
assert_eq!(me["username"], "e2euser");
|
||||||
|
|
||||||
|
// 3. 注册设备
|
||||||
|
let dev_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/devices/register")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"device_id": "e2e-device-001",
|
||||||
|
"device_name": "E2E Test Machine",
|
||||||
|
"platform": "windows",
|
||||||
|
"app_version": "1.0.0"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(dev_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 4. 创建 API Token
|
||||||
|
let tok_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/tokens")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"name": "e2e-token",
|
||||||
|
"permissions": ["model:read"]
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(tok_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 tok_body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
assert!(!tok_body["token"].is_null());
|
||||||
|
|
||||||
|
// 5. 修改密码
|
||||||
|
let pwd_req = Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri("/api/v1/auth/password")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"old_password": "password123",
|
||||||
|
"new_password": "e2e-new-password"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(pwd_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 6. 用新密码登录验证
|
||||||
|
let relogin_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&json!({
|
||||||
|
"username": "e2euser",
|
||||||
|
"password": "e2e-new-password"
|
||||||
|
})).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(relogin_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 login_body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
let new_token = login_body["token"].as_str().unwrap();
|
||||||
|
assert!(!new_token.is_empty());
|
||||||
|
|
||||||
|
// 7. 用新 token 列出设备
|
||||||
|
let list_req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/v1/devices")
|
||||||
|
.header("Authorization", auth_header(new_token))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(list_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 devs: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
assert_eq!(devs.as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
// 8. 用旧 token 应该仍可用 (JWT 未撤销)
|
||||||
|
let old_tok_list = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/v1/devices")
|
||||||
|
.header("Authorization", auth_header(&token))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(old_tok_list).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_config_sync() {
|
async fn test_config_sync() {
|
||||||
let app = build_test_app().await;
|
let app = build_test_app().await;
|
||||||
|
|||||||
339
desktop/src/components/SaaS/ConfigMigrationWizard.tsx
Normal file
339
desktop/src/components/SaaS/ConfigMigrationWizard.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { saasClient, type SaaSConfigItem } from '../../lib/saas-client';
|
||||||
|
import { ArrowLeft, ArrowRight, Upload, Check, Loader2, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LocalModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncDirection = 'local-to-saas' | 'saas-to-local' | 'merge';
|
||||||
|
|
||||||
|
interface SyncConflict {
|
||||||
|
key: string;
|
||||||
|
localValue: string | null;
|
||||||
|
saasValue: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigMigrationWizard({ onDone }: { onDone: () => void }) {
|
||||||
|
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||||
|
const [direction, setDirection] = useState<SyncDirection>('local-to-saas');
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
const [syncResult, setSyncResult] = useState<'success' | 'partial' | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const [localModels, setLocalModels] = useState<LocalModel[]>([]);
|
||||||
|
const [saasConfigs, setSaasConfigs] = useState<SaaSConfigItem[]>([]);
|
||||||
|
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Step 1: Load data
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 1) return;
|
||||||
|
|
||||||
|
// Load local models from localStorage
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('zclaw-custom-models');
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as LocalModel[];
|
||||||
|
setLocalModels(Array.isArray(parsed) ? parsed : []);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setLocalModels([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load SaaS config items
|
||||||
|
saasClient.listConfig().then(setSaasConfigs).catch(() => setSaasConfigs([]));
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
const localCount = localModels.length;
|
||||||
|
const saasCount = saasConfigs.length;
|
||||||
|
|
||||||
|
// Step 2: Compute conflicts based on direction
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 2) return;
|
||||||
|
|
||||||
|
const found: SyncConflict[] = [];
|
||||||
|
|
||||||
|
if (direction === 'local-to-saas' || direction === 'merge') {
|
||||||
|
// Check which local models already exist in SaaS
|
||||||
|
for (const model of localModels) {
|
||||||
|
const exists = saasConfigs.some((c) => c.key_path === `models.${model.id}`);
|
||||||
|
if (exists) {
|
||||||
|
found.push({
|
||||||
|
key: model.id,
|
||||||
|
localValue: JSON.stringify({ name: model.name, provider: model.provider }),
|
||||||
|
saasValue: '已存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === 'saas-to-local' || direction === 'merge') {
|
||||||
|
// SaaS configs that have values not in local
|
||||||
|
for (const config of saasConfigs) {
|
||||||
|
if (!config.current_value) continue;
|
||||||
|
const localRaw = localStorage.getItem('zclaw-custom-models');
|
||||||
|
const localModels: LocalModel[] = localRaw ? JSON.parse(localRaw) : [];
|
||||||
|
const isLocal = localModels.some((m) => m.id === config.key_path.replace('models.', ''));
|
||||||
|
if (!isLocal && config.category === 'model') {
|
||||||
|
found.push({
|
||||||
|
key: config.key_path,
|
||||||
|
localValue: null,
|
||||||
|
saasValue: config.current_value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setConflicts(found);
|
||||||
|
setSelectedKeys(new Set(found.map((c) => c.key)));
|
||||||
|
}, [step, direction, localModels, saasConfigs]);
|
||||||
|
|
||||||
|
// Step 3: Execute sync
|
||||||
|
async function executeSync() {
|
||||||
|
setIsSyncing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (direction === 'local-to-saas' && localModels.length > 0) {
|
||||||
|
// Push local models as config items
|
||||||
|
for (const model of localModels) {
|
||||||
|
const exists = saasConfigs.some((c) => c.key_path === `models.${model.id}`);
|
||||||
|
if (exists && !selectedKeys.has(model.id)) continue;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
category: 'model',
|
||||||
|
key_path: `models.${model.id}`,
|
||||||
|
value_type: 'json',
|
||||||
|
current_value: JSON.stringify({ name: model.name, provider: model.provider }),
|
||||||
|
source: 'desktop',
|
||||||
|
description: `从桌面端同步: ${model.name}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
await saasClient.request<unknown>('PUT', `/api/v1/config/items/${exists}`, body);
|
||||||
|
} else {
|
||||||
|
await saasClient.request<unknown>('POST', '/api/v1/config/items', body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (direction === 'saas-to-local' && saasConfigs.length > 0) {
|
||||||
|
// Pull SaaS models to local
|
||||||
|
const syncedModels = localModels.filter((m) => !selectedKeys.has(m.id));
|
||||||
|
const saasModels = saasConfigs
|
||||||
|
.filter((c) => c.category === 'model' && c.current_value)
|
||||||
|
.map((c) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(c.current_value!) as LocalModel;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((m): m is LocalModel => m !== null);
|
||||||
|
|
||||||
|
const merged = [...syncedModels, ...saasModels];
|
||||||
|
localStorage.setItem('zclaw-custom-models', JSON.stringify(merged));
|
||||||
|
} else if (direction === 'merge') {
|
||||||
|
// Merge: local wins for conflicts
|
||||||
|
const kept = localModels.filter((m) => !selectedKeys.has(m.id));
|
||||||
|
const saasOnly = saasConfigs
|
||||||
|
.filter((c) => c.category === 'model' && c.current_value)
|
||||||
|
.map((c) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(c.current_value!) as LocalModel;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((m): m is LocalModel => m !== null)
|
||||||
|
.filter((m) => !localModels.some((lm) => lm.id === m.id));
|
||||||
|
|
||||||
|
const merged = [...kept, ...saasOnly];
|
||||||
|
localStorage.setItem('zclaw-custom-models', JSON.stringify(merged));
|
||||||
|
}
|
||||||
|
|
||||||
|
setSyncResult(conflicts.length > 0 && conflicts.length === selectedKeys.size ? 'partial' : 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : '同步失败');
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
function reset() {
|
||||||
|
setStep(1);
|
||||||
|
setDirection('local-to-saas');
|
||||||
|
setSyncResult(null);
|
||||||
|
setError(null);
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Upload className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">配置迁移向导</span>
|
||||||
|
</div>
|
||||||
|
{step > 1 && (
|
||||||
|
<button onClick={() => setStep((step - 1) as 1 | 2)} className="text-xs text-gray-500 hover:text-gray-700 cursor-pointer">
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5 inline" /> 返回
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: Direction & Preview */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
选择迁移方向,检查本地和 SaaS 平台的配置差异。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<DirectionOption
|
||||||
|
label="本地 → SaaS"
|
||||||
|
description={`将 ${localCount} 个本地模型推送到 SaaS 平台`}
|
||||||
|
selected={direction === 'local-to-saas'}
|
||||||
|
onClick={() => setDirection('local-to-saas')}
|
||||||
|
/>
|
||||||
|
<DirectionOption
|
||||||
|
label="SaaS → 本地"
|
||||||
|
description={`从 SaaS 平台拉取 ${saasCount} 项配置到本地`}
|
||||||
|
selected={direction === 'saas-to-local'}
|
||||||
|
onClick={() => setDirection('saas-to-local')}
|
||||||
|
/>
|
||||||
|
<DirectionOption
|
||||||
|
label="双向合并"
|
||||||
|
description="合并两边配置,冲突时保留本地版本"
|
||||||
|
selected={direction === 'merge'}
|
||||||
|
onClick={() => setDirection('merge')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
disabled={localCount === 0 && saasCount === 0}
|
||||||
|
className="w-full py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
预览变更 <ArrowRight className="w-4 h-4 inline" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Resolve conflicts */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{conflicts.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-amber-600">
|
||||||
|
发现 {conflicts.length} 项冲突。勾选的项目将保留{direction === 'local-to-saas' ? '本地' : 'SaaS'}版本。
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{conflicts.map((c) => (
|
||||||
|
<label key={c.key} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50 cursor-pointer text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedKeys.has(c.key)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (e.target.checked) next.add(c.key);
|
||||||
|
else next.delete(c.key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-gray-800">{c.key}</span>
|
||||||
|
<span className="text-xs text-gray-400 truncate">
|
||||||
|
({direction === 'local-to-saas' ? '本地' : 'SaaS'}: {c.saasValue})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
<span>无冲突,可直接同步</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { setStep(3); executeSync(); }}
|
||||||
|
className="w-full py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
{isSyncing ? (
|
||||||
|
<><Loader2 className="w-4 h-4 inline animate-spin" /> 同步中...</>
|
||||||
|
) : (
|
||||||
|
<><ArrowRight className="w-4 h-4 inline" /> 执行同步</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Result */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{syncResult === 'success' ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
<span>配置同步成功完成</span>
|
||||||
|
</div>
|
||||||
|
) : syncResult === 'partial' ? (
|
||||||
|
<div className="flex items-center gap-2 text-amber-600">
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
<span>部分同步完成({conflicts.length} 项跳过)</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-sm text-red-500">{error}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="flex-1 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 inline" /> 重新开始
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDone}
|
||||||
|
className="flex-1 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectionOption({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
selected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full text-left p-3 rounded-lg border transition-colors cursor-pointer ${
|
||||||
|
selected ? 'border-emerald-500 bg-emerald-50' : 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-gray-800">{label}</div>
|
||||||
|
<div className="text-xs text-gray-500">{description}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useSaaSStore } from '../../store/saasStore';
|
import { useSaaSStore } from '../../store/saasStore';
|
||||||
import { SaaSLogin } from './SaaSLogin';
|
import { SaaSLogin } from './SaaSLogin';
|
||||||
import { SaaSStatus } from './SaaSStatus';
|
import { SaaSStatus } from './SaaSStatus';
|
||||||
|
import { ConfigMigrationWizard } from './ConfigMigrationWizard';
|
||||||
import { Cloud, Info, KeyRound } from 'lucide-react';
|
import { Cloud, Info, KeyRound } from 'lucide-react';
|
||||||
import { saasClient } from '../../lib/saas-client';
|
import { saasClient } from '../../lib/saas-client';
|
||||||
|
|
||||||
@@ -129,6 +130,16 @@ export function SaaSSettings() {
|
|||||||
|
|
||||||
{/* Password change section */}
|
{/* Password change section */}
|
||||||
{isLoggedIn && !showLogin && <ChangePasswordSection />}
|
{isLoggedIn && !showLogin && <ChangePasswordSection />}
|
||||||
|
|
||||||
|
{/* Config migration wizard */}
|
||||||
|
{isLoggedIn && !showLogin && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
数据迁移
|
||||||
|
</h2>
|
||||||
|
<ConfigMigrationWizard onDone={() => {/* no-op: wizard self-contained */}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface SaaSStatusProps {
|
|||||||
export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }: SaaSStatusProps) {
|
export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }: SaaSStatusProps) {
|
||||||
const availableModels = useSaaSStore((s) => s.availableModels);
|
const availableModels = useSaaSStore((s) => s.availableModels);
|
||||||
const fetchAvailableModels = useSaaSStore((s) => s.fetchAvailableModels);
|
const fetchAvailableModels = useSaaSStore((s) => s.fetchAvailableModels);
|
||||||
|
const [serverReachable, setServerReachable] = useState<boolean>(true);
|
||||||
|
|
||||||
const [checkingHealth, setCheckingHealth] = useState(false);
|
const [checkingHealth, setCheckingHealth] = useState(false);
|
||||||
const [healthOk, setHealthOk] = useState<boolean | null>(null);
|
const [healthOk, setHealthOk] = useState<boolean | null>(null);
|
||||||
@@ -25,6 +26,18 @@ export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }:
|
|||||||
}
|
}
|
||||||
}, [isLoggedIn, fetchAvailableModels]);
|
}, [isLoggedIn, fetchAvailableModels]);
|
||||||
|
|
||||||
|
// Poll server reachability every 30s
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn) return;
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
setServerReachable(saasClient.isServerReachable());
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
const timer = setInterval(check, 30000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
async function checkHealth() {
|
async function checkHealth() {
|
||||||
setCheckingHealth(true);
|
setCheckingHealth(true);
|
||||||
setHealthOk(null);
|
setHealthOk(null);
|
||||||
@@ -61,10 +74,17 @@ export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<div className="flex items-center gap-1.5 text-emerald-600 text-xs">
|
{serverReachable ? (
|
||||||
<Cloud className="w-3.5 h-3.5" />
|
<div className="flex items-center gap-1.5 text-emerald-600 text-xs">
|
||||||
<span>已连接</span>
|
<Cloud className="w-3.5 h-3.5" />
|
||||||
</div>
|
<span>已连接</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 text-amber-500 text-xs">
|
||||||
|
<CloudOff className="w-3.5 h-3.5" />
|
||||||
|
<span>离线</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDetails(!showDetails)}
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
className="px-2 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
className="px-2 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ interface SaaSRefreshResponse {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Device info stored on the SaaS backend */
|
||||||
|
export interface DeviceInfo {
|
||||||
|
id: string;
|
||||||
|
device_id: string;
|
||||||
|
device_name: string | null;
|
||||||
|
platform: string | null;
|
||||||
|
app_version: string | null;
|
||||||
|
last_seen_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
// === Error Class ===
|
// === Error Class ===
|
||||||
|
|
||||||
export class SaaSApiError extends Error {
|
export class SaaSApiError extends Error {
|
||||||
@@ -186,8 +197,17 @@ export class SaaSClient {
|
|||||||
|
|
||||||
// --- Core HTTP ---
|
// --- Core HTTP ---
|
||||||
|
|
||||||
|
/** Track whether the server appears reachable */
|
||||||
|
private _serverReachable: boolean = true;
|
||||||
|
|
||||||
|
/** Check if the SaaS server was last known to be reachable */
|
||||||
|
isServerReachable(): boolean {
|
||||||
|
return this._serverReachable;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make an authenticated request and parse the JSON response.
|
* Make an authenticated request with automatic retry on transient failures.
|
||||||
|
* Retries up to 2 times with exponential backoff (1s, 2s).
|
||||||
* Throws SaaSApiError on non-ok responses.
|
* Throws SaaSApiError on non-ok responses.
|
||||||
*/
|
*/
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
@@ -196,40 +216,66 @@ export class SaaSClient {
|
|||||||
body?: unknown,
|
body?: unknown,
|
||||||
timeoutMs = 15000,
|
timeoutMs = 15000,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers: Record<string, string> = {
|
const maxRetries = 2;
|
||||||
'Content-Type': 'application/json',
|
const baseDelay = 1000;
|
||||||
};
|
|
||||||
if (this.token) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
headers['Authorization'] = `Bearer ${this.token}`;
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (this.token) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
|
||||||
|
this._serverReachable = true;
|
||||||
|
|
||||||
|
// Handle 401 specially - caller may want to trigger re-auth
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = (await response.json().catch(() => null)) as SaaSErrorResponse | null;
|
||||||
|
throw new SaaSApiError(
|
||||||
|
response.status,
|
||||||
|
errorBody?.error || 'UNKNOWN',
|
||||||
|
errorBody?.message || `请求失败 (${response.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const isNetworkError = err instanceof TypeError
|
||||||
|
&& (err.message.includes('Failed to fetch') || err.message.includes('NetworkError'));
|
||||||
|
|
||||||
|
if (isNetworkError && attempt < maxRetries) {
|
||||||
|
this._serverReachable = false;
|
||||||
|
const delay = baseDelay * Math.pow(2, attempt);
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._serverReachable = false;
|
||||||
|
if (err instanceof SaaSApiError) throw err;
|
||||||
|
throw new SaaSApiError(0, 'NETWORK_ERROR', `网络错误: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
// Unreachable, but TypeScript needs it
|
||||||
method,
|
throw new SaaSApiError(0, 'UNKNOWN', '请求失败');
|
||||||
headers,
|
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle 401 specially - caller may want to trigger re-auth
|
|
||||||
if (response.status === 401) {
|
|
||||||
throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorBody = (await response.json().catch(() => null)) as SaaSErrorResponse | null;
|
|
||||||
throw new SaaSApiError(
|
|
||||||
response.status,
|
|
||||||
errorBody?.error || 'UNKNOWN',
|
|
||||||
errorBody?.message || `请求失败 (${response.status})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 204 No Content
|
|
||||||
if (response.status === 204) {
|
|
||||||
return undefined as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Health ---
|
// --- Health ---
|
||||||
@@ -304,6 +350,37 @@ export class SaaSClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Device Endpoints ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register or update this device with the SaaS backend.
|
||||||
|
* Uses UPSERT semantics — same (account, device_id) updates last_seen_at.
|
||||||
|
*/
|
||||||
|
async registerDevice(params: {
|
||||||
|
device_id: string;
|
||||||
|
device_name?: string;
|
||||||
|
platform?: string;
|
||||||
|
app_version?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.request<unknown>('POST', '/api/v1/devices/register', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a heartbeat to indicate the device is still active.
|
||||||
|
*/
|
||||||
|
async deviceHeartbeat(deviceId: string): Promise<void> {
|
||||||
|
await this.request<unknown>('POST', '/api/v1/devices/heartbeat', {
|
||||||
|
device_id: deviceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List devices registered for the current account.
|
||||||
|
*/
|
||||||
|
async listDevices(): Promise<DeviceInfo[]> {
|
||||||
|
return this.request<DeviceInfo[]>('GET', '/api/v1/devices');
|
||||||
|
}
|
||||||
|
|
||||||
// --- Model Endpoints ---
|
// --- Model Endpoints ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ import { createLogger } from '../lib/logger';
|
|||||||
|
|
||||||
const log = createLogger('SaaSStore');
|
const log = createLogger('SaaSStore');
|
||||||
|
|
||||||
|
// === Device ID ===
|
||||||
|
|
||||||
|
/** Generate or load a persistent device ID for this browser instance */
|
||||||
|
function getOrCreateDeviceId(): string {
|
||||||
|
const KEY = 'zclaw-device-id';
|
||||||
|
const existing = localStorage.getItem(KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
localStorage.setItem(KEY, newId);
|
||||||
|
return newId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_ID = getOrCreateDeviceId();
|
||||||
|
|
||||||
// === Types ===
|
// === Types ===
|
||||||
|
|
||||||
export type ConnectionMode = 'tauri' | 'gateway' | 'saas';
|
export type ConnectionMode = 'tauri' | 'gateway' | 'saas';
|
||||||
@@ -49,6 +63,7 @@ export interface SaaSActionsSlice {
|
|||||||
logout: () => void;
|
logout: () => void;
|
||||||
setConnectionMode: (mode: ConnectionMode) => void;
|
setConnectionMode: (mode: ConnectionMode) => void;
|
||||||
fetchAvailableModels: () => Promise<void>;
|
fetchAvailableModels: () => Promise<void>;
|
||||||
|
registerCurrentDevice: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
restoreSession: () => void;
|
restoreSession: () => void;
|
||||||
}
|
}
|
||||||
@@ -138,6 +153,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register device and start heartbeat in background
|
||||||
|
get().registerCurrentDevice().catch((err: unknown) => {
|
||||||
|
log.warn('Failed to register device:', err);
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch available models in background (non-blocking)
|
// Fetch available models in background (non-blocking)
|
||||||
get().fetchAvailableModels().catch((err: unknown) => {
|
get().fetchAvailableModels().catch((err: unknown) => {
|
||||||
log.warn('Failed to fetch models after login:', err);
|
log.warn('Failed to fetch models after login:', err);
|
||||||
@@ -209,6 +229,10 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
get().registerCurrentDevice().catch((err: unknown) => {
|
||||||
|
log.warn('Failed to register device after register:', err);
|
||||||
|
});
|
||||||
|
|
||||||
get().fetchAvailableModels().catch((err: unknown) => {
|
get().fetchAvailableModels().catch((err: unknown) => {
|
||||||
log.warn('Failed to fetch models after register:', err);
|
log.warn('Failed to fetch models after register:', err);
|
||||||
});
|
});
|
||||||
@@ -271,6 +295,41 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
registerCurrentDevice: async () => {
|
||||||
|
const { isLoggedIn, authToken, saasUrl } = get();
|
||||||
|
|
||||||
|
if (!isLoggedIn || !authToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
saasClient.setBaseUrl(saasUrl);
|
||||||
|
saasClient.setToken(authToken);
|
||||||
|
await saasClient.registerDevice({
|
||||||
|
device_id: DEVICE_ID,
|
||||||
|
device_name: `${navigator.userAgent.split(' ').slice(0, 3).join(' ')}`,
|
||||||
|
platform: navigator.platform,
|
||||||
|
app_version: __APP_VERSION__ || 'unknown',
|
||||||
|
});
|
||||||
|
log.info('Device registered successfully');
|
||||||
|
|
||||||
|
// Start periodic heartbeat (every 5 minutes)
|
||||||
|
if (typeof window !== 'undefined' && !get()._heartbeatTimer) {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
const state = get();
|
||||||
|
if (state.isLoggedIn && state.authToken) {
|
||||||
|
saasClient.deviceHeartbeat(DEVICE_ID).catch(() => {});
|
||||||
|
} else {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
set({ _heartbeatTimer: timer } as unknown as Partial<SaaSStore>);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
log.warn('Failed to register device:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearError: () => {
|
clearError: () => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user