feat: P1 profile/portrait API and freeze local-dev environment
Add host-first environment contracts (Local vs CI vs Prod), deps-only compose, and the Profile → Portrait → deep-access mock payment slice with device identity and auto-migrate on API startup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
import type { ApiResponse } from '@yuxingu/types'
|
||||
import type { ApiResponse, GrowthReport, Profile } from '@yuxingu/types'
|
||||
|
||||
/** Platform adapters so H5 and mini-program share one client. */
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||||
path: string
|
||||
body?: unknown
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ClientAdapters {
|
||||
/** Perform HTTP and return parsed JSON envelope. */
|
||||
request: <T>(opts: RequestOptions) => Promise<ApiResponse<T>>
|
||||
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 {
|
||||
@@ -21,15 +22,16 @@ export interface CreateClientOptions {
|
||||
|
||||
/**
|
||||
* createClient builds a typed API facade.
|
||||
* Side effect: network via adapters.request.
|
||||
*/
|
||||
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',
|
||||
@@ -37,6 +39,9 @@ export function createClient(opts: CreateClientOptions) {
|
||||
body: init?.body,
|
||||
headers,
|
||||
})
|
||||
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}`)
|
||||
}
|
||||
@@ -46,11 +51,26 @@ export function createClient(opts: CreateClientOptions) {
|
||||
return {
|
||||
healthz: () => call<{ status: string }>('/api/v1/healthz'),
|
||||
ping: () => call<{ pong: boolean }>('/api/v1/ping'),
|
||||
listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'),
|
||||
createProfile: (body: {
|
||||
relation: 'self' | 'other'
|
||||
birth_date: string
|
||||
display_name?: string
|
||||
relation_type?: string
|
||||
}) => call<Profile>('/api/v1/profiles', { method: 'POST', body }),
|
||||
createPortrait: (profile_id: string) =>
|
||||
call<GrowthReport>('/api/v1/reports/portrait', { method: 'POST', body: { profile_id } }),
|
||||
getReport: (id: string) => call<GrowthReport>(`/api/v1/reports/${id}`),
|
||||
createOrder: (body: { kind: 'membership' | 'deep_access'; plan?: string; report_id?: string }) =>
|
||||
call<{ order_id: string }>('/api/v1/orders', { method: 'POST', body }),
|
||||
payMock: (orderId: string) =>
|
||||
call<{ paid: boolean }>(`/api/v1/orders/${orderId}/pay-mock`, { method: 'POST' }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser fetch adapter for user-h5. */
|
||||
export function createBrowserAdapters(): ClientAdapters {
|
||||
const deviceKeyStorage = 'yxg_device_key'
|
||||
return {
|
||||
request: async <T>({ method = 'GET', path, body, headers }) => {
|
||||
const res = await fetch(path, {
|
||||
@@ -61,9 +81,12 @@ export function createBrowserAdapters(): ClientAdapters {
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
return (await res.json()) as ApiResponse<T>
|
||||
const json = (await res.json()) as ApiResponse<T>
|
||||
return Object.assign(json, { headers: res.headers })
|
||||
},
|
||||
getToken: () => localStorage.getItem('yxg_token'),
|
||||
getDeviceKey: () => localStorage.getItem(deviceKeyStorage),
|
||||
setDeviceKey: (key: string) => localStorage.setItem(deviceKeyStorage, key),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user