fix: harden ecommerce media history for launch

This commit is contained in:
2026-06-04 18:27:12 +08:00
parent e166722945
commit b81128d7ca
3 changed files with 244 additions and 11 deletions
+170 -9
View File
@@ -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<DurableMediaUrl> {
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<string, unknown>;
plan: Record<string, unknown>;
scenes: VideoHistoryScene[];
sourceImageUrls: string[];
uploadAssetByUrl?: UploadAssetByUrl;
}
export interface VideoHistoryItem {
id: number;
title: string;
@@ -293,22 +398,74 @@ function getAuthHeaders(): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function saveVideoHistory(payload: {
title: string;
config: Record<string, unknown>;
plan: Record<string, unknown>;
scenes: VideoHistoryScene[];
sourceImageUrls: string[];
}): Promise<{ id: number; createdAt: string }> {
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
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<void> {