Files
WanderQ-MiniAPP2.0/apps/api/src/routes/public.ts
2026-07-23 18:45:48 +08:00

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 });
});
}