src/sync/apiClient.ts
Rama main · réplica de lectura del repositorio original.
// 后端 API 客户端:fetch 封装 + token 注入 + 超时 + 错误语义化
import { SETTING_KEYS } from '../constants';
import { getSetting } from '../database/ledgerDB';
import type { AuthUser, FamilyInfo, FamilyMember, LedgerInfo, PushResult, SyncChanges } from './serverTypes';
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
// 当前配置(server_url + token),未配置返回 null
export async function getSyncConfig(): Promise<{ baseUrl: string; token: string } | null> {
const [url, token] = await Promise.all([
getSetting(SETTING_KEYS.SYNC_SERVER_URL),
getSetting(SETTING_KEYS.SYNC_TOKEN),
]);
if (!url || !token) return null;
return { baseUrl: url.replace(/\/+$/, ''), token };
}
// 底层请求(默认 10s 超时,可传更短的探测超时)
async function request<T>(
baseUrl: string,
path: string,
init: { method: string; body?: unknown; token?: string; timeoutMs?: number },
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), init.timeoutMs ?? 10_000);
try {
const res = await fetch(`${baseUrl}${path}`, {
method: init.method,
headers: {
'Content-Type': 'application/json',
...(init.token ? { Authorization: `Bearer ${init.token}` } : {}),
},
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
signal: controller.signal,
});
const text = await res.text();
const data = text ? JSON.parse(text) : {};
if (!res.ok) {
throw new ApiError(data.error ?? `请求失败(${res.status})`, res.status);
}
return data as T;
} catch (e) {
if (e instanceof ApiError) throw e;
if ((e as Error).name === 'AbortError') throw new ApiError('连接超时,请检查网络', 0);
throw new ApiError('无法连接服务器', 0);
} finally {
clearTimeout(timer);
}
}
// ===== 认证 =====
export async function apiRegister(baseUrl: string, username: string, password: string, displayName?: string) {
return request<{ token: string; user: AuthUser }>(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username, password, displayName },
});
}
export async function apiLogin(baseUrl: string, username: string, password: string) {
return request<{ token: string; user: AuthUser }>(baseUrl, '/api/auth/login', {
method: 'POST',
body: { username, password },
});
}
export async function apiMe(baseUrl: string, token: string) {
return request<{ user: AuthUser }>(baseUrl, '/api/me', { method: 'GET', token });
}
// ===== 家庭 =====
export async function apiCreateFamily(baseUrl: string, token: string, name: string) {
return request<{ family: FamilyInfo; user: AuthUser }>(baseUrl, '/api/family', {
method: 'POST',
body: { name },
token,
});
}
export async function apiJoinFamily(baseUrl: string, token: string, inviteCode: string) {
return request<{ family: FamilyInfo; user: AuthUser }>(baseUrl, '/api/family/join', {
method: 'POST',
body: { inviteCode: inviteCode.trim().toUpperCase() },
token,
});
}
export async function apiGetFamily(baseUrl: string, token: string) {
return request<{ family: FamilyInfo | null }>(baseUrl, '/api/family', { method: 'GET', token });
}
export async function apiFamilyMembers(baseUrl: string, token: string) {
return request<{ members: FamilyMember[] }>(baseUrl, '/api/family/members', { method: 'GET', token });
}
export async function apiRegenerateInvite(baseUrl: string, token: string) {
return request<{ inviteCode: string }>(baseUrl, '/api/family/invite/regenerate', { method: 'POST', token });
}
export async function apiLeaveFamily(baseUrl: string, token: string) {
return request<{ ok: boolean }>(baseUrl, '/api/family/leave', { method: 'POST', token });
}
// owner 移除成员(v0.5)
export async function apiRemoveMember(baseUrl: string, token: string, userId: number) {
return request<{ ok: boolean }>(baseUrl, `/api/family/members/${userId}`, { method: 'DELETE', token });
}
// 修改自己的昵称/头像(v0.5)
export async function apiUpdateMe(
baseUrl: string,
token: string,
patch: { displayName?: string; avatarEmoji?: string },
) {
return request<{ user: AuthUser }>(baseUrl, '/api/me', { method: 'PUT', body: patch, token });
}
// 修改密码(server 0.5.6):成功返回本端新 token;其它设备的旧 token 因 token_version 递增立即失效
export async function apiChangePassword(
baseUrl: string,
token: string,
currentPassword: string,
newPassword: string,
) {
return request<{ ok: boolean; token: string }>(baseUrl, '/api/auth/password', {
method: 'POST',
body: { currentPassword, newPassword },
token,
});
}
// 注销账号(server 0.5.4 就有接口,安卓端此前一直没有入口):
// 需带当前密码复核;创建者注销=解散家庭账本,普通成员=解绑(本机副本保留)
export async function apiDeleteAccount(baseUrl: string, token: string, password: string) {
return request<{ ok: boolean; dissolvedFamily: boolean; unboundMembers: number }>(baseUrl, '/api/me', {
method: 'DELETE',
body: { password },
token,
});
}
// 当前用户的账本列表(个人账本 + 家庭账本)
export async function apiGetLedgers(baseUrl: string, token: string) {
return request<{ ledgers: LedgerInfo[] }>(baseUrl, '/api/ledgers', { method: 'GET', token });
}
// ===== 同步 =====
export async function apiSyncPull(baseUrl: string, token: string, since: number, ledgerId: number) {
return request<{ serverTime: number; changes: SyncChanges }>(baseUrl, '/api/sync/pull', {
method: 'POST',
body: { since, ledgerId },
token,
});
}
export async function apiSyncPush(
baseUrl: string,
token: string,
changes: Partial<SyncChanges>,
ledgerId: number,
) {
return request<PushResult>(baseUrl, '/api/sync/push', {
method: 'POST',
body: { ...changes, ledgerId },
token,
});
}
// 健康检查(配置服务器地址时探测可达性;同步页用它刷新「服务器可达」状态,默认 5s 短超时)
export async function apiHealth(baseUrl: string, timeoutMs = 5_000) {
// version 自服务端 0.5.8 起返回;旧服务端没这个字段,客户端按「未知」处理即可
return request<{ ok: boolean; version?: string; time: number }>(baseUrl, '/api/health', {
method: 'GET',
timeoutMs,
});
}