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:
jackyu66git
2026-08-02 16:24:57 +08:00
co-authored by Cursor
parent dd94e57277
commit 0f320e040b
49 changed files with 1907 additions and 191 deletions
+29 -6
View File
@@ -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),
}
}
+19 -6
View File
@@ -5,17 +5,30 @@ export interface ApiResponse<T = unknown> {
data?: T
}
/** User profile used by decode / relation features. */
/** 个人档案 */
export interface Profile {
id: string
label: string
year: number
month: number
day: number
user_id: string
relation: 'self' | 'other'
display_name: string
birth_date: string
relation_type?: string | null
created_at: string
}
/** Minimal scale list item. */
/** 成长报告(detail 可能因权益被剥离) */
export interface GrowthReport {
id: string
user_id: string
profile_id: string
type: string
summary: Record<string, unknown>
detail?: Record<string, unknown> | null
has_deep_access: boolean
created_at: string
}
/** 探索测试列表项 */
export interface ScaleSummary {
slug: string
name: string