diff --git a/scripts/smoke-generation-mocked.mjs b/scripts/smoke-generation-mocked.mjs new file mode 100644 index 0000000..f078568 --- /dev/null +++ b/scripts/smoke-generation-mocked.mjs @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); +const failures = []; + +function read(relativePath) { + return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); +} + +function assertMatch(label, content, pattern) { + if (!pattern.test(content)) { + failures.push(label); + } +} + +function assertNoMatch(label, content, pattern) { + if (pattern.test(content)) { + failures.push(label); + } +} + +const serverConnection = read("src/api/serverConnection.ts"); +const generationClient = read("src/api/aiGenerationClient.ts"); +const ecommerceVideoService = read("src/features/ecommerce/ecommerceVideoService.ts"); +const workbenchPersistence = read("src/features/workbench/workbenchResultPersistence.ts"); + +assertMatch( + "serverConnection must build same-origin /api URLs", + serverConnection, + /return\s+`\/api\/\$\{cleanPath\}`;/, +); +assertNoMatch( + "frontend generation flow must not use fixed VITE environment config", + `${serverConnection}\n${generationClient}`, + /\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/, +); +assertNoMatch( + "frontend generation flow must not call provider hosts directly", + generationClient, + /dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i, +); +assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/); +assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/); +assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/); +assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/); +assertMatch( + "ecommerce video history must durable-copy media before saving", + ecommerceVideoService, + /buildDurableVideoHistoryPayload\(payload\)/, +); +assertMatch( + "ecommerce video history must filter temporary provider URLs on read", + ecommerceVideoService, + /items:\s*history\.items\.map\(removeTemporaryHistoryUrls\)/, +); +assertMatch( + "workbench results must persist generated media through OSS", + workbenchPersistence, + /uploadAssetByUrl\(/, +); + +if (failures.length) { + console.error("Mocked generation smoke check failed:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log("Mocked generation smoke check passed."); diff --git a/src/api/keyServerClient.ts b/src/api/keyServerClient.ts index 1acf077..151a551 100644 --- a/src/api/keyServerClient.ts +++ b/src/api/keyServerClient.ts @@ -913,7 +913,7 @@ export const keyServerClient = { async getProjectContent(projectId: string): Promise { const stored = readStoredSession(); if (!stored) { - throw new Error("闇€瑕佸厛鐧诲綍"); + throw new Error("需要先登录"); } const safeProjectId = encodeURIComponent(projectId.trim()); @@ -1000,7 +1000,7 @@ export const keyServerClient = { async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise { const stored = readStoredSession(); if (!stored) { - throw new Error("闇€瑕佸厛鐧诲綍"); + throw new Error("需要先登录"); } const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`; diff --git a/src/api/modelCapabilitiesClient.ts b/src/api/modelCapabilitiesClient.ts index 85b0f8b..33ce1f4 100644 --- a/src/api/modelCapabilitiesClient.ts +++ b/src/api/modelCapabilitiesClient.ts @@ -71,7 +71,7 @@ export const modelCapabilitiesClient = { let payload: unknown; try { - payload = await serverRequest(`config/profile?name=${encodeURIComponent(name)}`); + payload = await serverRequest(`public/config/profile?name=${encodeURIComponent(name)}`); } catch (error) { if (isOptionalApiRouteMissing(error)) { modelCapabilitiesRouteMissing = true; diff --git a/src/features/ecommerce/ecommerceVideoService.ts b/src/features/ecommerce/ecommerceVideoService.ts index 7f85cfa..d3d304a 100644 --- a/src/features/ecommerce/ecommerceVideoService.ts +++ b/src/features/ecommerce/ecommerceVideoService.ts @@ -19,6 +19,102 @@ import type { PlanStep, } from "./ecommerceVideoTypes"; +type UploadAssetByUrl = typeof aiGenerationClient.uploadAssetByUrl; + +interface DurableMediaUrl { + url: string | null; + originalUrl?: string | null; + ossKey?: string | null; +} + +const TEMP_MEDIA_HOST_RE = /^file\d*\.aitohumanize\.com$/i; +const OSS_MEDIA_HOST_RE = /\.oss-[^.]+\.aliyuncs\.com$/i; + +function isTemporaryProviderUrl(url: string): boolean { + try { + return TEMP_MEDIA_HOST_RE.test(new URL(url).hostname); + } catch { + return false; + } +} + +function isDurableOssUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" && OSS_MEDIA_HOST_RE.test(parsed.hostname); + } catch { + return false; + } +} + +function getMediaExtension(url: string, mimeType: string): string { + const normalizedMime = mimeType.split(";")[0]?.trim().toLowerCase(); + if (normalizedMime === "image/jpeg") return "jpg"; + if (normalizedMime === "image/png") return "png"; + if (normalizedMime === "image/webp") return "webp"; + if (normalizedMime === "image/gif") return "gif"; + if (normalizedMime === "video/mp4") return "mp4"; + if (normalizedMime === "video/webm") return "webm"; + if (normalizedMime === "video/quicktime") return "mov"; + + try { + const matched = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i); + if (matched?.[1]) return matched[1].toLowerCase(); + } catch { + // Keep mime fallback below. + } + + return mimeType.startsWith("video/") ? "mp4" : "png"; +} + +function buildDurableMediaName(prefix: string, url: string, mimeType: string): string { + const normalized = prefix + .trim() + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, " ") + .slice(0, 80) + .trim(); + return `${normalized || "ecommerce-video-media"}.${getMediaExtension(url, mimeType)}`; +} + +export async function resolveDurableMediaUrl( + url: string | null | undefined, + options: { + mediaType: "image" | "video"; + namePrefix: string; + scope?: string; + uploadAssetByUrl?: UploadAssetByUrl; + }, +): Promise { + const sourceUrl = String(url || "").trim(); + if (!sourceUrl) return { url: null }; + if (isDurableOssUrl(sourceUrl)) return { url: sourceUrl }; + + const mimeType = options.mediaType === "video" ? "video/mp4" : "image/png"; + const uploadAssetByUrl = options.uploadAssetByUrl || aiGenerationClient.uploadAssetByUrl.bind(aiGenerationClient); + + try { + const uploaded = await uploadAssetByUrl({ + sourceUrl, + name: buildDurableMediaName(options.namePrefix, sourceUrl, mimeType), + mimeType, + scope: options.scope || "ecommerce-video-history", + }); + return { + url: uploaded.url || null, + originalUrl: sourceUrl, + ossKey: uploaded.ossKey || null, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error || ""); + console.warn("[ecommerce-video] history media persistence failed:", message); + if (isTemporaryProviderUrl(sourceUrl)) { + return { url: null, originalUrl: sourceUrl }; + } + return { url: sourceUrl }; + } +} + export interface PlanCallbacks { onStepStart: (step: PlanStep) => void; onStepDone: (step: PlanStep) => void; @@ -268,6 +364,15 @@ export interface VideoHistoryScene { videoUrl?: string | null; } +interface SaveVideoHistoryPayload { + title: string; + config: Record; + plan: Record; + scenes: VideoHistoryScene[]; + sourceImageUrls: string[]; + uploadAssetByUrl?: UploadAssetByUrl; +} + export interface VideoHistoryItem { id: number; title: string; @@ -293,22 +398,74 @@ function getAuthHeaders(): Record { return token ? { Authorization: `Bearer ${token}` } : {}; } -export async function saveVideoHistory(payload: { - title: string; - config: Record; - plan: Record; - scenes: VideoHistoryScene[]; - sourceImageUrls: string[]; -}): Promise<{ id: number; createdAt: string }> { +export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise { + const uploadAssetByUrl = payload.uploadAssetByUrl; + const scenes = await Promise.all( + payload.scenes.map(async (scene) => { + const [image, video] = await Promise.all([ + resolveDurableMediaUrl(scene.imageUrl, { + mediaType: "image", + namePrefix: `ecommerce-scene-${scene.sceneId}-image`, + uploadAssetByUrl, + }), + resolveDurableMediaUrl(scene.videoUrl, { + mediaType: "video", + namePrefix: `ecommerce-scene-${scene.sceneId}-video`, + uploadAssetByUrl, + }), + ]); + return { + ...scene, + imageUrl: image.url, + videoUrl: video.url, + }; + }), + ); + + const sourceImageUrls = ( + await Promise.all( + payload.sourceImageUrls.map((url, index) => + resolveDurableMediaUrl(url, { + mediaType: "image", + namePrefix: `ecommerce-source-${index + 1}`, + uploadAssetByUrl, + }), + ), + ) + ) + .map((item) => item.url) + .filter((url): url is string => Boolean(url)); + + return { + ...payload, + scenes, + sourceImageUrls, + }; +} + +export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> { + const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload); const res = await fetch(API_BASE, { method: "POST", headers: { "Content-Type": "application/json", ...getAuthHeaders() }, - body: JSON.stringify(payload), + body: JSON.stringify(historyPayload), }); if (!res.ok) throw new Error("保存历史记录失败"); return res.json(); } +function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem { + return { + ...item, + scenes: item.scenes.map((scene) => ({ + ...scene, + imageUrl: scene.imageUrl && !isTemporaryProviderUrl(scene.imageUrl) ? scene.imageUrl : null, + videoUrl: scene.videoUrl && !isTemporaryProviderUrl(scene.videoUrl) ? scene.videoUrl : null, + })), + sourceImageUrls: item.sourceImageUrls.filter((url) => !isTemporaryProviderUrl(url)), + }; +} + export async function fetchVideoHistory( limit = 20, offset = 0, @@ -318,7 +475,11 @@ export async function fetchVideoHistory( { headers: getAuthHeaders() }, ); if (!res.ok) throw new Error("获取历史记录失败"); - return res.json(); + const history = (await res.json()) as VideoHistoryListResponse; + return { + ...history, + items: history.items.map(removeTemporaryHistoryUrls), + }; } export async function deleteVideoHistory(id: number): Promise { diff --git a/vite.config.ts b/vite.config.ts index 5dea423..49aaa45 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,7 +25,7 @@ export default defineConfig(() => ({ drop: ["console", "debugger"], }, build: { - sourcemap: "hidden", + sourcemap: false, rollupOptions: { output: { manualChunks(id: string) {