diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 951e9c5..7cd6fe2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -215,6 +215,24 @@ model EmailToken { @@index([expiresAt]) } +model PendingRegistration { + id String @id @default(cuid()) + email String @unique + passwordHash String + name String? + inviteCode String? + invitedById String? + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([consumedAt, expiresAt]) + @@index([expiresAt]) + @@index([invitedById]) +} + model SubscriptionCategory { id String @id @default(cuid()) name String @@ -780,10 +798,10 @@ model AppConfig { subscriptionRiskIpLimitPerHour Int @default(180) subscriptionRiskTokenLimitPerHour Int @default(60) nodeAccessRiskEnabled Boolean @default(true) - nodeAccessConnectionWarning Int @default(180) - nodeAccessConnectionSuspend Int @default(360) - nodeAccessUniqueTargetWarning Int @default(80) - nodeAccessUniqueTargetSuspend Int @default(160) + nodeAccessConnectionWarning Int @default(180) + nodeAccessConnectionSuspend Int @default(360) + nodeAccessUniqueTargetWarning Int @default(80) + nodeAccessUniqueTargetSuspend Int @default(160) inviteRewardCouponId String? inviteRewardRate Decimal @default(0) inviteRewardEnabled Boolean @default(false) diff --git a/src/actions/auth/email.ts b/src/actions/auth/email.ts index 68f30cc..cee98c1 100644 --- a/src/actions/auth/email.ts +++ b/src/actions/auth/email.ts @@ -7,7 +7,15 @@ import { prisma } from "@/lib/prisma"; import { rateLimit } from "@/lib/rate-limit"; import { getClientIp } from "@/lib/request-context"; import { getAppConfig } from "@/services/app-config"; -import { isSmtpConfigured, normalizeEmailAddress, sendPasswordResetEmail, sendRegistrationVerificationEmail, consumePasswordResetToken, verifyEmailToken } from "@/services/email"; +import { + consumePasswordResetToken, + isSmtpConfigured, + normalizeEmailAddress, + resendPendingRegistrationVerificationEmail, + sendPasswordResetEmail, + sendRegistrationVerificationEmail, + verifyEmailToken, +} from "@/services/email"; const emailSchema = z.object({ email: z.string().trim().email("请输入正确的邮箱"), @@ -83,7 +91,13 @@ export async function requestRegistrationVerification(formData: FormData) { email: user.email, ...context, }); + return; } + + await resendPendingRegistrationVerificationEmail({ + email, + ...context, + }); } export async function resetPasswordByEmail(formData: FormData) { diff --git a/src/app/(auth)/register/register-page-client.tsx b/src/app/(auth)/register/register-page-client.tsx index ec47236..8cbe890 100644 --- a/src/app/(auth)/register/register-page-client.tsx +++ b/src/app/(auth)/register/register-page-client.tsx @@ -68,7 +68,7 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {

{requiresEmailVerification ? "验证邮件已发送" : "注册成功"}

- {requiresEmailVerification ? "请查收邮箱并完成验证,验证后即可登录。" : "账户已创建,请登录。"} + {requiresEmailVerification ? "请查收邮箱并完成验证,验证后会自动创建账户。" : "账户已创建,请登录。"}

{requiresEmailVerification ? "没有收到?重新发送" : "去登录"} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 8343f20..d294d14 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -6,7 +6,7 @@ import { getAppConfig } from "@/services/app-config"; import { verifyTurnstile } from "@/lib/turnstile"; import { rateLimit } from "@/lib/rate-limit"; import { getClientIp } from "@/lib/request-context"; -import { isSmtpConfigured, normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email"; +import { isSmtpConfigured, normalizeEmailAddress, sendPendingRegistrationVerificationEmail } from "@/services/email"; import { decryptIfEncrypted } from "@/lib/crypto"; const schema = z.object({ @@ -90,33 +90,37 @@ export async function POST(req: Request) { } const hashedPassword = await bcrypt.hash(password, 12); - const user = await prisma.user.create({ - data: { - email, - emailVerifiedAt: config.emailVerificationRequired ? null : new Date(), - status: config.emailVerificationRequired ? "PENDING_EMAIL" : "ACTIVE", - password: hashedPassword, - name: name || null, - invitedById: inviterId, - }, - select: { id: true, email: true }, - }); - if (config.emailVerificationRequired) { try { - await sendRegistrationVerificationEmail({ - userId: user.id, - email: user.email, + await sendPendingRegistrationVerificationEmail({ + email, + passwordHash: hashedPassword, + name: name || null, + inviteCode: inviteCode || null, + invitedById: inviterId, headers: req.headers, requestUrl: req.url, }); } catch (error) { - await prisma.user.delete({ where: { id: user.id } }).catch(() => null); return NextResponse.json( { error: error instanceof Error ? error.message : "验证邮件发送失败" }, { status: 500 }, ); } + } else { + await prisma.$transaction(async (tx) => { + await tx.user.create({ + data: { + email, + emailVerifiedAt: new Date(), + status: "ACTIVE", + password: hashedPassword, + name: name || null, + invitedById: inviterId, + }, + }); + await tx.pendingRegistration.deleteMany({ where: { email } }); + }); } return NextResponse.json({ ok: true, requiresEmailVerification: config.emailVerificationRequired }); diff --git a/src/services/email.ts b/src/services/email.ts index cb26ca3..1912c5f 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -161,6 +161,85 @@ export async function sendRegistrationVerificationEmail(input: { }); } +export async function sendPendingRegistrationVerificationEmail(input: { + email: string; + passwordHash: string; + name?: string | null; + inviteCode?: string | null; + invitedById?: string | null; + headers?: Headers; + requestUrl?: string; +}) { + const config = await getAppConfig(); + const email = normalizeEmailAddress(input.email); + const token = crypto.randomBytes(TOKEN_BYTES).toString("hex"); + + await prisma.pendingRegistration.upsert({ + where: { email }, + create: { + email, + passwordHash: input.passwordHash, + name: input.name || null, + inviteCode: input.inviteCode || null, + invitedById: input.invitedById || null, + tokenHash: hashToken(token), + expiresAt: addMinutes(REGISTRATION_TTL_MINUTES), + }, + update: { + passwordHash: input.passwordHash, + name: input.name || null, + inviteCode: input.inviteCode || null, + invitedById: input.invitedById || null, + tokenHash: hashToken(token), + expiresAt: addMinutes(REGISTRATION_TTL_MINUTES), + consumedAt: null, + }, + }); + + const url = await buildActionUrl("/verify-email", token, input); + const template = renderRegistrationEmail(config.siteName, url); + + await sendMail(config, email, { + subject: `验证你的 ${config.siteName} 邮箱`, + ...template, + }); +} + +export async function resendPendingRegistrationVerificationEmail(input: { + email: string; + headers?: Headers; + requestUrl?: string; +}) { + const config = await getAppConfig(); + const email = normalizeEmailAddress(input.email); + const pending = await prisma.pendingRegistration.findUnique({ + where: { email }, + select: { id: true, consumedAt: true }, + }); + + if (!pending || pending.consumedAt) return false; + + const token = crypto.randomBytes(TOKEN_BYTES).toString("hex"); + await prisma.pendingRegistration.update({ + where: { id: pending.id }, + data: { + tokenHash: hashToken(token), + expiresAt: addMinutes(REGISTRATION_TTL_MINUTES), + consumedAt: null, + }, + }); + + const url = await buildActionUrl("/verify-email", token, input); + const template = renderRegistrationEmail(config.siteName, url); + + await sendMail(config, email, { + subject: `验证你的 ${config.siteName} 邮箱`, + ...template, + }); + + return true; +} + export async function sendPasswordResetEmail(input: { userId: string; email: string; @@ -237,6 +316,9 @@ export async function consumeEmailToken(token: string, purpose?: EmailPurpose) { } export async function verifyEmailToken(token: string) { + const pendingResult = await consumePendingRegistrationToken(token); + if (pendingResult) return pendingResult; + const record = await consumeEmailToken(token); if (!record) return { ok: false as const, message: "验证链接无效或已过期" }; @@ -280,6 +362,79 @@ export async function verifyEmailToken(token: string) { return { ok: false as const, message: "这个链接不能用于邮箱验证" }; } +async function consumePendingRegistrationToken(token: string) { + const tokenHash = hashToken(token.trim()); + const pending = await prisma.pendingRegistration.findUnique({ where: { tokenHash } }); + + if (!pending || pending.consumedAt || pending.expiresAt <= new Date()) { + return null; + } + + const now = new Date(); + + return prisma.$transaction(async (tx) => { + const consumed = await tx.pendingRegistration.updateMany({ + where: { + id: pending.id, + consumedAt: null, + expiresAt: { gt: now }, + }, + data: { consumedAt: now }, + }); + + if (consumed.count !== 1) { + return { ok: false as const, message: "验证链接无效或已过期" }; + } + + const existing = await tx.user.findUnique({ + where: { email: pending.email }, + select: { id: true }, + }); + if (existing) { + await tx.pendingRegistration.delete({ where: { id: pending.id } }); + return { ok: false as const, message: "这个邮箱已经注册,请直接登录。" }; + } + + let invitedById = pending.invitedById; + if (invitedById) { + const inviter = await tx.user.findUnique({ + where: { id: invitedById }, + select: { id: true }, + }); + invitedById = inviter?.id ?? null; + } + if (!invitedById && pending.inviteCode) { + const inviter = await tx.user.findUnique({ + where: { inviteCode: pending.inviteCode }, + select: { id: true }, + }); + invitedById = inviter?.id ?? null; + } + + try { + await tx.user.create({ + data: { + email: pending.email, + password: pending.passwordHash, + name: pending.name, + invitedById, + emailVerifiedAt: now, + status: "ACTIVE", + }, + }); + await tx.pendingRegistration.delete({ where: { id: pending.id } }); + } catch (error) { + if ((error as { code?: string }).code === "P2002") { + await tx.pendingRegistration.deleteMany({ where: { id: pending.id } }); + return { ok: false as const, message: "这个邮箱已经注册,请直接登录。" }; + } + throw error; + } + + return { ok: true as const, message: "邮箱验证完成,账户已创建,现在可以登录。" }; + }); +} + export async function consumePasswordResetToken(token: string) { const record = await consumeEmailToken(token, "PASSWORD_RESET"); if (!record?.userId) {