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,
|
||||
};
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -17,4 +17,7 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
|
||||
.route("/api/v1/tokens/{id}", delete(handlers::revoke_token))
|
||||
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
|
||||
.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
|
||||
);
|
||||
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#"
|
||||
@@ -319,7 +334,7 @@ mod tests {
|
||||
let tables = [
|
||||
"accounts", "api_tokens", "roles", "permission_templates",
|
||||
"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 {
|
||||
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);
|
||||
}
|
||||
|
||||
// ============ 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]
|
||||
async fn test_config_sync() {
|
||||
let app = build_test_app().await;
|
||||
|
||||
Reference in New Issue
Block a user