import axios, { type AxiosResponse } from "axios"; import pino from "pino"; import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; import type { AppReview, AppReviewReply, AuthSession, DownloadedAppList, DownloadedAppRecord, FavoriteFolder, FavoriteItem, RatingSummary, ReviewTags, SparkUser, SyncedAppList, SyncedAppListItem, } from "@/global/typedefinition"; const backend = axios.create({ baseURL: SPARK_BACKEND_BASE_URL, timeout: 10000, }); const logger = pino({ name: "backendApi" }); type ApiRecord = Record; const normalizeBackendAuthError = (error: unknown): Error => { if (!axios.isAxiosError(error)) { return error instanceof Error ? error : new Error("登录失败,请稍后重试。"); } logger.error( { code: error.code, message: error.message, status: error.response?.status, }, "Spark backend auth exchange failed", ); if (!error.response) { return new Error("无法连接星火账号服务,请确认后端服务已启动或稍后重试。"); } const status = error.response.status; if (status === 401) { return new Error("论坛登录失败,请检查账号和密码。"); } if (status === 503) { return new Error("星火账号服务暂时无法连接论坛,请稍后重试。"); } if (status === 500) { return new Error("星火账号服务异常,请确认后端数据库迁移已执行后重试。"); } return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`); }; const normalizeBackendMutationError = (error: unknown): Error => { if (!axios.isAxiosError(error)) { return error instanceof Error ? error : new Error("操作失败,请稍后重试。"); } logger.error( { code: error.code, message: error.message, status: error.response?.status, }, "Spark backend mutation failed", ); if (!error.response) { return new Error("无法连接星火账号服务,请稍后重试。"); } const status = error.response.status; if (status === 401) { return new Error("登录状态已失效,请重新登录星火账号。"); } if (status === 403) { return new Error("请登录星火账号后重试。"); } if (status === 422) { return new Error("提交内容格式不正确,请检查后重试。"); } if (status >= 500) { return new Error("星火账号服务异常,请稍后重试。"); } return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`); }; const asApiRecord = (value: unknown): ApiRecord => { if (value && typeof value === "object" && !Array.isArray(value)) { return value as ApiRecord; } return {}; }; const asApiRecordArray = (value: unknown): ApiRecord[] => { if (!Array.isArray(value)) return []; return value.map(asApiRecord); }; export const parseForumGroups = (raw: unknown): string[] => { if (Array.isArray(raw)) { return raw.filter((item): item is string => typeof item === "string"); } if (typeof raw !== "string" || raw.length === 0) return []; try { const parsed: unknown = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; } catch { return []; } }; const toUser = (raw: ApiRecord): SparkUser => ({ id: Number(raw.id), flarumUserId: String(raw.flarum_user_id || ""), username: String(raw.username || ""), displayName: String(raw.display_name || raw.username || ""), avatarUrl: String(raw.avatar_url || ""), coverUrl: String(raw.cover_url || raw.coverUrl || "") || undefined, forumLevel: String(raw.forum_level || "论坛用户"), forumGroups: parseForumGroups(raw.forum_groups), }); const toReview = (raw: ApiRecord): AppReview => ({ id: Number(raw.id), userId: raw.user_id === undefined ? undefined : Number(raw.user_id), rating: Number(raw.rating), content: String(raw.content || ""), version: String(raw.version || "unknown"), packageArch: String(raw.package_arch || "unknown"), clientArch: String(raw.client_arch || "unknown"), distro: String(raw.distro || "unknown"), origin: raw.origin === "spark" ? "spark" : "apm", category: String(raw.category || ""), createdAt: String(raw.created_at || ""), updatedAt: String(raw.updated_at || ""), userDisplayName: String(raw.user_display_name || ""), userAvatarUrl: String(raw.user_avatar_url || ""), likeCount: Number(raw.like_count || 0), likedByCurrentUser: Boolean(raw.liked_by_current_user), canDelete: raw.can_delete === undefined ? undefined : Boolean(raw.can_delete), isAuthor: raw.is_author === undefined ? undefined : Boolean(raw.is_author), isDeleted: Boolean(raw.is_deleted), replies: asApiRecordArray(raw.replies).map(toReviewReply), }); const toReviewReply = (raw: ApiRecord): AppReviewReply => ({ id: Number(raw.id), reviewId: Number(raw.review_id), parentId: raw.parent_id === null || raw.parent_id === undefined ? null : Number(raw.parent_id), content: String(raw.content || ""), createdAt: String(raw.created_at || ""), updatedAt: String(raw.updated_at || ""), userDisplayName: String(raw.user_display_name || ""), userAvatarUrl: String(raw.user_avatar_url || ""), likeCount: Number(raw.like_count || 0), likedByCurrentUser: Boolean(raw.liked_by_current_user), canDelete: Boolean(raw.can_delete), isAuthor: Boolean(raw.is_author), isDeleted: Boolean(raw.is_deleted), replies: asApiRecordArray(raw.replies).map(toReviewReply), }); export interface ReviewActionState { likedByCurrentUser: boolean; likeCount: number; } export interface CreateReviewReplyPayload { content: string; parentId?: number; } const toReviewActionState = (raw: ApiRecord): ReviewActionState => ({ likedByCurrentUser: Boolean(raw.liked_by_current_user), likeCount: Number(raw.like_count || 0), }); const toFavoriteFolder = (raw: ApiRecord): FavoriteFolder => ({ id: Number(raw.id), name: String(raw.name || ""), itemCount: Number(raw.item_count || 0), createdAt: String(raw.created_at || ""), updatedAt: String(raw.updated_at || ""), }); const toFavoriteItem = (raw: ApiRecord): FavoriteItem => ({ id: Number(raw.id), appKey: String(raw.app_key || ""), pkgname: String(raw.pkgname || ""), name: String(raw.name || ""), category: String(raw.category || ""), iconUrl: String(raw.icon_url || ""), createdAt: String(raw.created_at || ""), }); const toDownloadedApp = (raw: ApiRecord): DownloadedAppRecord => ({ id: Number(raw.id), appKey: String(raw.app_key || ""), pkgname: String(raw.pkgname || ""), name: String(raw.name || ""), category: String(raw.category || ""), selectedOrigin: raw.selected_origin === "spark" ? "spark" : "apm", version: String(raw.version || ""), packageArch: String(raw.package_arch || "unknown"), downloadedAt: String(raw.downloaded_at || ""), }); const toSyncedAppListItem = (raw: ApiRecord): SyncedAppListItem => ({ id: raw.id === undefined ? undefined : Number(raw.id), pkgname: String(raw.pkgname || ""), origin: raw.origin === "spark" ? "spark" : "apm", category: String(raw.category || ""), version: String(raw.version || ""), packageArch: String(raw.package_arch || "unknown"), appName: String(raw.app_name || ""), iconUrl: String(raw.icon_url || ""), }); const toSyncedAppList = ( raw: ApiRecord, fallback?: { clientArch: string; distro: string; items: SyncedAppListItem[] }, ): SyncedAppList => ({ snapshotName: String(raw.snapshot_name || "默认列表"), clientArch: String(raw.client_arch || fallback?.clientArch || "unknown"), distro: String(raw.distro || fallback?.distro || "unknown"), updatedAt: String(raw.updated_at || ""), items: raw.items ? asApiRecordArray(raw.items).map(toSyncedAppListItem) : fallback?.items || [], }); export const setBackendToken = (token: string | null): void => { const backendWithOptionalDefaults = backend as typeof backend & { defaults?: { headers?: { common?: Record } }; }; const commonHeaders = backendWithOptionalDefaults.defaults?.headers?.common; if (!commonHeaders) return; if (token) commonHeaders.Authorization = `Bearer ${token}`; else delete commonHeaders.Authorization; }; export const exchangeFlarumToken = async (payload: { flarumUserId: string; flarumToken: string; }): Promise => { let response: AxiosResponse; try { response = await backend.post("/auth/flarum", { flarum_user_id: payload.flarumUserId, flarum_token: payload.flarumToken, }); } catch (error) { throw normalizeBackendAuthError(error); } const data = asApiRecord(response.data); return { accessToken: String(data.access_token || ""), tokenType: "bearer", user: toUser(asApiRecord(data.user)), }; }; export const fetchMe = async (): Promise => { const response = await backend.get("/me"); return toUser(asApiRecord(response.data)); }; export const fetchRatingSummary = async ( appKey: string, ): Promise => { const response = await backend.get( `/apps/${encodeURIComponent(appKey)}/rating-summary`, ); const data = asApiRecord(response.data); return { averageRating: Number(data.average_rating || 0), reviewCount: Number(data.review_count || 0), starCounts: Object.fromEntries( Object.entries(asApiRecord(data.star_counts)).map(([key, value]) => [ Number(key), Number(value), ]), ), }; }; export const fetchReviews = async (appKey: string): Promise => { const response = await backend.get( `/apps/${encodeURIComponent(appKey)}/reviews`, ); return asApiRecordArray(response.data).map(toReview); }; export const submitReview = async ( appKey: string, payload: { rating: number; content: string; tags: ReviewTags }, ): Promise => { let response: AxiosResponse; try { response = await backend.post( `/apps/${encodeURIComponent(appKey)}/reviews`, { rating: payload.rating, content: payload.content, tags: { origin: payload.tags.origin, category: payload.tags.category, pkgname: payload.tags.pkgname, version: payload.tags.version, package_arch: payload.tags.packageArch, client_arch: payload.tags.clientArch, distro: payload.tags.distro, }, }, ); } catch (error) { throw normalizeBackendMutationError(error); } return toReview(asApiRecord(response.data)); }; export const likeReview = async ( appKey: string, reviewId: number, ): Promise => { let response: AxiosResponse; try { response = await backend.post( `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/like`, ); } catch (error) { throw normalizeBackendMutationError(error); } return toReviewActionState(asApiRecord(response.data)); }; export const createReviewReply = async ( appKey: string, reviewId: number, payload: CreateReviewReplyPayload, ): Promise => { let response: AxiosResponse; try { response = await backend.post( `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies`, { content: payload.content, ...(payload.parentId === undefined ? {} : { parent_id: payload.parentId }), }, ); } catch (error) { throw normalizeBackendMutationError(error); } return toReviewReply(asApiRecord(response.data)); }; export const deleteReview = async ( appKey: string, reviewId: number, ): Promise => { try { await backend.delete( `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}`, ); } catch (error) { throw normalizeBackendMutationError(error); } }; export const likeReviewReply = async ( appKey: string, reviewId: number, replyId: number, ): Promise => { let response: AxiosResponse; try { response = await backend.post( `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies/${replyId}/like`, ); } catch (error) { throw normalizeBackendMutationError(error); } return toReviewActionState(asApiRecord(response.data)); }; export const deleteReviewReply = async ( appKey: string, reviewId: number, replyId: number, ): Promise => { try { await backend.delete( `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies/${replyId}`, ); } catch (error) { throw normalizeBackendMutationError(error); } }; export const listFavoriteFolders = async (): Promise => { const response = await backend.get("/me/favorite-folders"); return asApiRecordArray(response.data).map(toFavoriteFolder); }; export const createFavoriteFolder = async ( name: string, ): Promise => { const response = await backend.post("/me/favorite-folders", { name }); return toFavoriteFolder(asApiRecord(response.data)); }; export const renameFavoriteFolder = async ( folderId: number, name: string, ): Promise => { const response = await backend.patch(`/me/favorite-folders/${folderId}`, { name, }); return toFavoriteFolder(asApiRecord(response.data)); }; export const deleteFavoriteFolder = async (folderId: number): Promise => { await backend.delete(`/me/favorite-folders/${folderId}`); }; export const listFavoriteItems = async ( folderId: number, ): Promise => { const response = await backend.get(`/me/favorite-folders/${folderId}/items`); return asApiRecordArray(response.data).map(toFavoriteItem); }; export const addFavoriteItem = async ( folderId: number | "default", item: Omit, ): Promise => { const response = await backend.post( `/me/favorite-folders/${folderId}/items`, { app_key: item.appKey, pkgname: item.pkgname, name: item.name, category: item.category, icon_url: item.iconUrl, }, ); return toFavoriteItem(asApiRecord(response.data)); }; export const deleteFavoriteItem = async ( folderId: number, itemId: number, ): Promise => { await backend.delete(`/me/favorite-folders/${folderId}/items/${itemId}`); }; export const bulkDeleteFavoriteItems = async ( folderId: number, itemIds: number[], ): Promise => { const response = await backend.post( `/me/favorite-folders/${folderId}/items/bulk-delete`, { item_ids: itemIds }, ); return Number(asApiRecord(response.data).deleted_count || 0); }; export const listDownloadedApps = async ( page = 1, pageSize = 20, ): Promise => { const response = await backend.get("/me/downloaded-apps", { params: { page, page_size: pageSize }, }); const data = asApiRecord(response.data); return { items: asApiRecordArray(data.items).map(toDownloadedApp), total: Number(data.total || 0), page: Number(data.page || page), pageSize: Number(data.page_size || pageSize), }; }; export const recordDownloadedApp = async ( item: Omit, ): Promise => { const response = await backend.post("/me/downloaded-apps", { app_key: item.appKey, pkgname: item.pkgname, name: item.name, category: item.category, selected_origin: item.selectedOrigin, version: item.version, package_arch: item.packageArch, }); return toDownloadedApp(asApiRecord(response.data)); }; export const fetchSyncedAppList = async (): Promise => { const response = await backend.get("/me/app-list"); if (!response.data) return null; return toSyncedAppList(asApiRecord(response.data)); }; export const uploadSyncedAppList = async (payload: { clientArch: string; distro: string; items: SyncedAppListItem[]; }): Promise => { const response = await backend.put("/me/app-list", { client_arch: payload.clientArch, distro: payload.distro, items: payload.items.map((item) => ({ pkgname: item.pkgname, origin: item.origin, category: item.category, version: item.version, package_arch: item.packageArch, app_name: item.appName, icon_url: item.iconUrl, })), }); return toSyncedAppList(asApiRecord(response.data), payload); };