From 881aceb78c56f26a3e820e814ba9d010bb5c235c Mon Sep 17 00:00:00 2001 From: momen Date: Mon, 18 May 2026 22:02:39 +0800 Subject: [PATCH] feat(account): add client account api foundation --- src/__tests__/setup.ts | 4 +- src/__tests__/unit/accountTypes.test.ts | 84 ++++++ src/global/storeConfig.ts | 8 + src/global/typedefinition.ts | 127 +++++++++ src/modules/backendApi.ts | 336 ++++++++++++++++++++++++ src/vite-env.d.ts | 4 + 6 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/unit/accountTypes.test.ts create mode 100644 src/modules/backendApi.ts diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 48ed77ed..681eafc6 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -20,11 +20,13 @@ Object.defineProperty(window, "ipcRenderer", { invoke: vi.fn(), removeListener: vi.fn(), }, + writable: true, }); // Mock window.apm_store Object.defineProperty(window, "apm_store", { value: { - arch: "amd64-store", + arch: "amd64", }, + writable: true, }); diff --git a/src/__tests__/unit/accountTypes.test.ts b/src/__tests__/unit/accountTypes.test.ts new file mode 100644 index 00000000..c8983772 --- /dev/null +++ b/src/__tests__/unit/accountTypes.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { + FLARUM_BASE_URL, + FLARUM_REGISTER_URL, + SPARK_BACKEND_BASE_URL, +} from "@/global/storeConfig"; +import type { + DownloadedAppRecord, + FavoriteFolder, + FavoriteItem, + ReviewTags, + SparkUser, + SyncedAppListItem, +} from "@/global/typedefinition"; + +describe("account shared types", () => { + it("exports backend/forum config and account shapes", () => { + const user: SparkUser = { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], + }; + const folder: FavoriteFolder = { + id: 1, + name: "默认收藏夹", + itemCount: 1, + createdAt: "2026-05-18T00:00:00Z", + updatedAt: "2026-05-18T00:00:00Z", + }; + const favorite: FavoriteItem = { + id: 2, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + iconUrl: "https://example.invalid/wps.png", + createdAt: "2026-05-18T00:00:00Z", + }; + const download: DownloadedAppRecord = { + id: 3, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + selectedOrigin: "apm", + version: "1.0.0", + packageArch: "amd64", + downloadedAt: "2026-05-18T00:00:00Z", + }; + const syncItem: SyncedAppListItem = { + pkgname: "wps", + origin: "apm", + category: "office", + version: "1.0.0", + packageArch: "amd64", + appName: "WPS", + iconUrl: "https://example.invalid/wps.png", + }; + const tags: ReviewTags = { + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", + }; + + expect(typeof SPARK_BACKEND_BASE_URL).toBe("string"); + expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store"); + expect(FLARUM_REGISTER_URL).toContain("register"); + expect(user.forumGroups).toEqual(["管理员"]); + expect(folder.itemCount).toBe(1); + expect(favorite.appKey).toBe("app:office:wps"); + expect(download.selectedOrigin).toBe("apm"); + expect(syncItem.origin).toBe("apm"); + expect(tags.packageArch).toBe("amd64"); + }); +}); diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts index 8113a59f..3f1b2134 100644 --- a/src/global/storeConfig.ts +++ b/src/global/storeConfig.ts @@ -7,6 +7,14 @@ export const APM_STORE_BASE_URL: string = export const APM_STORE_STATS_BASE_URL: string = import.meta.env.VITE_APM_STORE_STATS_BASE_URL || ""; +export const SPARK_BACKEND_BASE_URL: string = + import.meta.env.VITE_SPARK_BACKEND_BASE_URL || ""; + +export const FLARUM_BASE_URL = "https://bbs.spark-app.store"; +export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`; +export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`; +export const FLARUM_SETTINGS_URL = `${FLARUM_BASE_URL}/settings`; + // 下面的变量用于存储当前应用的信息,其实用在多个组件中 export const currentApp = ref(null); export const currentAppSparkInstalled = ref(false); diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index f2f7ccd6..135848c7 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -249,3 +249,130 @@ export interface SidebarEntry { type?: "category" | "search" | "link"; value?: string; } + +export interface SparkUser { + id: number; + flarumUserId: string; + username: string; + displayName: string; + avatarUrl: string; + forumLevel: string; + forumGroups: string[]; +} + +export interface AuthSession { + accessToken: string; + tokenType: "bearer"; + user: SparkUser; +} + +export interface FlarumLoginPayload { + identification: string; + password: string; +} + +export interface ReviewTags { + origin: "spark" | "apm"; + category: string; + pkgname: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; +} + +export interface RatingSummary { + averageRating: number; + reviewCount: number; + starCounts: Record; +} + +export interface AppReview { + id: number; + rating: number; + content: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; + origin: "spark" | "apm"; + category: string; + createdAt: string; + updatedAt: string; + userDisplayName: string; + userAvatarUrl: string; +} + +export interface FavoriteFolder { + id: number; + name: string; + itemCount: number; + createdAt: string; + updatedAt: string; +} + +export interface FavoriteItem { + id: number; + appKey: string; + pkgname: string; + name: string; + category: string; + iconUrl: string; + createdAt: string; +} + +export type FavoriteAvailabilityStatus = + | "installable" + | "installed" + | "platform-unavailable" + | "arch-unavailable" + | "downlisted"; + +export interface ResolvedFavoriteItem { + item: FavoriteItem; + status: FavoriteAvailabilityStatus; + reason: string; + selectedApp: App | null; +} + +export interface DownloadedAppRecord { + id: number; + appKey: string; + pkgname: string; + name: string; + category: string; + selectedOrigin: "spark" | "apm"; + version: string; + packageArch: string; + downloadedAt: string; +} + +export interface DownloadedAppList { + items: DownloadedAppRecord[]; + total: number; + page: number; + pageSize: number; +} + +export interface SyncedAppListItem { + id?: number; + pkgname: string; + origin: "spark" | "apm"; + category: string; + version: string; + packageArch: string; + appName: string; + iconUrl: string; +} + +export interface SyncedAppList { + snapshotName: string; + clientArch: string; + distro: string; + updatedAt: string; + items: SyncedAppListItem[]; +} + +export interface SystemInfo { + distro: string; +} diff --git a/src/modules/backendApi.ts b/src/modules/backendApi.ts new file mode 100644 index 00000000..6811c108 --- /dev/null +++ b/src/modules/backendApi.ts @@ -0,0 +1,336 @@ +import axios from "axios"; + +import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; +import type { + AppReview, + AuthSession, + DownloadedAppList, + DownloadedAppRecord, + FavoriteFolder, + FavoriteItem, + RatingSummary, + ReviewTags, + SparkUser, + SyncedAppList, + SyncedAppListItem, +} from "@/global/typedefinition"; + +const backend = axios.create({ + baseURL: SPARK_BACKEND_BASE_URL, + timeout: 10000, +}); + +type ApiRecord = Record; + +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 || ""), + forumLevel: String(raw.forum_level || "论坛用户"), + forumGroups: parseForumGroups(raw.forum_groups), +}); + +const toReview = (raw: ApiRecord): AppReview => ({ + id: Number(raw.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 || ""), +}); + +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 => { + if (token) backend.defaults.headers.common.Authorization = `Bearer ${token}`; + else delete backend.defaults.headers.common.Authorization; +}; + +export const exchangeFlarumToken = async (payload: { + flarumUserId: string; + flarumToken: string; +}): Promise => { + const response = await backend.post("/auth/flarum", { + flarum_user_id: payload.flarumUserId, + flarum_token: payload.flarumToken, + }); + 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 => { + const 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, + }, + }, + ); + return toReview(asApiRecord(response.data)); +}; + +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); +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 3de6f018..eebbaab2 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -10,6 +10,10 @@ declare module "*.vue" { } declare global { + interface ImportMetaEnv { + readonly VITE_SPARK_BACKEND_BASE_URL?: string; + } + interface Window { // expose in the `electron/preload/index.ts` ipcRenderer: IpcRendererFacade;