From 2cb92253e6c985713b0a06eed9b0bfe76c8e838e Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 17 Jun 2026 08:48:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(web/usercenter):=20http.ts=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=E7=9C=9F=E5=AE=9E=E5=90=8E=E7=AB=AF=E5=A5=91=E7=BA=A6?= =?UTF-8?q?(snake=E2=86=92camel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端保持 snake_case 扁平(app 共用),web 薄客户端做映射: - mapSession:{access_token,refresh_token,expires_in} → {accessToken,refreshToken,accessExpiresAt}。 login/loginTotp/refresh 统一走它。 - login:扁平 TokenPair → {kind:'session'};{totp_required,pending_token} → {kind:'totp_required'}。 - mapMe:snake → camel(devices_used/quota_today_min/weekly_gb/totp_enabled…,expires_at 截 YYYY-MM-DD)。 - listDevices:解包 {devices:[...]} + uuid→id、last_seen→lastActive。 - redeem:expires_at→expiresAt;totpSetup:otpauth_uri→otpauthUri。 - logout:带 X-Refresh-Token 头供后端撤销。 npm run build 通过(静态导出 + SRI)。 Co-Authored-By: Claude Opus 4.8 --- web/usercenter/lib/api/http.ts | 114 +++++++++++++++++++++++++++------ 1 file changed, 96 insertions(+), 18 deletions(-) diff --git a/web/usercenter/lib/api/http.ts b/web/usercenter/lib/api/http.ts index 2f15621..587867d 100644 --- a/web/usercenter/lib/api/http.ts +++ b/web/usercenter/lib/api/http.ts @@ -117,54 +117,132 @@ export class HttpClient implements ApiClient { } 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; + // 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 s = await this.request('/v1/auth/login/totp', { + const r = await this.request('/v1/auth/login/totp', { method: 'POST', body: { pending_token: pendingToken, code }, auth: false, }); - setSession(s); - return s; + 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' }); - const s = await this.request('/v1/auth/refresh', { + const r = await this.request('/v1/auth/refresh', { method: 'POST', auth: false, allowRefresh: false, refreshToken: rt, }); - setSession(s); - return s; + const session = mapSession(r); + setSession(session); + return session; } - getMe = () => this.request('/v1/me'); + getMe = async (): Promise => mapMe(await 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'); + 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 = (code: string) => this.request('/v1/me/redeem', { method: 'POST', body: { code } }); + 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 }; + }; - // 契约增补待 #1 合入:POST /v1/me/totp/setup|verify|disable - totpSetup = () => this.request('/v1/me/totp/setup', { method: 'POST' }); + 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 { - await this.request('/v1/auth/logout', { method: 'POST' }); + // 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, + }; +}