完善 H5 鉴权与登录流程

This commit is contained in:
inman
2026-07-24 14:55:13 +08:00
parent 160e82197e
commit c820a56e79
18 changed files with 1130 additions and 82 deletions

View File

@@ -13,6 +13,7 @@ OSS_BUCKET_NAME="one-feel-bucket"
# 可选OSS_REGION默认从 OSS_ENDPOINT 推导
# OSS_REGION="oss-cn-guangzhou"
VITE_API_BASE_URL="http://localhost:4000"
CORS_ORIGINS="https://wanderq.nianxx.com"
VITE_MEDIA_PUBLIC_BASE_URL=""
VITE_WECOM_CUSTOMER_SERVICE_URL="https://work.weixin.qq.com/kfid/your-customer-service-link"
VITE_WECOM_CORP_ID="ww_your_corp_id"

View File

@@ -0,0 +1,18 @@
CREATE TABLE "H5AuthTicket" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "H5AuthTicket_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "H5AuthTicket_tokenHash_key" ON "H5AuthTicket"("tokenHash");
CREATE INDEX "H5AuthTicket_expiresAt_idx" ON "H5AuthTicket"("expiresAt");
CREATE INDEX "H5AuthTicket_userId_createdAt_idx" ON "H5AuthTicket"("userId", "createdAt");
ALTER TABLE "H5AuthTicket"
ADD CONSTRAINT "H5AuthTicket_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "MiniProgramUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -264,12 +264,26 @@ model MiniProgramUser {
favorites MiniProgramFavorite[]
histories MiniProgramHistory[]
wecomContacts WecomExternalContact[]
h5AuthTickets H5AuthTicket[]
@@unique([appId, openId])
@@index([phone])
@@index([status, lastLoginAt])
}
model H5AuthTicket {
id String @id @default(uuid())
tokenHash String @unique
userId String
user MiniProgramUser @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())
@@index([expiresAt])
@@index([userId, createdAt])
}
model MiniProgramFavorite {
id String @id @default(uuid())
userId String

View File

@@ -1,12 +1,69 @@
import type { FastifyReply, FastifyRequest } from "fastify";
type JwtPrincipal = {
export type JwtPrincipal = {
sub?: string;
kind?: "admin" | "mini_program_user";
role?: string;
appId?: string;
channel?: "mini_program" | "h5";
};
export const H5_SESSION_COOKIE = "wanqu_h5_session";
const H5_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
function readCookie(request: FastifyRequest, name: string) {
const raw = request.headers.cookie;
if (!raw) return undefined;
for (const item of raw.split(";")) {
const separator = item.indexOf("=");
if (separator < 0) continue;
const key = item.slice(0, separator).trim();
if (key !== name) continue;
const value = item.slice(separator + 1).trim();
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
return undefined;
}
function readPublicSessionToken(request: FastifyRequest) {
const authorization = request.headers.authorization;
if (authorization?.startsWith("Bearer ")) return authorization.slice("Bearer ".length).trim();
return readCookie(request, H5_SESSION_COOKIE);
}
function verifyPublicSession(request: FastifyRequest) {
const token = readPublicSessionToken(request);
if (!token) throw new Error("Missing public session");
const principal = request.server.jwt.verify<JwtPrincipal>(token);
request.user = principal;
return principal;
}
export function setH5SessionCookie(reply: FastifyReply, token: string) {
const secure = process.env.NODE_ENV === "production" || process.env.H5_COOKIE_SECURE === "1";
const sameSite = secure ? "None" : "Lax";
const attributes = [
H5_SESSION_COOKIE + "=" + encodeURIComponent(token),
"Path=/",
"HttpOnly",
"Max-Age=" + H5_SESSION_MAX_AGE_SECONDS,
"SameSite=" + sameSite,
];
if (secure) attributes.push("Secure");
reply.header("Set-Cookie", attributes.join("; "));
}
export function clearH5SessionCookie(reply: FastifyReply) {
reply.header(
"Set-Cookie",
H5_SESSION_COOKIE + "=; Path=/; HttpOnly; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax",
);
}
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
@@ -27,15 +84,13 @@ export function getActorId(request: FastifyRequest) {
export async function requireMiniProgramUser(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
} catch {
return reply.status(401).send({ message: "请先登录小程序" });
}
const user = request.user as JwtPrincipal | undefined;
const user = verifyPublicSession(request);
if (!user?.sub || user.kind !== "mini_program_user") {
return reply.status(401).send({ message: "小程序登录状态无效" });
}
} catch {
return reply.status(401).send({ message: "请先登录小程序" });
}
}
export function getMiniProgramUserId(request: FastifyRequest) {
@@ -44,11 +99,10 @@ export function getMiniProgramUserId(request: FastifyRequest) {
}
export async function getOptionalMiniProgramUserId(request: FastifyRequest) {
if (!request.headers.authorization) return undefined;
try {
await request.jwtVerify();
const user = verifyPublicSession(request);
return user?.kind === "mini_program_user" ? user.sub : undefined;
} catch {
return undefined;
}
return getMiniProgramUserId(request);
}

View File

@@ -1,11 +1,18 @@
import bcrypt from "bcryptjs";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import { getMiniProgramUserId, getOptionalMiniProgramUserId, requireAdmin, requireMiniProgramUser } from "../auth.js";
import {
clearH5SessionCookie,
getMiniProgramUserId,
getOptionalMiniProgramUserId,
requireAdmin,
requireMiniProgramUser,
setH5SessionCookie,
} from "../auth.js";
import { DESTINATION_RECOMMENDATION_LIMIT, normalizeDestinationRecommendationIds } from "../destination-page.js";
import {
cloneHomeModuleContent,
@@ -31,6 +38,7 @@ import {
homeModuleContentSchema,
homeModuleCreateSchema,
homeModuleUpdateSchema,
h5TicketExchangeSchema,
leadAdminNoteSchema,
leadCreateSchema,
leadFollowupCreateSchema,
@@ -40,6 +48,7 @@ import {
miniProgramActivitySyncSchema,
miniProgramHistorySchema,
miniProgramLoginSchema,
miniProgramPhoneLoginSchema,
miniProgramPhoneSchema,
miniProgramProfileUpdateSchema,
miniProgramUserPatchSchema,
@@ -222,6 +231,13 @@ type MemoryHistory = {
lastViewedAt: string;
};
type MemoryH5Ticket = {
tokenHash: string;
userId: string;
expiresAt: string;
consumedAt?: string | null;
};
type SourceProduct = {
id: number;
title: string;
@@ -464,6 +480,7 @@ const mediaAssets: Array<{ id: string; url: string; name: string; mimeType: stri
const miniProgramUsers: MemoryMiniProgramUser[] = [];
const memoryFavorites: MemoryFavorite[] = [];
const memoryHistories: MemoryHistory[] = [];
const memoryH5Tickets: MemoryH5Ticket[] = [];
function memoryMaskPhone(phone?: string | null) {
if (!phone) return null;
@@ -497,6 +514,20 @@ function serializeMemoryUser(user: MemoryMiniProgramUser) {
};
}
function issueMemoryH5Ticket(userId: string) {
const now = Date.now();
const expiresAt = new Date(now + 2 * 60 * 1000).toISOString();
const ticket = "memory-" + randomUUID();
const tokenHash = createHash("sha256").update(ticket).digest("hex");
for (let index = memoryH5Tickets.length - 1; index >= 0; index -= 1) {
if (memoryH5Tickets[index].consumedAt || new Date(memoryH5Tickets[index].expiresAt).getTime() <= now) {
memoryH5Tickets.splice(index, 1);
}
}
memoryH5Tickets.push({ tokenHash, userId, expiresAt, consumedAt: null });
return { ticket, expiresAt };
}
function findMemoryProduct(id: string) {
return products.find((product) => product.id === id || String(product.sourceId) === id);
}
@@ -1112,6 +1143,47 @@ export async function registerMemoryRoutes(app: FastifyInstance) {
return { ...lead, phone: lead.phone };
});
app.post("/api/public/auth/wechat-phone-login", async (request, reply) => {
const body = miniProgramPhoneLoginSchema.parse(request.body);
const now = new Date().toISOString();
let user = miniProgramUsers.find((item) => item.openId === "memory-openid");
const phone = /^1\d{10}$/.test(body.phoneCode) ? body.phoneCode : "13800000000";
if (user?.status === "disabled") return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
if (user) {
user.nickname = user.nickname ?? body.nickname ?? null;
user.avatarUrl = user.avatarUrl ?? body.avatarUrl ?? null;
user.phone = phone;
user.status = user.status === "anonymized" ? "active" : user.status;
user.lastLoginAt = now;
} else {
user = {
id: randomUUID(),
appId: "mock-mini-program",
openId: "memory-openid",
nickname: body.nickname ?? null,
avatarUrl: body.avatarUrl ?? null,
phone,
status: "active",
source: body.source ?? "mini_program",
firstSeenAt: now,
lastLoginAt: now,
createdAt: now,
};
miniProgramUsers.push(user);
}
const linkedLeads = leads.filter((lead) => !lead.userId && lead.phone.replace(/\D/g, "") === phone);
linkedLeads.forEach((lead) => { lead.userId = user?.id; });
const token = app.jwt.sign({ kind: "mini_program_user", sub: user.id, appId: user.appId, channel: "mini_program" }, { expiresIn: "30d" });
const h5Ticket = issueMemoryH5Ticket(user.id);
return {
token,
h5Ticket: h5Ticket.ticket,
h5TicketExpiresAt: h5Ticket.expiresAt,
linkedLeadCount: linkedLeads.length,
user: serializeMemoryUser(user),
};
});
app.post("/api/public/auth/wechat-login", async (request, reply) => {
const body = miniProgramLoginSchema.parse(request.body);
const now = new Date().toISOString();
@@ -1141,6 +1213,32 @@ export async function registerMemoryRoutes(app: FastifyInstance) {
return { token, user: serializeMemoryUser(user) };
});
app.post("/api/public/auth/h5-ticket", { preHandler: requireMiniProgramUser }, async (request, reply) => {
const user = await requireActiveMemoryUser(request, reply);
if (!user) return;
return issueMemoryH5Ticket(user.id);
});
app.post("/api/public/auth/h5-exchange", async (request, reply) => {
const body = h5TicketExchangeSchema.parse(request.body);
const tokenHash = createHash("sha256").update(body.ticket).digest("hex");
const ticket = memoryH5Tickets.find((item) => item.tokenHash === tokenHash);
if (!ticket || ticket.consumedAt || new Date(ticket.expiresAt).getTime() <= Date.now()) {
return reply.status(401).send({ message: "登录票据已失效,请重新登录" });
}
const user = miniProgramUsers.find((item) => item.id === ticket.userId);
if (!user || user.status !== "active") return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
ticket.consumedAt = new Date().toISOString();
const sessionToken = app.jwt.sign({ kind: "mini_program_user", sub: user.id, appId: user.appId, channel: "h5" }, { expiresIn: "30d" });
setH5SessionCookie(reply, sessionToken);
return { user: serializeMemoryUser(user) };
});
app.post("/api/public/auth/logout", async (_request, reply) => {
clearH5SessionCookie(reply);
return { loggedOut: true };
});
app.get("/api/public/users/me", { preHandler: requireMiniProgramUser }, async (request, reply) => {
const user = await requireActiveMemoryUser(request, reply);
return user ? serializeMemoryUser(user) : undefined;

View File

@@ -1,16 +1,26 @@
import { createHash, randomBytes } from "node:crypto";
import type { Prisma } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { getMiniProgramUserId, getOptionalMiniProgramUserId, requireMiniProgramUser } from "../auth.js";
import {
clearH5SessionCookie,
getMiniProgramUserId,
getOptionalMiniProgramUserId,
requireMiniProgramUser,
setH5SessionCookie,
} 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,
h5TicketExchangeSchema,
leadCreateSchema,
miniProgramActivitySyncSchema,
miniProgramHistorySchema,
miniProgramLoginSchema,
miniProgramPhoneLoginSchema,
miniProgramPhoneSchema,
miniProgramProfileUpdateSchema,
} from "../schemas.js";
@@ -25,6 +35,7 @@ const productQuerySchema = z.object({
const publicUserProfileSelect = {
id: true,
appId: true,
nickname: true,
avatarUrl: true,
phone: true,
@@ -94,41 +105,14 @@ async function linkHistoricalLeads(userId: string, phone: string) {
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 : "微信登录失败,请稍后重试" });
}
async function upsertMiniProgramUser(
session: Awaited<ReturnType<typeof exchangeWechatLoginCode>>,
body: { nickname?: string | null; avatarUrl?: string | null; source?: string },
) {
const existing = await prisma.miniProgramUser.findUnique({
where: { appId_openId: { appId: getWechatAppId(), openId: session.openid } },
});
if (existing?.status === "disabled") return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
if (existing?.status === "disabled") return { user: null, disabled: true as const };
const user = existing
? await prisma.miniProgramUser.update({
@@ -153,16 +137,329 @@ export async function registerPublicRoutes(app: FastifyInstance) {
},
});
return { user, disabled: false as const };
}
async function mergeMiniProgramUserRecords(transaction: Prisma.TransactionClient, sourceId: string, targetId: string) {
if (sourceId === targetId) return;
const [source, target] = await Promise.all([
transaction.miniProgramUser.findUnique({ where: { id: sourceId } }),
transaction.miniProgramUser.findUnique({ where: { id: targetId } }),
]);
if (!source || !target) return;
const sourceFavorites = await transaction.miniProgramFavorite.findMany({ where: { userId: sourceId } });
if (sourceFavorites.length) {
await transaction.miniProgramFavorite.createMany({
data: sourceFavorites.map((favorite) => ({
userId: targetId,
productId: favorite.productId,
createdAt: favorite.createdAt,
})),
skipDuplicates: true,
});
await transaction.miniProgramFavorite.deleteMany({ where: { userId: sourceId } });
}
const sourceHistories = await transaction.miniProgramHistory.findMany({ where: { userId: sourceId } });
for (const history of sourceHistories) {
const existing = await transaction.miniProgramHistory.findUnique({
where: { userId_productId: { userId: targetId, productId: history.productId } },
});
if (existing) {
await transaction.miniProgramHistory.update({
where: { id: existing.id },
data: {
viewCount: existing.viewCount + history.viewCount,
lastViewedAt: existing.lastViewedAt > history.lastViewedAt ? existing.lastViewedAt : history.lastViewedAt,
},
});
} else {
await transaction.miniProgramHistory.create({
data: {
userId: targetId,
productId: history.productId,
viewCount: history.viewCount,
lastViewedAt: history.lastViewedAt,
},
});
}
}
if (sourceHistories.length) {
await transaction.miniProgramHistory.deleteMany({ where: { userId: sourceId } });
}
await transaction.lead.updateMany({ where: { userId: sourceId }, data: { userId: targetId } });
await transaction.wecomExternalContact.updateMany({ where: { miniProgramUserId: sourceId }, data: { miniProgramUserId: targetId } });
if (source.customerId && source.customerId !== target.customerId) {
if (target.customerId) {
const [sourceCustomer, targetCustomer] = await Promise.all([
transaction.customer.findUnique({ where: { id: source.customerId } }),
transaction.customer.findUnique({ where: { id: target.customerId } }),
]);
await transaction.order.updateMany({ where: { customerId: source.customerId }, data: { customerId: target.customerId } });
await transaction.wecomExternalContact.updateMany({
where: { customerId: source.customerId },
data: { customerId: target.customerId },
});
if (sourceCustomer && targetCustomer) {
await transaction.customer.update({
where: { id: target.customerId },
data: {
name: targetCustomer.name ?? sourceCustomer.name,
wechat: targetCustomer.wechat ?? sourceCustomer.wechat,
note: targetCustomer.note ?? sourceCustomer.note,
},
});
}
} else {
await transaction.miniProgramUser.update({
where: { id: targetId },
data: { customerId: source.customerId },
});
}
}
await transaction.miniProgramUser.update({
where: { id: targetId },
data: {
nickname: target.nickname ?? source.nickname,
avatarUrl: target.avatarUrl ?? source.avatarUrl,
unionId: target.unionId ?? source.unionId,
},
});
await transaction.miniProgramUser.update({
where: { id: sourceId },
data: {
nickname: null,
avatarUrl: null,
phone: null,
phoneAuthorizedAt: null,
note: null,
customerId: null,
status: "anonymized",
},
});
}
async function attachVerifiedPhone(userId: string, phone: string) {
const normalizedPhone = normalizePhone(phone);
const phoneVariants = [normalizedPhone, "86" + normalizedPhone];
const result = await prisma.$transaction(async (transaction) => {
const current = await transaction.miniProgramUser.findUnique({ where: { id: userId } });
if (!current) return { userId, disabled: false, missing: true };
const phoneOwner = await transaction.miniProgramUser.findFirst({
where: {
id: { not: userId },
OR: [
{ phone: { in: phoneVariants } },
{ customer: { phone: normalizedPhone } },
],
},
orderBy: { createdAt: "asc" },
});
if (phoneOwner?.status === "disabled") return { userId, disabled: true, missing: false };
let canonicalUserId = userId;
if (phoneOwner && phoneOwner.id !== userId) {
await mergeMiniProgramUserRecords(transaction, userId, phoneOwner.id);
canonicalUserId = phoneOwner.id;
}
let customer = await transaction.customer.findUnique({ where: { phone: normalizedPhone } });
if (!customer) {
customer = await transaction.customer.create({
data: {
phone: normalizedPhone,
name: current.nickname ?? undefined,
},
});
}
const updated = await transaction.miniProgramUser.update({
where: { id: canonicalUserId },
data: {
phone: normalizedPhone,
phoneAuthorizedAt: new Date(),
customerId: customer.id,
status: "active",
},
});
return { userId: updated.id, disabled: false, missing: false };
});
if (result.missing) return { ...result, linkedLeadCount: 0 };
if (result.disabled) return { ...result, linkedLeadCount: 0 };
const linkedLeadCount = await linkHistoricalLeads(result.userId, normalizedPhone);
return { ...result, linkedLeadCount };
}
function hashH5Ticket(ticket: string) {
return createHash("sha256").update(ticket).digest("hex");
}
async function issueH5Ticket(userId: string) {
const now = new Date();
const expiresAt = new Date(now.getTime() + 2 * 60 * 1000);
const ticket = randomBytes(32).toString("base64url");
await prisma.h5AuthTicket.deleteMany({
where: {
OR: [
{ consumedAt: { not: null } },
{ expiresAt: { lt: now } },
],
},
});
await prisma.h5AuthTicket.create({
data: {
tokenHash: hashH5Ticket(ticket),
userId,
expiresAt,
},
});
return { ticket, expiresAt };
}
async function getPublicUserSummary(userId: string) {
const user = await prisma.miniProgramUser.findUnique({ where: { id: userId }, select: publicUserProfileSelect });
if (!user) return null;
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 { user, counts: { leads: leadCount, favorites: favoriteCount, history: historyCount } };
}
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-phone-login", async (request, reply) => {
const body = miniProgramPhoneLoginSchema.parse(request.body);
let session: Awaited<ReturnType<typeof exchangeWechatLoginCode>>;
let phoneInfo: Awaited<ReturnType<typeof exchangeWechatPhoneCode>>;
try {
[session, phoneInfo] = await Promise.all([
exchangeWechatLoginCode(body.loginCode),
exchangeWechatPhoneCode(body.phoneCode),
]);
} catch (error) {
return reply.status(502).send({ message: error instanceof Error ? error.message : "微信手机号登录失败,请稍后重试" });
}
const phone = normalizePhone(phoneInfo.purePhoneNumber || phoneInfo.phoneNumber);
if (!/^1\d{10}$/.test(phone)) return reply.status(400).send({ message: "微信未返回有效手机号" });
const result = await upsertMiniProgramUser(session, body);
if (result.disabled || !result.user) return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
const linked = await attachVerifiedPhone(result.user.id, phone);
if (linked.disabled) return reply.status(403).send({ message: "该手机号关联的账号已停用,请联系服务管家" });
if (linked.missing) return reply.status(401).send({ message: "登录状态已失效,请重新授权" });
const summary = await getPublicUserSummary(linked.userId);
if (!summary || summary.user.status !== "active") return reply.status(401).send({ message: "用户状态无效,请重新登录" });
const token = app.jwt.sign(
{ kind: "mini_program_user", sub: user.id, appId: user.appId },
{ kind: "mini_program_user", sub: summary.user.id, appId: summary.user.appId, channel: "mini_program" },
{ expiresIn: "30d" },
);
return { token, user: serializeUser(user, { leads: leadCount, favorites: favoriteCount, history: historyCount }) };
const h5Ticket = await issueH5Ticket(summary.user.id);
return {
token,
h5Ticket: h5Ticket.ticket,
h5TicketExpiresAt: h5Ticket.expiresAt,
linkedLeadCount: linked.linkedLeadCount,
user: serializeUser(summary.user, summary.counts),
};
});
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 result = await upsertMiniProgramUser(session, body);
if (result.disabled || !result.user) return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
const summary = await getPublicUserSummary(result.user.id);
if (!summary) return reply.status(401).send({ message: "用户状态无效,请重新登录" });
const token = app.jwt.sign(
{ kind: "mini_program_user", sub: summary.user.id, appId: summary.user.appId, channel: "mini_program" },
{ expiresIn: "30d" },
);
return { token, user: serializeUser(summary.user, summary.counts) };
});
app.post("/api/public/auth/h5-ticket", { preHandler: requireMiniProgramUser }, async (request, reply) => {
const user = await requireActivePublicUser(request, reply);
if (!user) return;
const h5Ticket = await issueH5Ticket(user.id);
return { ticket: h5Ticket.ticket, expiresAt: h5Ticket.expiresAt };
});
app.post("/api/public/auth/h5-exchange", async (request, reply) => {
const body = h5TicketExchangeSchema.parse(request.body);
const now = new Date();
const result = await prisma.$transaction(async (transaction) => {
const ticket = await transaction.h5AuthTicket.findUnique({
where: { tokenHash: hashH5Ticket(body.ticket) },
select: { id: true, userId: true, expiresAt: true, consumedAt: true },
});
if (!ticket || ticket.consumedAt || ticket.expiresAt <= now) return { status: "invalid" as const };
const user = await transaction.miniProgramUser.findUnique({
where: { id: ticket.userId },
select: { id: true, appId: true, status: true },
});
if (!user || user.status !== "active") return { status: "disabled" as const };
const claimed = await transaction.h5AuthTicket.updateMany({
where: { id: ticket.id, consumedAt: null, expiresAt: { gt: now } },
data: { consumedAt: now },
});
if (claimed.count !== 1) return { status: "invalid" as const };
return { status: "ok" as const, userId: user.id, appId: user.appId };
});
if (result.status === "disabled") return reply.status(403).send({ message: "账号已停用,请联系服务管家" });
if (result.status !== "ok") return reply.status(401).send({ message: "登录票据已失效,请重新登录" });
const summary = await getPublicUserSummary(result.userId);
if (!summary || summary.user.status !== "active") return reply.status(401).send({ message: "用户状态无效,请重新登录" });
const token = app.jwt.sign(
{ kind: "mini_program_user", sub: summary.user.id, appId: result.appId, channel: "h5" },
{ expiresIn: "30d" },
);
setH5SessionCookie(reply, token);
return { user: serializeUser(summary.user, summary.counts) };
});
app.post("/api/public/auth/logout", async (_request, reply) => {
clearH5SessionCookie(reply);
return { loggedOut: true };
});
app.get("/api/public/users/me", { preHandler: requireMiniProgramUser }, async (request, reply) => {
@@ -196,29 +493,24 @@ export async function registerPublicRoutes(app: FastifyInstance) {
}
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) {
const normalizedPhone = normalizePhone(phone);
if (!/^1\d{10}$/.test(normalizedPhone)) return reply.status(400).send({ message: "微信未返回有效手机号" });
const linked = await attachVerifiedPhone(user.id, normalizedPhone);
if (linked.disabled) return reply.status(403).send({ message: "该手机号关联的账号已停用,请联系服务管家" });
if (linked.missing) return reply.status(401).send({ message: "登录状态已失效,请重新登录" });
const summary = await getPublicUserSummary(linked.userId);
if (!summary) return reply.status(401).send({ message: "用户状态无效,请重新登录" });
if (linked.linkedLeadCount > 0) {
await prisma.auditLog.create({
data: {
action: "link_historical_leads",
entity: "mini_program_user",
entityId: user.id,
after: { linkedLeadCount },
entityId: linked.userId,
after: { linkedLeadCount: linked.linkedLeadCount },
},
});
}
return { user: serializeUser(updated), linkedLeadCount };
return { user: serializeUser(summary.user, summary.counts), linkedLeadCount: linked.linkedLeadCount };
});
app.post("/api/public/users/me/close", { preHandler: requireMiniProgramUser }, async (request, reply) => {

View File

@@ -18,6 +18,18 @@ export const miniProgramLoginSchema = z.object({
source: z.string().trim().max(80).optional(),
});
export const miniProgramPhoneLoginSchema = z.object({
loginCode: z.string().trim().min(1).max(512),
phoneCode: z.string().trim().min(1).max(512),
nickname: z.string().trim().max(80).optional().nullable(),
avatarUrl: z.string().trim().url().max(500).optional().nullable(),
source: z.string().trim().max(80).optional(),
});
export const h5TicketExchangeSchema = z.object({
ticket: z.string().trim().min(20).max(256),
});
export const miniProgramProfileUpdateSchema = z.object({
nickname: z.string().trim().max(80).optional().nullable(),
avatarUrl: z.string().trim().url().max(500).optional().nullable(),

View File

@@ -16,8 +16,18 @@ export async function createServer() {
},
});
const configuredCorsOrigins = (process.env.CORS_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const corsOrigin = configuredCorsOrigins.length
? configuredCorsOrigins
: process.env.NODE_ENV === "production"
? ["https://wanderq.nianxx.com"]
: true;
await app.register(cors, {
origin: true,
origin: corsOrigin,
credentials: true,
});

View File

@@ -1,6 +1,6 @@
# 万趣微信小程序
这是唯一的用户端前台,基于 Taro + React + TypeScript覆盖探索、搜索、线路详情、收藏、最近浏览、微信登录、手机号授权、我的需求和原生企微“联系我”页。
这是唯一的用户端前台,基于 Taro + React + TypeScript覆盖探索、搜索、线路详情、收藏、最近浏览、微信手机号一键登录、H5 登录态同步、我的需求、退出登录和原生企微“联系我”页。
小程序已声明企微官方插件 `wx104a1a20c3f81ec2`(版本 `1.4.3`)。`pages/contact/index` 内置企业微信后台创建的联系我 `config_id`,直接渲染 `plugin://contactPlugin/cell`H5 中的“企微联系”入口只负责跳转到该原生页。后端不参与联系我插件的配置、同步或回调。
@@ -30,6 +30,8 @@ TARO_APP_API_BASE_URL="https://biz.wanderqtrip.com/api"
npm run build:miniapp
```
生产 API 需要配置 `WECHAT_APP_ID``WECHAT_APP_SECRET``DATABASE_URL``JWT_SECRET`,并将 API 域名与 OSS/CDN 域名加入微信小程序的 `request``downloadFile` 合法域名。联系我插件的 `config_id` 已随小程序代码发布,不需要后端企微密钥。图片统一使用 API 返回的 OSS WebP 公共 URL不打包本地图片素材
小程序H5 登录通过 `pages/login/index` 原生手机号授权页完成;授权后由首页生成一次性 H5 票据H5 兑换为 HttpOnly Cookie。直接打开 H5 且未携带票据时保持游客浏览
生产 API 需要配置 `WECHAT_APP_ID``WECHAT_APP_SECRET``DATABASE_URL``JWT_SECRET` 和允许携带 H5 登录 Cookie 的 `CORS_ORIGINS`(例如 `https://wanderq.nianxx.com`),并将 API 域名与 OSS/CDN 域名加入微信小程序的 `request``downloadFile` 合法域名。联系我插件的 `config_id` 已随小程序代码发布,不需要后端企微密钥。图片统一使用 API 返回的 OSS WebP 公共 URL不打包本地图片素材。
数据库首次部署执行 `npm run db:migrate`,初始化内容执行 `npm run db:seed`

View File

@@ -1,5 +1,5 @@
export default defineAppConfig({
pages: ["pages/index/index", "pages/contact/index"],
pages: ["pages/index/index", "pages/contact/index", "pages/login/index"],
plugins: {
contactPlugin: {
version: "1.4.3",

View File

@@ -127,6 +127,15 @@ page, view, text, image, input, button, scroll-view, swiper, swiper-item {
.contact-fallback { background: #e8f1e9; }
.contact-back { width: 100%; color: #19766a; background: transparent; border: 1px solid #bcd3c5; border-radius: 999px; font-size: 22px; }
.login-page { display: grid; align-content: start; gap: 22px; min-height: 100vh; padding: 64px 32px 100px; background: #f6f4ee; }
.login-hero { display: grid; gap: 14px; padding: 16px 4px 30px; }
.login-title { display: block; color: #182522; font-size: 42px; font-weight: 800; line-height: 1.3; }
.login-note { display: block; color: #718079; font-size: 22px; line-height: 1.6; }
.phone-login-button { width: 100%; color: #fff; background: #19766a; border-radius: 999px; font-size: 25px; }
.phone-login-button[disabled] { opacity: .6; }
.login-message { display: block; color: #b15842; text-align: center; font-size: 20px; line-height: 1.5; }
.login-privacy { display: block; color: #87938c; text-align: center; font-size: 17px; line-height: 1.5; }
@media (max-width: 680px) {
.product-grid { grid-template-columns: 1fr; }
.hero-copy > text:nth-child(2), .hero-fallback > text:nth-of-type(1) { font-size: 34px; }

View File

@@ -1,9 +1,40 @@
import { WebView } from "@tarojs/components";
import { useState } from "react";
import { useDidShow } from "@tarojs/taro";
import { createH5Ticket, getToken } from "../../services/api";
const H5_URL = String(process.env.TARO_APP_H5_URL ?? "https://wanderq.nianxx.com");
const H5_CACHE_VERSION = "ui-20260723-tab-seam-v7";
const H5_SRC = `${H5_URL}${H5_URL.includes("?") ? "&" : "?"}v=${H5_CACHE_VERSION}`;
const H5_CACHE_VERSION = "auth-20260724-v1";
function buildH5Src(ticket?: string) {
const separator = H5_URL.includes("?") ? "&" : "?";
return H5_URL
+ separator
+ "v="
+ encodeURIComponent(H5_CACHE_VERSION)
+ (ticket ? "&h5_ticket=" + encodeURIComponent(ticket) : "");
}
export default function Index() {
return <WebView src={H5_SRC} />;
const [h5Src, setH5Src] = useState(buildH5Src);
const [initialized, setInitialized] = useState(false);
const [lastToken, setLastToken] = useState<string | null>(null);
useDidShow(() => {
const token = getToken();
if (initialized && token === lastToken) return;
setInitialized(true);
setLastToken(token);
if (!token) {
setH5Src(buildH5Src());
return;
}
createH5Ticket()
.then(({ ticket }) => setH5Src(buildH5Src(ticket)))
.catch(() => setH5Src(buildH5Src()));
});
return <WebView src={h5Src} />;
}

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: "微信手机号登录",
backgroundTextStyle: "light",
});

View File

@@ -0,0 +1,63 @@
import { Button, Text, View } from "@tarojs/components";
import { useState } from "react";
import Taro, { useLoad } from "@tarojs/taro";
import { clearToken, loginMiniProgramWithPhone } from "../../services/api";
type LoginPageParams = {
mode?: string;
};
export default function LoginPage() {
const [mode, setMode] = useState<"login" | "logout">("login");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
useLoad<LoginPageParams>((params) => {
if (params.mode === "logout") {
clearToken();
setMode("logout");
void Taro.navigateBack();
}
});
const handlePhoneNumber = async (event: { detail: { code?: string; errMsg?: string } }) => {
const phoneCode = event.detail.code;
if (!phoneCode) {
setMessage(event.detail.errMsg?.includes("deny") ? "你取消了手机号授权" : "未获取到手机号授权,请重试");
return;
}
setLoading(true);
setMessage("");
try {
await loginMiniProgramWithPhone(phoneCode);
await Taro.navigateBack();
} catch (error) {
setMessage(error instanceof Error ? error.message : "登录失败,请稍后重试");
} finally {
setLoading(false);
}
};
if (mode === "logout") return null;
return (
<View className="login-page">
<View className="login-hero">
<Text className="eyebrow"></Text>
<Text className="login-title"> H5 </Text>
<Text className="login-note"></Text>
</View>
<Button
className="phone-login-button"
openType="getPhoneNumber"
onGetPhoneNumber={handlePhoneNumber}
disabled={loading}
>
{loading ? "登录中..." : "微信手机号一键登录"}
</Button>
{message ? <Text className="login-message">{message}</Text> : null}
<Text className="login-privacy"></Text>
</View>
);
}

View File

@@ -165,6 +165,33 @@ export async function loginMiniProgram() {
return response.user;
}
export async function loginMiniProgramWithPhone(phoneCode: string) {
const result = await Taro.login();
const response = await request<{
token: string;
h5Ticket: string;
h5TicketExpiresAt: string;
linkedLeadCount: number;
user: UserProfile;
}>("/api/public/auth/wechat-phone-login", {
method: "POST",
data: {
loginCode: result.code,
phoneCode,
source: "mini_program",
},
});
setToken(response.token);
return response;
}
export async function createH5Ticket() {
return request<{ ticket: string; expiresAt: string }>("/api/public/auth/h5-ticket", {
method: "POST",
data: {},
});
}
export async function fetchContent() {
const [site, products] = await Promise.all([
request<SiteConfig>("/api/public/site-config"),

View File

@@ -37,7 +37,14 @@ import {
import {
createPublicBooking,
createPublicLead,
clearPublicHistory,
exchangeH5Ticket,
fetchCurrentUser,
fetchPublicContent,
logoutPublicSession,
recordPublicHistory,
savePublicFavorite,
syncPublicActivity,
type ProductContentBlock,
type ProductDetailSection,
type ProductKeyFact,
@@ -46,6 +53,7 @@ import {
type PublicProduct,
type PublicSearchPageConfig,
type PublicSiteConfig,
type PublicUserProfile,
} from "./api";
import {
bottomCtas as fallbackBottomCtas,
@@ -237,6 +245,7 @@ declare global {
wx?: {
miniProgram?: {
navigateTo?: (options: { url: string; success?: (result: unknown) => void; fail?: (error: unknown) => void; complete?: (result: unknown) => void }) => void;
postMessage?: (options: { data: unknown; success?: (result: unknown) => void; fail?: (error: unknown) => void; complete?: (result: unknown) => void }) => void;
};
openCustomerServiceChat?: (options: WechatCustomerServiceOptions) => void;
};
@@ -285,6 +294,28 @@ function openWecomCustomerService() {
return false;
}
function openMiniProgramLogin() {
if (!window.wx?.miniProgram?.navigateTo) return false;
window.wx.miniProgram.navigateTo({
url: "/pages/login/index",
fail(error) {
console.warn("Failed to open the native login page.", error);
},
});
return true;
}
function openMiniProgramLogout() {
if (!window.wx?.miniProgram?.navigateTo) return false;
window.wx.miniProgram.navigateTo({
url: "/pages/login/index?mode=logout",
fail(error) {
console.warn("Failed to open the native logout page.", error);
},
});
return true;
}
function normalizeApiProduct(product: PublicProduct, index: number): Product {
return {
id: product.sourceId ?? fallbackNumberFromApiId(product.id, index),
@@ -791,6 +822,29 @@ const FAVORITE_PRODUCTS_KEY = "miniapp:favorite-products";
const BROWSING_HISTORY_KEY = "miniapp:browsing-history";
const SEARCH_HISTORY_KEY = "miniapp:search-history";
const LATEST_DEMAND_KEY = "miniapp:latest-demand";
const H5_TICKET_QUERY_KEY = "h5_ticket";
function readH5TicketFromUrl() {
if (typeof window === "undefined") return null;
return new URLSearchParams(window.location.search).get(H5_TICKET_QUERY_KEY);
}
function clearH5TicketFromUrl() {
if (typeof window === "undefined" || !window.history.replaceState) return;
const url = new URL(window.location.href);
url.searchParams.delete(H5_TICKET_QUERY_KEY);
window.history.replaceState({}, document.title, url.toString());
}
function clearStoredAccountData() {
try {
window.localStorage.removeItem(FAVORITE_PRODUCTS_KEY);
window.localStorage.removeItem(BROWSING_HISTORY_KEY);
window.localStorage.removeItem(LATEST_DEMAND_KEY);
} catch {
// Local storage may be unavailable in private browsing contexts.
}
}
function readStoredProducts(key: string) {
try {
@@ -2133,7 +2187,58 @@ function CardBenefitsPage({
);
}
function AuthSheet({
user,
canUseMiniProgram,
message,
busy,
onClose,
onLogin,
onLogout,
}: {
user: PublicUserProfile | null;
canUseMiniProgram: boolean;
message: string;
busy: boolean;
onClose: () => void;
onLogin: () => void;
onLogout: () => void;
}) {
return (
<section className="auth-sheet-mask" role="dialog" aria-modal="true" aria-labelledby="auth-sheet-title" onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}>
<div className="auth-sheet">
<button className="auth-sheet-close" onClick={onClose} aria-label="关闭登录弹窗">
<X size={24} />
</button>
<span className="auth-sheet-kicker"></span>
<h2 id="auth-sheet-title">{user ? "已登录万趣账号" : "登录后同步你的行程"}</h2>
<p>
{user
? (user.phoneMasked ?? "微信手机号") + " · 小程序与 H5 已同步"
: "收藏、浏览记录和出行需求会跟随你的账号。"}
</p>
{message ? <div className="auth-sheet-message" role="status">{message}</div> : null}
{user ? (
<button className="auth-sheet-action auth-sheet-action-secondary" onClick={onLogout} disabled={busy}>
{busy ? "退出中..." : "退出登录"}
</button>
) : (
<button className="auth-sheet-action" onClick={onLogin} disabled={busy}>
{busy ? "正在打开..." : "微信手机号一键登录"}
</button>
)}
{!user && !canUseMiniProgram ? <small className="auth-sheet-footnote"> H5 </small> : null}
</div>
</section>
);
}
function MyCenterPage({
user,
onLogin,
onLogout,
onProductSelect,
onDemand,
onExplore,
@@ -2145,6 +2250,9 @@ function MyCenterPage({
onToggleFavorite,
onClearHistory,
}: {
user: PublicUserProfile | null;
onLogin: () => void;
onLogout: () => void;
onProductSelect: (product: Product) => void;
onDemand: () => void;
onExplore: () => void;
@@ -2167,9 +2275,14 @@ function MyCenterPage({
<User size={31} />
</div>
<span>
<strong>{BRAND_NAME}</strong>
<small></small>
<strong>{user?.nickname || user?.phoneMasked || (BRAND_NAME + "游客")}</strong>
<small>{user ? "小程序与 H5 已同步" : "游客浏览,登录后同步行程"}</small>
</span>
{user ? (
<button className="mine-profile-action" onClick={onLogout}>退</button>
) : (
<button className="mine-profile-action" onClick={onLogin}></button>
)}
</div>
<div className="mine-summary-grid">
{[
@@ -3610,6 +3723,12 @@ export default function App() {
const [favoriteProducts, setFavoriteProducts] = useState<Product[]>(() => readStoredProducts(FAVORITE_PRODUCTS_KEY));
const [browsingHistory, setBrowsingHistory] = useState<Product[]>(() => readStoredProducts(BROWSING_HISTORY_KEY));
const [supportOpen, setSupportOpen] = useState(false);
const [authUser, setAuthUser] = useState<PublicUserProfile | null>(null);
const [authState, setAuthState] = useState<"loading" | "guest" | "authenticated">("loading");
const [authSheetOpen, setAuthSheetOpen] = useState(false);
const [authMessage, setAuthMessage] = useState("");
const [authBusy, setAuthBusy] = useState(false);
const canUseMiniProgram = Boolean(window.wx?.miniProgram?.navigateTo);
const isCampaignTheme = view === "home" || view === "trainActivity";
const showRootNav = ["home", "destinationHome", "mine"].includes(view);
const volumeModule = appData.homeModules.find((module) => (module.templateType ?? module.id) === "themes");
@@ -3647,6 +3766,49 @@ export default function App() {
};
}, [contentReloadKey]);
useEffect(() => {
let cancelled = false;
const bootstrapAuth = async () => {
const ticket = readH5TicketFromUrl();
if (ticket) clearH5TicketFromUrl();
let user: PublicUserProfile;
try {
user = ticket
? (await exchangeH5Ticket(ticket)).user
: await fetchCurrentUser();
} catch {
if (!cancelled) {
setAuthUser(null);
setAuthState("guest");
}
return;
}
if (cancelled) return;
setAuthUser(user);
setAuthState("authenticated");
try {
const activity = await syncPublicActivity(
favoriteProducts.map((product) => product.apiId).filter((id): id is string => Boolean(id)),
browsingHistory.map((product) => product.apiId).filter((id): id is string => Boolean(id)),
);
if (cancelled) return;
setFavoriteProducts(activity.favorites.map((product, index) => normalizeApiProduct(product, index)));
setBrowsingHistory(activity.history.map((product, index) => normalizeApiProduct(product, index)));
} catch (error) {
console.info("Authenticated activity sync unavailable.", error);
}
};
void bootstrapAuth();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
writeStoredProducts(FAVORITE_PRODUCTS_KEY, favoriteProducts);
}, [favoriteProducts]);
@@ -3667,7 +3829,46 @@ export default function App() {
writeStoredSearches(recentSearches);
}, [recentSearches]);
const showAuthSheet = (message = "请先完成微信手机号登录。") => {
setAuthMessage(message);
setAuthSheetOpen(true);
};
const handleLogin = () => {
setAuthBusy(true);
if (openMiniProgramLogin()) {
setAuthMessage("正在打开微信手机号授权页,完成后会自动返回 H5。");
} else {
setAuthMessage("当前 H5 仅支持游客浏览,请从万趣小程序进入并完成登录。");
}
setAuthBusy(false);
};
const handleLogout = async () => {
setAuthBusy(true);
try {
await logoutPublicSession();
} catch (error) {
console.warn("Failed to clear the H5 session.", error);
}
clearStoredAccountData();
setAuthUser(null);
setAuthState("guest");
setFavoriteProducts([]);
setBrowsingHistory([]);
setLatestDemand(null);
setLatestBooking(null);
setAuthSheetOpen(false);
setAuthMessage("");
setAuthBusy(false);
openMiniProgramLogout();
};
const changeView = (nextView: AppView) => {
if (nextView === "mine" && !authUser) {
showAuthSheet("进入“我的”前,请先完成微信手机号登录。");
return;
}
setSelectedProduct(null);
setSupportOpen(false);
if (nextView !== "booking") {
@@ -3737,16 +3938,34 @@ export default function App() {
product,
...items.filter((item) => productKey(item) !== productKey(product)),
].slice(0, 20));
if (authUser && product.apiId) {
void recordPublicHistory(product.apiId).catch((error) => {
console.info("Failed to record authenticated browsing history.", error);
});
}
};
const isProductFavorite = (product: Product) => favoriteProducts.some((item) => productKey(item) === productKey(product));
const toggleFavoriteProduct = (product: Product) => {
setFavoriteProducts((items) =>
items.some((item) => productKey(item) === productKey(product))
const saved = favoriteProducts.some((item) => productKey(item) === productKey(product));
setFavoriteProducts((items) => saved
? items.filter((item) => productKey(item) !== productKey(product))
: [product, ...items].slice(0, 50),
);
: [product, ...items].slice(0, 50));
if (authUser && product.apiId) {
void savePublicFavorite(product.apiId, !saved).catch((error) => {
console.info("Failed to update authenticated favorite.", error);
});
}
};
const clearBrowsingHistory = () => {
setBrowsingHistory([]);
if (authUser) {
void clearPublicHistory().catch((error) => {
console.info("Failed to clear authenticated browsing history.", error);
});
}
};
const openBooking = (product: Product) => {
@@ -3789,6 +4008,10 @@ export default function App() {
};
const submitLead = async (payload: PublicLeadPayload, openSupportAfterSubmit = true): Promise<LeadSubmitResult> => {
if (!authUser) {
showAuthSheet("提交出行需求前,请先完成微信手机号登录。");
return { ok: false, message: "请先完成微信手机号登录,登录完成后再提交需求。" };
}
try {
const lead = await createPublicLead(payload);
if (openSupportAfterSubmit) {
@@ -3802,6 +4025,10 @@ export default function App() {
};
const submitBooking = async (payload: PublicLeadPayload, openSupportAfterSubmit = false): Promise<LeadSubmitResult> => {
if (!authUser) {
showAuthSheet("提交预订申请前,请先完成微信手机号登录。");
return { ok: false, message: "请先完成微信手机号登录,登录完成后再提交预订申请。" };
}
try {
const booking = await createPublicBooking(payload);
if (openSupportAfterSubmit) {
@@ -3939,6 +4166,9 @@ export default function App() {
<CardBenefitsPage onBack={() => changeView("home")} onDemand={() => openDemand()} onSupport={() => setSupportOpen(true)} onProductSelect={openProduct} />
) : view === "mine" ? (
<MyCenterPage
user={authUser}
onLogin={() => showAuthSheet()}
onLogout={() => void handleLogout()}
onProductSelect={openProduct}
onDemand={() => openDemand()}
onExplore={() => changeView("home")}
@@ -3948,7 +4178,7 @@ export default function App() {
favoriteProducts={favoriteProducts}
browsingHistory={browsingHistory}
onToggleFavorite={toggleFavoriteProduct}
onClearHistory={() => setBrowsingHistory([])}
onClearHistory={clearBrowsingHistory}
/>
) : view === "booking" && bookingProduct ? (
<BookingFlowPage
@@ -3991,6 +4221,20 @@ export default function App() {
/>
) : null}
{supportOpen ? <SupportSheet onClose={() => setSupportOpen(false)} onDemand={() => openDemand()} onOnlineContact={openOnlineContact} /> : null}
{authSheetOpen ? (
<AuthSheet
user={authUser}
canUseMiniProgram={canUseMiniProgram}
message={authMessage}
busy={authBusy}
onClose={() => {
setAuthSheetOpen(false);
setAuthMessage("");
}}
onLogin={handleLogin}
onLogout={() => void handleLogout()}
/>
) : null}
</main>
</div>
</AppDataContext.Provider>

View File

@@ -153,6 +153,27 @@ export type PublicSiteConfig = {
searchPage?: PublicSearchPageConfig;
};
export type PublicUserProfile = {
id: string;
nickname?: string | null;
avatarUrl?: string | null;
phoneMasked: string | null;
hasPhone: boolean;
status: "active" | "disabled" | "anonymized";
source: string;
firstSeenAt: string;
lastLoginAt: string;
createdAt: string;
leads?: number;
favorites?: number;
history?: number;
};
export type PublicActivity = {
favorites: Array<PublicProduct & { savedAt: string }>;
history: Array<PublicProduct & { viewedAt: string; viewCount: number }>;
};
export type PublicLeadPayload = {
destination?: string;
phone: string;
@@ -175,11 +196,12 @@ export type PublicLeadPayload = {
async function request<T>(path: string, init?: RequestInit) {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: "include",
headers: {
"content-type": "application/json",
...init?.headers,
},
...init,
});
if (!response.ok) {
@@ -190,6 +212,47 @@ async function request<T>(path: string, init?: RequestInit) {
return response.json() as Promise<T>;
}
export async function fetchCurrentUser() {
return request<PublicUserProfile>("/api/public/users/me");
}
export async function exchangeH5Ticket(ticket: string) {
return request<{ user: PublicUserProfile }>("/api/public/auth/h5-exchange", {
method: "POST",
body: JSON.stringify({ ticket }),
});
}
export async function syncPublicActivity(favoriteProductIds: string[], historyProductIds: string[]) {
return request<PublicActivity>("/api/public/users/me/activity/sync", {
method: "POST",
body: JSON.stringify({ favoriteProductIds, historyProductIds }),
});
}
export async function savePublicFavorite(productId: string, saved: boolean) {
const path = "/api/public/users/me/favorites/" + encodeURIComponent(productId);
return request<{ saved: boolean }>(path, { method: saved ? "POST" : "DELETE" });
}
export async function recordPublicHistory(productId: string) {
return request<{ saved: boolean }>("/api/public/users/me/history", {
method: "POST",
body: JSON.stringify({ productId }),
});
}
export async function clearPublicHistory() {
return request<{ cleared: boolean }>("/api/public/users/me/history", { method: "DELETE" });
}
export async function logoutPublicSession() {
return request<{ loggedOut: boolean }>("/api/public/auth/logout", {
method: "POST",
body: JSON.stringify({}),
});
}
export async function fetchPublicContent() {
const [siteConfig, products] = await Promise.all([
request<PublicSiteConfig>("/api/public/site-config"),

View File

@@ -3133,7 +3133,7 @@ img {
.mine-profile {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
grid-template-columns: 52px minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
margin: 10px 12px 0;
@@ -3175,6 +3175,19 @@ img {
text-overflow: ellipsis;
}
.mine-profile-action {
align-self: center;
min-width: 54px;
height: 30px;
padding: 0 10px;
border: 1px solid #c9d9ca;
border-radius: 999px;
background: #f3f8f2;
color: #287642;
font-size: 12px;
font-weight: 700;
}
.mine-summary-grid,
.mine-tools {
display: grid;
@@ -4825,8 +4838,101 @@ img {
text-align: center;
}
.auth-sheet-mask {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 16px;
background: rgba(12, 21, 16, 0.62);
animation: manager-mask-in 220ms ease both;
}
.auth-sheet {
position: relative;
width: min(100%, 430px);
display: grid;
gap: 12px;
padding: 28px 22px 24px;
border: 1px solid #dce7dc;
border-radius: 24px;
background: #f9fbf8;
box-shadow: 0 24px 70px rgba(12, 27, 19, 0.24);
animation: manager-sheet-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.auth-sheet-close {
position: absolute;
top: 16px;
right: 16px;
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 11px;
background: #eef2ed;
color: #56645a;
}
.auth-sheet-kicker {
color: #718076;
font-size: 11px;
letter-spacing: 0.14em;
}
.auth-sheet h2 {
margin: 0;
color: #17231d;
font-size: 24px;
line-height: 31px;
font-weight: 760;
}
.auth-sheet p {
margin: 0;
color: #6c786f;
font-size: 13px;
line-height: 19px;
}
.auth-sheet-message {
padding: 11px 12px;
border-radius: 12px;
background: #eef5ed;
color: #41604a;
font-size: 13px;
line-height: 19px;
}
.auth-sheet-action {
width: 100%;
min-height: 46px;
margin-top: 5px;
border-radius: 999px;
background: #287642;
color: #fff;
font-size: 15px;
font-weight: 700;
}
.auth-sheet-action-secondary {
background: #fff;
border: 1px solid #b9cdbb;
color: #287642;
}
.auth-sheet-footnote {
color: #89948b;
font-size: 12px;
line-height: 18px;
text-align: center;
}
.manager-sheet button:focus-visible,
.manager-sheet a:focus-visible {
.manager-sheet a:focus-visible,
.auth-sheet button:focus-visible {
outline: 2px solid #6f9677;
outline-offset: 3px;
}