api.ts 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126
  1. /**
  2. * API 工具类
  3. * 统一管理后端 API 调用
  4. */
  5. import { getUserId, getUserToken } from './storage';
  6. /** 后端返回 detail 为 { code: string } 或旧版字符串 */
  7. function getDetailCode(detail: unknown): string | undefined {
  8. if (detail && typeof detail === 'object' && 'code' in detail && typeof (detail as { code: unknown }).code === 'string') {
  9. return (detail as { code: string }).code;
  10. }
  11. if (typeof detail === 'string') return detail;
  12. return undefined;
  13. }
  14. /**
  15. * 解析 4xx/5xx 响应为带 code 的 ApiError,供前端用 t('error.' + code) 展示
  16. */
  17. export class ApiError extends Error {
  18. code: string;
  19. status: number;
  20. constructor(code: string, status: number, message?: string) {
  21. super(message ?? code);
  22. this.name = 'ApiError';
  23. this.code = code;
  24. this.status = status;
  25. }
  26. }
  27. export async function parseApiError(response: Response, fallbackCode = 'UNKNOWN'): Promise<ApiError> {
  28. let code = fallbackCode;
  29. const contentType = response.headers.get('content-type') || '';
  30. if (contentType.includes('application/json')) {
  31. try {
  32. const body = await response.json();
  33. const detail = body?.detail;
  34. const parsed = getDetailCode(detail);
  35. if (parsed) code = parsed;
  36. } catch {
  37. // ignore
  38. }
  39. }
  40. return new ApiError(code, response.status, response.statusText);
  41. }
  42. /**
  43. * 获取 API Key(从环境变量读取)
  44. */
  45. const getApiKey = (): string => {
  46. return import.meta.env.VITE_API_CLIENT_KEY || '';
  47. };
  48. /**
  49. * 获取统一的请求头(包含 API Key 与用户会话信息)
  50. */
  51. export const getApiHeaders = (): Record<string, string> => {
  52. const headers: Record<string, string> = {
  53. 'Content-Type': 'application/json',
  54. };
  55. const apiKey = getApiKey();
  56. if (apiKey) {
  57. headers['X-Client-Key'] = apiKey;
  58. }
  59. // 附加用户会话信息(用于后端基于 token 的 HTTP 鉴权)
  60. const userId = getUserId();
  61. const token = getUserToken();
  62. if (userId && token) {
  63. headers['X-User-Id'] = userId;
  64. headers['X-User-Token'] = token;
  65. }
  66. return headers;
  67. };
  68. /**
  69. * 会话确保逻辑:
  70. * - 若本地已有 userId + token,直接复用(依赖服务端滑动续期,不主动换 token)
  71. * - 若没有,则通过 /api/session/init 创建一次会话
  72. * - 使用单例 Promise 防抖,避免并发多次刷新导致 token 频繁变更
  73. */
  74. let ensureSessionPromise: Promise<{ userId: string; token: string }> | null = null;
  75. export const ensureSession = async (): Promise<{ userId: string; token: string }> => {
  76. // 已有未完成的会话初始化,直接复用
  77. if (ensureSessionPromise) {
  78. return ensureSessionPromise;
  79. }
  80. // 动态导入,避免循环依赖
  81. const { getUserId: loadUserId, getUserToken: loadUserToken, setUserId, setUserToken } = await import('./storage');
  82. const existingUserId = loadUserId();
  83. const existingToken = loadUserToken();
  84. // 本地已经有 userId + token,则认为会话有效,直接返回
  85. if (existingUserId && existingToken) {
  86. return { userId: existingUserId, token: existingToken };
  87. }
  88. // 没有有效会话时,串行化一次 /api/session/init 调用
  89. ensureSessionPromise = (async () => {
  90. const resp = await initSession(existingUserId || undefined);
  91. if (!resp.success || !resp.data?.token || !resp.data?.user_id) {
  92. throw new Error('Session init failed');
  93. }
  94. setUserId(resp.data.user_id);
  95. setUserToken(resp.data.token);
  96. return { userId: resp.data.user_id, token: resp.data.token };
  97. })();
  98. try {
  99. return await ensureSessionPromise;
  100. } finally {
  101. ensureSessionPromise = null;
  102. }
  103. };
  104. /**
  105. * 获取后端 API 基础 URL
  106. */
  107. export const getApiBaseUrl = (): string => {
  108. // 开发环境:使用相对路径,通过 Vite 代理访问后端
  109. // 这样可以避免 Safari 对自签名证书的跨域限制
  110. if (import.meta.env.DEV) {
  111. return ''; // 使用相对路径,Vite 会代理到后端
  112. }
  113. // 生产环境:使用完整 URL
  114. // 优先使用环境变量指定的 API 地址
  115. if (import.meta.env.VITE_API_BASE_URL) {
  116. return import.meta.env.VITE_API_BASE_URL;
  117. }
  118. // 从 WebSocket URL 推导 API 基础 URL
  119. const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
  120. // 将 ws:// 或 wss:// 转换为 http:// 或 https://
  121. const apiUrl = wsUrl
  122. .replace(/^wss:\/\//, 'https://')
  123. .replace(/^ws:\/\//, 'http://')
  124. .replace(/\/ws$/, '');
  125. return apiUrl;
  126. };
  127. /**
  128. * 上报页面访问(每次打开任意界面时调用,保存到后台数据库)
  129. * 不阻塞 UI,失败静默忽略。
  130. */
  131. export const reportPageVisit = (path: string): void => {
  132. const apiBaseUrl = getApiBaseUrl();
  133. const body = JSON.stringify({ path: path || '/' });
  134. fetch(`${apiBaseUrl}/api/page-visit`, {
  135. method: 'POST',
  136. headers: getApiHeaders(),
  137. body,
  138. }).catch(() => {});
  139. };
  140. export interface UserEventPayload {
  141. event_id: string;
  142. user_id: string;
  143. event_name: string;
  144. event_label?: string;
  145. event_time: string;
  146. session_id?: string;
  147. platform: 'ios' | 'android' | 'web';
  148. app_version?: string;
  149. timezone_offset_minutes?: number;
  150. properties?: Record<string, unknown>;
  151. }
  152. /**
  153. * 上报单条用户事件(user_events)
  154. * - 不抛异常,失败静默
  155. * - 开发环境会打印请求与响应状态,便于排查未落库问题
  156. */
  157. export const reportUserEvent = (payload: UserEventPayload): void => {
  158. const apiBaseUrl = getApiBaseUrl();
  159. const url = `${apiBaseUrl}/api/user-events`;
  160. const headers = getApiHeaders();
  161. if (import.meta.env.DEV) {
  162. const hasAuth = !!(headers['X-User-Id'] && headers['X-User-Token']);
  163. const keys = Object.keys(payload);
  164. const required = ['event_id', 'user_id', 'event_name', 'event_time', 'platform'];
  165. const missing = required.filter((k) => !(k in payload) || payload[k as keyof UserEventPayload] == null);
  166. if (missing.length) {
  167. console.warn('[user_events] 请求体缺少必填字段:', missing, '当前 keys:', keys);
  168. }
  169. console.log('[user_events] 上报:', payload.event_name, { hasAuth, user_id: payload.user_id, keys });
  170. }
  171. fetch(url, {
  172. method: 'POST',
  173. headers,
  174. body: JSON.stringify(payload),
  175. })
  176. .then(async (res) => {
  177. if (import.meta.env.DEV && !res.ok) {
  178. const body = await res.text();
  179. try {
  180. const parsed = JSON.parse(body);
  181. const details = Array.isArray(parsed?.detail) ? parsed.detail : parsed?.detail ? [parsed.detail] : [];
  182. const first = details[0];
  183. const loc = first?.loc;
  184. console.warn('[user_events] 上报失败:', res.status, payload.event_name, '缺失/错误字段 loc:', loc, 'msg:', first?.msg, '详情:', details);
  185. } catch {
  186. console.warn('[user_events] 上报失败:', res.status, res.statusText, payload.event_name, body?.slice(0, 200));
  187. }
  188. }
  189. })
  190. .catch((err) => {
  191. if (import.meta.env.DEV) {
  192. console.warn('[user_events] 请求异常:', err?.message ?? err, payload.event_name);
  193. }
  194. });
  195. };
  196. /**
  197. * 创建用户
  198. */
  199. export interface CreateUserResponse {
  200. success: boolean;
  201. data: {
  202. user_id: string;
  203. username: string;
  204. user_type: string;
  205. user_level: number;
  206. current_background_image_id?: string;
  207. created_at: string;
  208. };
  209. }
  210. export const createUser = async (username: string = ''): Promise<CreateUserResponse> => {
  211. const apiBaseUrl = getApiBaseUrl();
  212. const url = `${apiBaseUrl}/api/users/create`;
  213. try {
  214. const response = await fetch(url, {
  215. method: 'POST',
  216. headers: getApiHeaders(),
  217. body: JSON.stringify({ username }),
  218. });
  219. if (!response.ok) {
  220. throw await parseApiError(response, 'CREATE_USER_FAILED');
  221. }
  222. const data = await response.json();
  223. // 同步背景图片到 localStorage(包括metadata中的透明度配置)
  224. if (data.success && data.data?.current_background_image_id) {
  225. const { syncBackgroundImageToStorage } = await import('./storage');
  226. syncBackgroundImageToStorage(
  227. data.data.current_background_image_id,
  228. data.data.current_background_image_metadata
  229. );
  230. }
  231. return data;
  232. } catch (error) {
  233. console.error('[API] 创建用户异常:', error);
  234. // 如果是网络错误,提供更友好的提示
  235. if (error instanceof TypeError && error.message.includes('fetch')) {
  236. throw new Error('网络请求失败,请检查后端服务是否运行');
  237. }
  238. throw error;
  239. }
  240. };
  241. /**
  242. * 初始化/刷新会话,返回 user_id + token
  243. */
  244. export interface InitSessionResponse {
  245. success: boolean;
  246. data: {
  247. user_id: string;
  248. token: string;
  249. expires_at: string;
  250. username?: string;
  251. user_level?: number;
  252. current_background_image_id?: string;
  253. current_background_image_metadata?: Record<string, any>;
  254. };
  255. }
  256. export const initSession = async (user_id?: string, username?: string): Promise<InitSessionResponse> => {
  257. const apiBaseUrl = getApiBaseUrl();
  258. const url = `${apiBaseUrl}/api/session/init`;
  259. const body: Record<string, string> = {};
  260. if (user_id) {
  261. body.user_id = user_id;
  262. }
  263. if (username) {
  264. body.username = username;
  265. }
  266. const response = await fetch(url, {
  267. method: 'POST',
  268. headers: getApiHeaders(),
  269. body: JSON.stringify(body),
  270. });
  271. if (!response.ok) {
  272. throw await parseApiError(response, 'INIT_SESSION_FAILED');
  273. }
  274. return response.json();
  275. };
  276. /**
  277. * 获取用户信息
  278. */
  279. export interface GetUserResponse {
  280. success: boolean;
  281. data: {
  282. user_id: string;
  283. username: string;
  284. user_type: string;
  285. user_segment?: string;
  286. org_id?: string | null;
  287. is_test_user?: boolean;
  288. region?: string | null;
  289. bot_name?: string;
  290. total_speech_duration_ms?: number;
  291. user_level?: number;
  292. current_background_image_id?: string;
  293. current_background_image_metadata?: Record<string, any>;
  294. preferred_language?: 'zh' | 'en';
  295. created_at: string;
  296. };
  297. }
  298. export const getUserInfo = async (user_id: string): Promise<GetUserResponse> => {
  299. const apiBaseUrl = getApiBaseUrl();
  300. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}`, {
  301. method: 'GET',
  302. headers: getApiHeaders(),
  303. });
  304. if (!response.ok) {
  305. throw await parseApiError(response, 'GET_USER_INFO_FAILED');
  306. }
  307. const data = await response.json();
  308. // 同步背景图片到 localStorage(包括metadata中的透明度配置)
  309. if (data.success && data.data?.current_background_image_id) {
  310. const { syncBackgroundImageToStorage } = await import('./storage');
  311. syncBackgroundImageToStorage(
  312. data.data.current_background_image_id,
  313. data.data.current_background_image_metadata
  314. );
  315. }
  316. return data;
  317. };
  318. export interface JournalLatestTopicData {
  319. scenario_key: string;
  320. topic_key: string;
  321. /** ISO 8601,个人页日记本场景图上的日期由此格式化 */
  322. conversed_at: string;
  323. }
  324. export interface JournalLatestTopicResponse {
  325. success: boolean;
  326. data: JournalLatestTopicData | null;
  327. }
  328. export const getJournalLatestTopic = async (user_id: string): Promise<JournalLatestTopicResponse> => {
  329. const apiBaseUrl = getApiBaseUrl();
  330. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/journal-latest-topic`, {
  331. method: 'GET',
  332. headers: getApiHeaders(),
  333. });
  334. if (!response.ok) {
  335. throw await parseApiError(response, 'SERVER_ERROR');
  336. }
  337. return response.json();
  338. };
  339. /**
  340. * 更新用户名
  341. */
  342. export interface UpdateUsernameResponse {
  343. success: boolean;
  344. message: string;
  345. }
  346. export const updateUsername = async (user_id: string, username: string): Promise<UpdateUsernameResponse> => {
  347. const apiBaseUrl = getApiBaseUrl();
  348. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/username`, {
  349. method: 'PUT',
  350. headers: getApiHeaders(),
  351. body: JSON.stringify({ username }),
  352. });
  353. if (!response.ok) {
  354. throw await parseApiError(response, 'UPDATE_USERNAME_FAILED');
  355. }
  356. return response.json();
  357. };
  358. /**
  359. * 更新背景图片ID
  360. */
  361. export interface UpdateBackgroundImageIdResponse {
  362. success: boolean;
  363. message: string;
  364. }
  365. export const updateBackgroundImageId = async (
  366. user_id: string,
  367. background_image_id: string,
  368. ): Promise<UpdateBackgroundImageIdResponse> => {
  369. const apiBaseUrl = getApiBaseUrl();
  370. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/background-image-id`, {
  371. method: 'PUT',
  372. headers: getApiHeaders(),
  373. body: JSON.stringify({ background_image_id }),
  374. });
  375. if (!response.ok) {
  376. throw await parseApiError(response, 'UPDATE_BG_IMAGE_FAILED');
  377. }
  378. return response.json();
  379. };
  380. /**
  381. * 更新机器人名字
  382. */
  383. export interface UpdateBotNameResponse {
  384. success: boolean;
  385. message: string;
  386. }
  387. export const updateBotName = async (user_id: string, bot_name: string): Promise<UpdateBotNameResponse> => {
  388. const apiBaseUrl = getApiBaseUrl();
  389. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/bot-name`, {
  390. method: 'PUT',
  391. headers: getApiHeaders(),
  392. body: JSON.stringify({ bot_name }),
  393. });
  394. if (!response.ok) {
  395. throw await parseApiError(response, 'UPDATE_BOT_NAME_FAILED');
  396. }
  397. return response.json();
  398. };
  399. /**
  400. * 更新用户首选界面语言(zh/en)
  401. */
  402. export interface UpdatePreferredLanguageResponse {
  403. success: boolean;
  404. message?: string;
  405. }
  406. export const updatePreferredLanguage = async (
  407. user_id: string,
  408. preferred_language: 'zh' | 'en',
  409. ): Promise<UpdatePreferredLanguageResponse> => {
  410. const apiBaseUrl = getApiBaseUrl();
  411. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/preferred-language`, {
  412. method: 'PUT',
  413. headers: getApiHeaders(),
  414. body: JSON.stringify({ preferred_language }),
  415. });
  416. if (!response.ok) {
  417. throw await parseApiError(response, 'UPDATE_PREFERRED_LANGUAGE_FAILED');
  418. }
  419. return response.json();
  420. };
  421. /**
  422. * 用户 UI 状态相关接口
  423. */
  424. export interface UserUIState {
  425. current_page: string | null;
  426. visited_routes: string[];
  427. has_shown_tap_to_chat: boolean;
  428. has_shown_cancel_tooltip: boolean;
  429. has_shown_send_tooltip: boolean;
  430. has_shown_progress_bar_tooltip: boolean;
  431. has_shown_mission_stone_modal: boolean;
  432. has_shown_conversation_stone_modal: boolean;
  433. has_clicked_mission_stone: boolean;
  434. has_clicked_conversation_stone: boolean;
  435. subtitle_on: boolean;
  436. avatar_c_unlocked: boolean;
  437. has_shared_avatar: boolean;
  438. current_avatar_id?: string | null;
  439. }
  440. export interface GetUserUIStateResponse {
  441. success: boolean;
  442. data: {
  443. user_id: string;
  444. } & UserUIState;
  445. }
  446. export const getUserUIState = async (user_id: string): Promise<GetUserUIStateResponse> => {
  447. const apiBaseUrl = getApiBaseUrl();
  448. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/ui-state`, {
  449. method: 'GET',
  450. headers: getApiHeaders(),
  451. });
  452. if (!response.ok) {
  453. throw await parseApiError(response, 'GET_UI_STATE_FAILED');
  454. }
  455. return response.json();
  456. };
  457. export interface UpdateUserUIStateResponse {
  458. success: boolean;
  459. data: {
  460. user_id: string;
  461. } & UserUIState;
  462. }
  463. export type UpdateUserUIStatePayload = Partial<UserUIState>;
  464. export const updateUserUIState = async (
  465. user_id: string,
  466. payload: UpdateUserUIStatePayload,
  467. ): Promise<UpdateUserUIStateResponse> => {
  468. const apiBaseUrl = getApiBaseUrl();
  469. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/ui-state`, {
  470. method: 'PUT',
  471. headers: getApiHeaders(),
  472. body: JSON.stringify(payload),
  473. });
  474. if (!response.ok) {
  475. throw await parseApiError(response, 'UPDATE_UI_STATE_FAILED');
  476. }
  477. return response.json();
  478. };
  479. /** 亲密度:heart_count 0-3,days_since_last_visit 距上次访问天数 */
  480. export interface GetIntimacyResponse {
  481. success: boolean;
  482. data: { heart_count: number; days_since_last_visit: number };
  483. }
  484. export const getIntimacy = async (user_id: string): Promise<GetIntimacyResponse> => {
  485. const apiBaseUrl = getApiBaseUrl();
  486. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/intimacy`, {
  487. method: 'GET',
  488. headers: getApiHeaders(),
  489. });
  490. if (!response.ok) {
  491. throw await parseApiError(response, 'GET_INTIMACY_FAILED');
  492. }
  493. return response.json();
  494. };
  495. export interface LightUpIntimacyResponse {
  496. success: boolean;
  497. data: { heart_count: number };
  498. }
  499. export const lightUpIntimacy = async (user_id: string): Promise<LightUpIntimacyResponse> => {
  500. const apiBaseUrl = getApiBaseUrl();
  501. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/intimacy/light-up`, {
  502. method: 'POST',
  503. headers: getApiHeaders(),
  504. });
  505. if (!response.ok) {
  506. throw await parseApiError(response, 'LIGHT_UP_INTIMACY_FAILED');
  507. }
  508. return response.json();
  509. };
  510. export interface GetIntimacyStatusResponse {
  511. success: boolean;
  512. data: {
  513. heart_count: number;
  514. can_light_up_today: boolean;
  515. };
  516. }
  517. export const getIntimacyStatus = async (user_id: string): Promise<GetIntimacyStatusResponse> => {
  518. const apiBaseUrl = getApiBaseUrl();
  519. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/intimacy/status`, {
  520. method: 'GET',
  521. headers: getApiHeaders(),
  522. });
  523. if (!response.ok) {
  524. throw await parseApiError(response, 'GET_INTIMACY_STATUS_FAILED');
  525. }
  526. return response.json();
  527. };
  528. /**
  529. * 注册/登录相关接口
  530. */
  531. // 手机号注册
  532. export interface RegisterPhoneResponse {
  533. success: boolean;
  534. data: {
  535. user_id: string;
  536. username: string;
  537. area_code?: string;
  538. phone: string;
  539. user_type: string;
  540. user_level?: number;
  541. token?: string;
  542. expires_at?: string;
  543. created_at: string;
  544. };
  545. }
  546. export const registerWithPhone = async (
  547. phone: string,
  548. username: string = '',
  549. user_id?: string | null,
  550. area_code?: string,
  551. ): Promise<RegisterPhoneResponse> => {
  552. const apiBaseUrl = getApiBaseUrl();
  553. const body: { phone: string; username: string; user_id?: string; area_code?: string } = { phone, username };
  554. if (user_id) {
  555. body.user_id = user_id;
  556. }
  557. if (area_code) {
  558. body.area_code = area_code;
  559. }
  560. const response = await fetch(`${apiBaseUrl}/api/auth/register/phone`, {
  561. method: 'POST',
  562. headers: getApiHeaders(),
  563. body: JSON.stringify(body),
  564. });
  565. if (!response.ok) {
  566. throw await parseApiError(response, 'REGISTER_FAILED');
  567. }
  568. return response.json();
  569. };
  570. // 邮箱注册
  571. export interface RegisterEmailResponse {
  572. success: boolean;
  573. data: {
  574. user_id: string;
  575. username: string;
  576. email: string;
  577. user_type: string;
  578. user_level?: number;
  579. token?: string;
  580. expires_at?: string;
  581. created_at: string;
  582. };
  583. }
  584. export const registerWithEmail = async (email: string, username: string = '', user_id?: string | null): Promise<RegisterEmailResponse> => {
  585. const apiBaseUrl = getApiBaseUrl();
  586. const body: { email: string; username: string; user_id?: string } = { email, username };
  587. if (user_id) {
  588. body.user_id = user_id;
  589. }
  590. const response = await fetch(`${apiBaseUrl}/api/auth/register/email`, {
  591. method: 'POST',
  592. headers: getApiHeaders(),
  593. body: JSON.stringify(body),
  594. });
  595. if (!response.ok) {
  596. throw await parseApiError(response, 'REGISTER_FAILED');
  597. }
  598. return response.json();
  599. };
  600. // 微信登录
  601. export interface LoginWechatResponse {
  602. success: boolean;
  603. data: {
  604. user_id: string;
  605. username: string;
  606. wechat_openid?: string;
  607. user_type: string;
  608. user_level?: number;
  609. token?: string;
  610. expires_at?: string;
  611. created_at: string;
  612. };
  613. }
  614. export const loginWithWechat = async (openid: string, username: string = ''): Promise<LoginWechatResponse> => {
  615. const apiBaseUrl = getApiBaseUrl();
  616. const response = await fetch(`${apiBaseUrl}/api/auth/login/wechat`, {
  617. method: 'POST',
  618. headers: getApiHeaders(),
  619. body: JSON.stringify({ openid, username }),
  620. });
  621. if (!response.ok) {
  622. throw await parseApiError(response, 'LOGIN_FAILED');
  623. }
  624. return response.json();
  625. };
  626. // QQ登录
  627. export interface LoginQQResponse {
  628. success: boolean;
  629. data: {
  630. user_id: string;
  631. username: string;
  632. qq_openid?: string;
  633. user_type: string;
  634. user_level?: number;
  635. token?: string;
  636. expires_at?: string;
  637. created_at: string;
  638. };
  639. }
  640. export const loginWithQQ = async (openid: string, username: string = ''): Promise<LoginQQResponse> => {
  641. const apiBaseUrl = getApiBaseUrl();
  642. const response = await fetch(`${apiBaseUrl}/api/auth/login/qq`, {
  643. method: 'POST',
  644. headers: getApiHeaders(),
  645. body: JSON.stringify({ openid, username }),
  646. });
  647. if (!response.ok) {
  648. throw await parseApiError(response, 'LOGIN_FAILED');
  649. }
  650. return response.json();
  651. };
  652. // 手机号登录
  653. export interface LoginPhoneResponse {
  654. success: boolean;
  655. data: {
  656. user_id: string;
  657. username: string;
  658. area_code?: string;
  659. phone: string;
  660. user_type: string;
  661. user_level?: number;
  662. token?: string;
  663. expires_at?: string;
  664. created_at: string;
  665. };
  666. }
  667. export const loginWithPhone = async (phone: string, area_code?: string): Promise<LoginPhoneResponse> => {
  668. const apiBaseUrl = getApiBaseUrl();
  669. const response = await fetch(`${apiBaseUrl}/api/auth/login/phone`, {
  670. method: 'POST',
  671. headers: getApiHeaders(),
  672. body: JSON.stringify({ phone, area_code }),
  673. });
  674. if (!response.ok) {
  675. throw await parseApiError(response, 'LOGIN_FAILED');
  676. }
  677. return response.json();
  678. };
  679. // 邮箱登录
  680. export interface LoginEmailResponse {
  681. success: boolean;
  682. data: {
  683. user_id: string;
  684. username: string;
  685. email: string;
  686. user_type: string;
  687. user_level?: number;
  688. token?: string;
  689. expires_at?: string;
  690. created_at: string;
  691. };
  692. }
  693. export const loginWithEmail = async (email: string): Promise<LoginEmailResponse> => {
  694. const apiBaseUrl = getApiBaseUrl();
  695. const response = await fetch(`${apiBaseUrl}/api/auth/login/email`, {
  696. method: 'POST',
  697. headers: getApiHeaders(),
  698. body: JSON.stringify({ email }),
  699. });
  700. if (!response.ok) {
  701. throw await parseApiError(response, 'LOGIN_FAILED');
  702. }
  703. return response.json();
  704. };
  705. // 发送验证码
  706. export interface SendCodeRequest {
  707. area_code?: string; // 手机区号(如 +86)
  708. phone?: string;
  709. email?: string;
  710. send_type: 'domestic_sms' | 'international_sms' | 'email';
  711. }
  712. export interface SendCodeResponse {
  713. success: boolean;
  714. data: {
  715. code_id: string;
  716. message: string;
  717. };
  718. }
  719. export const sendCode = async (request: SendCodeRequest): Promise<SendCodeResponse> => {
  720. const apiBaseUrl = getApiBaseUrl();
  721. const response = await fetch(`${apiBaseUrl}/api/auth/send-code`, {
  722. method: 'POST',
  723. headers: getApiHeaders(),
  724. body: JSON.stringify(request),
  725. });
  726. if (!response.ok) {
  727. throw await parseApiError(response, 'SEND_CODE_FAILED');
  728. }
  729. return response.json();
  730. };
  731. // 验证验证码
  732. export interface VerifyCodeRequest {
  733. phone?: string;
  734. email?: string;
  735. code: string;
  736. send_type: 'domestic_sms' | 'international_sms' | 'email';
  737. }
  738. export interface VerifyCodeResponse {
  739. success: boolean;
  740. data: {
  741. valid: boolean;
  742. message: string;
  743. };
  744. }
  745. export const verifyCode = async (request: VerifyCodeRequest): Promise<VerifyCodeResponse> => {
  746. const apiBaseUrl = getApiBaseUrl();
  747. const response = await fetch(`${apiBaseUrl}/api/auth/verify-code`, {
  748. method: 'POST',
  749. headers: getApiHeaders(),
  750. body: JSON.stringify(request),
  751. });
  752. if (!response.ok) {
  753. throw await parseApiError(response, 'VERIFY_CODE_FAILED');
  754. }
  755. return response.json();
  756. };
  757. // V2.0:验证码通过后统一登录/注册
  758. export interface VerifyAndAuthRequest {
  759. type: 'email' | 'phone';
  760. code: string;
  761. email?: string;
  762. phone?: string;
  763. area_code?: string;
  764. preferred_language?: 'zh' | 'en';
  765. }
  766. export interface VerifyAndAuthResponse {
  767. success: boolean;
  768. data: {
  769. user_id: string;
  770. username?: string;
  771. email?: string;
  772. phone?: string;
  773. user_type: string;
  774. user_level?: number;
  775. token?: string;
  776. expires_at?: string;
  777. is_new_user: boolean;
  778. [key: string]: unknown;
  779. };
  780. }
  781. export const verifyAndAuth = async (request: VerifyAndAuthRequest): Promise<VerifyAndAuthResponse> => {
  782. const apiBaseUrl = getApiBaseUrl();
  783. const response = await fetch(`${apiBaseUrl}/api/auth/verify-and-auth`, {
  784. method: 'POST',
  785. headers: getApiHeaders(),
  786. body: JSON.stringify(request),
  787. });
  788. if (!response.ok) {
  789. throw await parseApiError(response, 'CODE_INVALID');
  790. }
  791. return response.json();
  792. };
  793. // V2.0:获取「退出早期共创社区」选项(登录页回显)
  794. export const getCommunityChoice = async (userId: string): Promise<{ success: boolean; data: { opted_out?: boolean } }> => {
  795. const apiBaseUrl = getApiBaseUrl();
  796. const response = await fetch(`${apiBaseUrl}/api/users/${userId}/community-choice`, {
  797. method: 'GET',
  798. headers: getApiHeaders(),
  799. });
  800. if (!response.ok) throw await parseApiError(response, 'SERVER_ERROR');
  801. return response.json();
  802. };
  803. // V2.0:更新「退出早期共创社区」选项
  804. export const updateCommunityChoice = async (userId: string, optedOut: boolean): Promise<{ success: boolean }> => {
  805. const apiBaseUrl = getApiBaseUrl();
  806. const response = await fetch(`${apiBaseUrl}/api/users/${userId}/community-choice`, {
  807. method: 'PUT',
  808. headers: getApiHeaders(),
  809. body: JSON.stringify({ opted_out: optedOut }),
  810. });
  811. if (!response.ok) throw await parseApiError(response, 'SERVER_ERROR');
  812. return response.json();
  813. };
  814. // V2.0:获取早期共创状态
  815. export const getEarlyCommunity = async (userId: string): Promise<{ success: boolean; data: { popup_shown?: boolean; email?: string | null } }> => {
  816. const apiBaseUrl = getApiBaseUrl();
  817. const response = await fetch(`${apiBaseUrl}/api/users/${userId}/early-community`, {
  818. method: 'GET',
  819. headers: getApiHeaders(),
  820. });
  821. if (!response.ok) throw await parseApiError(response, 'SERVER_ERROR');
  822. return response.json();
  823. };
  824. // V2.0:提交早期共创(标记弹窗已展示,可选留邮箱)
  825. export const submitEarlyCommunity = async (userId: string, email?: string | null): Promise<{ success: boolean; data: Record<string, unknown> }> => {
  826. const apiBaseUrl = getApiBaseUrl();
  827. const response = await fetch(`${apiBaseUrl}/api/users/${userId}/early-community`, {
  828. method: 'POST',
  829. headers: getApiHeaders(),
  830. body: JSON.stringify({ email: email || undefined }),
  831. });
  832. if (!response.ok) throw await parseApiError(response, 'SERVER_ERROR');
  833. return response.json();
  834. };
  835. /**
  836. * OAuth 授权相关接口
  837. */
  838. // 获取微信授权URL
  839. export const getWechatAuthUrl = (): string => {
  840. const apiBaseUrl = getApiBaseUrl();
  841. return `${apiBaseUrl}/api/auth/wechat/authorize`;
  842. };
  843. // 获取QQ授权URL
  844. export const getQQAuthUrl = (from?: string, user_id?: string | null): string => {
  845. const apiBaseUrl = getApiBaseUrl();
  846. const url = `${apiBaseUrl}/api/auth/qq/authorize`;
  847. const params = new URLSearchParams();
  848. if (from) {
  849. params.append('from', from);
  850. }
  851. if (user_id) {
  852. params.append('user_id', user_id);
  853. }
  854. const queryString = params.toString();
  855. return queryString ? `${url}?${queryString}` : url;
  856. };
  857. /**
  858. * 头像 GIF 配置
  859. */
  860. export interface AvatarGifSet {
  861. default: string;
  862. positive: string;
  863. negative: string;
  864. talking: string;
  865. }
  866. export interface AvatarGifConfigResponse {
  867. success: boolean;
  868. data: {
  869. avatar_default?: Partial<AvatarGifSet>;
  870. avatar_registered_cap?: Partial<AvatarGifSet>;
  871. };
  872. }
  873. export const getAvatarGifs = async (): Promise<AvatarGifConfigResponse> => {
  874. const apiBaseUrl = getApiBaseUrl();
  875. const response = await fetch(`${apiBaseUrl}/api/avatar-gifs`, {
  876. method: 'GET',
  877. headers: getApiHeaders(),
  878. });
  879. if (!response.ok) {
  880. throw await parseApiError(response, 'GET_AVATARS_FAILED');
  881. }
  882. return response.json();
  883. };
  884. /**
  885. * 分享图片
  886. */
  887. export interface ShareImageItem {
  888. id: string;
  889. url: string;
  890. title?: string;
  891. sort_order?: number;
  892. is_active?: boolean;
  893. }
  894. export interface GetShareImagesResponse {
  895. success: boolean;
  896. data: ShareImageItem[];
  897. }
  898. export const getShareImages = async (): Promise<GetShareImagesResponse> => {
  899. const apiBaseUrl = getApiBaseUrl();
  900. const response = await fetch(`${apiBaseUrl}/api/share-images`, {
  901. method: 'GET',
  902. headers: getApiHeaders(),
  903. });
  904. if (!response.ok) {
  905. throw await parseApiError(response, 'GET_CONFIG_FAILED');
  906. }
  907. return response.json();
  908. };
  909. /**
  910. * 领取分享奖励
  911. */
  912. export interface ClaimShareRewardResponse {
  913. success: boolean;
  914. data?: {
  915. user_id: string;
  916. background_image_id: string;
  917. background_image_metadata?: Record<string, any>;
  918. message: string;
  919. };
  920. message?: string;
  921. }
  922. export const claimShareReward = async (user_id: string): Promise<ClaimShareRewardResponse> => {
  923. const apiBaseUrl = getApiBaseUrl();
  924. const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/claim-share-reward`, {
  925. method: 'POST',
  926. headers: getApiHeaders(),
  927. });
  928. if (!response.ok) {
  929. throw await parseApiError(response, 'CLAIM_SHARE_REWARD_FAILED');
  930. }
  931. const data = await response.json();
  932. // 如果成功,同步背景图片到 localStorage
  933. if (data.success && data.data?.background_image_id) {
  934. const { syncBackgroundImageToStorage } = await import('./storage');
  935. syncBackgroundImageToStorage(
  936. data.data.background_image_id,
  937. data.data.background_image_metadata || { opacity: 0.5 } // 使用后端返回的metadata,或默认透明度
  938. );
  939. }
  940. return data;
  941. };
  942. /**
  943. * 获取所有背景图片配置
  944. */
  945. export interface BackgroundImageConfig {
  946. image_id: string;
  947. image_name: string;
  948. image_url: string;
  949. thumbnail_url?: string;
  950. unlock_type: 'default' | 'level' | 'purchase' | 'mission';
  951. unlock_level?: number;
  952. unlock_mission_type?: string;
  953. display_order: number;
  954. is_active: boolean;
  955. description?: string;
  956. metadata?: Record<string, any>;
  957. created_at?: string;
  958. updated_at?: string;
  959. }
  960. export interface GetBackgroundImagesResponse {
  961. success: boolean;
  962. data: BackgroundImageConfig[];
  963. }
  964. export const getBackgroundImages = async (): Promise<GetBackgroundImagesResponse> => {
  965. const apiBaseUrl = getApiBaseUrl();
  966. const response = await fetch(`${apiBaseUrl}/api/background-images`, {
  967. method: 'GET',
  968. headers: getApiHeaders(),
  969. });
  970. if (!response.ok) {
  971. throw await parseApiError(response, 'GET_BG_CONFIG_FAILED');
  972. }
  973. return response.json();
  974. };