import { invokeIpc } from '@/lib/api-client'; import { AppError } from '@/lib/error-model'; import { hostApiFetch } from '@/lib/host-api'; import { LEARNING_API_PATH, LEARNING_MEDIA_MAX_BYTES, type LearningProjectDetail, type LearningProjectDownloadResult, type LearningProjectListQuery, type LearningProjectPage, } from '../../shared/learning'; type LearningEnvelope = { success?: boolean; status?: number; code?: string; error?: string; data?: T; }; type LearningProjectMedia = { dataBase64: string; mimeType: string; }; const PROJECT_MEDIA_URL_PATTERN = /^\/api\/learning\/projects\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\/media\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const PROJECT_MEDIA_MIME_TYPES = new Set([ 'image/avif', 'image/gif', 'image/jpeg', 'image/png', 'image/webp', ]); const MAX_MEDIA_BASE64_LENGTH = Math.ceil(LEARNING_MEDIA_MAX_BYTES / 3) * 4; export class LearningApiError extends Error { constructor( readonly status: number, readonly code: string, message: string, ) { super(message); this.name = 'LearningApiError'; } } function errorStatus(error: unknown): number { return error instanceof AppError && typeof error.details?.status === 'number' ? error.details.status : 502; } function errorCode(error: unknown, status: number): string { if (error instanceof AppError && typeof error.details?.backendCode === 'string') { return error.details.backendCode; } if (status === 401) return 'LEARNING_AUTH_REQUIRED'; if (status === 404) return 'LEARNING_PROJECT_NOT_FOUND'; return 'LEARNING_REQUEST_FAILED'; } async function requestData(path: string, init?: RequestInit): Promise { let response: LearningEnvelope; try { response = await hostApiFetch>(path, init); } catch (error) { const status = errorStatus(error); throw new LearningApiError( status, errorCode(error, status), error instanceof Error ? error.message : '学习项目请求失败', ); } if (!response.success || response.data === undefined) { throw new LearningApiError( response.status ?? 502, response.code ?? 'LEARNING_REQUEST_FAILED', response.error ?? '学习项目请求失败', ); } return response.data; } function listQuery(query: LearningProjectListQuery): string { const params = new URLSearchParams(); if (query.cursor?.trim()) params.set('cursor', query.cursor.trim()); if (query.limit !== undefined) params.set('limit', String(query.limit)); const encoded = params.toString(); return encoded ? `?${encoded}` : ''; } export function fetchLearningProjects( query: LearningProjectListQuery = {}, ): Promise { return requestData(`${LEARNING_API_PATH}/projects${listQuery(query)}`); } export function fetchLearningProject(projectId: string): Promise { return requestData(`${LEARNING_API_PATH}/projects/${encodeURIComponent(projectId)}`); } export function downloadLearningProject( projectId: string, ): Promise { return requestData(`${LEARNING_API_PATH}/projects/${encodeURIComponent(projectId)}/download`, { method: 'POST', }); } export async function fetchLearningProjectMedia(mediaUrl: string): Promise { if (!PROJECT_MEDIA_URL_PATTERN.test(mediaUrl)) { throw new LearningApiError(400, 'LEARNING_INVALID_MEDIA_URL', '项目图片地址无效'); } const localPath = mediaUrl.replace('/api/learning/', `${LEARNING_API_PATH}/`); const payload = await hostApiFetch(localPath); const mimeType = typeof payload?.mimeType === 'string' ? payload.mimeType.trim().toLowerCase() : ''; const dataBase64 = typeof payload?.dataBase64 === 'string' ? payload.dataBase64 : ''; if ( !PROJECT_MEDIA_MIME_TYPES.has(mimeType) || !dataBase64 || dataBase64.length > MAX_MEDIA_BASE64_LENGTH || !/^[A-Za-z0-9+/]*={0,2}$/.test(dataBase64) || dataBase64.length % 4 === 1 ) { throw new LearningApiError(502, 'LEARNING_INVALID_MEDIA_RESPONSE', '项目图片返回了无效数据'); } return `data:${mimeType};base64,${dataBase64}`; } export function isLearningProjectMediaUrl(value: string): boolean { return PROJECT_MEDIA_URL_PATTERN.test(value); } export function isSafeLearningImageUrl(value: string): boolean { if (PROJECT_MEDIA_URL_PATTERN.test(value)) return true; try { const url = new URL(value); return url.protocol === 'https:' && !url.username && !url.password; } catch { return false; } } export async function openLearningExternalLink(value: string): Promise { let url: URL; try { url = new URL(value); } catch { throw new LearningApiError(400, 'LEARNING_INVALID_LINK', '项目链接无效'); } if (url.protocol !== 'https:' || url.username || url.password) { throw new LearningApiError(400, 'LEARNING_INVALID_LINK', '项目链接无效'); } await invokeIpc('shell:openExternal', url.toString()); }