完善 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

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

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,6 +105,234 @@ async function linkHistoricalLeads(userId: string, phone: string) {
return ids.length;
}
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 { 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<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: 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<ReturnType<typeof exchangeWechatLoginCode>>;
@@ -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) => {

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