From a92841e91208d33fc7133bb6bec135ae9de80c41 Mon Sep 17 00:00:00 2001 From: steven_li Date: Mon, 16 Mar 2026 09:39:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=B7=BB=E5=8A=A0=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=92=8C=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E6=8E=A7=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 auth-client.ts 文件,提供登录、注册和前端跳转URL构建功能 - 实现 fetchJSON 工具函数,支持请求超时控制和错误处理 - 添加 runtime-control.ts 文件,提供部署控制API调用功能 - 实现标准化Token响应数据结构的功能 - 支持前端基础URL自动解析和手off码处理 --- auth-portal/src/lib/auth-client.ts | 107 ++++++++++++++++++++++ auth-portal/src/lib/runtime-control.ts | 117 +++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 auth-portal/src/lib/auth-client.ts create mode 100644 auth-portal/src/lib/runtime-control.ts diff --git a/auth-portal/src/lib/auth-client.ts b/auth-portal/src/lib/auth-client.ts new file mode 100644 index 0000000..8e5b1d1 --- /dev/null +++ b/auth-portal/src/lib/auth-client.ts @@ -0,0 +1,107 @@ +'use client'; + +import type { TokenResponse } from '@/types/auth'; + +const REQUEST_TIMEOUT_MS = 8000; + +function normalizeBaseUrl(value?: string | null): string | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + return trimmed.replace(/\/+$/, ''); +} + +function getFrontendBaseUrl(response: TokenResponse): string | null { + const explicit = normalizeBaseUrl(response.backend_connection?.frontend_base_url); + if (explicit) return explicit; + + const fromBackend = normalizeBaseUrl( + response.backend_connection?.api_base_url || + response.backend_connection?.public_base_url || + response.local_backend?.public_base_url + ); + return fromBackend; +} + +function buildApiUrl(path: string): string { + return path; +} + +async function fetchJSON(path: string, options?: RequestInit): Promise { + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(buildApiUrl(path), { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options?.headers || {}), + }, + signal: controller.signal, + }); + + if (!response.ok) { + const text = await response.text(); + let detail = text; + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed.detail === 'string') { + detail = parsed.detail; + } + } catch { + // keep raw text + } + throw new Error(`接口错误 ${response.status}: ${detail}`); + } + + return response.json(); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error('请求超时'); + } + throw error; + } finally { + window.clearTimeout(timeoutId); + } +} + +export async function login(username: string, password: string): Promise { + return fetchJSON('/api/runtime/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }); +} + +export async function register(username: string, email: string, password: string): Promise { + return fetchJSON('/api/runtime/register', { + method: 'POST', + body: JSON.stringify({ username, email, password }), + }); +} + +export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string { + const frontendBaseUrl = getFrontendBaseUrl(response); + if (!frontendBaseUrl) { + throw new Error('后端未返回目标前端地址'); + } + const handoffCode = response.handoff_code?.trim(); + if (!handoffCode) { + throw new Error('后端未返回 handoff code'); + } + + const url = new URL('/handoff', frontendBaseUrl); + url.searchParams.set('code', handoffCode); + if (nextPath) { + url.searchParams.set('next', nextPath); + } + return url.toString(); +} + +export function withNext(path: '/login' | '/register', nextPath: string): string { + const params = new URLSearchParams(); + if (nextPath) { + params.set('next', nextPath); + } + const query = params.toString(); + return query ? `${path}?${query}` : path; +} diff --git a/auth-portal/src/lib/runtime-control.ts b/auth-portal/src/lib/runtime-control.ts new file mode 100644 index 0000000..833f355 --- /dev/null +++ b/auth-portal/src/lib/runtime-control.ts @@ -0,0 +1,117 @@ +import type { TokenResponse } from '@/types/auth'; + +const DEPLOY_API_BASE_URL = (process.env.DEPLOY_API_BASE_URL || 'http://127.0.0.1:8090').trim().replace(/\/+$/, ''); +const DEPLOY_API_TOKEN = (process.env.DEPLOY_API_TOKEN || '').trim(); +const REQUEST_TIMEOUT_MS = 15000; + +type JsonObject = Record; + +export class HttpError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +function asObject(value: unknown): JsonObject { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}; +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers || {}), + }, + cache: 'no-store', + signal: controller.signal, + }); + + const raw = await response.text(); + let payload: unknown = {}; + if (raw) { + try { + payload = JSON.parse(raw); + } catch { + payload = { detail: raw }; + } + } + + if (!response.ok) { + const detail = asString(asObject(payload).detail) || `request failed with status ${response.status}`; + throw new HttpError(response.status, detail); + } + + return payload as T; + } catch (error) { + if (error instanceof HttpError) { + throw error; + } + if (error instanceof DOMException && error.name === 'AbortError') { + throw new HttpError(504, 'request timed out'); + } + throw new HttpError(502, error instanceof Error ? error.message : 'request failed'); + } finally { + clearTimeout(timeoutId); + } +} + +export async function callDeployControl(path: string, payload: JsonObject): Promise { + const headers: Record = {}; + if (DEPLOY_API_TOKEN) { + headers.Authorization = `Bearer ${DEPLOY_API_TOKEN}`; + } + return fetchJson(`${DEPLOY_API_BASE_URL}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); +} + +export async function callInstanceApi(apiBaseUrl: string, path: string, payload: JsonObject): Promise { + const baseUrl = apiBaseUrl.trim().replace(/\/+$/, ''); + if (!baseUrl) { + throw new HttpError(500, 'instance api base url is missing'); + } + return fetchJson(`${baseUrl}${path}`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function normalizeTokenResponse( + response: TokenResponse, + routing: { + frontend_base_url?: unknown; + api_base_url?: unknown; + public_url?: unknown; + } +): TokenResponse { + const frontendBaseUrl = asString(routing.frontend_base_url); + const apiBaseUrl = asString(routing.api_base_url) || asString(routing.public_url); + const publicUrl = asString(routing.public_url) || apiBaseUrl; + const backendConnection = asObject(response.backend_connection); + + const mergedBackendConnection = { + ...backendConnection, + frontend_base_url: asString(backendConnection.frontend_base_url) || frontendBaseUrl || publicUrl || null, + api_base_url: asString(backendConnection.api_base_url) || apiBaseUrl || publicUrl || null, + public_base_url: asString(backendConnection.public_base_url) || publicUrl || apiBaseUrl || null, + }; + + return { + ...response, + backend_connection: mergedBackendConnection, + }; +}