Ctrl K
ComunidadGuías…

src/sync/apiClient.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 后端 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,
  });
}