feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+161
-1
@@ -4,6 +4,8 @@ import type {
|
||||
AskMessage,
|
||||
AskQuota,
|
||||
AskThread,
|
||||
AuthSession,
|
||||
AuthUser,
|
||||
CreateOrderBody,
|
||||
CreateProfileBody,
|
||||
ExploreCategory,
|
||||
@@ -33,6 +35,8 @@ export interface RequestOptions {
|
||||
path: string
|
||||
body?: unknown
|
||||
headers?: Record<string, string>
|
||||
/** Prefer for unload flush (maps to fetch keepalive). */
|
||||
keepalive?: boolean
|
||||
}
|
||||
|
||||
export interface ClientAdapters {
|
||||
@@ -65,6 +69,7 @@ export function createClient(opts: CreateClientOptions) {
|
||||
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)
|
||||
@@ -78,6 +83,12 @@ export function createClient(opts: CreateClientOptions) {
|
||||
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'),
|
||||
listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'),
|
||||
createProfile: (body: CreateProfileBody) =>
|
||||
call<Profile>('/api/v1/profiles', { method: 'POST', body }),
|
||||
@@ -94,6 +105,11 @@ export function createClient(opts: CreateClientOptions) {
|
||||
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}`,
|
||||
@@ -152,6 +168,8 @@ export function createClient(opts: CreateClientOptions) {
|
||||
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) =>
|
||||
@@ -163,6 +181,147 @@ export function createClient(opts: CreateClientOptions) {
|
||||
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'),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +329,7 @@ export function createClient(opts: CreateClientOptions) {
|
||||
export function createBrowserAdapters(): ClientAdapters {
|
||||
const deviceKeyStorage = 'yxg_device_key'
|
||||
return {
|
||||
request: async <T>({ method = 'GET', path, body, headers }: RequestOptions) => {
|
||||
request: async <T>({ method = 'GET', path, body, headers, keepalive }: RequestOptions) => {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: {
|
||||
@@ -178,6 +337,7 @@ export function createBrowserAdapters(): ClientAdapters {
|
||||
...(headers || {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
keepalive: keepalive === true,
|
||||
})
|
||||
const text = await res.text()
|
||||
let json: ApiResponse<T>
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface GrowthReport {
|
||||
id: string
|
||||
user_id: string
|
||||
profile_id: string
|
||||
peer_profile_id?: string
|
||||
type: string
|
||||
summary: Record<string, unknown>
|
||||
detail?: Record<string, unknown> | null
|
||||
@@ -53,6 +54,18 @@ export interface GrowthReport {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
phone: string
|
||||
nickname: string
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
token: string
|
||||
expires_at: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
/** 探索测试列表项 — 对齐 Go ScaleListItem / listScales 响应 */
|
||||
export interface ScaleSummary {
|
||||
slug: string
|
||||
@@ -93,7 +106,7 @@ export interface MembershipMe {
|
||||
}
|
||||
|
||||
export interface CreateOrderBody {
|
||||
kind: 'membership' | 'deep_access'
|
||||
kind: 'membership' | 'deep_access' | 'ask_pack'
|
||||
plan?: string
|
||||
report_id?: string
|
||||
}
|
||||
@@ -121,7 +134,8 @@ export interface AskQuota {
|
||||
active_membership: boolean
|
||||
remaining: number
|
||||
free_limit: number
|
||||
source: 'membership' | 'free' | string
|
||||
paid_left?: number
|
||||
source: 'membership' | 'free' | 'paid' | 'mixed' | string
|
||||
}
|
||||
|
||||
export interface SynastryNearbyItem {
|
||||
|
||||
Reference in New Issue
Block a user