feat: 邮箱绑定、找回密码、个人中心
- 后端新增邮箱绑定/验证、忘记密码/重置密码、修改密码接口 - users 表新增 nickname、avatar 字段,含迁移脚本 - 新增 PUT /api/auth/me 更新头像和昵称 - 新增 POST /api/auth/change-password 修改密码(需旧密码) - 前端新增 ProfileModal 个人中心(头像上传、昵称、邀请码、邮箱绑定、修改密码、退出) - LoginModal 新增忘记密码流程 - UserMenu 点击头像直接打开个人中心 - server/lib/email.ts:nodemailer 邮件发送封装 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f2681cb3de
commit
b864b2903a
+31
-5
@@ -14,11 +14,19 @@ sqlite.pragma('foreign_keys = ON')
|
||||
export const db = drizzle(sqlite)
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
username: text('username').notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
cloudEnabled: integer('cloud_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
id: text('id').primaryKey(),
|
||||
username: text('username').notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
cloudEnabled: integer('cloud_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
nickname: text('nickname'),
|
||||
avatar: text('avatar'),
|
||||
email: text('email'),
|
||||
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
||||
emailVerifyCode: text('email_verify_code'),
|
||||
emailVerifyExpiry: integer('email_verify_expiry'),
|
||||
resetCode: text('reset_code'),
|
||||
resetCodeExpiry: integer('reset_code_expiry'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
})
|
||||
|
||||
export const notes = sqliteTable('notes', {
|
||||
@@ -64,6 +72,12 @@ export function initDb() {
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
cloud_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
email TEXT,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
email_verify_code TEXT,
|
||||
email_verify_expiry INTEGER,
|
||||
reset_code TEXT,
|
||||
reset_code_expiry INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
@@ -108,4 +122,16 @@ export function initDb() {
|
||||
]
|
||||
const insert = sqlite.prepare(`INSERT OR IGNORE INTO invite_codes (code) VALUES (?)`)
|
||||
for (const code of INVITE_CODES) insert.run(code)
|
||||
|
||||
// 迁移:为已有数据库添加邮箱相关字段
|
||||
const cols = sqlite.prepare(`PRAGMA table_info(users)`).all() as { name: string }[]
|
||||
const colNames = cols.map(c => c.name)
|
||||
if (!colNames.includes('email')) sqlite.exec(`ALTER TABLE users ADD COLUMN email TEXT`)
|
||||
if (!colNames.includes('email_verified')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`)
|
||||
if (!colNames.includes('email_verify_code')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verify_code TEXT`)
|
||||
if (!colNames.includes('email_verify_expiry')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verify_expiry INTEGER`)
|
||||
if (!colNames.includes('reset_code')) sqlite.exec(`ALTER TABLE users ADD COLUMN reset_code TEXT`)
|
||||
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`)
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
import 'dotenv/config'
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'node:path'
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') })
|
||||
import { Hono } from 'hono'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { cors } from 'hono/cors'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { initDb } from './db'
|
||||
import { authRouter } from './routes/auth'
|
||||
import { notesRouter } from './routes/notes'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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!,
|
||||
},
|
||||
})
|
||||
|
||||
export async function sendVerifyCode(to: string, code: string) {
|
||||
await transporter.sendMail({
|
||||
from: '"MikiVL 笔记" <mikivl@126.com>',
|
||||
to,
|
||||
subject: '邮箱验证码',
|
||||
text: `你的验证码是:${code},10 分钟内有效。`,
|
||||
html: `<p style="font-family:sans-serif">你的验证码是:<strong style="font-size:1.2em;letter-spacing:0.1em">${code}</strong>,10 分钟内有效。</p>`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendResetCode(to: string, code: string) {
|
||||
await transporter.sendMail({
|
||||
from: '"MikiVL 笔记" <mikivl@126.com>',
|
||||
to,
|
||||
subject: '重置密码验证码',
|
||||
text: `你的重置密码验证码是:${code},10 分钟内有效。如非本人操作请忽略。`,
|
||||
html: `<p style="font-family:sans-serif">你的重置密码验证码是:<strong style="font-size:1.2em;letter-spacing:0.1em">${code}</strong>,10 分钟内有效。如非本人操作请忽略。</p>`,
|
||||
})
|
||||
}
|
||||
+127
-2
@@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db, users, inviteCodes } from '../db'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
import { sendVerifyCode, sendResetCode } from '../lib/email'
|
||||
|
||||
export const authRouter = new Hono()
|
||||
|
||||
@@ -11,6 +12,10 @@ function nanoid() {
|
||||
return Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
function randomCode() {
|
||||
return String(Math.floor(100000 + Math.random() * 900000))
|
||||
}
|
||||
|
||||
authRouter.post('/register', async (c) => {
|
||||
const { username, password } = await c.req.json<{ username: string; password: string }>()
|
||||
if (!username || !password) return c.json({ error: '用户名和密码不能为空' }, 400)
|
||||
@@ -25,7 +30,7 @@ authRouter.post('/register', async (c) => {
|
||||
db.insert(users).values({ id, username, passwordHash, cloudEnabled: false, createdAt: Date.now() }).run()
|
||||
|
||||
const token = jwt.sign({ userId: id, username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
||||
return c.json({ token, user: { id, username, cloudEnabled: false } })
|
||||
return c.json({ token, user: { id, username, cloudEnabled: false, email: null, emailVerified: false, nickname: null, avatar: null } })
|
||||
})
|
||||
|
||||
authRouter.post('/login', async (c) => {
|
||||
@@ -39,7 +44,7 @@ authRouter.post('/login', async (c) => {
|
||||
if (!ok) return c.json({ error: '用户名或密码错误' }, 401)
|
||||
|
||||
const token = jwt.sign({ userId: user.id, username: user.username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
||||
return c.json({ token, user: { id: user.id, username: user.username, cloudEnabled: user.cloudEnabled } })
|
||||
return c.json({ token, user: { id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null } })
|
||||
})
|
||||
|
||||
authRouter.post('/activate', requireAuth, async (c) => {
|
||||
@@ -56,3 +61,123 @@ authRouter.post('/activate', requireAuth, async (c) => {
|
||||
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// 获取当前用户信息
|
||||
authRouter.get('/me', requireAuth, async (c) => {
|
||||
const userId = c.get('userId')
|
||||
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||
return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
|
||||
})
|
||||
|
||||
// 更新个人资料(昵称、头像)
|
||||
authRouter.put('/me', requireAuth, async (c) => {
|
||||
const userId = c.get('userId')
|
||||
const { nickname, avatar } = await c.req.json<{ nickname?: string; avatar?: string }>()
|
||||
const updates: Record<string, any> = {}
|
||||
if (nickname !== undefined) {
|
||||
if (nickname.length > 20) return c.json({ error: '昵称不超过 20 字' }, 400)
|
||||
updates.nickname = nickname.trim() || null
|
||||
}
|
||||
if (avatar !== undefined) {
|
||||
// 限制头像大小(base64 约 100KB)
|
||||
if (avatar && avatar.length > 150000) return c.json({ error: '头像不能超过 100KB' }, 400)
|
||||
updates.avatar = avatar || null
|
||||
}
|
||||
if (Object.keys(updates).length === 0) return c.json({ error: '无更新内容' }, 400)
|
||||
db.update(users).set(updates).where(eq(users.id, userId)).run()
|
||||
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||
return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
|
||||
})
|
||||
|
||||
// 发送邮箱绑定验证码
|
||||
authRouter.post('/email/send-verify', requireAuth, async (c) => {
|
||||
const userId = c.get('userId')
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
try {
|
||||
await sendVerifyCode(email, code)
|
||||
} catch {
|
||||
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// 验证邮箱绑定验证码
|
||||
authRouter.post('/email/verify', requireAuth, async (c) => {
|
||||
const userId = c.get('userId')
|
||||
const { code } = await c.req.json<{ code: string }>()
|
||||
if (!code) return c.json({ error: '请输入验证码' }, 400)
|
||||
|
||||
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||
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()
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// 忘记密码:发送重置验证码
|
||||
authRouter.post('/forgot-password', async (c) => {
|
||||
const { email } = await c.req.json<{ email: string }>()
|
||||
if (!email) return c.json({ error: '请输入邮箱' }, 400)
|
||||
|
||||
const [user] = db.select().from(users).where(eq(users.email, email)).all()
|
||||
// 无论是否找到用户都返回成功,避免枚举攻击
|
||||
if (!user || !user.emailVerified) return c.json({ success: true })
|
||||
|
||||
const code = randomCode()
|
||||
const expiry = Date.now() + 10 * 60 * 1000
|
||||
db.update(users).set({ resetCode: code, resetCodeExpiry: expiry }).where(eq(users.id, user.id)).run()
|
||||
|
||||
try {
|
||||
await sendResetCode(email, code)
|
||||
} catch {
|
||||
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// 重置密码:验证码 + 新密码
|
||||
authRouter.post('/reset-password', async (c) => {
|
||||
const { email, code, newPassword } = await c.req.json<{ email: string; code: string; newPassword: string }>()
|
||||
if (!email || !code || !newPassword) return c.json({ error: '参数不完整' }, 400)
|
||||
if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400)
|
||||
|
||||
const [user] = db.select().from(users).where(eq(users.email, email)).all()
|
||||
if (!user) return c.json({ error: '邮箱未绑定任何账号' }, 404)
|
||||
if (!user.resetCode || user.resetCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
|
||||
if (!user.resetCodeExpiry || Date.now() > user.resetCodeExpiry) return c.json({ error: '验证码已过期,请重新获取' }, 400)
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10)
|
||||
db.update(users).set({ passwordHash, resetCode: null, resetCodeExpiry: null }).where(eq(users.id, user.id)).run()
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// 修改密码(需要旧密码)
|
||||
authRouter.post('/change-password', requireAuth, async (c) => {
|
||||
const userId = c.get('userId')
|
||||
const { oldPassword, newPassword } = await c.req.json<{ oldPassword: string; newPassword: string }>()
|
||||
if (!oldPassword || !newPassword) return c.json({ error: '参数不完整' }, 400)
|
||||
if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400)
|
||||
|
||||
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||
const ok = await bcrypt.compare(oldPassword, user.passwordHash)
|
||||
if (!ok) return c.json({ error: '当前密码错误' }, 400)
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10)
|
||||
db.update(users).set({ passwordHash }).where(eq(users.id, userId)).run()
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user