MikiVL b864b2903a 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>
2026-05-05 14:01:23 +08:00

138 lines
5.4 KiB
TypeScript

import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import path from 'path'
import fs from 'fs'
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), 'data', 'app.db')
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
const sqlite = new Database(DB_PATH)
sqlite.pragma('journal_mode = WAL')
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),
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', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
title: text('title').notNull().default(''),
content: text('content').notNull().default(''),
folderId: text('folder_id'),
tags: text('tags').notNull().default('[]'),
starred: integer('starred', { mode: 'boolean' }).notNull().default(false),
wordCount: integer('word_count').notNull().default(0),
deletedAt: integer('deleted_at'),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
})
export const folders = sqliteTable('folders', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
name: text('name').notNull(),
parentId: text('parent_id'),
order: integer('order').notNull().default(0),
createdAt: integer('created_at').notNull(),
})
export const comments = sqliteTable('comments', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
content: text('content').notNull(),
createdAt: integer('created_at').notNull(),
})
export const inviteCodes = sqliteTable('invite_codes', {
code: text('code').primaryKey(),
usedByUserId: text('used_by_user_id'),
usedAt: integer('used_at'),
})
export function initDb() {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
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 (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
folder_id TEXT,
tags TEXT NOT NULL DEFAULT '[]',
starred INTEGER NOT NULL DEFAULT 0,
word_count INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
parent_id TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS invite_codes (
code TEXT PRIMARY KEY,
used_by_user_id TEXT,
used_at INTEGER
);
`)
const INVITE_CODES = [
'MIKI-A7X2-KP9Q', 'MIKI-B3N8-WR4L', 'MIKI-C6M1-ZT7Y',
'MIKI-D9F5-HJ2V', 'MIKI-E4K3-QN6U', 'MIKI-F8R7-XG1S',
'MIKI-G2W4-LB5E', 'MIKI-H5T9-MC8D', 'MIKI-J1P6-VF3O',
'MIKI-K7Q2-YH4N',
]
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`)
}