import type { AcceptSynastryInviteBody, ApiResponse, AskMessage, AskQuota, AskThread, AuthSession, AuthUser, CreateOrderBody, CreateProfileBody, ExploreCategory, GrowthCheckin, GrowthPlan, GrowthReport, HomeDailyTips, ImageCardDrawn, ImageCardQuota, ImageCardScene, MembershipMe, MoodEntry, Profile, RelationInsightResult, ScaleDetail, ScaleSubmitResult, ScaleSummary, SolarTermToday, SynastryInviteCreated, SynastryInviteView, SynastryNearbyItem, UpdateProfileBody, } from '@yuxingu/types' /** Platform adapters so H5 and mini-program share one client. */ export interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' path: string body?: unknown headers?: Record /** Prefer for unload flush (maps to fetch keepalive). */ keepalive?: boolean } export interface ClientAdapters { request: (opts: RequestOptions) => Promise & { headers?: Headers }> getToken?: () => string | null | Promise getDeviceKey?: () => string | null setDeviceKey?: (key: string) => void } export interface CreateClientOptions { baseURL: string adapters: ClientAdapters } /** * createClient builds a typed API facade aligned with proto/openapi.yaml paths. */ export function createClient(opts: CreateClientOptions) { const { baseURL, adapters } = opts async function call(path: string, init?: Omit): Promise { const token = adapters.getToken ? await adapters.getToken() : null const deviceKey = adapters.getDeviceKey ? adapters.getDeviceKey() : null const headers: Record = { ...(init?.headers || {}) } if (token) headers.Authorization = `Bearer ${token}` if (deviceKey) headers['X-Device-Key'] = deviceKey const res = await adapters.request({ method: init?.method || 'GET', path: joinURL(baseURL, path), body: init?.body, headers, keepalive: init?.keepalive, }) const newKey = res.headers?.get?.('X-Device-Key') || res.headers?.get?.('x-device-key') if (newKey && adapters.setDeviceKey) adapters.setDeviceKey(newKey) if (res.code !== 0) { throw new Error(res.message || `api error ${res.code}`) } return res.data as T } return { healthz: () => call<{ status: string }>('/api/v1/healthz'), ping: () => call<{ pong: boolean }>('/api/v1/ping'), authRegister: (body: { phone: string; password: string; nickname?: string }) => call('/api/v1/auth/register', { method: 'POST', body }), authLogin: (body: { phone: string; password: string }) => call('/api/v1/auth/login', { method: 'POST', body }), authLogout: () => call<{ ok: boolean }>('/api/v1/auth/logout', { method: 'POST' }), authMe: () => call('/api/v1/auth/me'), authUpdateMe: (body: { nickname: string }) => call('/api/v1/auth/me', { method: 'PATCH', body }), authUploadAvatar: (file: Blob, filename = 'avatar.jpg') => { const fd = new FormData() fd.append('file', file, filename) return call('/api/v1/auth/me/avatar', { method: 'POST', body: fd }) }, listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'), createProfile: (body: CreateProfileBody) => call('/api/v1/profiles', { method: 'POST', body }), updateProfile: (id: string, body: UpdateProfileBody) => call(`/api/v1/profiles/${id}`, { method: 'PATCH', body }), deleteProfile: (id: string) => call<{ deleted: boolean }>(`/api/v1/profiles/${id}`, { method: 'DELETE' }), createPortrait: (profile_id: string) => call('/api/v1/reports/portrait', { method: 'POST', body: { profile_id } }), createStar: (profile_id: string) => call('/api/v1/reports/star', { method: 'POST', body: { profile_id } }), createSynastry: (profile_id_a: string, profile_id_b: string, as_of?: string) => call('/api/v1/reports/synastry', { method: 'POST', body: { profile_id_a, profile_id_b, ...(as_of ? { as_of } : {}) }, }), getLatestReport: (profile_id: string, type: string, peer_profile_id?: string) => { const q = new URLSearchParams({ profile_id, type }) if (peer_profile_id) q.set('peer_profile_id', peer_profile_id) return call(`/api/v1/reports/latest?${q.toString()}`) }, listSynastryNearby: (lat: number, lng: number, radius_km = 50) => call<{ items: SynastryNearbyItem[] }>( `/api/v1/synastry/nearby?lat=${lat}&lng=${lng}&radius_km=${radius_km}`, ), createSynastryInvite: (profile_id: string) => call('/api/v1/synastry/invites', { method: 'POST', body: { profile_id }, }), getSynastryInvite: (token: string) => call(`/api/v1/synastry/invites/${token}`), acceptSynastryInvite: (token: string, body: AcceptSynastryInviteBody) => call(`/api/v1/synastry/invites/${token}/accept`, { method: 'POST', body }), createRhythm: (profile_id: string) => call('/api/v1/reports/rhythm', { method: 'POST', body: { profile_id } }), listImageCardScenes: () => call<{ items: ImageCardScene[] }>('/api/v1/image-cards/scenes'), getImageCardQuota: () => call('/api/v1/image-cards/quota'), drawImageCard: (body: { profile_id: string; scene?: string; depth?: boolean }) => call('/api/v1/image-cards/draw', { method: 'POST', body }), getSolarTermsToday: () => call('/api/v1/solar-terms/today'), saveMood: (body: { score?: number; note?: string; day?: string }) => call('/api/v1/moods', { method: 'POST', body }), getMoodToday: () => call<{ mood: MoodEntry | null }>('/api/v1/moods/today'), getMoodsRecent: () => call<{ items: MoodEntry[] }>('/api/v1/moods/recent'), getExploreCatalog: () => call<{ categories: ExploreCategory[] }>('/api/v1/explore/catalog'), getExploreCategory: (key: string) => call(`/api/v1/explore/catalog/${key}`), listGrowthPlans: () => call<{ items: GrowthPlan[] }>('/api/v1/growth/plans'), createGrowthPlan: (body: { title: string; focus?: string }) => call('/api/v1/growth/plans', { method: 'POST', body }), createGrowthCheckin: (planId: string, body?: { note?: string }) => call<{ id: string; day: string }>(`/api/v1/growth/plans/${planId}/checkin`, { method: 'POST', body: body || {}, }), listGrowthCheckins: (planId: string) => call<{ items: GrowthCheckin[] }>(`/api/v1/growth/plans/${planId}/checkins`), listReports: () => call<{ items: GrowthReport[] }>('/api/v1/reports'), getReport: (id: string) => call(`/api/v1/reports/${id}`), getMembership: () => call('/api/v1/membership/me'), createOrder: (body: CreateOrderBody) => call<{ order_id: string }>('/api/v1/orders', { method: 'POST', body }), payMock: (orderId: string) => call<{ paid: boolean }>(`/api/v1/orders/${orderId}/pay-mock`, { method: 'POST' }), createRelationInsight: (profile_a_id: string, profile_b_id: string) => call('/api/v1/relation/insight', { method: 'POST', body: { profile_a_id, profile_b_id }, }), listScales: () => call<{ items: ScaleSummary[] }>('/api/v1/scales'), getScaleBankCatalog: () => call<{ featured: { slug: string title: string description: string category_key: string icon: string path: string }[] categories: { key: string title: string description: string icon: string count: number featured: { slug: string title: string description: string category_key: string icon: string path: string }[] path: string }[] }>('/api/v1/scale-bank/catalog'), getScaleBankCategory: (key: string) => call<{ category: { key: string title: string description: string icon: string count: number path: string } items: { slug: string title: string description: string icon?: string question_count: number path: string }[] }>(`/api/v1/scale-bank/categories/${key}`), getScale: (slug: string) => call(`/api/v1/scales/${slug}`), getScaleResult: (slug: string) => call(`/api/v1/scales/${slug}/result`), submitScale: (slug: string, profile_id: string, answers: Record) => call(`/api/v1/scales/${slug}/result`, { method: 'POST', body: { profile_id, answers }, }), getAskQuota: () => call('/api/v1/ask/quota'), createAskThread: (body: { profile_id: string; scene?: string }) => call('/api/v1/ask/threads', { method: 'POST', body }), clearAskThread: (threadId: string) => call<{ cleared: boolean }>(`/api/v1/ask/threads/${threadId}`, { method: 'DELETE' }), listAskMessages: (threadId: string) => call<{ items: AskMessage[] }>(`/api/v1/ask/threads/${threadId}/messages`), sendAskMessage: (threadId: string, content: string) => call<{ user_message: AskMessage assistant_message: AskMessage quota: AskQuota }>(`/api/v1/ask/threads/${threadId}/messages`, { method: 'POST', body: { content }, }), sendAskMessageStream: ( threadId: string, content: string, handlers: { onMeta?: (user_message: AskMessage) => void onDelta?: (text: string) => void onDone?: (payload: { assistant_message: AskMessage; quota: AskQuota }) => void onError?: (message: string, code?: number) => void }, ) => streamAskMessage(opts, threadId, content, handlers), postAnalyticsEvents: ( body: { items: Array<{ name: string session_id: string page_path?: string client_ts: string | number props?: Record }> }, init?: { keepalive?: boolean }, ) => call<{ accepted: number }>('/api/v1/analytics/events', { method: 'POST', body, keepalive: init?.keepalive, }), getHomeTools: () => call<{ items: Array<{ id: string row_index: number sort_order: number path: string icon: string label: string badge?: string | null badge_tone?: string | null enabled?: boolean }> }>('/api/v1/home/tools'), getHomeDailyTips: () => call('/api/v1/home/daily-tips'), } } async function streamAskMessage( opts: CreateClientOptions, threadId: string, content: string, handlers: { onMeta?: (user_message: AskMessage) => void onDelta?: (text: string) => void onDone?: (payload: { assistant_message: AskMessage; quota: AskQuota }) => void onError?: (message: string, code?: number) => void }, ): Promise { const { baseURL, adapters } = opts const token = adapters.getToken ? await adapters.getToken() : null const deviceKey = adapters.getDeviceKey ? adapters.getDeviceKey() : null const headers: Record = { 'Content-Type': 'application/json', Accept: 'text/event-stream', } if (token) headers.Authorization = `Bearer ${token}` if (deviceKey) headers['X-Device-Key'] = deviceKey const path = joinURL(baseURL, `/api/v1/ask/threads/${threadId}/messages?stream=1`) const res = await fetch(path, { method: 'POST', headers, body: JSON.stringify({ content }), }) const newKey = res.headers.get('X-Device-Key') || res.headers.get('x-device-key') if (newKey && adapters.setDeviceKey) adapters.setDeviceKey(newKey) if (!res.ok || !res.body) { let msg = `HTTP ${res.status}` try { const j = (await res.json()) as ApiResponse if (j.message) msg = j.message handlers.onError?.(msg, j.code) } catch { handlers.onError?.(msg) } throw new Error(msg) } const reader = res.body.getReader() const decoder = new TextDecoder('utf-8') let buffer = '' let eventName = 'message' let sawDone = false const flushBlock = (block: string) => { const lines = block.split('\n') let dataLines: string[] = [] eventName = 'message' for (const line of lines) { if (line.startsWith('event:')) { eventName = line.slice(6).trim() } else if (line.startsWith('data:')) { dataLines.push(line.slice(5).trimStart()) } } if (!dataLines.length) return const raw = dataLines.join('\n') let payload: Record try { payload = JSON.parse(raw) as Record } catch { return } if (eventName === 'meta' && payload.user_message) { handlers.onMeta?.(payload.user_message as AskMessage) } else if (eventName === 'delta' && typeof payload.text === 'string') { handlers.onDelta?.(payload.text) } else if (eventName === 'done') { sawDone = true handlers.onDone?.(payload as { assistant_message: AskMessage; quota: AskQuota }) } else if (eventName === 'error') { const message = String(payload.message || '流式回复失败') const code = typeof payload.code === 'number' ? payload.code : undefined handlers.onError?.(message, code) throw new Error(message) } } while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const parts = buffer.split('\n\n') buffer = parts.pop() || '' for (const block of parts) { if (block.trim()) flushBlock(block) } } if (buffer.trim()) flushBlock(buffer) if (!sawDone) { const msg = '回复中断,请重试' handlers.onError?.(msg) throw new Error(msg) } } /** Browser fetch adapter for user-h5. */ export function createBrowserAdapters(): ClientAdapters { const deviceKeyStorage = 'yxg_device_key' return { request: async ({ method = 'GET', path, body, headers, keepalive }: RequestOptions) => { const isForm = typeof FormData !== 'undefined' && body instanceof FormData const res = await fetch(path, { method, headers: { ...(isForm ? {} : { 'Content-Type': 'application/json' }), ...(headers || {}), }, body: body === undefined ? undefined : isForm ? (body as FormData) : JSON.stringify(body), keepalive: keepalive === true, }) const text = await res.text() let json: ApiResponse try { json = JSON.parse(text) as ApiResponse } catch { throw new Error( `接口返回异常(HTTP ${res.status}),请确认 API 已重启且代理指向 :8080`, ) } return Object.assign(json, { headers: res.headers }) }, getToken: () => localStorage.getItem('yxg_token'), getDeviceKey: () => localStorage.getItem(deviceKeyStorage), setDeviceKey: (key: string) => localStorage.setItem(deviceKeyStorage, key), } } function joinURL(base: string, path: string): string { if (path.startsWith('http')) return path const b = base.replace(/\/$/, '') const p = path.startsWith('/') ? path : `/${path}` return `${b}${p}` }