// http.ts — HttpClient:真实后端薄客户端(阶段 B 联调)。 // 特性:API 域名池(故障转移) + 指数退避重试 + 统一错误体解析 + 静默续期。 import { ApiClient, ApiError, ApiErrorBody, Device, LoginResult, Me, RedeemResult, Session, SubscriptionInfo, TotpSetup, } from './types'; import { accessValid, clearSession, getAccessToken, getRefreshToken, setSession, } from './session'; function domains(): string[] { const raw = process.env.NEXT_PUBLIC_API_DOMAINS || ''; const list = raw.split(',').map((s) => s.trim()).filter(Boolean); return list.length ? list : ['/api']; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); interface ReqOpts { method?: string; body?: unknown; auth?: boolean; /** 401 时是否尝试静默续期后重放(默认 true,refresh 自身置 false 防递归) */ allowRefresh?: boolean; refreshToken?: string; } export class HttpClient implements ApiClient { private refreshing: Promise | null = null; /** 跨域名池 + 退避的核心请求 */ private async request(path: string, opts: ReqOpts = {}): Promise { const { method = 'GET', body, auth = true, allowRefresh = true } = opts; if (auth && !accessValid() && getRefreshToken()) { await this.ensureFresh(); } const pool = domains(); let lastErr: unknown; for (let attempt = 0; attempt < pool.length * 2; attempt++) { const base = pool[attempt % pool.length]; try { const headers: Record = { Accept: 'application/json' }; if (body !== undefined) headers['Content-Type'] = 'application/json'; const token = getAccessToken(); if (auth && token) headers['Authorization'] = `Bearer ${token}`; if (opts.refreshToken) headers['X-Refresh-Token'] = opts.refreshToken; const res = await fetch(base + path, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, credentials: 'omit', }); if (res.status === 401 && auth && allowRefresh && getRefreshToken()) { await this.ensureFresh(true); // 续期后重放一次(不再允许二次续期) return this.request(path, { ...opts, allowRefresh: false }); } if (!res.ok) throw await this.toApiError(res); if (res.status === 204) return undefined as T; return (await res.json()) as T; } catch (err) { lastErr = err; // 业务错误(ApiError)不重试;仅网络/5xx 才退避换域名 if (err instanceof ApiError && err.code !== 'network') throw err; await sleep(Math.min(1000 * 2 ** attempt, 4000)); } } throw lastErr instanceof ApiError ? lastErr : new ApiError({ code: 'network', message_zh: '网络异常,请稍后重试', message_en: 'Network error, please retry' }); } private async toApiError(res: Response): Promise { let bodyJson: Partial = {}; try { bodyJson = await res.json(); } catch { /* non-json error */ } const code = bodyJson.code || (res.status === 401 ? 'unauthorized' : 'unknown'); const retryAfter = Number(res.headers.get('Retry-After')) || undefined; return new ApiError( { code, message_zh: bodyJson.message_zh || '', message_en: bodyJson.message_en || '', }, retryAfter, ); } private ensureFresh(force = false): Promise { if (!force && accessValid()) return Promise.resolve(null as unknown as Session); if (this.refreshing) return this.refreshing; this.refreshing = this.refresh().finally(() => { this.refreshing = null; }); return this.refreshing; } async login(email: string, password: string): Promise { const r = await this.request< { kind: 'session'; session: Session } | { kind: 'totp_required'; pending_token: string } >('/v1/auth/login', { method: 'POST', body: { email, password }, auth: false }); if ('pending_token' in r) return { kind: 'totp_required', pendingToken: r.pending_token }; setSession(r.session); return r; } async loginTotp(pendingToken: string, code: string): Promise { const s = await this.request('/v1/auth/login/totp', { method: 'POST', body: { pending_token: pendingToken, code }, auth: false, }); setSession(s); return s; } async refresh(): Promise { const rt = getRefreshToken(); if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' }); const s = await this.request('/v1/auth/refresh', { method: 'POST', auth: false, allowRefresh: false, refreshToken: rt, }); setSession(s); return s; } getMe = () => this.request('/v1/me'); getSubscription = () => this.request('/v1/me/subscription'); resetSubscription = () => this.request('/v1/me/subscription/reset', { method: 'POST' }); listDevices = () => this.request('/v1/me/devices'); removeDevice = (id: string) => this.request(`/v1/me/devices/${encodeURIComponent(id)}`, { method: 'DELETE' }); redeem = (code: string) => this.request('/v1/me/redeem', { method: 'POST', body: { code } }); // 契约增补待 #1 合入:POST /v1/me/totp/setup|verify|disable totpSetup = () => this.request('/v1/me/totp/setup', { method: 'POST' }); totpVerify = (code: string) => this.request('/v1/me/totp/verify', { method: 'POST', body: { code } }); totpDisable = (code: string) => this.request('/v1/me/totp/disable', { method: 'POST', body: { code } }); async logout(): Promise { try { await this.request('/v1/auth/logout', { method: 'POST' }); } finally { clearSession(); } } }