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:
@@ -72,6 +72,17 @@ interface SaaSRefreshResponse {
|
||||
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 ===
|
||||
|
||||
export class SaaSApiError extends Error {
|
||||
@@ -186,8 +197,17 @@ export class SaaSClient {
|
||||
|
||||
// --- 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.
|
||||
*/
|
||||
private async request<T>(
|
||||
@@ -196,40 +216,66 @@ export class SaaSClient {
|
||||
body?: unknown,
|
||||
timeoutMs = 15000,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
const maxRetries = 2;
|
||||
const baseDelay = 1000;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
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}`, {
|
||||
method,
|
||||
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>;
|
||||
// Unreachable, but TypeScript needs it
|
||||
throw new SaaSApiError(0, 'UNKNOWN', '请求失败');
|
||||
}
|
||||
|
||||
// --- 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 ---
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user