fix: ????????? OSS ???? #13
@@ -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.");
|
||||||
@@ -913,7 +913,7 @@ export const keyServerClient = {
|
|||||||
async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> {
|
async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> {
|
||||||
const stored = readStoredSession();
|
const stored = readStoredSession();
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
throw new Error("闇€瑕佸厛鐧诲綍");
|
throw new Error("需要先登录");
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeProjectId = encodeURIComponent(projectId.trim());
|
const safeProjectId = encodeURIComponent(projectId.trim());
|
||||||
@@ -1000,7 +1000,7 @@ export const keyServerClient = {
|
|||||||
async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> {
|
async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> {
|
||||||
const stored = readStoredSession();
|
const stored = readStoredSession();
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
throw new Error("闇€瑕佸厛鐧诲綍");
|
throw new Error("需要先登录");
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`;
|
const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`;
|
||||||
|
|||||||
@@ -19,6 +19,102 @@ import type {
|
|||||||
PlanStep,
|
PlanStep,
|
||||||
} from "./ecommerceVideoTypes";
|
} 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 {
|
export interface PlanCallbacks {
|
||||||
onStepStart: (step: PlanStep) => void;
|
onStepStart: (step: PlanStep) => void;
|
||||||
onStepDone: (step: PlanStep) => void;
|
onStepDone: (step: PlanStep) => void;
|
||||||
@@ -268,6 +364,15 @@ export interface VideoHistoryScene {
|
|||||||
videoUrl?: string | null;
|
videoUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SaveVideoHistoryPayload {
|
||||||
|
title: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
plan: Record<string, unknown>;
|
||||||
|
scenes: VideoHistoryScene[];
|
||||||
|
sourceImageUrls: string[];
|
||||||
|
uploadAssetByUrl?: UploadAssetByUrl;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VideoHistoryItem {
|
export interface VideoHistoryItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -293,22 +398,74 @@ function getAuthHeaders(): Record<string, string> {
|
|||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveVideoHistory(payload: {
|
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
|
||||||
title: string;
|
const uploadAssetByUrl = payload.uploadAssetByUrl;
|
||||||
config: Record<string, unknown>;
|
const scenes = await Promise.all(
|
||||||
plan: Record<string, unknown>;
|
payload.scenes.map(async (scene) => {
|
||||||
scenes: VideoHistoryScene[];
|
const [image, video] = await Promise.all([
|
||||||
sourceImageUrls: string[];
|
resolveDurableMediaUrl(scene.imageUrl, {
|
||||||
}): Promise<{ id: number; createdAt: string }> {
|
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, {
|
const res = await fetch(API_BASE, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(historyPayload),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("保存历史记录失败");
|
if (!res.ok) throw new Error("保存历史记录失败");
|
||||||
return res.json();
|
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(
|
export async function fetchVideoHistory(
|
||||||
limit = 20,
|
limit = 20,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
@@ -318,7 +475,11 @@ export async function fetchVideoHistory(
|
|||||||
{ headers: getAuthHeaders() },
|
{ headers: getAuthHeaders() },
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error("获取历史记录失败");
|
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> {
|
export async function deleteVideoHistory(id: number): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user