1162 lines
48 KiB
TypeScript
1162 lines
48 KiB
TypeScript
import { config as loadEnv } from "dotenv";
|
||
import { createHash } from "node:crypto";
|
||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||
import { dirname, resolve } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { Prisma, PrismaClient } from "@prisma/client";
|
||
import { bottomCtas, destinations, heroSlides, themeCards } from "../../../src/content.ts";
|
||
import { DESTINATION_PAGE_CONFIG_ID, DESTINATION_RECOMMENDATION_LIMIT } from "../src/destination-page.ts";
|
||
import { createHomeModuleSnapshot, DEFAULT_HOME_MODULES } from "../src/home-module-defaults.ts";
|
||
import { DEFAULT_SEARCH_PAGE_CONFIG, SEARCH_PAGE_CONFIG_ID } from "../src/search-page.ts";
|
||
import { isManagedMediaUrl, mediaPublicBaseUrl } from "../src/media-storage.ts";
|
||
|
||
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||
loadEnv({ path: resolve(rootDir, ".env") });
|
||
|
||
const prisma = new PrismaClient();
|
||
|
||
const SNAPSHOT_FORMAT_VERSION = 1 as const;
|
||
const RELEASE_TITLE_PREFIX = "content-release:";
|
||
const LEGACY_CAMPAIGN_SLUGS = ["classic-deal", "outdoor-deal", "mixed-route"] as const;
|
||
const LEGACY_HOME_MODULE_IDS = ["routes"] as const;
|
||
|
||
const DESTINATION_ALIASES: Record<string, string[]> = {
|
||
贵阳: ["贵阳", "青岩", "花溪", "高坡", "天河潭"],
|
||
黄果树: ["黄果树", "安顺", "坝陵河", "瀑布"],
|
||
荔波小七孔: ["荔波", "小七孔", "茂兰", "水上森林"],
|
||
西江苗寨: ["西江", "苗寨", "郎德", "雷山", "苗岭"],
|
||
梵净山: ["梵净山", "铜仁", "云舍", "寨沙"],
|
||
镇远: ["镇远", "青龙洞", "古城"],
|
||
肇兴: ["肇兴", "侗寨", "堂安", "加榜", "黎平"],
|
||
万峰林: ["万峰林", "万峰湖", "马岭河", "兴义", "黔西南"],
|
||
织金洞: ["织金洞", "织金", "洞穴", "喀斯特"],
|
||
赤水丹霞: ["赤水", "丹霞", "竹海", "丙安"],
|
||
青岩古镇: ["青岩", "古镇", "屯堡"],
|
||
百里杜鹃: ["百里杜鹃", "毕节", "花季"],
|
||
乌蒙草原: ["乌蒙", "六盘水", "草原", "避暑"],
|
||
中国天眼: ["中国天眼", "平塘", "观星", "科普"],
|
||
遵义: ["遵义", "黔北", "红色文化"],
|
||
茅台镇: ["茅台", "酱香", "酒旅"],
|
||
};
|
||
|
||
type SourceProduct = {
|
||
id: number;
|
||
title: string;
|
||
price: string;
|
||
tags: string[];
|
||
image: string;
|
||
summary?: string;
|
||
destinationName?: string;
|
||
priceUnit?: string;
|
||
pricingTiers?: Array<{
|
||
groupSize: number;
|
||
adultPrice: number;
|
||
child6PlusPrice: number;
|
||
childUnder6Price: number;
|
||
}>;
|
||
images?: { url: string; alt?: string | null; sortOrder?: number }[];
|
||
durationDays?: number | null;
|
||
durationNights?: number | null;
|
||
departureDates?: string[];
|
||
remainingSpots?: number | null;
|
||
recommendation?: string | null;
|
||
keyFacts?: Array<{ label: string; value: string }>;
|
||
contentBlocks?: Array<{ type: "title"; text: string } | { type: "image"; url: string; alt?: string | null }>;
|
||
detailSections?: Array<{
|
||
key: string;
|
||
label: string;
|
||
title?: string | null;
|
||
blocks: Array<{ type: "text"; text: string } | { type: "image"; url: string; alt?: string | null }>;
|
||
}>;
|
||
};
|
||
|
||
type ReleaseData = {
|
||
releaseKey: string;
|
||
contentHash: string;
|
||
homeModules: typeof DEFAULT_HOME_MODULES;
|
||
heroSlides: typeof heroSlides;
|
||
destinations: typeof destinations;
|
||
themeCards: typeof themeCards;
|
||
ctaBanners: typeof bottomCtas;
|
||
searchPage: typeof DEFAULT_SEARCH_PAGE_CONFIG;
|
||
products: SourceProduct[];
|
||
campaigns: Array<{
|
||
slug: string;
|
||
title: string;
|
||
start: number;
|
||
end: number;
|
||
coverImage?: string;
|
||
}>;
|
||
mediaAssets: Array<{ url: string; group: string; name?: string }>;
|
||
};
|
||
|
||
type ReleaseManifest = {
|
||
homeModuleIds: string[];
|
||
heroSlideIds: string[];
|
||
destinationIds: string[];
|
||
themeCardIds: string[];
|
||
ctaBannerIds: string[];
|
||
productIds: string[];
|
||
campaignIds: string[];
|
||
mediaUrls: string[];
|
||
};
|
||
|
||
type ContentSnapshot = {
|
||
formatVersion: typeof SNAPSHOT_FORMAT_VERSION;
|
||
releaseKey: string;
|
||
contentHash: string;
|
||
createdAt: string;
|
||
rows: {
|
||
homeModules: unknown[];
|
||
heroSlides: unknown[];
|
||
destinations: unknown[];
|
||
themeCards: unknown[];
|
||
ctaBanners: unknown[];
|
||
destinationPageConfig: unknown | null;
|
||
searchPageConfig: unknown | null;
|
||
products: unknown[];
|
||
campaigns: unknown[];
|
||
mediaAssets: unknown[];
|
||
siteVersion: unknown | null;
|
||
};
|
||
};
|
||
|
||
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||
|
||
type DestinationWithAliases = Prisma.DestinationGetPayload<{ include: { aliases: true } }>;
|
||
type ProductWithImages = Prisma.ProductGetPayload<{ include: { images: true } }>;
|
||
type CampaignWithProducts = Prisma.CampaignGetPayload<{ include: { products: true } }>;
|
||
|
||
type ContentState = {
|
||
homeModules: Awaited<ReturnType<DbClient["homeModule"]["findMany"]>>;
|
||
heroSlides: Awaited<ReturnType<DbClient["heroSlide"]["findMany"]>>;
|
||
destinations: DestinationWithAliases[];
|
||
themeCards: Awaited<ReturnType<DbClient["themeCard"]["findMany"]>>;
|
||
ctaBanners: Awaited<ReturnType<DbClient["ctaBanner"]["findMany"]>>;
|
||
products: ProductWithImages[];
|
||
campaigns: CampaignWithProducts[];
|
||
destinationPageConfig: Awaited<ReturnType<DbClient["destinationPageConfig"]["findUnique"]>>;
|
||
searchPageConfig: Awaited<ReturnType<DbClient["searchPageConfig"]["findUnique"]>>;
|
||
mediaAssets: Awaited<ReturnType<DbClient["mediaAsset"]["findMany"]>>;
|
||
siteVersion: Awaited<ReturnType<DbClient["siteVersion"]["findUnique"]>>;
|
||
};
|
||
|
||
function slugify(input: string) {
|
||
return encodeURIComponent(input).replace(/%/g, "").toLowerCase();
|
||
}
|
||
|
||
function stableUuid(input: string) {
|
||
const hex = createHash("sha256").update(input).digest("hex").slice(0, 32);
|
||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||
}
|
||
|
||
function jsonClone<T>(value: T): T {
|
||
return JSON.parse(JSON.stringify(value)) as T;
|
||
}
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||
}
|
||
|
||
function stringArray(value: unknown): string[] {
|
||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||
}
|
||
|
||
function emptyManifest(): ReleaseManifest {
|
||
return {
|
||
homeModuleIds: [],
|
||
heroSlideIds: [],
|
||
destinationIds: [],
|
||
themeCardIds: [],
|
||
ctaBannerIds: [],
|
||
productIds: [],
|
||
campaignIds: [],
|
||
mediaUrls: [],
|
||
};
|
||
}
|
||
|
||
function parseManifest(snapshot: unknown): ReleaseManifest | null {
|
||
const root = asRecord(snapshot);
|
||
const owned = asRecord(root?.owned);
|
||
if (!owned) return null;
|
||
return {
|
||
homeModuleIds: stringArray(owned.homeModuleIds),
|
||
heroSlideIds: stringArray(owned.heroSlideIds),
|
||
destinationIds: stringArray(owned.destinationIds),
|
||
themeCardIds: stringArray(owned.themeCardIds),
|
||
ctaBannerIds: stringArray(owned.ctaBannerIds),
|
||
productIds: stringArray(owned.productIds),
|
||
campaignIds: stringArray(owned.campaignIds),
|
||
mediaUrls: stringArray(owned.mediaUrls),
|
||
};
|
||
}
|
||
|
||
function mergeStrings(...values: string[][]) {
|
||
return [...new Set(values.flat())];
|
||
}
|
||
|
||
function destinationKey(name: string) {
|
||
return slugify(name);
|
||
}
|
||
|
||
function heroKey(value: { title: string; targetType?: string | null; targetValue?: string | null }) {
|
||
return value.targetValue ? `${value.targetType ?? ""}:${value.targetValue}` : `${value.targetType ?? ""}:${value.title}`;
|
||
}
|
||
|
||
function themeKey(value: { label: string; targetType?: string | null; targetValue?: string | null }) {
|
||
return value.targetValue ? `${value.targetType ?? ""}:${value.targetValue}` : `${value.targetType ?? ""}:${value.label}`;
|
||
}
|
||
|
||
function ctaKey(value: { alt: string; targetType: string; targetValue?: string | null }) {
|
||
return `${value.targetType}:${value.targetValue ?? ""}:${value.alt}`;
|
||
}
|
||
|
||
function packageOwnedHero(row: { id: string; title: string; targetType: string | null; targetValue: string | null }, previous: ReleaseManifest | null, baselineKeys: Set<string>) {
|
||
return previous?.heroSlideIds.includes(row.id)
|
||
|| baselineKeys.has(heroKey(row));
|
||
}
|
||
|
||
function packageOwnedTheme(row: { id: string; label: string; targetType: string | null; targetValue: string | null }, previous: ReleaseManifest | null, baselineKeys: Set<string>) {
|
||
return previous?.themeCardIds.includes(row.id)
|
||
|| baselineKeys.has(themeKey(row));
|
||
}
|
||
|
||
function packageOwnedCta(row: { id: string; alt: string; targetType: string; targetValue: string | null }, previous: ReleaseManifest | null, baselineKeys: Set<string>) {
|
||
return previous?.ctaBannerIds.includes(row.id) || baselineKeys.has(ctaKey(row));
|
||
}
|
||
|
||
function packageOwnedDestination(row: { id: string; name: string; slug: string }, previous: ReleaseManifest | null, baselineSlugs: Set<string>) {
|
||
return previous?.destinationIds.includes(row.id) || baselineSlugs.has(row.slug) || baselineSlugs.has(destinationKey(row.name));
|
||
}
|
||
|
||
function packageOwnedCampaign(row: { id: string; slug: string }, previous: ReleaseManifest | null, baselineSlugs: Set<string>) {
|
||
return previous?.campaignIds.includes(row.id) || baselineSlugs.has(row.slug) || LEGACY_CAMPAIGN_SLUGS.includes(row.slug as typeof LEGACY_CAMPAIGN_SLUGS[number]);
|
||
}
|
||
|
||
function packageOwnedHomeModule(row: { id: string; isSystem: boolean }, previous: ReleaseManifest | null, baselineIds: Set<string>) {
|
||
return previous?.homeModuleIds.includes(row.id) || baselineIds.has(row.id) || row.isSystem || LEGACY_HOME_MODULE_IDS.includes(row.id as typeof LEGACY_HOME_MODULE_IDS[number]);
|
||
}
|
||
|
||
function collectImageUrls(value: unknown, fieldName?: string): string[] {
|
||
if (typeof value === "string") {
|
||
return fieldName && new Set(["image", "coverImage", "url"]).has(fieldName) ? [value] : [];
|
||
}
|
||
if (Array.isArray(value)) return value.flatMap((item) => collectImageUrls(item));
|
||
if (!value || typeof value !== "object") return [];
|
||
return Object.entries(value).flatMap(([key, child]) => collectImageUrls(child, key));
|
||
}
|
||
|
||
function addMedia(map: Map<string, { url: string; group: string; name?: string }>, value: unknown, group: string, name?: string) {
|
||
if (typeof value === "string") {
|
||
if (value) map.set(value, { url: value, group, name });
|
||
return;
|
||
}
|
||
for (const url of collectImageUrls(value)) {
|
||
if (!url) continue;
|
||
map.set(url, { url, group, name });
|
||
}
|
||
}
|
||
|
||
function buildMediaAssets(data: Omit<ReleaseData, "mediaAssets" | "releaseKey" | "contentHash">) {
|
||
const media = new Map<string, { url: string; group: string; name?: string }>();
|
||
addMedia(media, data.homeModules, "home-module");
|
||
addMedia(media, data.searchPage, "search-page");
|
||
for (const item of data.heroSlides) addMedia(media, item.image, "hero", item.title);
|
||
for (const item of data.destinations) addMedia(media, item.image, "destination", item.label);
|
||
for (const item of data.themeCards) addMedia(media, item.image, "theme", item.label);
|
||
for (const item of data.ctaBanners) addMedia(media, item.image, "cta", item.alt);
|
||
for (const product of data.products) {
|
||
addMedia(media, product.image, "product", product.title);
|
||
addMedia(media, product.images, "product-gallery", product.title);
|
||
addMedia(media, product.contentBlocks, "product-content", product.title);
|
||
addMedia(media, product.detailSections, "product-detail", product.title);
|
||
}
|
||
for (const campaign of data.campaigns) addMedia(media, campaign.coverImage, "campaign", campaign.title);
|
||
return [...media.values()];
|
||
}
|
||
|
||
function assertBaseline(data: Omit<ReleaseData, "releaseKey" | "contentHash" | "mediaAssets">) {
|
||
const productIds = data.products.map((product) => product.id);
|
||
if (new Set(productIds).size !== productIds.length) throw new Error("内容包中的商品 sourceId 重复");
|
||
if (data.products.some((product) => !Number.isInteger(Number(product.price)) || Number(product.price) < 0)) {
|
||
throw new Error("内容包中存在非法商品价格");
|
||
}
|
||
|
||
const destinationLabels = data.destinations.map((item) => item.label);
|
||
if (new Set(destinationLabels).size !== destinationLabels.length) throw new Error("内容包中的目的地名称重复");
|
||
|
||
const mediaAssets = buildMediaAssets(data);
|
||
if (!mediaPublicBaseUrl || mediaPublicBaseUrl === "https://.") {
|
||
throw new Error("无法确定 OSS 公共地址,请配置 MEDIA_PUBLIC_BASE_URL 或 OSS_BUCKET_NAME/OSS_ENDPOINT");
|
||
}
|
||
const unmanaged = mediaAssets.filter((asset) => !isManagedMediaUrl(asset.url));
|
||
if (unmanaged.length) {
|
||
throw new Error(`内容包包含未托管的图片 URL:${unmanaged.slice(0, 5).map((item) => item.url).join(", ")}`);
|
||
}
|
||
return mediaAssets;
|
||
}
|
||
|
||
async function loadReleaseData(): Promise<ReleaseData> {
|
||
const productFile = await readFile(resolve(rootDir, "src/generated-products.json"), "utf8");
|
||
const products = JSON.parse(productFile) as SourceProduct[];
|
||
const data = {
|
||
homeModules: DEFAULT_HOME_MODULES,
|
||
heroSlides,
|
||
destinations,
|
||
themeCards,
|
||
ctaBanners: bottomCtas,
|
||
searchPage: DEFAULT_SEARCH_PAGE_CONFIG,
|
||
products,
|
||
campaigns: [
|
||
{ slug: "classic-deal", title: "经典打卡特惠", start: 0, end: 8, coverImage: heroSlides[0]?.image },
|
||
{ slug: "outdoor-deal", title: "山野野咖特惠", start: 8, end: 16, coverImage: heroSlides[1]?.image },
|
||
{ slug: "mixed-route", title: "人文户外混搭", start: 16, end: 24, coverImage: heroSlides[2]?.image },
|
||
],
|
||
};
|
||
const mediaAssets = assertBaseline(data);
|
||
const contentHash = createHash("sha256").update(JSON.stringify(data)).digest("hex");
|
||
return {
|
||
...data,
|
||
mediaAssets,
|
||
contentHash,
|
||
releaseKey: `miniapp-content-${contentHash.slice(0, 16)}`,
|
||
};
|
||
}
|
||
|
||
async function latestRelease(db: DbClient) {
|
||
return db.siteVersion.findFirst({
|
||
where: { title: { startsWith: RELEASE_TITLE_PREFIX }, status: "published" },
|
||
orderBy: { createdAt: "desc" },
|
||
});
|
||
}
|
||
|
||
async function readContentState(db: DbClient, data: ReleaseData, previous: ReleaseManifest | null): Promise<ContentState> {
|
||
const baselineHomeIds = new Set(data.homeModules.map((item) => item.id));
|
||
const baselineHeroKeys = new Set(data.heroSlides.map((item) => heroKey(item)));
|
||
const baselineThemeKeys = new Set(data.themeCards.map((item) => themeKey(item)));
|
||
const baselineCtaKeys = new Set(data.ctaBanners.map((item) => ctaKey(item)));
|
||
const baselineDestinationSlugs = new Set(data.destinations.map((item) => destinationKey(item.label)));
|
||
const baselineCampaignSlugs = new Set(data.campaigns.map((item) => item.slug));
|
||
const mediaUrls = mergeStrings(data.mediaAssets.map((item) => item.url), previous?.mediaUrls ?? []);
|
||
|
||
const [allHomeModules, allHeroSlides, allDestinations, allThemeCards, allCtas, allProducts, allCampaigns, destinationPageConfig, searchPageConfig, mediaAssets, release] = await Promise.all([
|
||
db.homeModule.findMany(),
|
||
db.heroSlide.findMany(),
|
||
db.destination.findMany({ include: { aliases: true } }),
|
||
db.themeCard.findMany(),
|
||
db.ctaBanner.findMany(),
|
||
db.product.findMany({ include: { images: true } }),
|
||
db.campaign.findMany({ include: { products: true } }),
|
||
db.destinationPageConfig.findUnique({ where: { id: DESTINATION_PAGE_CONFIG_ID } }),
|
||
db.searchPageConfig.findUnique({ where: { id: SEARCH_PAGE_CONFIG_ID } }),
|
||
mediaUrls.length ? db.mediaAsset.findMany({ where: { url: { in: mediaUrls } } }) : Promise.resolve([]),
|
||
db.siteVersion.findUnique({ where: { id: stableUuid(data.releaseKey) } }),
|
||
]);
|
||
|
||
return {
|
||
homeModules: allHomeModules.filter((row) => packageOwnedHomeModule(row, previous, baselineHomeIds)),
|
||
heroSlides: allHeroSlides.filter((row) => packageOwnedHero(row, previous, baselineHeroKeys)),
|
||
destinations: allDestinations.filter((row) => packageOwnedDestination(row, previous, baselineDestinationSlugs)),
|
||
themeCards: allThemeCards.filter((row) => packageOwnedTheme(row, previous, baselineThemeKeys)),
|
||
ctaBanners: allCtas.filter((row) => packageOwnedCta(row, previous, baselineCtaKeys)),
|
||
products: allProducts.filter((row) => row.sourceId !== null || previous?.productIds.includes(row.id)),
|
||
campaigns: allCampaigns.filter((row) => packageOwnedCampaign(row, previous, baselineCampaignSlugs)),
|
||
destinationPageConfig,
|
||
searchPageConfig,
|
||
mediaAssets,
|
||
siteVersion: release,
|
||
};
|
||
}
|
||
|
||
function sourceProductFields(product: SourceProduct, destinationId: string | null, publishedAt: Date): Prisma.ProductUncheckedCreateInput {
|
||
const summary = product.summary ?? product.tags.join(" · ");
|
||
const priceAmount = Number(product.price);
|
||
return {
|
||
sourceId: product.id,
|
||
title: product.title,
|
||
subtitle: product.title.replace(/^【.*?】\s*/, "").split("·")[0],
|
||
destinationId,
|
||
priceAmount,
|
||
priceUnit: product.priceUnit ?? "咨询价",
|
||
pricingTiers: product.pricingTiers?.length ? product.pricingTiers as Prisma.InputJsonValue : Prisma.JsonNull,
|
||
tags: product.tags,
|
||
coverImage: product.image,
|
||
summary,
|
||
durationDays: product.durationDays ?? null,
|
||
durationNights: product.durationNights ?? null,
|
||
departureDates: product.departureDates ?? [],
|
||
remainingSpots: product.remainingSpots ?? null,
|
||
recommendation: product.recommendation ?? null,
|
||
keyFacts: (product.keyFacts?.length ? product.keyFacts : defaultKeyFacts(product)) as Prisma.InputJsonValue,
|
||
contentBlocks: product.contentBlocks?.length ? product.contentBlocks as Prisma.InputJsonValue : Prisma.JsonNull,
|
||
detailSections: product.detailSections?.length ? product.detailSections as Prisma.InputJsonValue : Prisma.JsonNull,
|
||
status: "published",
|
||
sortWeight: product.id,
|
||
publishedAt,
|
||
};
|
||
}
|
||
|
||
function defaultKeyFacts(product: SourceProduct) {
|
||
const duration = product.title.match(/\d+天\d+晚/)?.[0];
|
||
return [
|
||
duration ? { label: "行程时长", value: duration } : null,
|
||
product.tags[0] ? { label: "线路类型", value: product.tags[0] } : null,
|
||
{ label: "成团方式", value: "专属小包团" },
|
||
].filter((fact): fact is { label: string; value: string } => Boolean(fact));
|
||
}
|
||
|
||
function productImages(product: SourceProduct) {
|
||
return product.images?.length ? product.images : [{ url: product.image, alt: product.title, sortOrder: 0 }];
|
||
}
|
||
|
||
function currentProductBySourceId(products: ProductWithImages[], sourceId: number) {
|
||
const matches = products.filter((item) => item.sourceId === sourceId);
|
||
if (matches.length > 1) throw new Error(`生产库存在重复的商品 sourceId:${sourceId}`);
|
||
return matches[0];
|
||
}
|
||
|
||
function currentByKey<T>(rows: T[], key: (row: T) => string, target: string, label: string) {
|
||
const matches = rows.filter((row) => key(row) === target);
|
||
if (matches.length > 1) throw new Error(`生产库存在重复的${label}内容标识:${target}`);
|
||
return matches[0];
|
||
}
|
||
|
||
function archivedPublishedConfig(value: Prisma.JsonValue | null) {
|
||
const snapshot = asRecord(value);
|
||
if (!snapshot) return undefined;
|
||
return {
|
||
...snapshot,
|
||
isActive: false,
|
||
isDeleted: true,
|
||
} as Prisma.InputJsonValue;
|
||
}
|
||
|
||
async function upsertMedia(db: Prisma.TransactionClient, media: ReleaseData["mediaAssets"]) {
|
||
for (const item of media) {
|
||
await db.mediaAsset.upsert({
|
||
where: { url: item.url },
|
||
update: { group: item.group, name: item.name },
|
||
create: { url: item.url, group: item.group, name: item.name },
|
||
});
|
||
}
|
||
}
|
||
|
||
async function applyContent(db: Prisma.TransactionClient, data: ReleaseData, previous: ReleaseManifest | null) {
|
||
const current = await readContentState(db, data, previous);
|
||
const manifest = emptyManifest();
|
||
const baselineHomeIds = new Set(data.homeModules.map((item) => item.id));
|
||
const baselineHeroKeys = new Set(data.heroSlides.map((item) => heroKey(item)));
|
||
const baselineThemeKeys = new Set(data.themeCards.map((item) => themeKey(item)));
|
||
const baselineCtaKeys = new Set(data.ctaBanners.map((item) => ctaKey(item)));
|
||
const baselineDestinationSlugs = new Set(data.destinations.map((item) => destinationKey(item.label)));
|
||
const baselineCampaignSlugs = new Set(data.campaigns.map((item) => item.slug));
|
||
const baselineProductSourceIds = new Set(data.products.map((item) => item.id));
|
||
|
||
for (const module of data.homeModules) {
|
||
const snapshot = createHomeModuleSnapshot(module);
|
||
const row = await db.homeModule.upsert({
|
||
where: { id: module.id },
|
||
update: {
|
||
label: module.label,
|
||
templateType: module.templateType,
|
||
content: module.content as Prisma.InputJsonValue,
|
||
publishedConfig: snapshot as Prisma.InputJsonValue,
|
||
sortOrder: module.sortOrder,
|
||
isActive: module.isActive,
|
||
isSystem: true,
|
||
isDeleted: module.isDeleted,
|
||
},
|
||
create: {
|
||
id: module.id,
|
||
label: module.label,
|
||
templateType: module.templateType,
|
||
content: module.content as Prisma.InputJsonValue,
|
||
publishedConfig: snapshot as Prisma.InputJsonValue,
|
||
sortOrder: module.sortOrder,
|
||
isActive: module.isActive,
|
||
isSystem: true,
|
||
isDeleted: module.isDeleted,
|
||
},
|
||
});
|
||
manifest.homeModuleIds.push(row.id);
|
||
}
|
||
|
||
for (const row of current.homeModules) {
|
||
if (!baselineHomeIds.has(row.id)) {
|
||
await db.homeModule.update({
|
||
where: { id: row.id },
|
||
data: {
|
||
isActive: false,
|
||
isDeleted: true,
|
||
publishedConfig: archivedPublishedConfig(row.publishedConfig),
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
for (const [index, slide] of data.heroSlides.entries()) {
|
||
const existing = currentByKey(current.heroSlides, heroKey, heroKey(slide), "轮播");
|
||
const row = existing
|
||
? await db.heroSlide.update({
|
||
where: { id: existing.id },
|
||
data: {
|
||
title: slide.title,
|
||
kicker: slide.kicker,
|
||
actionLabel: slide.action,
|
||
image: slide.image,
|
||
targetType: slide.targetType,
|
||
targetValue: slide.targetValue,
|
||
sortOrder: index,
|
||
isActive: true,
|
||
},
|
||
})
|
||
: await db.heroSlide.create({
|
||
data: {
|
||
title: slide.title,
|
||
kicker: slide.kicker,
|
||
actionLabel: slide.action,
|
||
image: slide.image,
|
||
targetType: slide.targetType,
|
||
targetValue: slide.targetValue,
|
||
sortOrder: index,
|
||
isActive: true,
|
||
},
|
||
});
|
||
manifest.heroSlideIds.push(row.id);
|
||
}
|
||
for (const row of current.heroSlides) {
|
||
if (!baselineHeroKeys.has(heroKey(row))) {
|
||
await db.heroSlide.update({ where: { id: row.id }, data: { isActive: false } });
|
||
}
|
||
}
|
||
|
||
const destinationMap = new Map<string, string>();
|
||
for (const [index, item] of data.destinations.entries()) {
|
||
const slug = destinationKey(item.label);
|
||
const existing = currentByKey(current.destinations, (row) => row.slug === slug || row.name === item.label ? "match" : "no-match", "match", "目的地");
|
||
const row = existing
|
||
? await db.destination.update({
|
||
where: { id: existing.id },
|
||
data: {
|
||
name: item.label,
|
||
slug,
|
||
image: item.image,
|
||
isHot: index < 8,
|
||
sortOrder: index,
|
||
isActive: true,
|
||
},
|
||
})
|
||
: await db.destination.create({
|
||
data: {
|
||
name: item.label,
|
||
slug,
|
||
image: item.image,
|
||
isHot: index < 8,
|
||
sortOrder: index,
|
||
isActive: true,
|
||
},
|
||
});
|
||
await db.destinationAlias.deleteMany({ where: { destinationId: row.id } });
|
||
const aliases = DESTINATION_ALIASES[item.label] ?? [item.label];
|
||
if (aliases.length) await db.destinationAlias.createMany({ data: aliases.map((alias) => ({ destinationId: row.id, alias })) });
|
||
destinationMap.set(item.label, row.id);
|
||
manifest.destinationIds.push(row.id);
|
||
}
|
||
for (const row of current.destinations) {
|
||
if (!baselineDestinationSlugs.has(row.slug) && !baselineDestinationSlugs.has(destinationKey(row.name))) {
|
||
await db.destination.update({ where: { id: row.id }, data: { isActive: false } });
|
||
}
|
||
}
|
||
|
||
for (const [index, card] of data.themeCards.entries()) {
|
||
const existing = currentByKey(current.themeCards, themeKey, themeKey(card), "主题卡片");
|
||
const row = existing
|
||
? await db.themeCard.update({
|
||
where: { id: existing.id },
|
||
data: {
|
||
label: card.label,
|
||
image: card.image,
|
||
targetType: card.targetType,
|
||
targetValue: card.targetValue,
|
||
sortOrder: index,
|
||
isActive: true,
|
||
},
|
||
})
|
||
: await db.themeCard.create({
|
||
data: {
|
||
label: card.label,
|
||
image: card.image,
|
||
targetType: card.targetType,
|
||
targetValue: card.targetValue,
|
||
sortOrder: index,
|
||
isActive: true,
|
||
},
|
||
});
|
||
manifest.themeCardIds.push(row.id);
|
||
}
|
||
for (const row of current.themeCards) {
|
||
if (!baselineThemeKeys.has(themeKey(row))) await db.themeCard.update({ where: { id: row.id }, data: { isActive: false } });
|
||
}
|
||
|
||
for (const [index, cta] of data.ctaBanners.entries()) {
|
||
const existing = currentByKey(current.ctaBanners, ctaKey, ctaKey(cta), "底部 CTA");
|
||
const row = existing
|
||
? await db.ctaBanner.update({
|
||
where: { id: existing.id },
|
||
data: {
|
||
alt: cta.alt,
|
||
image: cta.image,
|
||
targetType: cta.targetType,
|
||
targetValue: cta.targetValue,
|
||
sortOrder: index,
|
||
isActive: true,
|
||
},
|
||
})
|
||
: await db.ctaBanner.create({
|
||
data: {
|
||
alt: cta.alt,
|
||
image: cta.image,
|
||
targetType: cta.targetType,
|
||
targetValue: cta.targetValue,
|
||
sortOrder: index,
|
||
isActive: true,
|
||
},
|
||
});
|
||
manifest.ctaBannerIds.push(row.id);
|
||
}
|
||
for (const row of current.ctaBanners) {
|
||
if (!baselineCtaKeys.has(ctaKey(row))) await db.ctaBanner.update({ where: { id: row.id }, data: { isActive: false } });
|
||
}
|
||
|
||
const productMap = new Map<number, string>();
|
||
for (const product of data.products) {
|
||
const matchedDestination = product.destinationName && destinationMap.has(product.destinationName)
|
||
? product.destinationName
|
||
: [...destinationMap.keys()].find((label) => product.title.includes(label) || product.tags.includes(label));
|
||
const existing = currentProductBySourceId(current.products, product.id);
|
||
const publishedAt = existing?.status === "published" && existing.publishedAt ? existing.publishedAt : new Date();
|
||
const fields = sourceProductFields(product, matchedDestination ? destinationMap.get(matchedDestination) ?? null : null, publishedAt);
|
||
const row = existing
|
||
? await db.product.update({ where: { id: existing.id }, data: fields as Prisma.ProductUncheckedUpdateInput })
|
||
: await db.product.create({ data: fields });
|
||
await db.productImage.deleteMany({ where: { productId: row.id } });
|
||
await db.productImage.createMany({
|
||
data: productImages(product).map((image, index) => ({
|
||
productId: row.id,
|
||
url: image.url,
|
||
alt: image.alt ?? product.title,
|
||
sortOrder: image.sortOrder ?? index,
|
||
})),
|
||
});
|
||
productMap.set(product.id, row.id);
|
||
manifest.productIds.push(row.id);
|
||
}
|
||
for (const row of current.products) {
|
||
if (row.sourceId === null || baselineProductSourceIds.has(row.sourceId)) continue;
|
||
await db.product.update({ where: { id: row.id }, data: { status: "archived" } });
|
||
}
|
||
|
||
for (const seed of data.campaigns) {
|
||
const existing = current.campaigns.find((campaign) => campaign.slug === seed.slug);
|
||
const campaign = existing
|
||
? await db.campaign.update({
|
||
where: { id: existing.id },
|
||
data: {
|
||
title: seed.title,
|
||
description: "由当前 H5 万趣贵州小包团内容导入的活动专题。",
|
||
coverImage: seed.coverImage,
|
||
status: "published",
|
||
},
|
||
})
|
||
: await db.campaign.create({
|
||
data: {
|
||
slug: seed.slug,
|
||
title: seed.title,
|
||
description: "由当前 H5 万趣贵州小包团内容导入的活动专题。",
|
||
coverImage: seed.coverImage,
|
||
status: "published",
|
||
},
|
||
});
|
||
await db.campaignProduct.deleteMany({ where: { campaignId: campaign.id } });
|
||
const linkedProducts = [...productMap.entries()]
|
||
.filter(([sourceId]) => sourceId >= seed.start + 1 && sourceId <= seed.end)
|
||
.sort(([left], [right]) => left - right);
|
||
if (linkedProducts.length) {
|
||
await db.campaignProduct.createMany({
|
||
data: linkedProducts.map(([, productId], index) => ({ campaignId: campaign.id, productId, sortOrder: index })),
|
||
});
|
||
}
|
||
manifest.campaignIds.push(campaign.id);
|
||
}
|
||
for (const row of current.campaigns) {
|
||
if (!baselineCampaignSlugs.has(row.slug)) await db.campaign.update({ where: { id: row.id }, data: { status: "archived" } });
|
||
}
|
||
|
||
const recommendedProductIds = data.products
|
||
.slice()
|
||
.sort((left, right) => left.id - right.id)
|
||
.map((product) => productMap.get(product.id))
|
||
.filter((id): id is string => Boolean(id))
|
||
.slice(0, DESTINATION_RECOMMENDATION_LIMIT);
|
||
await db.destinationPageConfig.upsert({
|
||
where: { id: DESTINATION_PAGE_CONFIG_ID },
|
||
update: { recommendedProductIds: recommendedProductIds as Prisma.InputJsonValue },
|
||
create: { id: DESTINATION_PAGE_CONFIG_ID, recommendedProductIds: recommendedProductIds as Prisma.InputJsonValue },
|
||
});
|
||
|
||
await db.searchPageConfig.upsert({
|
||
where: { id: SEARCH_PAGE_CONFIG_ID },
|
||
update: {
|
||
title: data.searchPage.modules.seasonalInspiration.title,
|
||
subtitle: data.searchPage.modules.seasonalInspiration.subtitle,
|
||
placeholder: data.searchPage.placeholder,
|
||
modules: data.searchPage.modules as Prisma.InputJsonValue,
|
||
popularKeywords: data.searchPage.popularKeywords as Prisma.InputJsonValue,
|
||
groups: data.searchPage.groups as Prisma.InputJsonValue,
|
||
},
|
||
create: {
|
||
id: SEARCH_PAGE_CONFIG_ID,
|
||
title: data.searchPage.modules.seasonalInspiration.title,
|
||
subtitle: data.searchPage.modules.seasonalInspiration.subtitle,
|
||
placeholder: data.searchPage.placeholder,
|
||
modules: data.searchPage.modules as Prisma.InputJsonValue,
|
||
popularKeywords: data.searchPage.popularKeywords as Prisma.InputJsonValue,
|
||
groups: data.searchPage.groups as Prisma.InputJsonValue,
|
||
},
|
||
});
|
||
|
||
await upsertMedia(db, data.mediaAssets);
|
||
manifest.mediaUrls = data.mediaAssets.map((item) => item.url);
|
||
|
||
const siteVersionId = stableUuid(data.releaseKey);
|
||
await db.siteVersion.upsert({
|
||
where: { id: siteVersionId },
|
||
update: {
|
||
title: `${RELEASE_TITLE_PREFIX}${data.releaseKey}`,
|
||
status: "published",
|
||
publishedAt: new Date(),
|
||
snapshot: {
|
||
formatVersion: SNAPSHOT_FORMAT_VERSION,
|
||
releaseKey: data.releaseKey,
|
||
contentHash: data.contentHash,
|
||
owned: manifest,
|
||
counts: {
|
||
homeModules: data.homeModules.length,
|
||
heroSlides: data.heroSlides.length,
|
||
destinations: data.destinations.length,
|
||
themeCards: data.themeCards.length,
|
||
ctaBanners: data.ctaBanners.length,
|
||
products: data.products.length,
|
||
campaigns: data.campaigns.length,
|
||
mediaAssets: data.mediaAssets.length,
|
||
},
|
||
} as Prisma.InputJsonValue,
|
||
},
|
||
create: {
|
||
id: siteVersionId,
|
||
title: `${RELEASE_TITLE_PREFIX}${data.releaseKey}`,
|
||
status: "published",
|
||
publishedAt: new Date(),
|
||
snapshot: {
|
||
formatVersion: SNAPSHOT_FORMAT_VERSION,
|
||
releaseKey: data.releaseKey,
|
||
contentHash: data.contentHash,
|
||
owned: manifest,
|
||
counts: {
|
||
homeModules: data.homeModules.length,
|
||
heroSlides: data.heroSlides.length,
|
||
destinations: data.destinations.length,
|
||
themeCards: data.themeCards.length,
|
||
ctaBanners: data.ctaBanners.length,
|
||
products: data.products.length,
|
||
campaigns: data.campaigns.length,
|
||
mediaAssets: data.mediaAssets.length,
|
||
},
|
||
} as Prisma.InputJsonValue,
|
||
},
|
||
});
|
||
|
||
return { manifest, siteVersionId };
|
||
}
|
||
|
||
function without<T extends Record<string, unknown>>(row: T, keys: string[]) {
|
||
const copy = { ...row };
|
||
for (const key of keys) delete copy[key];
|
||
return copy;
|
||
}
|
||
|
||
function restoreDates(row: Record<string, unknown>, fields: string[]) {
|
||
const copy = { ...row };
|
||
for (const field of fields) {
|
||
if (typeof copy[field] === "string") copy[field] = new Date(copy[field] as string);
|
||
}
|
||
return copy;
|
||
}
|
||
|
||
async function restoreSnapshot(db: Prisma.TransactionClient, snapshot: ContentSnapshot) {
|
||
const rows = snapshot.rows;
|
||
|
||
for (const raw of rows.homeModules) {
|
||
const row = raw as Record<string, unknown>;
|
||
const data = restoreDates(without(row, ["id", "createdAt"]), ["updatedAt"]);
|
||
await db.homeModule.upsert({
|
||
where: { id: String(row.id) },
|
||
update: data as Prisma.HomeModuleUncheckedUpdateInput,
|
||
create: restoreDates(row, ["createdAt", "updatedAt"]) as Prisma.HomeModuleUncheckedCreateInput,
|
||
});
|
||
}
|
||
for (const raw of rows.heroSlides) {
|
||
const row = raw as Record<string, unknown>;
|
||
const data = restoreDates(without(row, ["id", "createdAt"]), ["updatedAt"]);
|
||
await db.heroSlide.upsert({
|
||
where: { id: String(row.id) },
|
||
update: data as Prisma.HeroSlideUncheckedUpdateInput,
|
||
create: restoreDates(row, ["createdAt", "updatedAt"]) as Prisma.HeroSlideUncheckedCreateInput,
|
||
});
|
||
}
|
||
for (const raw of rows.destinations) {
|
||
const row = raw as Record<string, unknown>;
|
||
const aliases = Array.isArray(row.aliases) ? row.aliases as Record<string, unknown>[] : [];
|
||
const data = restoreDates(without(row, ["id", "createdAt", "aliases"]), ["updatedAt"]);
|
||
await db.destination.upsert({
|
||
where: { id: String(row.id) },
|
||
update: data as Prisma.DestinationUncheckedUpdateInput,
|
||
create: restoreDates(without(row, ["aliases"]), ["createdAt", "updatedAt"]) as Prisma.DestinationUncheckedCreateInput,
|
||
});
|
||
await db.destinationAlias.deleteMany({ where: { destinationId: String(row.id) } });
|
||
if (aliases.length) {
|
||
await db.destinationAlias.createMany({
|
||
data: aliases.map((alias) => restoreDates(alias, ["createdAt", "updatedAt"]) as Prisma.DestinationAliasUncheckedCreateInput),
|
||
});
|
||
}
|
||
}
|
||
for (const raw of rows.themeCards) {
|
||
const row = raw as Record<string, unknown>;
|
||
const data = restoreDates(without(row, ["id", "createdAt"]), ["updatedAt"]);
|
||
await db.themeCard.upsert({
|
||
where: { id: String(row.id) },
|
||
update: data as Prisma.ThemeCardUncheckedUpdateInput,
|
||
create: restoreDates(row, ["createdAt", "updatedAt"]) as Prisma.ThemeCardUncheckedCreateInput,
|
||
});
|
||
}
|
||
for (const raw of rows.ctaBanners) {
|
||
const row = raw as Record<string, unknown>;
|
||
const data = restoreDates(without(row, ["id", "createdAt"]), ["updatedAt"]);
|
||
await db.ctaBanner.upsert({
|
||
where: { id: String(row.id) },
|
||
update: data as Prisma.CtaBannerUncheckedUpdateInput,
|
||
create: restoreDates(row, ["createdAt", "updatedAt"]) as Prisma.CtaBannerUncheckedCreateInput,
|
||
});
|
||
}
|
||
for (const raw of rows.products) {
|
||
const row = raw as Record<string, unknown>;
|
||
const images = Array.isArray(row.images) ? row.images as Record<string, unknown>[] : [];
|
||
const data = restoreDates(without(row, ["id", "createdAt", "images"]), ["updatedAt", "publishedAt"]);
|
||
await db.product.upsert({
|
||
where: { id: String(row.id) },
|
||
update: data as Prisma.ProductUncheckedUpdateInput,
|
||
create: restoreDates(without(row, ["images"]), ["createdAt", "updatedAt", "publishedAt"]) as Prisma.ProductUncheckedCreateInput,
|
||
});
|
||
await db.productImage.deleteMany({ where: { productId: String(row.id) } });
|
||
if (images.length) {
|
||
await db.productImage.createMany({
|
||
data: images.map((image) => restoreDates(image, ["createdAt", "updatedAt"]) as Prisma.ProductImageUncheckedCreateInput),
|
||
});
|
||
}
|
||
}
|
||
for (const raw of rows.campaigns) {
|
||
const row = raw as Record<string, unknown>;
|
||
const products = Array.isArray(row.products) ? row.products as Record<string, unknown>[] : [];
|
||
const data = restoreDates(without(row, ["id", "createdAt", "products"]), ["updatedAt", "startsAt", "endsAt"]);
|
||
await db.campaign.upsert({
|
||
where: { id: String(row.id) },
|
||
update: data as Prisma.CampaignUncheckedUpdateInput,
|
||
create: restoreDates(without(row, ["products"]), ["createdAt", "updatedAt", "startsAt", "endsAt"]) as Prisma.CampaignUncheckedCreateInput,
|
||
});
|
||
await db.campaignProduct.deleteMany({ where: { campaignId: String(row.id) } });
|
||
if (products.length) {
|
||
await db.campaignProduct.createMany({
|
||
data: products.map((product) => product as Prisma.CampaignProductUncheckedCreateInput),
|
||
});
|
||
}
|
||
}
|
||
|
||
if (rows.destinationPageConfig) {
|
||
const row = rows.destinationPageConfig as Record<string, unknown>;
|
||
await db.destinationPageConfig.upsert({
|
||
where: { id: String(row.id) },
|
||
update: restoreDates(without(row, ["id", "createdAt"]), ["updatedAt"]),
|
||
create: restoreDates(row, ["createdAt", "updatedAt"]) as Prisma.DestinationPageConfigUncheckedCreateInput,
|
||
});
|
||
} else {
|
||
await db.destinationPageConfig.deleteMany({ where: { id: DESTINATION_PAGE_CONFIG_ID } });
|
||
}
|
||
if (rows.searchPageConfig) {
|
||
const row = rows.searchPageConfig as Record<string, unknown>;
|
||
await db.searchPageConfig.upsert({
|
||
where: { id: String(row.id) },
|
||
update: restoreDates(without(row, ["id", "createdAt"]), ["updatedAt"]),
|
||
create: restoreDates(row, ["createdAt", "updatedAt"]) as Prisma.SearchPageConfigUncheckedCreateInput,
|
||
});
|
||
} else {
|
||
await db.searchPageConfig.deleteMany({ where: { id: SEARCH_PAGE_CONFIG_ID } });
|
||
}
|
||
|
||
for (const raw of rows.mediaAssets) {
|
||
const row = raw as Record<string, unknown>;
|
||
await db.mediaAsset.upsert({
|
||
where: { id: String(row.id) },
|
||
update: restoreDates(without(row, ["id", "createdAt"]), ["updatedAt"]),
|
||
create: restoreDates(row, ["createdAt", "updatedAt"]) as Prisma.MediaAssetUncheckedCreateInput,
|
||
});
|
||
}
|
||
if (rows.siteVersion) {
|
||
const row = rows.siteVersion as Record<string, unknown>;
|
||
await db.siteVersion.upsert({
|
||
where: { id: String(row.id) },
|
||
update: restoreDates(without(row, ["id", "createdAt"]), ["updatedAt", "publishedAt"]),
|
||
create: restoreDates(row, ["createdAt", "publishedAt"]) as Prisma.SiteVersionUncheckedCreateInput,
|
||
});
|
||
} else {
|
||
await db.siteVersion.deleteMany({ where: { id: stableUuid(snapshot.releaseKey) } });
|
||
}
|
||
}
|
||
|
||
async function archiveNewRows(db: Prisma.TransactionClient, snapshot: ContentSnapshot, currentManifest: ReleaseManifest | null) {
|
||
if (!currentManifest) return;
|
||
const oldIds = {
|
||
home: new Set(snapshot.rows.homeModules.map((row) => String((row as Record<string, unknown>).id))),
|
||
hero: new Set(snapshot.rows.heroSlides.map((row) => String((row as Record<string, unknown>).id))),
|
||
destination: new Set(snapshot.rows.destinations.map((row) => String((row as Record<string, unknown>).id))),
|
||
theme: new Set(snapshot.rows.themeCards.map((row) => String((row as Record<string, unknown>).id))),
|
||
cta: new Set(snapshot.rows.ctaBanners.map((row) => String((row as Record<string, unknown>).id))),
|
||
product: new Set(snapshot.rows.products.map((row) => String((row as Record<string, unknown>).id))),
|
||
campaign: new Set(snapshot.rows.campaigns.map((row) => String((row as Record<string, unknown>).id))),
|
||
};
|
||
for (const id of currentManifest.homeModuleIds.filter((item) => !oldIds.home.has(item))) {
|
||
const row = await db.homeModule.findUnique({ where: { id } });
|
||
await db.homeModule.update({
|
||
where: { id },
|
||
data: {
|
||
isActive: false,
|
||
isDeleted: true,
|
||
publishedConfig: archivedPublishedConfig(row?.publishedConfig ?? null) ?? ({
|
||
label: row?.label ?? "已归档模块",
|
||
sortOrder: row?.sortOrder ?? 0,
|
||
isActive: false,
|
||
templateType: row?.templateType ?? "explore",
|
||
content: { items: [] },
|
||
isDeleted: true,
|
||
} as Prisma.InputJsonValue),
|
||
},
|
||
});
|
||
}
|
||
for (const id of currentManifest.heroSlideIds.filter((item) => !oldIds.hero.has(item))) {
|
||
await db.heroSlide.update({ where: { id }, data: { isActive: false } });
|
||
}
|
||
for (const id of currentManifest.destinationIds.filter((item) => !oldIds.destination.has(item))) {
|
||
await db.destination.update({ where: { id }, data: { isActive: false } });
|
||
}
|
||
for (const id of currentManifest.themeCardIds.filter((item) => !oldIds.theme.has(item))) {
|
||
await db.themeCard.update({ where: { id }, data: { isActive: false } });
|
||
}
|
||
for (const id of currentManifest.ctaBannerIds.filter((item) => !oldIds.cta.has(item))) {
|
||
await db.ctaBanner.update({ where: { id }, data: { isActive: false } });
|
||
}
|
||
for (const id of currentManifest.productIds.filter((item) => !oldIds.product.has(item))) {
|
||
await db.product.update({ where: { id }, data: { status: "archived" } });
|
||
}
|
||
for (const id of currentManifest.campaignIds.filter((item) => !oldIds.campaign.has(item))) {
|
||
await db.campaign.update({ where: { id }, data: { status: "archived" } });
|
||
}
|
||
}
|
||
|
||
async function rollbackContent(db: Prisma.TransactionClient, snapshot: ContentSnapshot) {
|
||
const release = await db.siteVersion.findUnique({ where: { id: stableUuid(snapshot.releaseKey) } });
|
||
if (!release) throw new Error(`找不到发布记录 ${snapshot.releaseKey},为避免误回滚已停止`);
|
||
const currentManifest = parseManifest(release?.snapshot);
|
||
await archiveNewRows(db, snapshot, currentManifest);
|
||
await restoreSnapshot(db, snapshot);
|
||
if (currentManifest) {
|
||
const snapshotMedia = new Set(snapshot.rows.mediaAssets.map((row) => String((row as Record<string, unknown>).url)));
|
||
for (const url of currentManifest.mediaUrls.filter((item) => !snapshotMedia.has(item))) {
|
||
await db.mediaAsset.deleteMany({ where: { url } });
|
||
}
|
||
}
|
||
}
|
||
|
||
function defaultBackupPath(releaseKey: string) {
|
||
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||
return resolve(rootDir, "artifacts", `content-release-${releaseKey}-${stamp}.json`);
|
||
}
|
||
|
||
async function writeSnapshot(path: string, snapshot: ContentSnapshot) {
|
||
await mkdir(dirname(path), { recursive: true });
|
||
try {
|
||
await access(path);
|
||
throw new Error(`备份文件已存在,为避免覆盖请更换路径:${path}`);
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||
}
|
||
await writeFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||
}
|
||
|
||
async function readSnapshot(path: string): Promise<ContentSnapshot> {
|
||
const snapshot = JSON.parse(await readFile(path, "utf8")) as ContentSnapshot;
|
||
if (snapshot.formatVersion !== SNAPSHOT_FORMAT_VERSION || !snapshot.releaseKey || !snapshot.rows) {
|
||
throw new Error(`备份文件格式不受支持:${path}`);
|
||
}
|
||
return snapshot;
|
||
}
|
||
|
||
function planSummary(data: ReleaseData, current: ContentState, previous: ReleaseManifest | null) {
|
||
const currentHome = new Set(current.homeModules.map((row) => row.id));
|
||
const currentProducts = new Set(current.products.map((row) => row.sourceId).filter((id): id is number => id !== null));
|
||
const currentDestinations = new Set(current.destinations.map((row) => row.slug));
|
||
const currentCampaigns = new Set(current.campaigns.map((row) => row.slug));
|
||
const baselineHome = new Set(data.homeModules.map((item) => item.id));
|
||
const baselineDestinations = new Set(data.destinations.map((item) => destinationKey(item.label)));
|
||
const baselineCampaigns = new Set(data.campaigns.map((item) => item.slug));
|
||
const baselineProducts = new Set(data.products.map((item) => item.id));
|
||
const count = (source: Set<string | number>, target: Set<string | number>) => ({
|
||
create: [...target].filter((item) => !source.has(item)).length,
|
||
update: [...target].filter((item) => source.has(item)).length,
|
||
archive: [...source].filter((item) => !target.has(item)).length,
|
||
});
|
||
return {
|
||
releaseKey: data.releaseKey,
|
||
contentHash: data.contentHash,
|
||
previousReleaseFound: Boolean(previous),
|
||
baseline: {
|
||
homeModules: data.homeModules.length,
|
||
heroSlides: data.heroSlides.length,
|
||
destinations: data.destinations.length,
|
||
themeCards: data.themeCards.length,
|
||
ctaBanners: data.ctaBanners.length,
|
||
products: data.products.length,
|
||
campaigns: data.campaigns.length,
|
||
mediaAssets: data.mediaAssets.length,
|
||
},
|
||
ownedRows: {
|
||
homeModules: current.homeModules.length,
|
||
heroSlides: current.heroSlides.length,
|
||
destinations: current.destinations.length,
|
||
themeCards: current.themeCards.length,
|
||
ctaBanners: current.ctaBanners.length,
|
||
products: current.products.length,
|
||
campaigns: current.campaigns.length,
|
||
mediaAssets: current.mediaAssets.length,
|
||
},
|
||
changes: {
|
||
homeModules: count(currentHome, baselineHome),
|
||
destinations: count(currentDestinations, baselineDestinations),
|
||
products: count(currentProducts, baselineProducts),
|
||
campaigns: count(currentCampaigns, baselineCampaigns),
|
||
},
|
||
preserved: "生产中未被本发布包识别的内容和所有业务数据",
|
||
};
|
||
}
|
||
|
||
function parseMode(argv: string[]) {
|
||
const mode = argv.find((arg) => !arg.startsWith("--")) ?? "plan";
|
||
if (mode !== "validate" && mode !== "plan" && mode !== "apply" && mode !== "rollback") throw new Error(`未知模式:${mode},可选 validate、plan、apply、rollback`);
|
||
const backupArg = argv.find((arg) => arg.startsWith("--backup-file="));
|
||
return { mode, backupPath: backupArg ? resolve(rootDir, backupArg.slice("--backup-file=".length)) : undefined } as const;
|
||
}
|
||
|
||
async function main() {
|
||
const { mode, backupPath: suppliedBackupPath } = parseMode(process.argv.slice(2));
|
||
if (mode === "rollback" && !suppliedBackupPath) throw new Error("rollback 必须指定 --backup-file=...,避免误用错误快照");
|
||
|
||
if (mode === "rollback") {
|
||
const snapshot = await readSnapshot(suppliedBackupPath as string);
|
||
await prisma.$transaction((tx) => rollbackContent(tx, snapshot), {
|
||
maxWait: 10_000,
|
||
timeout: 120_000,
|
||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||
});
|
||
console.log(JSON.stringify({ mode, releaseKey: snapshot.releaseKey, backupFile: suppliedBackupPath, status: "rolled-back" }, null, 2));
|
||
return;
|
||
}
|
||
|
||
const data = await loadReleaseData();
|
||
if (mode === "validate") {
|
||
console.log(JSON.stringify({
|
||
mode,
|
||
status: "valid",
|
||
releaseKey: data.releaseKey,
|
||
contentHash: data.contentHash,
|
||
counts: {
|
||
homeModules: data.homeModules.length,
|
||
heroSlides: data.heroSlides.length,
|
||
destinations: data.destinations.length,
|
||
themeCards: data.themeCards.length,
|
||
ctaBanners: data.ctaBanners.length,
|
||
products: data.products.length,
|
||
campaigns: data.campaigns.length,
|
||
mediaAssets: data.mediaAssets.length,
|
||
},
|
||
}, null, 2));
|
||
return;
|
||
}
|
||
const previousRow = await latestRelease(prisma);
|
||
const previous = parseManifest(previousRow?.snapshot);
|
||
const current = await readContentState(prisma, data, previous);
|
||
if (mode === "plan") {
|
||
console.log(JSON.stringify(planSummary(data, current, previous), null, 2));
|
||
return;
|
||
}
|
||
|
||
const backupPath = suppliedBackupPath ?? defaultBackupPath(data.releaseKey);
|
||
const result = await prisma.$transaction(async (tx) => {
|
||
const txPreviousRow = await latestRelease(tx);
|
||
const txPrevious = parseManifest(txPreviousRow?.snapshot);
|
||
const before = await readContentState(tx, data, txPrevious);
|
||
const snapshot: ContentSnapshot = {
|
||
formatVersion: SNAPSHOT_FORMAT_VERSION,
|
||
releaseKey: data.releaseKey,
|
||
contentHash: data.contentHash,
|
||
createdAt: new Date().toISOString(),
|
||
rows: jsonClone({
|
||
homeModules: before.homeModules,
|
||
heroSlides: before.heroSlides,
|
||
destinations: before.destinations,
|
||
themeCards: before.themeCards,
|
||
ctaBanners: before.ctaBanners,
|
||
destinationPageConfig: before.destinationPageConfig,
|
||
searchPageConfig: before.searchPageConfig,
|
||
products: before.products,
|
||
campaigns: before.campaigns,
|
||
mediaAssets: before.mediaAssets,
|
||
siteVersion: before.siteVersion,
|
||
}),
|
||
};
|
||
await writeSnapshot(backupPath, snapshot);
|
||
const applied = await applyContent(tx, data, txPrevious);
|
||
return { ...applied, backupPath };
|
||
}, {
|
||
maxWait: 10_000,
|
||
timeout: 120_000,
|
||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||
});
|
||
|
||
console.log(JSON.stringify({
|
||
mode,
|
||
status: "applied",
|
||
releaseKey: data.releaseKey,
|
||
contentHash: data.contentHash,
|
||
backupFile: result.backupPath,
|
||
rollback: `npm run db:content:release -- rollback --backup-file=${result.backupPath}`,
|
||
siteVersionId: result.siteVersionId,
|
||
}, null, 2));
|
||
}
|
||
|
||
main()
|
||
.catch((error) => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
})
|
||
.finally(async () => {
|
||
await prisma.$disconnect();
|
||
});
|