// http.ts — HttpClient:真实后端薄客户端(阶段 B 联调)。 // 特性:API 域名池(故障转移) + 指数退避重试 + 统一错误体解析 + 静默续期。 import { ApiClient, ApiError, ApiErrorBody, Device, LoginResult, Me, RedeemResult, Session, SubscriptionInfo, TotpSetup, } from './types'; import { accessValid, clearSession, getAccessToken, getRefreshToken, setEmail, 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 { // Backend returns either a flat TokenPair, or {totp_required, pending_token} // when 2FA is enabled (app shares this endpoint, so the shape stays flat). const r = await this.request( '/v1/auth/login', { method: 'POST', body: { email, password }, auth: false }, ); if (r.totp_required && r.pending_token) { return { kind: 'totp_required', pendingToken: r.pending_token }; } const session = mapSession(r); setSession(session); return { kind: 'session', session }; } async loginTotp(pendingToken: string, code: string): Promise { const r = await this.request('/v1/auth/login/totp', { method: 'POST', body: { pending_token: pendingToken, code }, auth: false, }); const session = mapSession(r); setSession(session); return session; } // App→Web 免登录:POST /v1/auth/web-ticket/exchange(公开端点,无 Authorization 头)。 // 服务端消费一次性票据后返回与 /v1/auth/login 相同的扁平 TokenPair(不会走 TOTP 分支—— // 票据本身已代表 App 内已完成的完整登录),故直接映射 session 并 setSession,与 // login()/loginTotp() 落地同一份会话状态。 async exchangeWebTicket(ticket: string): Promise { const r = await this.request('/v1/auth/web-ticket/exchange', { method: 'POST', body: { ticket }, auth: false, }); const session = mapSession(r); setSession(session); return session; } async refresh(): Promise { const rt = getRefreshToken(); if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' }); // 服务端 /v1/auth/refresh 从 **请求体** {refresh_token} 读取(与原生 Flutter 客户端 // auth_api.dart 一致);此处曾误用 X-Refresh-Token 头(仅 /auth/logout 读头),导致 // 服务端拿到空 token → 400 invalid_request → 每次刷新页面即被登出。改回 body。 const r = await this.request('/v1/auth/refresh', { method: 'POST', auth: false, allowRefresh: false, body: { refresh_token: rt }, }); const session = mapSession(r); setSession(session); return session; } getMe = async (): Promise => { const me = mapMe(await this.request('/v1/me')); setEmail(me.email); // 同源官网读取显示用户名;clearSession/logout 时删除 return me; }; getSubscription = () => this.request('/v1/me/subscription'); resetSubscription = () => this.request('/v1/me/subscription/reset', { method: 'POST' }); listDevices = async (): Promise => { const r = await this.request<{ devices: RawDevice[] }>('/v1/me/devices'); return (r.devices ?? []).map(mapDevice); }; removeDevice = (id: string) => this.request(`/v1/me/devices/${encodeURIComponent(id)}`, { method: 'DELETE' }); redeem = async (code: string): Promise => { const r = await this.request<{ plan: string; expires_at?: string }>('/v1/me/redeem', { method: 'POST', body: { code }, }); return { plan: (r.plan as RedeemResult['plan']) ?? 'free', expiresAt: r.expires_at ?? null }; }; totpSetup = async (): Promise => { const r = await this.request<{ secret: string; otpauth_uri: string }>('/v1/me/totp/setup', { method: 'POST' }); return { secret: r.secret, otpauthUri: r.otpauth_uri }; }; 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 { // Server revokes the refresh JTI; the token is sent via X-Refresh-Token. await this.request('/v1/auth/logout', { method: 'POST', refreshToken: getRefreshToken() ?? undefined, }); } finally { clearSession(); } } } // ── 后端(snake_case) → 前端(camelCase)映射 ─────────────────────────────────── interface RawTokenPair { access_token: string; refresh_token: string; expires_in: number; // seconds } interface RawMe { email: string; plan: string; expires_at: string | null; devices_used: number; devices_max: number; quota_today_min: number | null; data_today_gb: number; weekly_gb: number[]; totp_enabled: boolean; } interface RawDevice { uuid: string; name: string; platform: string; last_seen: string | null; } function mapSession(r: RawTokenPair): Session { return { accessToken: r.access_token, refreshToken: r.refresh_token, accessExpiresAt: Date.now() + (r.expires_in ?? 0) * 1000, }; } function mapMe(r: RawMe): Me { return { email: r.email, plan: (r.plan as Me['plan']) ?? 'free', expiresAt: r.expires_at ? r.expires_at.slice(0, 10) : null, // YYYY-MM-DD devicesUsed: r.devices_used ?? 0, devicesMax: r.devices_max ?? 0, quotaTodayMin: r.quota_today_min ?? null, dataTodayGB: r.data_today_gb ?? 0, weeklyGB: r.weekly_gb ?? [], totpEnabled: !!r.totp_enabled, }; } function mapDevice(d: RawDevice): Device { return { id: d.uuid, name: d.name, platform: d.platform, lastActive: d.last_seen ?? '', current: false, }; }