Files
digital-psychology/packages/sdk/src/index.ts
T
jackyu66gitandCursor 89756f65b4 feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:27:58 +08:00

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}`
}