2026-06-12 11:12:55 +08:00
|
|
|
import {
|
|
|
|
|
buildGenerationOssScope,
|
|
|
|
|
deleteGenerationRecordByClientId,
|
2026-06-18 10:16:40 +08:00
|
|
|
listGenerationRecords,
|
2026-06-12 11:12:55 +08:00
|
|
|
saveGenerationRecord,
|
2026-06-18 10:16:40 +08:00
|
|
|
type GenerationRecord,
|
2026-06-12 11:12:55 +08:00
|
|
|
type GenerationRecordAsset,
|
|
|
|
|
type SaveGenerationRecordInput,
|
|
|
|
|
} from "../../api/generationRecordClient";
|
2026-06-18 10:16:40 +08:00
|
|
|
import {
|
|
|
|
|
defaultCloneDetailModuleIds,
|
|
|
|
|
defaultCloneSetCounts,
|
|
|
|
|
ecommerceHistoryStorageKey,
|
|
|
|
|
normalizeEcommerceHistoryRecord,
|
|
|
|
|
type CloneImageItem,
|
|
|
|
|
type CloneReplicateLevelKey,
|
|
|
|
|
type CloneResult,
|
|
|
|
|
type CloneSetCountKey,
|
|
|
|
|
type EcommerceHistoryRecord,
|
|
|
|
|
type EcommerceHistoryStatus,
|
|
|
|
|
type EcommerceHistoryTurn,
|
|
|
|
|
} from "./utils/clonePersistence";
|
|
|
|
|
import {
|
|
|
|
|
defaultCloneOutput,
|
|
|
|
|
defaultEcommercePlatform,
|
|
|
|
|
getPlatformDefaultLanguage,
|
|
|
|
|
getPlatformDefaultRatio,
|
|
|
|
|
marketOptions,
|
|
|
|
|
type CloneOutputKey,
|
|
|
|
|
normalizeLanguageForPlatform,
|
|
|
|
|
normalizeMarket,
|
|
|
|
|
normalizePlatform,
|
|
|
|
|
normalizeRatioForPlatform,
|
|
|
|
|
} from "./utils/platformRules";
|
2026-06-12 11:12:55 +08:00
|
|
|
|
|
|
|
|
export const ecommerceOssScopes = {
|
|
|
|
|
productSource: buildGenerationOssScope(["ecommerce", "source", "product"]),
|
|
|
|
|
cloneResult: (mode: string) => buildGenerationOssScope(["ecommerce", "result", mode]),
|
|
|
|
|
videoSource: buildGenerationOssScope(["ecommerce", "short-video", "source"]),
|
|
|
|
|
videoHistory: buildGenerationOssScope(["ecommerce", "short-video", "history"]),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export interface EcommerceUnifiedRecordInput {
|
|
|
|
|
clientRecordId: string;
|
|
|
|
|
title: string;
|
|
|
|
|
mode: string;
|
|
|
|
|
prompt?: string;
|
|
|
|
|
status?: SaveGenerationRecordInput["status"];
|
|
|
|
|
sourceImages?: Array<{ url: string; ossKey?: string | null; label?: string }>;
|
|
|
|
|
results?: Array<{ url: string; label?: string; mediaType?: "image" | "video" | string; taskId?: string | null }>;
|
|
|
|
|
taskIds?: string[];
|
|
|
|
|
config?: Record<string, unknown>;
|
|
|
|
|
result?: Record<string, unknown>;
|
|
|
|
|
metadata?: Record<string, unknown>;
|
|
|
|
|
createdAt?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function saveUnifiedEcommerceGenerationRecord(input: EcommerceUnifiedRecordInput): Promise<{ source: "server" | "local"; id: string }> {
|
|
|
|
|
const assets: GenerationRecordAsset[] = [
|
|
|
|
|
...(input.sourceImages || []).map((item): GenerationRecordAsset => ({
|
|
|
|
|
role: "source",
|
|
|
|
|
mediaType: "image",
|
|
|
|
|
url: item.url,
|
|
|
|
|
ossKey: item.ossKey,
|
|
|
|
|
label: item.label,
|
|
|
|
|
scope: ecommerceOssScopes.productSource,
|
|
|
|
|
})),
|
|
|
|
|
...(input.results || []).map((item): GenerationRecordAsset => ({
|
|
|
|
|
role: "result",
|
|
|
|
|
mediaType: item.mediaType || "image",
|
|
|
|
|
url: item.url,
|
|
|
|
|
label: item.label,
|
|
|
|
|
taskId: item.taskId,
|
|
|
|
|
scope: item.mediaType === "video" ? ecommerceOssScopes.videoHistory : ecommerceOssScopes.cloneResult(input.mode),
|
|
|
|
|
})),
|
|
|
|
|
].filter((asset) => Boolean(asset.url));
|
|
|
|
|
|
|
|
|
|
return saveGenerationRecord({
|
|
|
|
|
clientRecordId: input.clientRecordId,
|
|
|
|
|
tool: "ecommerce",
|
|
|
|
|
mode: input.mode,
|
|
|
|
|
title: input.title,
|
|
|
|
|
status: input.status || "completed",
|
|
|
|
|
prompt: input.prompt,
|
|
|
|
|
taskIds: input.taskIds,
|
|
|
|
|
assets,
|
|
|
|
|
config: input.config,
|
|
|
|
|
result: input.result,
|
|
|
|
|
metadata: input.metadata,
|
|
|
|
|
createdAt: input.createdAt,
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteEcommerceGenerationRecord(clientRecordId: string): Promise<void> {
|
|
|
|
|
await deleteGenerationRecordByClientId(clientRecordId);
|
|
|
|
|
}
|
2026-06-18 10:16:40 +08:00
|
|
|
|
|
|
|
|
const ecommerceHistoryStatuses = new Set<EcommerceHistoryStatus>(["generating", "done", "failed"]);
|
|
|
|
|
const cloneOutputs = new Set<CloneOutputKey>(["set", "detail", "model", "video", "hot"]);
|
|
|
|
|
const generationKinds = new Set<EcommerceHistoryTurn["generationKind"]>(["singleImage", "imageEdit", "imageSet", "video"]);
|
|
|
|
|
const replicateLevels = new Set<CloneReplicateLevelKey>(["style", "high"]);
|
|
|
|
|
|
|
|
|
|
function stringValue(value: unknown, fallback = ""): string {
|
|
|
|
|
return typeof value === "string" && value.trim() ? value : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function numberValue(value: unknown, fallback: number): number {
|
|
|
|
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function objectValue(value: unknown): Record<string, unknown> {
|
|
|
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringArrayValue(value: unknown): string[] {
|
|
|
|
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())) : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeOutput(value: unknown): CloneOutputKey {
|
|
|
|
|
if (value === "short-video") return "video";
|
|
|
|
|
return cloneOutputs.has(value as CloneOutputKey) ? value as CloneOutputKey : defaultCloneOutput;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeStatus(value: unknown): EcommerceHistoryStatus {
|
|
|
|
|
if (value === "completed") return "done";
|
|
|
|
|
return ecommerceHistoryStatuses.has(value as EcommerceHistoryStatus) ? value as EcommerceHistoryStatus : "done";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeGenerationKind(value: unknown, output: CloneOutputKey): EcommerceHistoryTurn["generationKind"] {
|
|
|
|
|
if (generationKinds.has(value as EcommerceHistoryTurn["generationKind"])) return value as EcommerceHistoryTurn["generationKind"];
|
|
|
|
|
if (output === "video") return "video";
|
|
|
|
|
if (output === "set") return "imageSet";
|
|
|
|
|
return "singleImage";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeReplicateLevel(value: unknown): CloneReplicateLevelKey {
|
|
|
|
|
return replicateLevels.has(value as CloneReplicateLevelKey) ? value as CloneReplicateLevelKey : "high";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeSetCounts(value: unknown): Record<CloneSetCountKey, number> {
|
|
|
|
|
const counts = objectValue(value);
|
|
|
|
|
return {
|
|
|
|
|
selling: numberValue(counts.selling, defaultCloneSetCounts.selling),
|
|
|
|
|
white: numberValue(counts.white, defaultCloneSetCounts.white),
|
|
|
|
|
scene: numberValue(counts.scene, defaultCloneSetCounts.scene),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function timestampValue(value: unknown, fallback: number): number {
|
|
|
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
|
|
|
if (typeof value === "string") {
|
|
|
|
|
const parsed = new Date(value).getTime();
|
|
|
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
|
|
|
}
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function imageFromAsset(asset: GenerationRecordAsset, index: number): CloneImageItem {
|
|
|
|
|
return {
|
|
|
|
|
id: stringValue(asset.taskId, `server-source-${index + 1}`),
|
|
|
|
|
src: asset.url,
|
|
|
|
|
name: stringValue(asset.label, `source-${index + 1}`),
|
|
|
|
|
ossKey: asset.ossKey || undefined,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resultFromAsset(asset: GenerationRecordAsset, index: number): CloneResult {
|
|
|
|
|
return {
|
|
|
|
|
id: stringValue(asset.taskId, `server-result-${index + 1}`),
|
|
|
|
|
src: asset.url,
|
|
|
|
|
label: stringValue(asset.label, `result-${index + 1}`),
|
|
|
|
|
type: asset.mediaType === "video" ? "video" : "image",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeHistoryImages(value: unknown, fallback: CloneImageItem[] = []): CloneImageItem[] {
|
|
|
|
|
if (!Array.isArray(value)) return fallback;
|
|
|
|
|
return value
|
|
|
|
|
.map((item, index): CloneImageItem | null => {
|
|
|
|
|
const record = objectValue(item);
|
|
|
|
|
const src = stringValue(record.src);
|
|
|
|
|
if (!src) return null;
|
|
|
|
|
return {
|
|
|
|
|
id: stringValue(record.id, `server-image-${index + 1}`),
|
|
|
|
|
src,
|
|
|
|
|
name: stringValue(record.name, `image-${index + 1}`),
|
|
|
|
|
width: typeof record.width === "number" ? record.width : undefined,
|
|
|
|
|
height: typeof record.height === "number" ? record.height : undefined,
|
|
|
|
|
format: typeof record.format === "string" ? record.format : undefined,
|
|
|
|
|
mimeType: typeof record.mimeType === "string" ? record.mimeType : undefined,
|
|
|
|
|
ossKey: typeof record.ossKey === "string" ? record.ossKey : undefined,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter((item): item is CloneImageItem => Boolean(item));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeHistoryResults(value: unknown, fallback: CloneResult[] = []): CloneResult[] {
|
|
|
|
|
if (!Array.isArray(value)) return fallback;
|
|
|
|
|
return value
|
|
|
|
|
.map((item, index): CloneResult | null => {
|
|
|
|
|
const record = objectValue(item);
|
|
|
|
|
const src = stringValue(record.src);
|
|
|
|
|
if (!src) return null;
|
|
|
|
|
return {
|
|
|
|
|
id: stringValue(record.id, `server-result-${index + 1}`),
|
|
|
|
|
src,
|
|
|
|
|
label: stringValue(record.label, `result-${index + 1}`),
|
|
|
|
|
type: record.type === "video" ? "video" : "image",
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter((item): item is CloneResult => Boolean(item));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildTurnFromMetadata(value: unknown, fallback: Omit<EcommerceHistoryTurn, "id" | "createdAt">, fallbackCreatedAt: number, index: number): EcommerceHistoryTurn | null {
|
|
|
|
|
const turn = objectValue(value);
|
|
|
|
|
if (!Object.keys(turn).length) return null;
|
|
|
|
|
const output = normalizeOutput(turn.output ?? fallback.output);
|
|
|
|
|
const platform = normalizePlatform(stringValue(turn.platform, fallback.platform));
|
|
|
|
|
const market = normalizeMarket(stringValue(turn.market, fallback.market));
|
|
|
|
|
const language = normalizeLanguageForPlatform(platform, market, stringValue(turn.language, fallback.language));
|
|
|
|
|
const ratio = normalizeRatioForPlatform(platform, stringValue(turn.ratio, fallback.ratio), output === "hot" ? undefined : output);
|
|
|
|
|
const results = normalizeHistoryResults(turn.results, fallback.results);
|
|
|
|
|
const setResultImages = stringArrayValue(turn.setResultImages).length ? stringArrayValue(turn.setResultImages) : fallback.setResultImages;
|
|
|
|
|
const status = normalizeStatus(turn.status ?? fallback.status);
|
|
|
|
|
return {
|
|
|
|
|
id: stringValue(turn.id, `server-turn-${index + 1}`),
|
|
|
|
|
createdAt: timestampValue(turn.createdAt, fallbackCreatedAt),
|
|
|
|
|
status,
|
|
|
|
|
errorMessage: status === "failed" ? stringValue(turn.errorMessage, fallback.errorMessage) : undefined,
|
|
|
|
|
output,
|
|
|
|
|
modeLabel: typeof turn.modeLabel === "string" ? turn.modeLabel : fallback.modeLabel,
|
|
|
|
|
settingLabel: typeof turn.settingLabel === "string" ? turn.settingLabel : fallback.settingLabel,
|
|
|
|
|
generationKind: normalizeGenerationKind(turn.generationKind ?? fallback.generationKind, output),
|
|
|
|
|
platform,
|
|
|
|
|
market,
|
|
|
|
|
language,
|
|
|
|
|
ratio,
|
|
|
|
|
requirement: stringValue(turn.requirement, fallback.requirement),
|
|
|
|
|
productImages: normalizeHistoryImages(turn.productImages, fallback.productImages),
|
|
|
|
|
results,
|
|
|
|
|
setResultImages,
|
|
|
|
|
setCounts: normalizeSetCounts(turn.setCounts ?? fallback.setCounts),
|
|
|
|
|
detailModules: stringArrayValue(turn.detailModules).length ? stringArrayValue(turn.detailModules) : fallback.detailModules,
|
|
|
|
|
modelScenes: stringArrayValue(turn.modelScenes).length ? stringArrayValue(turn.modelScenes) : fallback.modelScenes,
|
|
|
|
|
referenceImages: normalizeHistoryImages(turn.referenceImages, fallback.referenceImages),
|
|
|
|
|
replicateLevel: normalizeReplicateLevel(turn.replicateLevel ?? fallback.replicateLevel),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ecommerceHistoryRecordFromGenerationRecord(record: GenerationRecord): EcommerceHistoryRecord | null {
|
|
|
|
|
if (record.tool !== "ecommerce") return null;
|
|
|
|
|
|
|
|
|
|
const createdAt = timestampValue(record.createdAt, Date.now());
|
|
|
|
|
const output = normalizeOutput(record.mode);
|
|
|
|
|
const config = objectValue(record.config);
|
|
|
|
|
const metadata = objectValue(record.metadata);
|
|
|
|
|
const sourceImages = record.assets.filter((asset) => asset.role === "source").map(imageFromAsset);
|
|
|
|
|
const results = record.assets.filter((asset) => asset.role === "result").map(resultFromAsset);
|
|
|
|
|
const hasHistoryMarker = metadata.localHistoryStorageKey === ecommerceHistoryStorageKey || typeof metadata.turnCount === "number";
|
|
|
|
|
if (!hasHistoryMarker && record.status !== "completed") return null;
|
|
|
|
|
if (!hasHistoryMarker && !sourceImages.length && !results.length) return null;
|
|
|
|
|
const platform = normalizePlatform(stringValue(config.platform, defaultEcommercePlatform));
|
|
|
|
|
const market = normalizeMarket(stringValue(config.market, marketOptions[0]));
|
|
|
|
|
const language = normalizeLanguageForPlatform(platform, market, stringValue(config.language, getPlatformDefaultLanguage(platform, market)));
|
|
|
|
|
const ratio = normalizeRatioForPlatform(platform, stringValue(config.ratio, getPlatformDefaultRatio(platform, output === "hot" ? undefined : output)), output === "hot" ? undefined : output);
|
|
|
|
|
const setResultImages = results.filter((item) => item.type !== "video").map((item) => item.src);
|
|
|
|
|
const status = normalizeStatus(record.status);
|
|
|
|
|
const baseTurn: Omit<EcommerceHistoryTurn, "id" | "createdAt"> = {
|
|
|
|
|
status,
|
|
|
|
|
errorMessage: status === "failed" ? "生成失败" : undefined,
|
|
|
|
|
output,
|
|
|
|
|
modeLabel: typeof metadata.modeLabel === "string" ? metadata.modeLabel : undefined,
|
|
|
|
|
settingLabel: typeof metadata.settingLabel === "string" ? metadata.settingLabel : undefined,
|
|
|
|
|
generationKind: normalizeGenerationKind(metadata.generationKind, output),
|
|
|
|
|
platform,
|
|
|
|
|
market,
|
|
|
|
|
language,
|
|
|
|
|
ratio,
|
|
|
|
|
requirement: record.prompt ?? "",
|
|
|
|
|
productImages: sourceImages,
|
|
|
|
|
results,
|
|
|
|
|
setResultImages: output === "set" ? setResultImages : [],
|
|
|
|
|
setCounts: normalizeSetCounts(config.setCounts),
|
|
|
|
|
detailModules: stringArrayValue(config.detailModules).length ? stringArrayValue(config.detailModules) : defaultCloneDetailModuleIds,
|
|
|
|
|
modelScenes: stringArrayValue(config.modelScenes),
|
|
|
|
|
referenceImages: normalizeHistoryImages(metadata.referenceImages),
|
|
|
|
|
replicateLevel: normalizeReplicateLevel(config.replicateLevel),
|
|
|
|
|
};
|
|
|
|
|
const turns = Array.isArray(metadata.turns)
|
|
|
|
|
? metadata.turns
|
|
|
|
|
.map((turn, index) => buildTurnFromMetadata(turn, baseTurn, createdAt, index))
|
|
|
|
|
.filter((turn): turn is EcommerceHistoryTurn => Boolean(turn))
|
|
|
|
|
: [];
|
|
|
|
|
const latestTurn = turns[turns.length - 1] ?? { id: `${record.clientRecordId}-turn-initial`, createdAt, ...baseTurn };
|
|
|
|
|
|
|
|
|
|
return normalizeEcommerceHistoryRecord({
|
|
|
|
|
id: record.clientRecordId,
|
|
|
|
|
title: record.title || record.prompt || "生成记录",
|
|
|
|
|
createdAt,
|
|
|
|
|
status: latestTurn.status,
|
|
|
|
|
errorMessage: latestTurn.errorMessage,
|
|
|
|
|
output: latestTurn.output,
|
|
|
|
|
modeLabel: latestTurn.modeLabel,
|
|
|
|
|
settingLabel: latestTurn.settingLabel,
|
|
|
|
|
generationKind: latestTurn.generationKind,
|
|
|
|
|
platform: latestTurn.platform,
|
|
|
|
|
market: latestTurn.market,
|
|
|
|
|
language: latestTurn.language,
|
|
|
|
|
ratio: latestTurn.ratio,
|
|
|
|
|
requirement: latestTurn.requirement,
|
|
|
|
|
productImages: latestTurn.productImages,
|
|
|
|
|
results: latestTurn.results,
|
|
|
|
|
setResultImages: latestTurn.setResultImages,
|
|
|
|
|
setCounts: latestTurn.setCounts,
|
|
|
|
|
detailModules: latestTurn.detailModules,
|
|
|
|
|
modelScenes: latestTurn.modelScenes,
|
|
|
|
|
referenceImages: latestTurn.referenceImages,
|
|
|
|
|
replicateLevel: latestTurn.replicateLevel,
|
|
|
|
|
turns: turns.length ? turns : [latestTurn],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function listEcommerceGenerationHistory(limit = 30): Promise<EcommerceHistoryRecord[]> {
|
|
|
|
|
const payload = await listGenerationRecords({ tool: "ecommerce", limit });
|
|
|
|
|
return payload.items
|
|
|
|
|
.map(ecommerceHistoryRecordFromGenerationRecord)
|
|
|
|
|
.filter((record): record is EcommerceHistoryRecord => Boolean(record))
|
|
|
|
|
.sort((a, b) => b.createdAt - a.createdAt)
|
|
|
|
|
.slice(0, limit);
|
|
|
|
|
}
|