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 = { 贵阳: ["贵阳", "青岩", "花溪", "高坡", "天河潭"], 黄果树: ["黄果树", "安顺", "坝陵河", "瀑布"], 荔波小七孔: ["荔波", "小七孔", "茂兰", "水上森林"], 西江苗寨: ["西江", "苗寨", "郎德", "雷山", "苗岭"], 梵净山: ["梵净山", "铜仁", "云舍", "寨沙"], 镇远: ["镇远", "青龙洞", "古城"], 肇兴: ["肇兴", "侗寨", "堂安", "加榜", "黎平"], 万峰林: ["万峰林", "万峰湖", "马岭河", "兴义", "黔西南"], 织金洞: ["织金洞", "织金", "洞穴", "喀斯特"], 赤水丹霞: ["赤水", "丹霞", "竹海", "丙安"], 青岩古镇: ["青岩", "古镇", "屯堡"], 百里杜鹃: ["百里杜鹃", "毕节", "花季"], 乌蒙草原: ["乌蒙", "六盘水", "草原", "避暑"], 中国天眼: ["中国天眼", "平塘", "观星", "科普"], 遵义: ["遵义", "黔北", "红色文化"], 茅台镇: ["茅台", "酱香", "酒旅"], }; 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>; heroSlides: Awaited>; destinations: DestinationWithAliases[]; themeCards: Awaited>; ctaBanners: Awaited>; products: ProductWithImages[]; campaigns: CampaignWithProducts[]; destinationPageConfig: Awaited>; searchPageConfig: Awaited>; mediaAssets: Awaited>; siteVersion: Awaited>; }; 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(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : 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) { 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) { 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) { return previous?.ctaBannerIds.includes(row.id) || baselineKeys.has(ctaKey(row)); } function packageOwnedDestination(row: { id: string; name: string; slug: string }, previous: ReleaseManifest | null, baselineSlugs: Set) { 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) { 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) { 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, 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) { const media = new Map(); 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) { 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 { 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 { 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(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(); 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(); 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>(row: T, keys: string[]) { const copy = { ...row }; for (const key of keys) delete copy[key]; return copy; } function restoreDates(row: Record, 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; 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; 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; const aliases = Array.isArray(row.aliases) ? row.aliases as Record[] : []; 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; 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; 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; const images = Array.isArray(row.images) ? row.images as Record[] : []; 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; const products = Array.isArray(row.products) ? row.products as Record[] : []; 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; 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; 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; 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; 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).id))), hero: new Set(snapshot.rows.heroSlides.map((row) => String((row as Record).id))), destination: new Set(snapshot.rows.destinations.map((row) => String((row as Record).id))), theme: new Set(snapshot.rows.themeCards.map((row) => String((row as Record).id))), cta: new Set(snapshot.rows.ctaBanners.map((row) => String((row as Record).id))), product: new Set(snapshot.rows.products.map((row) => String((row as Record).id))), campaign: new Set(snapshot.rows.campaigns.map((row) => String((row as Record).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).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 { 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, target: Set) => ({ 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(); });