落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
423 lines
15 KiB
TypeScript
423 lines
15 KiB
TypeScript
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<string, string>
|
|
/** Prefer for unload flush (maps to fetch keepalive). */
|
|
keepalive?: boolean
|
|
}
|
|
|
|
export interface ClientAdapters {
|
|
request: <T>(opts: RequestOptions) => Promise<ApiResponse<T> & { headers?: Headers }>
|
|
getToken?: () => string | null | Promise<string | null>
|
|
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<T>(path: string, init?: Omit<RequestOptions, 'path'>): Promise<T> {
|
|
const token = adapters.getToken ? await adapters.getToken() : null
|
|
const deviceKey = adapters.getDeviceKey ? adapters.getDeviceKey() : null
|
|
const headers: Record<string, string> = { ...(init?.headers || {}) }
|
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
if (deviceKey) headers['X-Device-Key'] = deviceKey
|
|
|
|
const res = await adapters.request<T>({
|
|
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<AuthSession>('/api/v1/auth/register', { method: 'POST', body }),
|
|
authLogin: (body: { phone: string; password: string }) =>
|
|
call<AuthSession>('/api/v1/auth/login', { method: 'POST', body }),
|
|
authLogout: () => call<{ ok: boolean }>('/api/v1/auth/logout', { method: 'POST' }),
|
|
authMe: () => call<AuthUser>('/api/v1/auth/me'),
|
|
authUpdateMe: (body: { nickname: string }) =>
|
|
call<AuthUser>('/api/v1/auth/me', { method: 'PATCH', body }),
|
|
authUploadAvatar: (file: Blob, filename = 'avatar.jpg') => {
|
|
const fd = new FormData()
|
|
fd.append('file', file, filename)
|
|
return call<AuthUser>('/api/v1/auth/me/avatar', { method: 'POST', body: fd })
|
|
},
|
|
listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'),
|
|
createProfile: (body: CreateProfileBody) =>
|
|
call<Profile>('/api/v1/profiles', { method: 'POST', body }),
|
|
updateProfile: (id: string, body: UpdateProfileBody) =>
|
|
call<Profile>(`/api/v1/profiles/${id}`, { method: 'PATCH', body }),
|
|
deleteProfile: (id: string) =>
|
|
call<{ deleted: boolean }>(`/api/v1/profiles/${id}`, { method: 'DELETE' }),
|
|
createPortrait: (profile_id: string) =>
|
|
call<GrowthReport>('/api/v1/reports/portrait', { method: 'POST', body: { profile_id } }),
|
|
createStar: (profile_id: string) =>
|
|
call<GrowthReport>('/api/v1/reports/star', { method: 'POST', body: { profile_id } }),
|
|
createSynastry: (profile_id_a: string, profile_id_b: string, as_of?: string) =>
|
|
call<GrowthReport>('/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<GrowthReport>(`/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<SynastryInviteCreated>('/api/v1/synastry/invites', {
|
|
method: 'POST',
|
|
body: { profile_id },
|
|
}),
|
|
getSynastryInvite: (token: string) =>
|
|
call<SynastryInviteView>(`/api/v1/synastry/invites/${token}`),
|
|
acceptSynastryInvite: (token: string, body: AcceptSynastryInviteBody) =>
|
|
call<GrowthReport>(`/api/v1/synastry/invites/${token}/accept`, { method: 'POST', body }),
|
|
createRhythm: (profile_id: string) =>
|
|
call<GrowthReport>('/api/v1/reports/rhythm', { method: 'POST', body: { profile_id } }),
|
|
listImageCardScenes: () => call<{ items: ImageCardScene[] }>('/api/v1/image-cards/scenes'),
|
|
getImageCardQuota: () => call<ImageCardQuota>('/api/v1/image-cards/quota'),
|
|
drawImageCard: (body: { profile_id: string; scene?: string; depth?: boolean }) =>
|
|
call<ImageCardDrawn>('/api/v1/image-cards/draw', { method: 'POST', body }),
|
|
getSolarTermsToday: () => call<SolarTermToday>('/api/v1/solar-terms/today'),
|
|
saveMood: (body: { score?: number; note?: string; day?: string }) =>
|
|
call<MoodEntry>('/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<ExploreCategory>(`/api/v1/explore/catalog/${key}`),
|
|
listGrowthPlans: () => call<{ items: GrowthPlan[] }>('/api/v1/growth/plans'),
|
|
createGrowthPlan: (body: { title: string; focus?: string }) =>
|
|
call<GrowthPlan>('/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<GrowthReport>(`/api/v1/reports/${id}`),
|
|
getMembership: () => call<MembershipMe>('/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<RelationInsightResult>('/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<ScaleDetail>(`/api/v1/scales/${slug}`),
|
|
getScaleResult: (slug: string) =>
|
|
call<ScaleSubmitResult>(`/api/v1/scales/${slug}/result`),
|
|
submitScale: (slug: string, profile_id: string, answers: Record<string, string>) =>
|
|
call<ScaleSubmitResult>(`/api/v1/scales/${slug}/result`, {
|
|
method: 'POST',
|
|
body: { profile_id, answers },
|
|
}),
|
|
getAskQuota: () => call<AskQuota>('/api/v1/ask/quota'),
|
|
createAskThread: (body: { profile_id: string; scene?: string }) =>
|
|
call<AskThread>('/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<string, string | number | boolean>
|
|
}>
|
|
},
|
|
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<HomeDailyTips>('/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<void> {
|
|
const { baseURL, adapters } = opts
|
|
const token = adapters.getToken ? await adapters.getToken() : null
|
|
const deviceKey = adapters.getDeviceKey ? adapters.getDeviceKey() : null
|
|
const headers: Record<string, string> = {
|
|
'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<unknown>
|
|
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<string, unknown>
|
|
try {
|
|
payload = JSON.parse(raw) as Record<string, unknown>
|
|
} 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 <T>({ 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<T>
|
|
try {
|
|
json = JSON.parse(text) as ApiResponse<T>
|
|
} 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}`
|
|
}
|