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[0], reply: Parameters[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>; 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>; 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 }); }); }