chore: initialize WanderQ MiniAPP 2.0 repository
This commit is contained in:
381
apps/api/prisma/seed.ts
Normal file
381
apps/api/prisma/seed.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { readFile } 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 { collectImageUrls, materializeImageUrls } from "../src/media-migration.ts";
|
||||
import { mediaStorage } from "../src/media-storage.ts";
|
||||
import { DEFAULT_SEARCH_PAGE_CONFIG, SEARCH_PAGE_CONFIG_ID } from "../src/search-page.ts";
|
||||
import { getWecomContactWayConfigDefaults } from "../src/wecom.ts";
|
||||
|
||||
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 }> }>;
|
||||
};
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
function slugify(input: string) {
|
||||
return encodeURIComponent(input).replace(/%/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
async function createMedia(url: string, group: string, name?: string) {
|
||||
if (!url) return;
|
||||
await prisma.mediaAsset.upsert({
|
||||
where: { url },
|
||||
update: { group, name },
|
||||
create: { url, group, name },
|
||||
});
|
||||
}
|
||||
|
||||
function defaultDetailSections(summary: string, location: string) {
|
||||
return [
|
||||
{
|
||||
key: "overview",
|
||||
label: "行程概述",
|
||||
title: "小包团专属概览",
|
||||
blocks: [{ type: "text", text: `${summary}。万趣会按同行人、预算、酒店偏好和体力强度重排细节,保留小车小团、错峰入园与在地向导服务。` }],
|
||||
},
|
||||
{
|
||||
key: "itinerary",
|
||||
label: "每日行程",
|
||||
title: `${location} 弹性安排`,
|
||||
blocks: [{ type: "text", text: "默认按抵达接站、核心景点游览、特色体验、酒店休整和返程送站安排每日节奏;具体天数、停留时长和餐食可在行前由服务管家二次确认。" }],
|
||||
},
|
||||
{
|
||||
key: "service",
|
||||
label: "包含/不含服务",
|
||||
title: "费用边界清晰",
|
||||
blocks: [{ type: "text", text: "通常包含当地用车、行程内住宿、列明门票/体验、必要讲解和服务管家跟进;大交通、个人消费、未列明餐食和自选项目以最终方案为准。" }],
|
||||
},
|
||||
{
|
||||
key: "notice",
|
||||
label: "出行须知",
|
||||
title: "贵州山地旅行提示",
|
||||
blocks: [{ type: "text", text: "贵州多山多雨,建议准备防滑鞋、轻便雨具和薄外套;溶洞、漂流、徒步等体验会按天气和同行人体力调整。" }],
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
label: "价格区间",
|
||||
title: "按人数、酒店和季节报价",
|
||||
blocks: [{ type: "text", text: "页面价格为参考起价,节假日、旺季房态、用车车型和体验资源会影响最终报价;提交需求后由服务管家给出可执行方案。" }],
|
||||
},
|
||||
{
|
||||
key: "manager",
|
||||
label: "服务管家",
|
||||
title: "直接添加服务管家",
|
||||
blocks: [{ type: "text", text: "点击底部“服务管家”或拨打 18786174929,可直接添加服务管家沟通出行人数、日期、酒店偏好和预算。" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mediaStorage.ensureReady();
|
||||
const productFile = await readFile(resolve(rootDir, "src/generated-products.json"), "utf8");
|
||||
const products = await materializeImageUrls(JSON.parse(productFile) as SourceProduct[]);
|
||||
const seededHomeModules = await materializeImageUrls(DEFAULT_HOME_MODULES);
|
||||
const seededHeroSlides = await materializeImageUrls(heroSlides);
|
||||
const seededDestinations = await materializeImageUrls(destinations);
|
||||
const seededThemeCards = await materializeImageUrls(themeCards);
|
||||
const seededCtas = await materializeImageUrls(bottomCtas);
|
||||
const seededSearchPage = await materializeImageUrls(DEFAULT_SEARCH_PAGE_CONFIG);
|
||||
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.wecomExternalContact.deleteMany();
|
||||
await prisma.siteVersion.deleteMany();
|
||||
await prisma.homeModule.deleteMany();
|
||||
await prisma.searchPageConfig.deleteMany();
|
||||
await prisma.miniProgramHistory.deleteMany();
|
||||
await prisma.miniProgramFavorite.deleteMany();
|
||||
await prisma.miniProgramUser.deleteMany();
|
||||
await prisma.leadFollowup.deleteMany();
|
||||
await prisma.lead.deleteMany();
|
||||
await prisma.order.deleteMany();
|
||||
await prisma.customer.deleteMany();
|
||||
await prisma.campaignProduct.deleteMany();
|
||||
await prisma.campaign.deleteMany();
|
||||
await prisma.productImage.deleteMany();
|
||||
await prisma.product.deleteMany();
|
||||
await prisma.ctaBanner.deleteMany();
|
||||
await prisma.themeCard.deleteMany();
|
||||
await prisma.heroSlide.deleteMany();
|
||||
await prisma.destinationAlias.deleteMany();
|
||||
await prisma.destination.deleteMany();
|
||||
await prisma.mediaAsset.deleteMany();
|
||||
|
||||
const wecomDefaults = getWecomContactWayConfigDefaults();
|
||||
await prisma.wecomContactWay.upsert({
|
||||
where: { configId: wecomDefaults.configId },
|
||||
update: {},
|
||||
create: wecomDefaults,
|
||||
});
|
||||
|
||||
await prisma.homeModule.createMany({
|
||||
data: seededHomeModules.map((module) => ({
|
||||
...module,
|
||||
content: module.content as Prisma.InputJsonValue,
|
||||
publishedConfig: createHomeModuleSnapshot(module) as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
|
||||
for (const url of collectImageUrls(seededHomeModules)) await createMedia(url, "home-module");
|
||||
for (const url of collectImageUrls(seededSearchPage)) await createMedia(url, "search-page");
|
||||
|
||||
const passwordHash = await bcrypt.hash("ChangeMe123!", 12);
|
||||
await prisma.adminUser.upsert({
|
||||
where: { email: "admin@example.com" },
|
||||
update: { passwordHash, isActive: true },
|
||||
create: {
|
||||
email: "admin@example.com",
|
||||
name: "后台管理员",
|
||||
passwordHash,
|
||||
role: "super_admin",
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, slide] of seededHeroSlides.entries()) {
|
||||
await createMedia(slide.image, "hero", slide.title);
|
||||
await prisma.heroSlide.create({
|
||||
data: {
|
||||
title: slide.title,
|
||||
kicker: slide.kicker,
|
||||
actionLabel: slide.action,
|
||||
image: slide.image,
|
||||
targetType: slide.targetType,
|
||||
targetValue: slide.targetValue,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const destinationMap = new Map<string, string>();
|
||||
for (const [index, item] of seededDestinations.entries()) {
|
||||
if (item.image) await createMedia(item.image, "destination", item.label);
|
||||
const destination = await prisma.destination.create({
|
||||
data: {
|
||||
name: item.label,
|
||||
slug: slugify(item.label),
|
||||
image: item.image,
|
||||
isHot: index < 8,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
destinationMap.set(item.label, destination.id);
|
||||
}
|
||||
|
||||
const aliases: Record<string, string[]> = {
|
||||
贵阳: ["贵阳", "青岩", "花溪", "高坡", "天河潭"],
|
||||
黄果树: ["黄果树", "安顺", "坝陵河", "瀑布"],
|
||||
荔波小七孔: ["荔波", "小七孔", "茂兰", "水上森林"],
|
||||
西江苗寨: ["西江", "苗寨", "郎德", "雷山", "苗岭"],
|
||||
梵净山: ["梵净山", "铜仁", "云舍", "寨沙"],
|
||||
镇远: ["镇远", "青龙洞", "古城"],
|
||||
肇兴: ["肇兴", "侗寨", "堂安", "加榜", "黎平"],
|
||||
万峰林: ["万峰林", "万峰湖", "马岭河", "兴义", "黔西南"],
|
||||
织金洞: ["织金洞", "织金", "洞穴", "喀斯特"],
|
||||
赤水丹霞: ["赤水", "丹霞", "竹海", "丙安"],
|
||||
青岩古镇: ["青岩", "古镇", "屯堡"],
|
||||
百里杜鹃: ["百里杜鹃", "毕节", "花季"],
|
||||
乌蒙草原: ["乌蒙", "六盘水", "草原", "避暑"],
|
||||
中国天眼: ["中国天眼", "平塘", "观星", "科普"],
|
||||
遵义: ["遵义", "黔北", "红色文化"],
|
||||
茅台镇: ["茅台", "酱香", "酒旅"],
|
||||
};
|
||||
|
||||
for (const [label, values] of Object.entries(aliases)) {
|
||||
const destinationId = destinationMap.get(label);
|
||||
if (!destinationId) continue;
|
||||
for (const alias of values) {
|
||||
await prisma.destinationAlias.create({ data: { destinationId, alias } });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, card] of seededThemeCards.entries()) {
|
||||
await createMedia(card.image, "theme", card.label);
|
||||
await prisma.themeCard.create({
|
||||
data: {
|
||||
label: card.label,
|
||||
image: card.image,
|
||||
targetType: card.targetType,
|
||||
targetValue: card.targetValue,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, cta] of seededCtas.entries()) {
|
||||
await createMedia(cta.image, "cta", cta.alt);
|
||||
await prisma.ctaBanner.create({
|
||||
data: {
|
||||
alt: cta.alt,
|
||||
image: cta.image,
|
||||
targetType: cta.targetType,
|
||||
targetValue: cta.targetValue,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const product of products) {
|
||||
await createMedia(product.image, "product", product.title);
|
||||
for (const image of product.images ?? []) await createMedia(image.url, "product-gallery", image.alt ?? product.title);
|
||||
for (const block of product.contentBlocks ?? []) {
|
||||
if (block.type === "image") await createMedia(block.url, "product-content", block.alt ?? product.title);
|
||||
}
|
||||
const matchedDestination = product.destinationName && destinationMap.has(product.destinationName)
|
||||
? product.destinationName
|
||||
: [...destinationMap.keys()].find((label) => product.title.includes(label) || product.tags.includes(label));
|
||||
const summary = product.summary ?? product.tags.join(" · ");
|
||||
const created = await prisma.product.create({
|
||||
data: {
|
||||
sourceId: product.id,
|
||||
title: product.title,
|
||||
subtitle: product.title.replace(/^【.*?】\s*/, "").split("·")[0],
|
||||
destinationId: matchedDestination ? destinationMap.get(matchedDestination) : undefined,
|
||||
priceAmount: product.price ? Number(product.price) : null,
|
||||
priceUnit: product.priceUnit ?? "咨询价",
|
||||
pricingTiers: product.pricingTiers?.length ? (product.pricingTiers as Prisma.InputJsonValue) : undefined,
|
||||
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) : undefined,
|
||||
status: "published",
|
||||
sortWeight: product.id,
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const gallery = product.images?.length ? product.images : [{ url: product.image, alt: product.title, sortOrder: 0 }];
|
||||
await prisma.productImage.createMany({
|
||||
data: gallery.map((image, index) => ({ productId: created.id, url: image.url, alt: image.alt ?? product.title, sortOrder: image.sortOrder ?? index })),
|
||||
});
|
||||
}
|
||||
|
||||
const defaultRecommendedProducts = await prisma.product.findMany({
|
||||
where: { status: "published" },
|
||||
orderBy: [{ sortWeight: "asc" }, { createdAt: "asc" }],
|
||||
take: DESTINATION_RECOMMENDATION_LIMIT,
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.destinationPageConfig.upsert({
|
||||
where: { id: DESTINATION_PAGE_CONFIG_ID },
|
||||
update: { recommendedProductIds: defaultRecommendedProducts.map((product) => product.id) as Prisma.InputJsonValue },
|
||||
create: { id: DESTINATION_PAGE_CONFIG_ID, recommendedProductIds: defaultRecommendedProducts.map((product) => product.id) as Prisma.InputJsonValue },
|
||||
});
|
||||
|
||||
await prisma.searchPageConfig.upsert({
|
||||
where: { id: SEARCH_PAGE_CONFIG_ID },
|
||||
update: {
|
||||
title: seededSearchPage.modules.seasonalInspiration.title,
|
||||
subtitle: seededSearchPage.modules.seasonalInspiration.subtitle,
|
||||
placeholder: seededSearchPage.placeholder,
|
||||
modules: seededSearchPage.modules as Prisma.InputJsonValue,
|
||||
popularKeywords: seededSearchPage.popularKeywords as Prisma.InputJsonValue,
|
||||
groups: seededSearchPage.groups as Prisma.InputJsonValue,
|
||||
},
|
||||
create: {
|
||||
id: SEARCH_PAGE_CONFIG_ID,
|
||||
title: seededSearchPage.modules.seasonalInspiration.title,
|
||||
subtitle: seededSearchPage.modules.seasonalInspiration.subtitle,
|
||||
placeholder: seededSearchPage.placeholder,
|
||||
modules: seededSearchPage.modules as Prisma.InputJsonValue,
|
||||
popularKeywords: seededSearchPage.popularKeywords as Prisma.InputJsonValue,
|
||||
groups: seededSearchPage.groups as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const campaignSeeds = [
|
||||
{ slug: "classic-deal", title: "经典打卡特惠", start: 0, end: 8, coverImage: seededHeroSlides[0]?.image },
|
||||
{ slug: "outdoor-deal", title: "山野野咖特惠", start: 8, end: 16, coverImage: seededHeroSlides[1]?.image },
|
||||
{ slug: "mixed-route", title: "人文户外混搭", start: 16, end: 24, coverImage: seededHeroSlides[2]?.image },
|
||||
];
|
||||
|
||||
for (const seed of campaignSeeds) {
|
||||
const campaign = await prisma.campaign.create({
|
||||
data: {
|
||||
slug: seed.slug,
|
||||
title: seed.title,
|
||||
description: "由当前 H5 万趣贵州小包团内容导入的活动专题。",
|
||||
coverImage: seed.coverImage,
|
||||
status: "published",
|
||||
},
|
||||
});
|
||||
|
||||
const linkedProducts = await prisma.product.findMany({
|
||||
where: { sourceId: { gte: seed.start + 1, lte: seed.end } },
|
||||
orderBy: { sourceId: "asc" },
|
||||
});
|
||||
|
||||
for (const [index, product] of linkedProducts.entries()) {
|
||||
await prisma.campaignProduct.create({
|
||||
data: { campaignId: campaign.id, productId: product.id, sortOrder: index },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await prisma.siteVersion.create({
|
||||
data: {
|
||||
title: "seed-initial",
|
||||
status: "published",
|
||||
publishedAt: new Date(),
|
||||
snapshot: {
|
||||
homeModules: seededHomeModules.length,
|
||||
heroSlides: seededHeroSlides.length,
|
||||
destinations: seededDestinations.length,
|
||||
themeCards: seededThemeCards.length,
|
||||
products: products.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Seed complete. Admin login: admin@example.com / ChangeMe123!`);
|
||||
console.log(`Initial site version: ${snapshot.id}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Reference in New Issue
Block a user