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:
iven
2026-03-27 15:07:03 +08:00
parent 8cce2283f7
commit bc12f6899a
9 changed files with 1007 additions and 39 deletions

View File

@@ -5,7 +5,7 @@ use axum::{
Json,
};
use crate::state::AppState;
use crate::error::SaasResult;
use crate::error::{SaasError, SaasResult};
use crate::auth::types::AuthContext;
use crate::auth::handlers::{log_operation, check_permission};
use super::{types::*, service};
@@ -179,3 +179,97 @@ pub async fn dashboard_stats(
"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))
}