完善 H5 鉴权与登录流程
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user