502 lines
21 KiB
TypeScript
502 lines
21 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import { z } from "zod";
|
|
import { getMiniProgramUserId, getOptionalMiniProgramUserId, requireMiniProgramUser } from "../auth.js";
|
|
import { DESTINATION_PAGE_CONFIG_ID, normalizeDestinationRecommendationIds } from "../destination-page.js";
|
|
import { listHomeModules } from "../home-modules.js";
|
|
import { prisma } from "../prisma.js";
|
|
import { normalizeSearchPageConfig, publicSearchPageConfig, SEARCH_PAGE_CONFIG_ID } from "../search-page.js";
|
|
import {
|
|
bookingCreateSchema,
|
|
leadCreateSchema,
|
|
miniProgramActivitySyncSchema,
|
|
miniProgramHistorySchema,
|
|
miniProgramLoginSchema,
|
|
miniProgramPhoneSchema,
|
|
miniProgramProfileUpdateSchema,
|
|
} from "../schemas.js";
|
|
import { exchangeWechatLoginCode, exchangeWechatPhoneCode, getWechatAppId } from "../wechat.js";
|
|
|
|
const productQuerySchema = z.object({
|
|
keyword: z.string().optional(),
|
|
destinationId: z.string().uuid().optional(),
|
|
status: z.string().default("published"),
|
|
take: z.coerce.number().int().min(1).max(100).default(48),
|
|
});
|
|
|
|
const publicUserProfileSelect = {
|
|
id: true,
|
|
nickname: true,
|
|
avatarUrl: true,
|
|
phone: true,
|
|
status: true,
|
|
source: true,
|
|
firstSeenAt: true,
|
|
lastLoginAt: true,
|
|
createdAt: true,
|
|
} as const;
|
|
|
|
function maskPhone(phone: string | null | undefined) {
|
|
if (!phone) return null;
|
|
const normalized = phone.replace(/\D/g, "");
|
|
return normalized.length >= 7 ? `${normalized.slice(0, 3)}****${normalized.slice(-4)}` : "已绑定";
|
|
}
|
|
|
|
function normalizePhone(phone: string) {
|
|
const normalized = phone.replace(/\D/g, "");
|
|
return normalized.startsWith("86") && normalized.length > 11 ? normalized.slice(-11) : normalized;
|
|
}
|
|
|
|
function serializeUser(user: { id: string; nickname: string | null; avatarUrl: string | null; phone: string | null; status: string; source: string; firstSeenAt: Date; lastLoginAt: Date; createdAt: Date }, counts?: { leads: number; favorites: number; history: number }) {
|
|
return {
|
|
id: user.id,
|
|
nickname: user.nickname,
|
|
avatarUrl: user.avatarUrl,
|
|
phoneMasked: maskPhone(user.phone),
|
|
hasPhone: Boolean(user.phone),
|
|
status: user.status,
|
|
source: user.source,
|
|
firstSeenAt: user.firstSeenAt,
|
|
lastLoginAt: user.lastLoginAt,
|
|
createdAt: user.createdAt,
|
|
...counts,
|
|
};
|
|
}
|
|
|
|
async function requireActivePublicUser(request: Parameters<typeof requireMiniProgramUser>[0], reply: Parameters<typeof requireMiniProgramUser>[1]) {
|
|
const userId = getMiniProgramUserId(request);
|
|
if (!userId) return null;
|
|
const user = await prisma.miniProgramUser.findUnique({ where: { id: userId }, select: publicUserProfileSelect });
|
|
if (!user) {
|
|
reply.status(401).send({ message: "用户不存在,请重新登录" });
|
|
return null;
|
|
}
|
|
if (user.status !== "active") {
|
|
reply.status(403).send({ message: user.status === "disabled" ? "账号已停用,请联系服务管家" : "账号已注销,请重新登录" });
|
|
return null;
|
|
}
|
|
return user;
|
|
}
|
|
|
|
async function resolvePublishedProductId(id: string) {
|
|
const numericId = Number(id);
|
|
const product = await prisma.product.findFirst({
|
|
where: Number.isFinite(numericId) ? { sourceId: numericId, status: "published" } : { id, status: "published" },
|
|
select: { id: true },
|
|
});
|
|
return product?.id ?? null;
|
|
}
|
|
|
|
async function linkHistoricalLeads(userId: string, phone: string) {
|
|
const normalizedPhone = normalizePhone(phone);
|
|
const candidates = await prisma.lead.findMany({ where: { userId: null }, select: { id: true, phone: true } });
|
|
const ids = candidates.filter((lead) => normalizePhone(lead.phone) === normalizedPhone).map((lead) => lead.id);
|
|
if (ids.length) await prisma.lead.updateMany({ where: { id: { in: ids }, userId: null }, data: { userId } });
|
|
return ids.length;
|
|
}
|
|
|
|
async function buildActivity(userId: string) {
|
|
const [favorites, history] = await Promise.all([
|
|
prisma.miniProgramFavorite.findMany({
|
|
where: { userId },
|
|
include: { product: { include: { destination: true, images: { orderBy: { sortOrder: "asc" } } } } },
|
|
orderBy: { createdAt: "desc" },
|
|
}),
|
|
prisma.miniProgramHistory.findMany({
|
|
where: { userId },
|
|
include: { product: { include: { destination: true, images: { orderBy: { sortOrder: "asc" } } } } },
|
|
orderBy: { lastViewedAt: "desc" },
|
|
take: 50,
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
favorites: favorites.map((item) => ({ ...item.product, savedAt: item.createdAt })),
|
|
history: history.map((item) => ({ ...item.product, viewedAt: item.lastViewedAt, viewCount: item.viewCount })),
|
|
};
|
|
}
|
|
|
|
export async function registerPublicRoutes(app: FastifyInstance) {
|
|
app.post("/api/public/auth/wechat-login", async (request, reply) => {
|
|
const body = miniProgramLoginSchema.parse(request.body);
|
|
let session: Awaited<ReturnType<typeof exchangeWechatLoginCode>>;
|
|
try {
|
|
session = await exchangeWechatLoginCode(body.code);
|
|
} catch (error) {
|
|
return reply.status(502).send({ message: error instanceof Error ? error.message : "微信登录失败,请稍后重试" });
|
|
}
|
|
|
|
const existing = await prisma.miniProgramUser.findUnique({
|
|
where: { appId_openId: { appId: getWechatAppId(), openId: session.openid } },
|
|
});
|
|
if (existing?.status === "disabled") return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
|
|
|
|
const user = existing
|
|
? await prisma.miniProgramUser.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
unionId: session.unionid ?? existing.unionId,
|
|
nickname: existing.nickname ?? body.nickname ?? null,
|
|
avatarUrl: existing.avatarUrl ?? body.avatarUrl ?? null,
|
|
source: existing.source || body.source || "mini_program",
|
|
status: existing.status === "anonymized" ? "active" : existing.status,
|
|
lastLoginAt: new Date(),
|
|
},
|
|
})
|
|
: await prisma.miniProgramUser.create({
|
|
data: {
|
|
appId: getWechatAppId(),
|
|
openId: session.openid,
|
|
unionId: session.unionid,
|
|
nickname: body.nickname ?? null,
|
|
avatarUrl: body.avatarUrl ?? null,
|
|
source: body.source || "mini_program",
|
|
},
|
|
});
|
|
|
|
const [leadCount, favoriteCount, historyCount] = await Promise.all([
|
|
prisma.lead.count({ where: { userId: user.id } }),
|
|
prisma.miniProgramFavorite.count({ where: { userId: user.id } }),
|
|
prisma.miniProgramHistory.count({ where: { userId: user.id } }),
|
|
]);
|
|
const token = app.jwt.sign(
|
|
{ kind: "mini_program_user", sub: user.id, appId: user.appId },
|
|
{ expiresIn: "30d" },
|
|
);
|
|
return { token, user: serializeUser(user, { leads: leadCount, favorites: favoriteCount, history: historyCount }) };
|
|
});
|
|
|
|
app.get("/api/public/users/me", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
|
const user = await requireActivePublicUser(request, reply);
|
|
if (!user) return;
|
|
const [leadCount, favoriteCount, historyCount] = await Promise.all([
|
|
prisma.lead.count({ where: { userId: user.id } }),
|
|
prisma.miniProgramFavorite.count({ where: { userId: user.id } }),
|
|
prisma.miniProgramHistory.count({ where: { userId: user.id } }),
|
|
]);
|
|
return serializeUser(user, { leads: leadCount, favorites: favoriteCount, history: historyCount });
|
|
});
|
|
|
|
app.patch("/api/public/users/me", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
|
const user = await requireActivePublicUser(request, reply);
|
|
if (!user) return;
|
|
const body = miniProgramProfileUpdateSchema.parse(request.body);
|
|
const updated = await prisma.miniProgramUser.update({ where: { id: user.id }, data: body });
|
|
return serializeUser(updated);
|
|
});
|
|
|
|
app.post("/api/public/users/me/phone", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
|
const user = await requireActivePublicUser(request, reply);
|
|
if (!user) return;
|
|
const body = miniProgramPhoneSchema.parse(request.body);
|
|
let phoneInfo: Awaited<ReturnType<typeof exchangeWechatPhoneCode>>;
|
|
try {
|
|
phoneInfo = await exchangeWechatPhoneCode(body.code);
|
|
} catch (error) {
|
|
return reply.status(502).send({ message: error instanceof Error ? error.message : "手机号授权失败,请稍后重试" });
|
|
}
|
|
|
|
const phone = phoneInfo.purePhoneNumber || phoneInfo.phoneNumber;
|
|
const linkedCustomer = await prisma.customer.findUnique({ where: { phone } });
|
|
const customer = linkedCustomer ?? await prisma.customer.create({ data: { phone, name: user.nickname ?? undefined } });
|
|
const alreadyLinked = await prisma.miniProgramUser.findFirst({ where: { customerId: customer.id, id: { not: user.id } }, select: { id: true } });
|
|
const updated = await prisma.miniProgramUser.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
phone,
|
|
phoneAuthorizedAt: new Date(),
|
|
customerId: alreadyLinked ? undefined : customer.id,
|
|
},
|
|
});
|
|
const linkedLeadCount = await linkHistoricalLeads(user.id, phone);
|
|
if (linkedLeadCount > 0) {
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
action: "link_historical_leads",
|
|
entity: "mini_program_user",
|
|
entityId: user.id,
|
|
after: { linkedLeadCount },
|
|
},
|
|
});
|
|
}
|
|
return { user: serializeUser(updated), linkedLeadCount };
|
|
});
|
|
|
|
app.post("/api/public/users/me/close", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
|
const user = await requireActivePublicUser(request, reply);
|
|
if (!user) return;
|
|
await prisma.$transaction(async (transaction) => {
|
|
const current = await transaction.miniProgramUser.findUnique({ where: { id: user.id } });
|
|
if (!current) return;
|
|
if (current.customerId) {
|
|
await transaction.customer.update({
|
|
where: { id: current.customerId },
|
|
data: { phone: null, name: null, wechat: null, note: null },
|
|
});
|
|
}
|
|
await transaction.lead.updateMany({
|
|
where: { userId: user.id },
|
|
data: { phone: "已注销用户", contactName: null, wechat: null, note: null },
|
|
});
|
|
await transaction.miniProgramFavorite.deleteMany({ where: { userId: user.id } });
|
|
await transaction.miniProgramHistory.deleteMany({ where: { userId: user.id } });
|
|
await transaction.miniProgramUser.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
nickname: null,
|
|
avatarUrl: null,
|
|
phone: null,
|
|
phoneAuthorizedAt: null,
|
|
note: null,
|
|
customerId: null,
|
|
status: "anonymized",
|
|
},
|
|
});
|
|
});
|
|
return { closed: true };
|
|
});
|
|
|
|
app.get("/api/public/users/me/activity", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
|
const user = await requireActivePublicUser(request, reply);
|
|
if (!user) return;
|
|
return buildActivity(user.id);
|
|
});
|
|
|
|
app.post("/api/public/users/me/activity/sync", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
|
const user = await requireActivePublicUser(request, reply);
|
|
if (!user) return;
|
|
const body = miniProgramActivitySyncSchema.parse(request.body);
|
|
const favoriteIds = (await Promise.all(body.favoriteProductIds.map(resolvePublishedProductId))).filter((id): id is string => Boolean(id));
|
|
const historyIds = (await Promise.all(body.historyProductIds.map(resolvePublishedProductId))).filter((id): id is string => Boolean(id));
|
|
await prisma.miniProgramFavorite.createMany({
|
|
data: favoriteIds.map((productId) => ({ userId: user.id, productId })),
|
|
skipDuplicates: true,
|
|
});
|
|
for (const productId of historyIds) {
|
|
await prisma.miniProgramHistory.upsert({
|
|
where: { userId_productId: { userId: user.id, productId } },
|
|
update: { lastViewedAt: new Date(), viewCount: { increment: 1 } },
|
|
create: { userId: user.id, productId },
|
|
});
|
|
}
|
|
await prisma.miniProgramHistory.deleteMany({
|
|
where: {
|
|
userId: user.id,
|
|
id: { notIn: (await prisma.miniProgramHistory.findMany({ where: { userId: user.id }, orderBy: { lastViewedAt: "desc" }, take: 50, select: { id: true } })).map((item) => item.id) },
|
|
},
|
|
});
|
|
return buildActivity(user.id);
|
|
});
|
|
|
|
app.post("/api/public/users/me/favorites/:productId", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
|
const user = await requireActivePublicUser(request, reply);
|
|
if (!user) return;
|
|
const { productId } = z.object({ productId: z.string().min(1) }).parse(request.params);
|
|
const resolvedProductId = await resolvePublishedProductId(productId);
|
|
if (!resolvedProductId) return reply.status(404).send({ message: "线路不存在" });
|
|
await prisma.miniProgramFavorite.upsert({
|
|
where: { userId_productId: { userId: user.id, productId: resolvedProductId } },
|
|
update: {},
|
|
create: { userId: user.id, productId: resolvedProductId },
|
|
});
|
|
return { saved: true };
|
|
});
|
|
|
|
app.delete("/api/public/users/me/favorites/:productId", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
|
const user = await requireActivePublicUser(request, reply);
|
|
if (!user) return;
|
|
const { productId } = z.object({ productId: z.string().min(1) }).parse(request.params);
|
|
const resolvedProductId = await resolvePublishedProductId(productId);
|
|
if (resolvedProductId) await prisma.miniProgramFavorite.deleteMany({ where: { userId: user.id, productId: resolvedProductId } });
|
|
return { saved: false };
|
|
});
|
|
|
|
app.post("/api/public/users/me/history", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
|
const user = await requireActivePublicUser(request, reply);
|
|
if (!user) return;
|
|
const body = miniProgramHistorySchema.parse(request.body);
|
|
const resolvedProductId = await resolvePublishedProductId(body.productId);
|
|
if (!resolvedProductId) return reply.status(404).send({ message: "线路不存在" });
|
|
await prisma.miniProgramHistory.upsert({
|
|
where: { userId_productId: { userId: user.id, productId: resolvedProductId } },
|
|
update: { lastViewedAt: new Date(), viewCount: { increment: 1 } },
|
|
create: { userId: user.id, productId: resolvedProductId },
|
|
});
|
|
await prisma.miniProgramHistory.deleteMany({
|
|
where: {
|
|
userId: user.id,
|
|
id: { notIn: (await prisma.miniProgramHistory.findMany({ where: { userId: user.id }, orderBy: { lastViewedAt: "desc" }, take: 50, select: { id: true } })).map((item) => item.id) },
|
|
},
|
|
});
|
|
return { saved: true };
|
|
});
|
|
|
|
app.delete("/api/public/users/me/history", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
|
const user = await requireActivePublicUser(request, reply);
|
|
if (!user) return;
|
|
await prisma.miniProgramHistory.deleteMany({ where: { userId: user.id } });
|
|
return { cleared: true };
|
|
});
|
|
|
|
app.get("/api/public/users/me/leads", { preHandler: requireMiniProgramUser }, async (request, reply) => {
|
|
const user = await requireActivePublicUser(request, reply);
|
|
if (!user) return;
|
|
return prisma.lead.findMany({
|
|
where: { userId: user.id },
|
|
select: {
|
|
id: true,
|
|
requestType: true,
|
|
destination: true,
|
|
contactName: true,
|
|
travelDate: true,
|
|
peopleCount: true,
|
|
adultCount: true,
|
|
childCount: true,
|
|
plan: true,
|
|
note: true,
|
|
status: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
sourceProduct: { select: { id: true, title: true, coverImage: true } },
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
});
|
|
|
|
app.get("/health", async () => ({ ok: true, service: "miniapp-api", mode: "postgres", persistent: true }));
|
|
|
|
app.get("/api/public/site-config", async () => {
|
|
const [homeModules, heroSlides, destinations, themes, ctaBanners, campaigns, destinationPageConfig, searchPageConfig] = await Promise.all([
|
|
listHomeModules(true),
|
|
prisma.heroSlide.findMany({ where: { isActive: true }, orderBy: { sortOrder: "asc" } }),
|
|
prisma.destination.findMany({ where: { isActive: true }, include: { aliases: true }, orderBy: { sortOrder: "asc" } }),
|
|
prisma.themeCard.findMany({ where: { isActive: true }, orderBy: { sortOrder: "asc" } }),
|
|
prisma.ctaBanner.findMany({ where: { isActive: true }, orderBy: { sortOrder: "asc" } }),
|
|
prisma.campaign.findMany({ where: { status: "published" }, orderBy: { updatedAt: "desc" } }),
|
|
prisma.destinationPageConfig.findUnique({ where: { id: DESTINATION_PAGE_CONFIG_ID } }),
|
|
prisma.searchPageConfig.findUnique({ where: { id: SEARCH_PAGE_CONFIG_ID } }),
|
|
]);
|
|
|
|
const products = await prisma.product.findMany({
|
|
where: { status: "published" },
|
|
orderBy: [{ sortWeight: "asc" }, { createdAt: "asc" }],
|
|
take: 48,
|
|
});
|
|
|
|
const destinationRecommendations = {
|
|
productIds: destinationPageConfig
|
|
? normalizeDestinationRecommendationIds(destinationPageConfig.recommendedProductIds)
|
|
: products.slice(0, 4).map((product) => product.id),
|
|
};
|
|
|
|
return {
|
|
homeModules,
|
|
heroSlides,
|
|
destinations,
|
|
themes,
|
|
ctaBanners,
|
|
campaigns,
|
|
destinationRecommendations,
|
|
searchPage: publicSearchPageConfig(normalizeSearchPageConfig(searchPageConfig)),
|
|
};
|
|
});
|
|
|
|
app.get("/api/public/products", async (request) => {
|
|
const query = productQuerySchema.parse(request.query);
|
|
const products = await prisma.product.findMany({
|
|
where: {
|
|
status: query.status,
|
|
destinationId: query.destinationId,
|
|
OR: query.keyword
|
|
? [
|
|
{ title: { contains: query.keyword, mode: "insensitive" } },
|
|
{ subtitle: { contains: query.keyword, mode: "insensitive" } },
|
|
{ tags: { has: query.keyword } },
|
|
{ destination: { name: { contains: query.keyword, mode: "insensitive" } } },
|
|
{ destination: { aliases: { some: { alias: { contains: query.keyword, mode: "insensitive" } } } } },
|
|
]
|
|
: undefined,
|
|
},
|
|
include: { destination: true, images: { orderBy: { sortOrder: "asc" } } },
|
|
orderBy: [{ sortWeight: "asc" }, { createdAt: "asc" }],
|
|
take: query.take,
|
|
});
|
|
return { items: products };
|
|
});
|
|
|
|
app.get("/api/public/products/:id", async (request, reply) => {
|
|
const params = z.object({ id: z.string() }).parse(request.params);
|
|
const numericId = Number(params.id);
|
|
const product = await prisma.product.findFirst({
|
|
where: Number.isFinite(numericId) ? { sourceId: numericId } : { id: params.id },
|
|
include: { destination: true, images: { orderBy: { sortOrder: "asc" } } },
|
|
});
|
|
if (!product) return reply.status(404).send({ message: "线路不存在" });
|
|
return product;
|
|
});
|
|
|
|
app.get("/api/public/destinations", async () => {
|
|
const destinations = await prisma.destination.findMany({
|
|
where: { isActive: true },
|
|
include: { aliases: true },
|
|
orderBy: { sortOrder: "asc" },
|
|
});
|
|
return { items: destinations };
|
|
});
|
|
|
|
app.post("/api/public/bookings", async (request, reply) => {
|
|
const body = bookingCreateSchema.parse(request.body);
|
|
const userId = await getOptionalMiniProgramUserId(request);
|
|
let authorizedPhone: string | undefined;
|
|
if (userId) {
|
|
const user = await prisma.miniProgramUser.findUnique({ where: { id: userId }, select: { status: true, phone: true } });
|
|
if (!user || user.status !== "active") return reply.status(403).send({ message: "请重新登录后提交预订申请" });
|
|
authorizedPhone = user.phone ?? undefined;
|
|
}
|
|
|
|
const sourceProductId = await resolvePublishedProductId(body.sourceProductId);
|
|
if (!sourceProductId) return reply.status(404).send({ message: "预订线路不存在或已下架" });
|
|
|
|
const { phone: submittedPhone, sourceProductId: _requestedProductId, ...bookingBody } = body;
|
|
const phone = submittedPhone ?? authorizedPhone;
|
|
if (!phone) return reply.status(400).send({ message: "请先授权手机号后提交预订申请" });
|
|
const lead = await prisma.lead.create({
|
|
data: {
|
|
...bookingBody,
|
|
requestType: "booking",
|
|
sourceProductId,
|
|
phone,
|
|
userId,
|
|
travelDate: body.travelDate ? new Date(body.travelDate) : undefined,
|
|
},
|
|
});
|
|
return reply.status(201).send({ id: lead.id, status: lead.status, requestType: lead.requestType });
|
|
});
|
|
|
|
app.post("/api/public/leads", async (request, reply) => {
|
|
const body = leadCreateSchema.parse(request.body);
|
|
const userId = await getOptionalMiniProgramUserId(request);
|
|
let authorizedPhone: string | undefined;
|
|
if (userId) {
|
|
const user = await prisma.miniProgramUser.findUnique({ where: { id: userId }, select: { status: true, phone: true } });
|
|
if (!user || user.status !== "active") return reply.status(403).send({ message: "请重新登录后提交需求" });
|
|
authorizedPhone = user.phone ?? undefined;
|
|
}
|
|
const sourceProductId = body.sourceProductId ? await resolvePublishedProductId(body.sourceProductId) : undefined;
|
|
if (body.sourceProductId && !sourceProductId) return reply.status(404).send({ message: "关联线路不存在或已下架" });
|
|
const { phone: submittedPhone, sourceProductId: _requestedProductId, ...leadBody } = body;
|
|
const phone = submittedPhone ?? authorizedPhone;
|
|
if (!phone) return reply.status(400).send({ message: "请先授权手机号后提交需求" });
|
|
const lead = await prisma.lead.create({
|
|
data: {
|
|
...leadBody,
|
|
requestType: "custom",
|
|
sourceProductId,
|
|
phone,
|
|
userId,
|
|
travelDate: body.travelDate ? new Date(body.travelDate) : undefined,
|
|
},
|
|
});
|
|
return reply.status(201).send({ id: lead.id, status: lead.status, requestType: lead.requestType });
|
|
});
|
|
}
|