chore: initialize WanderQ MiniAPP 2.0 repository

This commit is contained in:
inman
2026-07-23 15:48:34 +08:00
commit b4c0a1c516
93 changed files with 53525 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
CREATE TABLE "HomeModule" (
"id" TEXT NOT NULL,
"label" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HomeModule_pkey" PRIMARY KEY ("id")
);
INSERT INTO "HomeModule" ("id", "label", "sortOrder", "isActive", "createdAt", "updatedAt")
VALUES
('explore', '探索贵州', 0, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('themes', '主题甄选', 1, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('deals', '特价优惠', 2, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('routes', '精选线路', 3, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('hotels', '特色酒店', 4, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('vehicles', '万趣用车', 5, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

View File

@@ -0,0 +1,3 @@
ALTER TABLE "Product"
ADD COLUMN "keyFacts" JSONB,
ADD COLUMN "contentBlocks" JSONB;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "HomeModule"
ADD COLUMN "templateType" TEXT NOT NULL DEFAULT 'explore',
ADD COLUMN "content" JSONB,
ADD COLUMN "publishedConfig" JSONB,
ADD COLUMN "isSystem" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,8 @@
CREATE TABLE "DestinationPageConfig" (
"id" TEXT NOT NULL,
"recommendedProductIds" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DestinationPageConfig_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,12 @@
CREATE TABLE "SearchPageConfig" (
"id" TEXT NOT NULL DEFAULT 'search-page',
"title" TEXT NOT NULL,
"subtitle" TEXT NOT NULL,
"placeholder" TEXT NOT NULL,
"popularKeywords" JSONB NOT NULL,
"groups" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SearchPageConfig_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,17 @@
ALTER TABLE "Product"
ADD COLUMN "volumeKey" TEXT,
ADD COLUMN "durationDays" INTEGER,
ADD COLUMN "durationNights" INTEGER,
ADD COLUMN "departureDates" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "remainingSpots" INTEGER,
ADD COLUMN "recommendation" TEXT;
ALTER TABLE "Lead"
ADD COLUMN "contactName" TEXT,
ADD COLUMN "wechat" TEXT,
ADD COLUMN "adultCount" INTEGER,
ADD COLUMN "childCount" INTEGER,
ADD COLUMN "roomType" TEXT,
ADD COLUMN "plan" TEXT,
ADD COLUMN "addOns" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "sourceTheme" TEXT;

View File

@@ -0,0 +1,15 @@
ALTER TABLE "SearchPageConfig"
ADD COLUMN "modules" JSONB NOT NULL DEFAULT '{}';
UPDATE "SearchPageConfig"
SET "modules" = jsonb_build_object(
'seasonalInspiration', jsonb_build_object(
'title', "title",
'subtitle', "subtitle"
),
'preferenceDiscovery', jsonb_build_object(
'title', '换一种方式找灵感',
'subtitle', '按区域、主题和体验偏好,找到下一处想去的地方。'
)
)
WHERE "modules" = '{}'::jsonb;

View File

@@ -0,0 +1,76 @@
ALTER TABLE "Customer"
ALTER COLUMN "phone" DROP NOT NULL;
CREATE TABLE "MiniProgramUser" (
"id" TEXT NOT NULL,
"appId" TEXT NOT NULL,
"openId" TEXT NOT NULL,
"unionId" TEXT,
"nickname" TEXT,
"avatarUrl" TEXT,
"phone" TEXT,
"phoneAuthorizedAt" TIMESTAMP(3),
"note" TEXT,
"source" TEXT NOT NULL DEFAULT 'mini_program',
"status" TEXT NOT NULL DEFAULT 'active',
"customerId" TEXT,
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastLoginAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MiniProgramUser_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "MiniProgramFavorite" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MiniProgramFavorite_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "MiniProgramHistory" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"viewCount" INTEGER NOT NULL DEFAULT 1,
"lastViewedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MiniProgramHistory_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "Lead"
ADD COLUMN "userId" TEXT;
CREATE UNIQUE INDEX "MiniProgramUser_appId_openId_key" ON "MiniProgramUser"("appId", "openId");
CREATE UNIQUE INDEX "MiniProgramUser_customerId_key" ON "MiniProgramUser"("customerId");
CREATE INDEX "MiniProgramUser_phone_idx" ON "MiniProgramUser"("phone");
CREATE INDEX "MiniProgramUser_status_lastLoginAt_idx" ON "MiniProgramUser"("status", "lastLoginAt");
CREATE UNIQUE INDEX "MiniProgramFavorite_userId_productId_key" ON "MiniProgramFavorite"("userId", "productId");
CREATE INDEX "MiniProgramFavorite_productId_idx" ON "MiniProgramFavorite"("productId");
CREATE UNIQUE INDEX "MiniProgramHistory_userId_productId_key" ON "MiniProgramHistory"("userId", "productId");
CREATE INDEX "MiniProgramHistory_userId_lastViewedAt_idx" ON "MiniProgramHistory"("userId", "lastViewedAt");
CREATE INDEX "Lead_userId_createdAt_idx" ON "Lead"("userId", "createdAt");
CREATE INDEX "Lead_phone_idx" ON "Lead"("phone");
ALTER TABLE "MiniProgramUser"
ADD CONSTRAINT "MiniProgramUser_customerId_fkey"
FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "MiniProgramFavorite"
ADD CONSTRAINT "MiniProgramFavorite_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "MiniProgramUser"("id") ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT "MiniProgramFavorite_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "MiniProgramHistory"
ADD CONSTRAINT "MiniProgramHistory_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "MiniProgramUser"("id") ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT "MiniProgramHistory_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Lead"
ADD CONSTRAINT "Lead_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "MiniProgramUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1 @@
ALTER TABLE "Product" ADD COLUMN "pricingTiers" JSONB;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "Product"
DROP COLUMN "volumeKey";

View File

@@ -0,0 +1,12 @@
UPDATE "HomeModule"
SET
"isActive" = false,
"isDeleted" = true,
"publishedConfig" = CASE
WHEN "publishedConfig" IS NULL THEN NULL
ELSE jsonb_set(
jsonb_set("publishedConfig", '{isActive}', 'false'::jsonb, true),
'{isDeleted}', 'true'::jsonb, true
)
END
WHERE "id" = 'routes';

View File

@@ -0,0 +1,67 @@
ALTER TABLE "Lead"
ADD COLUMN "wecomContactId" TEXT;
CREATE TABLE "WecomContactWay" (
"id" TEXT NOT NULL,
"configId" TEXT NOT NULL,
"type" INTEGER NOT NULL DEFAULT 1,
"scene" INTEGER NOT NULL DEFAULT 1,
"style" INTEGER NOT NULL DEFAULT 1,
"remark" TEXT,
"state" TEXT,
"wecomUserId" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"lastSyncedAt" TIMESTAMP(3),
"lastSyncError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WecomContactWay_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "WecomExternalContact" (
"id" TEXT NOT NULL,
"externalUserId" TEXT NOT NULL,
"wecomUserId" TEXT NOT NULL,
"contactWayId" TEXT,
"miniProgramUserId" TEXT,
"customerId" TEXT,
"name" TEXT,
"avatarUrl" TEXT,
"type" INTEGER,
"gender" INTEGER,
"unionId" TEXT,
"corpName" TEXT,
"position" TEXT,
"remark" TEXT,
"description" TEXT,
"profile" JSONB,
"status" TEXT NOT NULL DEFAULT 'active',
"addedAt" TIMESTAMP(3),
"lastEventAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WecomExternalContact_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "WecomContactWay_configId_key" ON "WecomContactWay"("configId");
CREATE UNIQUE INDEX "WecomExternalContact_externalUserId_wecomUserId_key" ON "WecomExternalContact"("externalUserId", "wecomUserId");
CREATE INDEX "Lead_wecomContactId_idx" ON "Lead"("wecomContactId");
CREATE INDEX "WecomExternalContact_status_lastEventAt_idx" ON "WecomExternalContact"("status", "lastEventAt");
CREATE INDEX "WecomExternalContact_miniProgramUserId_idx" ON "WecomExternalContact"("miniProgramUserId");
CREATE INDEX "WecomExternalContact_customerId_idx" ON "WecomExternalContact"("customerId");
CREATE INDEX "WecomExternalContact_contactWayId_idx" ON "WecomExternalContact"("contactWayId");
ALTER TABLE "Lead"
ADD CONSTRAINT "Lead_wecomContactId_fkey"
FOREIGN KEY ("wecomContactId") REFERENCES "WecomExternalContact"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "WecomExternalContact"
ADD CONSTRAINT "WecomExternalContact_contactWayId_fkey"
FOREIGN KEY ("contactWayId") REFERENCES "WecomContactWay"("id") ON DELETE SET NULL ON UPDATE CASCADE,
ADD CONSTRAINT "WecomExternalContact_miniProgramUserId_fkey"
FOREIGN KEY ("miniProgramUserId") REFERENCES "MiniProgramUser"("id") ON DELETE SET NULL ON UPDATE CASCADE,
ADD CONSTRAINT "WecomExternalContact_customerId_fkey"
FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
ALTER TABLE "Lead"
ADD COLUMN "requestType" TEXT NOT NULL DEFAULT 'custom';
CREATE INDEX "Lead_requestType_status_createdAt_idx"
ON "Lead"("requestType", "status", "createdAt");

View File

@@ -0,0 +1,6 @@
ALTER TABLE "Lead"
ADD COLUMN "adminNote" TEXT,
ADD COLUMN "processed" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "Lead_processed_requestType_createdAt_idx"
ON "Lead"("processed", "requestType", "createdAt");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -0,0 +1,396 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model AdminUser {
id String @id @default(uuid())
email String @unique
name String
passwordHash String
role String @default("admin")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
auditLogs AuditLog[]
assignedLeads Lead[] @relation("LeadAssignee")
}
model MediaAsset {
id String @id @default(uuid())
url String @unique
name String?
mimeType String?
sizeBytes Int?
group String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model HeroSlide {
id String @id @default(uuid())
title String
kicker String?
actionLabel String?
image String
targetType String?
targetValue String?
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Destination {
id String @id @default(uuid())
name String @unique
slug String @unique
region String?
image String?
isHot Boolean @default(false)
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
aliases DestinationAlias[]
products Product[]
}
model DestinationAlias {
id String @id @default(uuid())
alias String
destinationId String
destination Destination @relation(fields: [destinationId], references: [id], onDelete: Cascade)
@@unique([alias, destinationId])
}
model ThemeCard {
id String @id @default(uuid())
label String
image String
targetType String?
targetValue String?
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CtaBanner {
id String @id @default(uuid())
alt String
image String
targetType String
targetValue String?
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model DestinationPageConfig {
id String @id @default("destination-home")
recommendedProductIds Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SearchPageConfig {
id String @id @default("search-page")
title String
subtitle String
placeholder String
modules Json @default("{}")
popularKeywords Json
groups Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model HomeModule {
id String @id
label String
templateType String @default("explore")
content Json?
publishedConfig Json?
sortOrder Int @default(0)
isActive Boolean @default(true)
isSystem Boolean @default(false)
isDeleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Product {
id String @id @default(uuid())
sourceId Int? @unique
title String
subtitle String?
destinationId String?
destination Destination? @relation(fields: [destinationId], references: [id], onDelete: SetNull)
priceAmount Int?
priceUnit String @default("起/人")
pricingTiers Json?
tags String[]
coverImage String?
summary String?
durationDays Int?
durationNights Int?
departureDates String[] @default([])
remainingSpots Int?
recommendation String?
keyFacts Json?
contentBlocks Json?
detailSections Json?
status String @default("published")
sortWeight Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
images ProductImage[]
campaignLinks CampaignProduct[]
leads Lead[]
favorites MiniProgramFavorite[]
histories MiniProgramHistory[]
orders Order[]
}
model ProductImage {
id String @id @default(uuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
url String
alt String?
sortOrder Int @default(0)
}
model Campaign {
id String @id @default(uuid())
slug String @unique
title String
description String?
coverImage String?
status String @default("published")
startsAt DateTime?
endsAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
products CampaignProduct[]
}
model CampaignProduct {
campaignId String
productId String
sortOrder Int @default(0)
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@id([campaignId, productId])
}
model Lead {
id String @id @default(uuid())
requestType String @default("custom")
userId String?
user MiniProgramUser? @relation(fields: [userId], references: [id], onDelete: SetNull)
wecomContactId String?
wecomContact WecomExternalContact? @relation(fields: [wecomContactId], references: [id], onDelete: SetNull)
destination String?
phone String
contactName String?
wechat String?
travelDate DateTime?
peopleCount Int?
adultCount Int?
childCount Int?
roomType String?
plan String?
addOns String[] @default([])
budgetMin Int?
budgetMax Int?
note String?
sourcePage String?
sourceTheme String?
sourceProductId String?
sourceProduct Product? @relation(fields: [sourceProductId], references: [id], onDelete: SetNull)
adminNote String?
processed Boolean @default(false)
status String @default("new")
assignedUserId String?
assignedUser AdminUser? @relation("LeadAssignee", fields: [assignedUserId], references: [id], onDelete: SetNull)
followups LeadFollowup[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, createdAt])
@@index([phone])
@@index([requestType, status, createdAt])
@@index([wecomContactId])
}
model LeadFollowup {
id String @id @default(uuid())
leadId String
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
content String
nextAt DateTime?
createdAt DateTime @default(now())
}
model MiniProgramUser {
id String @id @default(uuid())
appId String
openId String
unionId String?
nickname String?
avatarUrl String?
phone String?
phoneAuthorizedAt DateTime?
note String?
source String @default("mini_program")
status String @default("active")
customerId String? @unique
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
firstSeenAt DateTime @default(now())
lastLoginAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
leads Lead[]
favorites MiniProgramFavorite[]
histories MiniProgramHistory[]
wecomContacts WecomExternalContact[]
@@unique([appId, openId])
@@index([phone])
@@index([status, lastLoginAt])
}
model MiniProgramFavorite {
id String @id @default(uuid())
userId String
productId String
user MiniProgramUser @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, productId])
@@index([productId])
}
model MiniProgramHistory {
id String @id @default(uuid())
userId String
productId String
viewCount Int @default(1)
lastViewedAt DateTime @default(now())
user MiniProgramUser @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([userId, productId])
@@index([userId, lastViewedAt])
}
model Customer {
id String @id @default(uuid())
phone String? @unique
name String?
wechat String?
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
miniProgramUser MiniProgramUser?
orders Order[]
wecomContacts WecomExternalContact[]
}
model WecomContactWay {
id String @id @default(uuid())
configId String @unique
type Int @default(1)
scene Int @default(1)
style Int @default(1)
remark String?
state String?
wecomUserId String?
enabled Boolean @default(true)
lastSyncedAt DateTime?
lastSyncError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
externalContacts WecomExternalContact[]
}
model WecomExternalContact {
id String @id @default(uuid())
externalUserId String
wecomUserId String
contactWayId String?
contactWay WecomContactWay? @relation(fields: [contactWayId], references: [id], onDelete: SetNull)
miniProgramUserId String?
miniProgramUser MiniProgramUser? @relation(fields: [miniProgramUserId], references: [id], onDelete: SetNull)
customerId String?
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
name String?
avatarUrl String?
type Int?
gender Int?
unionId String?
corpName String?
position String?
remark String?
description String?
profile Json?
status String @default("active")
addedAt DateTime?
lastEventAt DateTime @default(now())
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
leads Lead[]
@@unique([externalUserId, wecomUserId])
@@index([status, lastEventAt])
@@index([miniProgramUserId])
@@index([customerId])
@@index([contactWayId])
}
model Order {
id String @id @default(uuid())
customerId String?
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
productId String?
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
status String @default("draft")
travelDate DateTime?
amount Int?
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SiteVersion {
id String @id @default(uuid())
title String
status String @default("draft")
snapshot Json
publishedAt DateTime?
createdAt DateTime @default(now())
}
model AuditLog {
id String @id @default(uuid())
actorId String?
actor AdminUser? @relation(fields: [actorId], references: [id], onDelete: SetNull)
action String
entity String
entityId String?
before Json?
after Json?
createdAt DateTime @default(now())
}

381
apps/api/prisma/seed.ts Normal file
View 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();
});