Files
WanderQ-MiniAPP2.0/apps/api/prisma/content-release.ts

1162 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
});