From 689c8b2d8600d617dea0aababdf12a25276f1201 Mon Sep 17 00:00:00 2001 From: MikiVL Date: Tue, 5 May 2026 14:37:05 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=82=AE=E4=BB=B6=E6=87=92=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E3=80=81cloudEnabled=20=E5=88=B7=E6=96=B0=E3=80=81?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E5=86=99=E5=85=A5=E6=97=B6=E6=9C=BA=E3=80=81?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E9=87=8D=E5=8F=91=E5=86=B7=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/lib/email.ts: transporter 改为懒加载,避免 ESM import 提升导致 EMAIL_PASS 未加载 - server/routes/auth.ts: send-verify 改为写 pendingEmail,验证成功后才写 email;新增 60s 冷却;catch 块打印错误 - server/db.ts: 新增 pendingEmail 字段及迁移 - src/stores/appStore.ts: 新增 refreshCurrentUser,启动时调用 apiGetMe 拉取真实 cloudEnabled - src/App.tsx: 启动时调用 refreshCurrentUser - ProfileModal/AccountModal: 加入 60s 倒计时和重新发送按钮 Co-Authored-By: Claude Sonnet 4.6 --- server/db.ts | 2 + server/lib/email.ts | 24 +++-- server/routes/auth.ts | 17 ++- src/App.tsx | 3 +- src/components/auth/AccountModal.tsx | 149 +++++++++++---------------- src/components/auth/ProfileModal.tsx | 41 +++++++- src/stores/appStore.ts | 17 ++- 7 files changed, 143 insertions(+), 110 deletions(-) diff --git a/server/db.ts b/server/db.ts index 859f69e..ffca662 100644 --- a/server/db.ts +++ b/server/db.ts @@ -22,6 +22,7 @@ export const users = sqliteTable('users', { avatar: text('avatar'), email: text('email'), emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), + pendingEmail: text('pending_email'), emailVerifyCode: text('email_verify_code'), emailVerifyExpiry: integer('email_verify_expiry'), resetCode: text('reset_code'), @@ -134,4 +135,5 @@ export function initDb() { if (!colNames.includes('reset_code_expiry')) sqlite.exec(`ALTER TABLE users ADD COLUMN reset_code_expiry INTEGER`) if (!colNames.includes('nickname')) sqlite.exec(`ALTER TABLE users ADD COLUMN nickname TEXT`) if (!colNames.includes('avatar')) sqlite.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`) + if (!colNames.includes('pending_email')) sqlite.exec(`ALTER TABLE users ADD COLUMN pending_email TEXT`) } diff --git a/server/lib/email.ts b/server/lib/email.ts index 4c5ed0a..af9200e 100644 --- a/server/lib/email.ts +++ b/server/lib/email.ts @@ -1,17 +1,19 @@ import nodemailer from 'nodemailer' -const transporter = nodemailer.createTransport({ - host: 'smtp.126.com', - port: 465, - secure: true, - auth: { - user: 'mikivl@126.com', - pass: process.env.EMAIL_PASS!, - }, -}) +function createTransporter() { + return nodemailer.createTransport({ + host: 'smtp.126.com', + port: 465, + secure: true, + auth: { + user: 'mikivl@126.com', + pass: process.env.EMAIL_PASS!, + }, + }) +} export async function sendVerifyCode(to: string, code: string) { - await transporter.sendMail({ + await createTransporter().sendMail({ from: '"MikiVL 笔记" ', to, subject: '邮箱验证码', @@ -21,7 +23,7 @@ export async function sendVerifyCode(to: string, code: string) { } export async function sendResetCode(to: string, code: string) { - await transporter.sendMail({ + await createTransporter().sendMail({ from: '"MikiVL 笔记" ', to, subject: '重置密码验证码', diff --git a/server/routes/auth.ts b/server/routes/auth.ts index e4fd113..0665776 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -96,17 +96,25 @@ authRouter.post('/email/send-verify', requireAuth, async (c) => { const { email } = await c.req.json<{ email: string }>() if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400) - // 检查邮箱是否已被其他用户绑定 + // 检查邮箱是否已被其他已验证用户绑定 const existing = db.select().from(users).where(eq(users.email, email)).all() if (existing.some(u => u.id !== userId)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409) + // 60 秒冷却:上次发送时间在 60 秒内则拒绝 + const [user] = db.select().from(users).where(eq(users.id, userId)).all() + if (user?.emailVerifyExpiry && user.emailVerifyExpiry - Date.now() > 9 * 60 * 1000) { + return c.json({ error: '请等待 60 秒后再重新发送' }, 429) + } + const code = randomCode() const expiry = Date.now() + 10 * 60 * 1000 - db.update(users).set({ email, emailVerifyCode: code, emailVerifyExpiry: expiry, emailVerified: false }).where(eq(users.id, userId)).run() + // 只写入 pendingEmail,不修改 email 字段 + db.update(users).set({ pendingEmail: email, emailVerifyCode: code, emailVerifyExpiry: expiry }).where(eq(users.id, userId)).run() try { await sendVerifyCode(email, code) - } catch { + } catch (err) { + console.error('[email]', err) return c.json({ error: '邮件发送失败,请稍后再试' }, 500) } return c.json({ success: true }) @@ -123,7 +131,8 @@ authRouter.post('/email/verify', requireAuth, async (c) => { if (!user.emailVerifyCode || user.emailVerifyCode !== code.trim()) return c.json({ error: '验证码错误' }, 400) if (!user.emailVerifyExpiry || Date.now() > user.emailVerifyExpiry) return c.json({ error: '验证码已过期,请重新发送' }, 400) - db.update(users).set({ emailVerified: true, emailVerifyCode: null, emailVerifyExpiry: null }).where(eq(users.id, userId)).run() + // 验证成功:将 pendingEmail 写入正式 email 字段 + db.update(users).set({ email: user.pendingEmail, emailVerified: true, pendingEmail: null, emailVerifyCode: null, emailVerifyExpiry: null }).where(eq(users.id, userId)).run() return c.json({ success: true }) }) diff --git a/src/App.tsx b/src/App.tsx index 7044905..9e283f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ import { useAppStore } from './stores/appStore' import { seedIfEmpty, deduplicateDB } from './db' export default function App() { - const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore() + const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId, refreshCurrentUser } = useAppStore() const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) useEffect(() => { @@ -17,6 +17,7 @@ export default function App() { seedIfEmpty() .then(() => deduplicateDB()) .then(() => loadAll()) + refreshCurrentUser() }, []) useEffect(() => { diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index ab330d7..31266a2 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -1,17 +1,31 @@ -import { useState } from 'react' -import { X, CheckCircle, Mail } from 'lucide-react' +import { useState, useRef, useEffect } from 'react' +import { X, CheckCircle } from 'lucide-react' import { apiSendEmailVerify, apiVerifyEmail, apiGetMe } from '../../lib/auth' import { useAppStore } from '../../stores/appStore' -type Step = 'status' | 'enter-email' | 'enter-code' +type Step = 'enter-email' | 'enter-code' export function AccountModal({ onClose }: { onClose: () => void }) { const { currentUser, setCurrentUser } = useAppStore() - const [step, setStep] = useState('status') + const [step, setStep] = useState('enter-email') const [email, setEmail] = useState('') const [code, setCode] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [countdown, setCountdown] = useState(0) + const timerRef = useRef | null>(null) + + useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, []) + + function startCountdown() { + setCountdown(60) + timerRef.current = setInterval(() => { + setCountdown(n => { + if (n <= 1) { clearInterval(timerRef.current!); return 0 } + return n - 1 + }) + }, 1000) + } async function handleSendCode(e: React.FormEvent) { e.preventDefault() @@ -20,6 +34,7 @@ export function AccountModal({ onClose }: { onClose: () => void }) { try { await apiSendEmailVerify(email) setStep('enter-code') + startCountdown() } catch (err: any) { setError(err.message) } finally { @@ -31,9 +46,8 @@ export function AccountModal({ onClose }: { onClose: () => void }) { setError('') setLoading(true) try { - await apiSendEmailVerify(currentUser?.email ?? '') - setStep('enter-code') - setEmail(currentUser?.email ?? '') + await apiSendEmailVerify(email) + startCountdown() } catch (err: any) { setError(err.message) } finally { @@ -49,8 +63,7 @@ export function AccountModal({ onClose }: { onClose: () => void }) { await apiVerifyEmail(code) const updated = await apiGetMe() setCurrentUser(updated) - setStep('status') - setCode('') + onClose() } catch (err: any) { setError(err.message) } finally { @@ -81,63 +94,20 @@ export function AccountModal({ onClose }: { onClose: () => void }) { onClick={e => e.stopPropagation()} >
-

账号管理

+

+ {currentUser?.emailVerified ? '邮箱已绑定' : '绑定邮箱'} +

- {step === 'status' && ( -
-
- 绑定邮箱 - {currentUser?.emailVerified ? ( -
- - {currentUser.email} -
- ) : currentUser?.email ? ( -
-
- - - {currentUser.email}(未验证) - -
- {error &&

{error}

} -
- - · - -
-
- ) : ( - - )} -
+ {currentUser?.emailVerified ? ( +
+ + {currentUser.email}
- )} - - {step === 'enter-email' && ( + ) : step === 'enter-email' ? (
void }) { autoFocus /> {error &&

{error}

} -
- - -
+
- )} - - {step === 'enter-code' && ( + ) : (

- 验证码已发送至 {email || currentUser?.email},10 分钟内有效 + 验证码已发送至 {email},10 分钟内有效

void }) { autoFocus /> {error &&

{error}

} -
+ +
diff --git a/src/components/auth/ProfileModal.tsx b/src/components/auth/ProfileModal.tsx index a206d7e..8b1a8c4 100644 --- a/src/components/auth/ProfileModal.tsx +++ b/src/components/auth/ProfileModal.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' import { X, CheckCircle, Mail, Camera, Key, LogOut, Cloud } from 'lucide-react' import { apiUpdateProfile, apiChangePassword, @@ -30,6 +30,8 @@ export function ProfileModal({ onClose }: { onClose: () => void }) { const [emailCode, setEmailCode] = useState('') const [emailLoading, setEmailLoading] = useState(false) const [emailError, setEmailError] = useState('') + const [resendCountdown, setResendCountdown] = useState(0) + const countdownRef = useRef | null>(null) // 修改密码 const [oldPwd, setOldPwd] = useState('') @@ -38,6 +40,18 @@ export function ProfileModal({ onClose }: { onClose: () => void }) { const [pwdError, setPwdError] = useState('') const [pwdOk, setPwdOk] = useState(false) + useEffect(() => () => { if (countdownRef.current) clearInterval(countdownRef.current) }, []) + + function startCountdown() { + setResendCountdown(60) + countdownRef.current = setInterval(() => { + setResendCountdown(n => { + if (n <= 1) { clearInterval(countdownRef.current!); return 0 } + return n - 1 + }) + }, 1000) + } + const inputStyle: React.CSSProperties = { background: 'var(--bg-muted)', border: '1px solid var(--border)', @@ -102,6 +116,20 @@ export function ProfileModal({ onClose }: { onClose: () => void }) { try { await apiSendEmailVerify(email) setSection('email-code') + startCountdown() + } catch (err: any) { + setEmailError(err.message) + } finally { + setEmailLoading(false) + } + } + + async function handleResendEmailCode() { + setEmailError('') + setEmailLoading(true) + try { + await apiSendEmailVerify(email) + startCountdown() } catch (err: any) { setEmailError(err.message) } finally { @@ -369,7 +397,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) { {section === 'email-code' && (

- 验证码已发送至 {email || currentUser?.email},10 分钟内有效 + 验证码已发送至 {email},10 分钟内有效

void }) { > {emailLoading ? '验证中…' : '验证'} +
)} diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 01a36b6..e1d7d59 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { db, type Note, type Folder } from '../db' import { generateId, extractTextFromJSON } from '../lib/utils' -import { getToken, clearToken, parseToken, type CurrentUser } from '../lib/auth' +import { getToken, clearToken, parseToken, apiGetMe, type CurrentUser } from '../lib/auth' import { pullAll, pushAll, pushNote, pushFolder, pushDeleteNote, pushDeleteFolder, mergeNotes, mergeFolders } from '../lib/sync' interface AppState { @@ -49,6 +49,7 @@ interface AppState { syncStatus: 'idle' | 'syncing' | 'error' setCurrentUser: (user: CurrentUser | null) => void logout: () => void + refreshCurrentUser: () => Promise syncFromCloud: () => Promise } @@ -264,6 +265,20 @@ export const useAppStore = create((set, get) => ({ set({ currentUser: null, syncStatus: 'idle' }) }, + refreshCurrentUser: async () => { + const token = getToken() + if (!token) return + try { + const user = await apiGetMe() + set({ currentUser: user }) + if (user.cloudEnabled) get().syncFromCloud() + } catch { + // token 失效则清除 + clearToken() + set({ currentUser: null }) + } + }, + syncFromCloud: async () => { const { notes, folders, currentUser } = get() if (!currentUser?.cloudEnabled) return