diff --git a/.env.example b/.env.example index f31f81e..2f9d5a4 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/apps/api/prisma/migrations/20260724120000_h5_auth_tickets/migration.sql b/apps/api/prisma/migrations/20260724120000_h5_auth_tickets/migration.sql new file mode 100644 index 0000000..9d5f8e5 --- /dev/null +++ b/apps/api/prisma/migrations/20260724120000_h5_auth_tickets/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b478467..568f732 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index b7fe5cb..98b09e4 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -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(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(); + 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: "请先登录小程序" }); } - - const user = request.user as JwtPrincipal | undefined; - if (!user?.sub || user.kind !== "mini_program_user") { - 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); } diff --git a/apps/api/src/routes/memory.ts b/apps/api/src/routes/memory.ts index 0b17c22..bed1dba 100644 --- a/apps/api/src/routes/memory.ts +++ b/apps/api/src/routes/memory.ts @@ -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; diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index 1767994..0f3e573 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -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,6 +105,234 @@ async function linkHistoricalLeads(userId: string, phone: string) { return ids.length; } +async function upsertMiniProgramUser( + session: Awaited>, + 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 { user: null, disabled: true as const }; + + 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", + }, + }); + + 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({ @@ -116,6 +355,44 @@ async function buildActivity(userId: string) { } 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>; + let phoneInfo: Awaited>; + 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: summary.user.id, appId: summary.user.appId, channel: "mini_program" }, + { expiresIn: "30d" }, + ); + 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>; @@ -125,44 +402,64 @@ export async function registerPublicRoutes(app: FastifyInstance) { 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 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: 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 }) }; + 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) => { diff --git a/apps/api/src/schemas.ts b/apps/api/src/schemas.ts index a14936f..6c6c0f1 100644 --- a/apps/api/src/schemas.ts +++ b/apps/api/src/schemas.ts @@ -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(), diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index c5d9d5e..e045caa 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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, }); diff --git a/apps/miniprogram/README.md b/apps/miniprogram/README.md index 66244eb..07905ce 100644 --- a/apps/miniprogram/README.md +++ b/apps/miniprogram/README.md @@ -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`。 diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index 0bb71c8..892d375 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -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", diff --git a/apps/miniprogram/src/app.css b/apps/miniprogram/src/app.css index 5e78bc5..68ae989 100644 --- a/apps/miniprogram/src/app.css +++ b/apps/miniprogram/src/app.css @@ -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; } diff --git a/apps/miniprogram/src/pages/index/index.tsx b/apps/miniprogram/src/pages/index/index.tsx index 9dbf0f0..e336653 100644 --- a/apps/miniprogram/src/pages/index/index.tsx +++ b/apps/miniprogram/src/pages/index/index.tsx @@ -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 ; + const [h5Src, setH5Src] = useState(buildH5Src); + const [initialized, setInitialized] = useState(false); + const [lastToken, setLastToken] = useState(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 ; } diff --git a/apps/miniprogram/src/pages/login/index.config.ts b/apps/miniprogram/src/pages/login/index.config.ts new file mode 100644 index 0000000..73fccff --- /dev/null +++ b/apps/miniprogram/src/pages/login/index.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationBarTitleText: "微信手机号登录", + backgroundTextStyle: "light", +}); diff --git a/apps/miniprogram/src/pages/login/index.tsx b/apps/miniprogram/src/pages/login/index.tsx new file mode 100644 index 0000000..008a4ab --- /dev/null +++ b/apps/miniprogram/src/pages/login/index.tsx @@ -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((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 ( + + + 万趣账号 + 登录后,行程在小程序和 H5 之间同步 + 手机号仅用于识别账号和联系服务管家,不会公开展示。 + + + {message ? {message} : null} + 登录即表示你同意万趣为本次服务保存必要的账号信息。 + + ); +} diff --git a/apps/miniprogram/src/services/api.ts b/apps/miniprogram/src/services/api.ts index b0ec833..ba50451 100644 --- a/apps/miniprogram/src/services/api.ts +++ b/apps/miniprogram/src/services/api.ts @@ -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("/api/public/site-config"), diff --git a/src/App.tsx b/src/App.tsx index e524df9..d80c703 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( +
{ + if (event.target === event.currentTarget) onClose(); + }}> +
+ + 万趣账号 +

{user ? "已登录万趣账号" : "登录后同步你的行程"}

+

+ {user + ? (user.phoneMasked ?? "微信手机号") + " · 小程序与 H5 已同步" + : "收藏、浏览记录和出行需求会跟随你的账号。"} +

+ {message ?
{message}
: null} + {user ? ( + + ) : ( + + )} + {!user && !canUseMiniProgram ? 当前 H5 仅支持游客浏览,请从万趣小程序进入并完成登录。 : null} +
+
+ ); +} + 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({ - {BRAND_NAME}会员 - 提交的需求、收藏行程与浏览记录 + {user?.nickname || user?.phoneMasked || (BRAND_NAME + "游客")} + {user ? "小程序与 H5 已同步" : "游客浏览,登录后同步行程"} + {user ? ( + + ) : ( + + )}
{[ @@ -3610,6 +3723,12 @@ export default function App() { const [favoriteProducts, setFavoriteProducts] = useState(() => readStoredProducts(FAVORITE_PRODUCTS_KEY)); const [browsingHistory, setBrowsingHistory] = useState(() => readStoredProducts(BROWSING_HISTORY_KEY)); const [supportOpen, setSupportOpen] = useState(false); + const [authUser, setAuthUser] = useState(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)) - ? items.filter((item) => productKey(item) !== productKey(product)) - : [product, ...items].slice(0, 50), - ); + const saved = favoriteProducts.some((item) => productKey(item) === productKey(product)); + setFavoriteProducts((items) => saved + ? items.filter((item) => productKey(item) !== productKey(product)) + : [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 => { + 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 => { + if (!authUser) { + showAuthSheet("提交预订申请前,请先完成微信手机号登录。"); + return { ok: false, message: "请先完成微信手机号登录,登录完成后再提交预订申请。" }; + } try { const booking = await createPublicBooking(payload); if (openSupportAfterSubmit) { @@ -3939,6 +4166,9 @@ export default function App() { changeView("home")} onDemand={() => openDemand()} onSupport={() => setSupportOpen(true)} onProductSelect={openProduct} /> ) : view === "mine" ? ( 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 ? ( ) : null} {supportOpen ? setSupportOpen(false)} onDemand={() => openDemand()} onOnlineContact={openOnlineContact} /> : null} + {authSheetOpen ? ( + { + setAuthSheetOpen(false); + setAuthMessage(""); + }} + onLogin={handleLogin} + onLogout={() => void handleLogout()} + /> + ) : null}
diff --git a/src/api.ts b/src/api.ts index a7e361b..67073b4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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; + history: Array; +}; + export type PublicLeadPayload = { destination?: string; phone: string; @@ -175,11 +196,12 @@ export type PublicLeadPayload = { async function request(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(path: string, init?: RequestInit) { return response.json() as Promise; } +export async function fetchCurrentUser() { + return request("/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("/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("/api/public/site-config"), diff --git a/src/styles.css b/src/styles.css index 01d1375..5343613 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; }