feat: 初始化 kenaito-notebook 桌面记事本项目
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("邮箱或密码错误")
|
||||
ErrUserExists = errors.New("邮箱已被注册")
|
||||
ErrInvalidToken = errors.New("无效的 token")
|
||||
ErrTokenExpired = errors.New("token 已过期")
|
||||
)
|
||||
|
||||
// JWT 密钥,生产环境应从环境变量或配置文件读取
|
||||
var jwtSecret = []byte("kenaito-notebook-jwt-secret-2026")
|
||||
|
||||
type jwtHeader struct {
|
||||
Alg string `json:"alg"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
|
||||
type jwtPayload struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
Exp int64 `json:"exp"`
|
||||
Iat int64 `json:"iat"`
|
||||
}
|
||||
|
||||
type AuthService struct{}
|
||||
|
||||
func NewAuthService() *AuthService {
|
||||
return &AuthService{}
|
||||
}
|
||||
|
||||
// HashPassword 使用 bcrypt 哈希密码
|
||||
func (s *AuthService) HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码
|
||||
func (s *AuthService) CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GenerateToken 生成 JWT token
|
||||
func (s *AuthService) GenerateToken(userID int64, email string) (string, error) {
|
||||
now := time.Now()
|
||||
payload := jwtPayload{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Exp: now.Add(7 * 24 * time.Hour).Unix(), // 7 天过期
|
||||
Iat: now.Unix(),
|
||||
}
|
||||
|
||||
// Header
|
||||
header := jwtHeader{Alg: "HS256", Typ: "JWT"}
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
|
||||
// Payload
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||||
|
||||
// 签名
|
||||
signingInput := headerB64 + "." + payloadB64
|
||||
mac := hmac.New(sha256.New, jwtSecret)
|
||||
mac.Write([]byte(signingInput))
|
||||
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return signingInput + "." + signature, nil
|
||||
}
|
||||
|
||||
// ValidateToken 验证 JWT token,返回 payload
|
||||
func (s *AuthService) ValidateToken(tokenStr string) (*jwtPayload, error) {
|
||||
parts := strings.Split(tokenStr, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
signingInput := parts[0] + "." + parts[1]
|
||||
|
||||
// 验证签名
|
||||
mac := hmac.New(sha256.New, jwtSecret)
|
||||
mac.Write([]byte(signingInput))
|
||||
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
if !hmac.Equal([]byte(parts[2]), []byte(expectedSig)) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// 解析 payload
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
var payload jwtPayload
|
||||
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// 检查过期
|
||||
if time.Now().Unix() > payload.Exp {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
// ExtractTokenID 从 token 字符串中提取用户 ID(不验证签名,仅用于快速检查)
|
||||
func ExtractTokenID(token string) (int64, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return 0, fmt.Errorf("invalid token format")
|
||||
}
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var payload jwtPayload
|
||||
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return payload.UserID, nil
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"kenaito-notebook-server/models"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewDatabase() (*Database, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
dbDir := filepath.Join(homeDir, ".kenaito-notebook")
|
||||
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(dbDir, "notes.db")
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
|
||||
}
|
||||
|
||||
if err := createTables(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Database{db: db}, nil
|
||||
}
|
||||
|
||||
func createTables(db *sql.DB) error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
parent_id INTEGER DEFAULT 0,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT DEFAULT '',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_folder BOOLEAN DEFAULT FALSE,
|
||||
tag TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_parent_id ON notes(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_sort_order ON notes(sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
hashed_password TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
// 迁移:给已有表加 deleted_at 列
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name='deleted_at'").Scan(&count)
|
||||
if err == nil && count == 0 {
|
||||
_, err = db.Exec("ALTER TABLE notes ADD COLUMN deleted_at DATETIME DEFAULT NULL")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移:给已有表加 user_id 列
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name='user_id'").Scan(&count)
|
||||
if err == nil && count == 0 {
|
||||
_, err = db.Exec("ALTER TABLE notes ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.Exec(query)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
// --- 笔记相关方法 ---
|
||||
|
||||
func (d *Database) CreateNote(userID int64, parentID int64, title string, isFolder bool) (*models.Note, error) {
|
||||
now := time.Now()
|
||||
result, err := d.db.Exec(`
|
||||
INSERT INTO notes (user_id, parent_id, title, is_folder, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, userID, parentID, title, isFolder, now, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.Note{
|
||||
ID: id,
|
||||
ParentID: parentID,
|
||||
Title: title,
|
||||
IsFolder: isFolder,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetNote(id int64) (*models.Note, error) {
|
||||
note := &models.Note{}
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes WHERE id = ?
|
||||
`, id).Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return note, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetNotesByParentID(userID, parentID int64) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes WHERE user_id = ? AND parent_id = ? AND deleted_at IS NULL
|
||||
ORDER BY sort_order, created_at
|
||||
`, userID, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllNotes(userID int64) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes WHERE user_id = ? AND deleted_at IS NULL AND is_folder = FALSE
|
||||
ORDER BY sort_order, created_at
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllNotesWithFolders(userID int64) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes WHERE user_id = ? AND deleted_at IS NULL
|
||||
ORDER BY sort_order, created_at
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetFolderTree(userID, folderID int64) (*models.Note, error) {
|
||||
folder, err := d.GetNote(folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
children, err := d.GetNotesByParentID(userID, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
folder.Children = make([]*models.Note, len(children))
|
||||
for i, child := range children {
|
||||
if child.IsFolder {
|
||||
childFolder, err := d.GetFolderTree(userID, child.ID)
|
||||
if err == nil {
|
||||
folder.Children[i] = childFolder
|
||||
} else {
|
||||
folder.Children[i] = child
|
||||
}
|
||||
} else {
|
||||
folder.Children[i] = child
|
||||
}
|
||||
}
|
||||
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllNotesTree(userID int64) ([]*models.NoteTree, error) {
|
||||
notes, err := d.GetAllNotesWithFolders(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildTree(notes, 0), nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateNote(id int64, title, content, tag string) error {
|
||||
now := time.Now()
|
||||
_, err := d.db.Exec(`
|
||||
UPDATE notes SET title = ?, content = ?, tag = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, title, content, tag, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) DeleteNote(id int64) error {
|
||||
now := time.Now()
|
||||
|
||||
err := d.softDeleteChildren(id, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = d.db.Exec("UPDATE notes SET deleted_at = ? WHERE id = ?", now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) softDeleteChildren(parentID int64, deletedAt time.Time) error {
|
||||
rows, err := d.db.Query("SELECT id FROM notes WHERE parent_id = ? AND deleted_at IS NULL", parentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.softDeleteChildren(id, deletedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = d.db.Exec("UPDATE notes SET deleted_at = ? WHERE parent_id = ? AND deleted_at IS NULL", deletedAt, parentID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetNoteTree(userID int64) ([]*models.NoteTree, error) {
|
||||
notes, err := d.getAllNotes(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildTree(notes, 0), nil
|
||||
}
|
||||
|
||||
func (d *Database) getAllNotes(userID int64) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
ORDER BY sort_order, created_at
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func buildTree(notes []*models.Note, parentID int64) []*models.NoteTree {
|
||||
var result []*models.NoteTree
|
||||
for _, note := range notes {
|
||||
if note.ParentID == parentID {
|
||||
node := &models.NoteTree{
|
||||
Note: *note,
|
||||
}
|
||||
node.Children = buildTree(notes, note.ID)
|
||||
result = append(result, node)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *Database) SearchNotes(userID int64, keyword string) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes
|
||||
WHERE user_id = ? AND (title LIKE ? OR content LIKE ?) AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC
|
||||
`, userID, "%"+keyword+"%", "%"+keyword+"%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateSortOrder(id int64, sortOrder int) error {
|
||||
_, err := d.db.Exec("UPDATE notes SET sort_order = ? WHERE id = ?", sortOrder, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetTrashNotes(userID int64) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes
|
||||
WHERE user_id = ? AND deleted_at IS NOT NULL
|
||||
ORDER BY deleted_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func (d *Database) RestoreNote(id int64) error {
|
||||
note, err := d.GetNote(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if note.IsFolder {
|
||||
err = d.restoreChildrenRecursive(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = d.db.Exec("UPDATE notes SET deleted_at = NULL WHERE id = ?", id)
|
||||
} else {
|
||||
_, err = d.db.Exec("UPDATE notes SET deleted_at = NULL, parent_id = 0 WHERE id = ?", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) restoreChildrenRecursive(parentID int64) error {
|
||||
var parentDeletedAt *time.Time
|
||||
err := d.db.QueryRow("SELECT deleted_at FROM notes WHERE id = ?", parentID).Scan(&parentDeletedAt)
|
||||
if err != nil || parentDeletedAt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := d.db.Query(
|
||||
"SELECT id, is_folder FROM notes WHERE parent_id = ? AND deleted_at = ?",
|
||||
parentID, *parentDeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type childInfo struct {
|
||||
id int64
|
||||
isFolder bool
|
||||
}
|
||||
var children []childInfo
|
||||
|
||||
for rows.Next() {
|
||||
var child childInfo
|
||||
if err := rows.Scan(&child.id, &child.isFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
children = append(children, child)
|
||||
}
|
||||
|
||||
for _, child := range children {
|
||||
if child.isFolder {
|
||||
if err := d.restoreChildrenRecursive(child.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(children) > 0 {
|
||||
_, err = d.db.Exec("UPDATE notes SET deleted_at = NULL WHERE parent_id = ? AND deleted_at = ?", parentID, *parentDeletedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) PermanentDeleteNote(id int64) error {
|
||||
err := d.permanentDeleteChildren(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = d.db.Exec("DELETE FROM notes WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) permanentDeleteChildren(parentID int64) error {
|
||||
rows, err := d.db.Query("SELECT id FROM notes WHERE parent_id = ?", parentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.permanentDeleteChildren(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = d.db.Exec("DELETE FROM notes WHERE parent_id = ?", parentID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) EmptyTrash(userID int64) error {
|
||||
_, err := d.db.Exec("DELETE FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL", userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- 用户相关方法 ---
|
||||
|
||||
type UserRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
HashedPassword string `json:"-"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (d *Database) CreateUser(email, hashedPassword string) (*UserRecord, error) {
|
||||
now := time.Now()
|
||||
result, err := d.db.Exec(`
|
||||
INSERT INTO users (email, hashed_password, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`, email, hashedPassword, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserRecord{
|
||||
ID: id,
|
||||
HashedPassword: hashedPassword,
|
||||
Email: email,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserByEmail(email string) (*UserRecord, error) {
|
||||
user := &UserRecord{}
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, hashed_password, email, created_at
|
||||
FROM users WHERE email = ?
|
||||
`, email).Scan(&user.ID, &user.HashedPassword, &user.Email, &user.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserByID(id int64) (*UserRecord, error) {
|
||||
user := &UserRecord{}
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, hashed_password, email, created_at
|
||||
FROM users WHERE id = ?
|
||||
`, id).Scan(&user.ID, &user.HashedPassword, &user.Email, &user.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdatePassword(id int64, hashedPassword string) error {
|
||||
_, err := d.db.Exec("UPDATE users SET hashed_password = ? WHERE id = ?", hashedPassword, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type EmailConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
type EmailService struct {
|
||||
cfg EmailConfig
|
||||
configured bool
|
||||
}
|
||||
|
||||
func NewEmailService(cfg EmailConfig) *EmailService {
|
||||
configured := cfg.Host != "" && cfg.Username != "" && cfg.Password != ""
|
||||
if !configured {
|
||||
log.Println("[Email] SMTP not configured, will print codes to console")
|
||||
}
|
||||
return &EmailService{cfg: cfg, configured: configured}
|
||||
}
|
||||
|
||||
// SendVerificationCode 发送验证码邮件
|
||||
func (s *EmailService) SendVerificationCode(to, code, purpose string) error {
|
||||
var subject, body string
|
||||
switch purpose {
|
||||
case "register":
|
||||
subject = "Kenaito Notebook - 注册验证码"
|
||||
body = fmt.Sprintf(`
|
||||
<div style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<h2 style="color: #333;">Kenaito Notebook</h2>
|
||||
<p>你正在注册账号,验证码如下:</p>
|
||||
<div style="background: #f5f7fa; padding: 16px; border-radius: 8px; text-align: center; margin: 20px 0;">
|
||||
<span style="font-size: 32px; font-weight: bold; color: #409eff; letter-spacing: 8px;">%s</span>
|
||||
</div>
|
||||
<p style="color: #909399; font-size: 14px;">验证码 5 分钟内有效,请勿泄露给他人。</p>
|
||||
</div>`, code)
|
||||
case "reset":
|
||||
subject = "Kenaito Notebook - 重置密码验证码"
|
||||
body = fmt.Sprintf(`
|
||||
<div style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<h2 style="color: #333;">Kenaito Notebook</h2>
|
||||
<p>你正在重置密码,验证码如下:</p>
|
||||
<div style="background: #f5f7fa; padding: 16px; border-radius: 8px; text-align: center; margin: 20px 0;">
|
||||
<span style="font-size: 32px; font-weight: bold; color: #e6a23c; letter-spacing: 8px;">%s</span>
|
||||
</div>
|
||||
<p style="color: #909399; font-size: 14px;">验证码 5 分钟内有效,请勿泄露给他人。</p>
|
||||
</div>`, code)
|
||||
default:
|
||||
return fmt.Errorf("unknown purpose: %s", purpose)
|
||||
}
|
||||
|
||||
if !s.configured {
|
||||
log.Printf("[Email] SMTP not configured. Verification code for %s (%s): %s", to, purpose, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.send(to, subject, body)
|
||||
}
|
||||
|
||||
func (s *EmailService) send(to, subject, htmlBody string) error {
|
||||
addr := net.JoinHostPort(s.cfg.Host, s.cfg.Port)
|
||||
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = s.cfg.From
|
||||
headers["To"] = to
|
||||
headers["Subject"] = subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/html; charset=UTF-8"
|
||||
|
||||
var msg strings.Builder
|
||||
for k, v := range headers {
|
||||
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
||||
}
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(htmlBody)
|
||||
|
||||
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
|
||||
|
||||
// SSL connection (port 465)
|
||||
if s.cfg.Port == "465" {
|
||||
return s.sendTLS(addr, auth, to, msg.String())
|
||||
}
|
||||
|
||||
// STARTTLS connection (port 587)
|
||||
return smtp.SendMail(addr, auth, s.cfg.From, []string{to}, []byte(msg.String()))
|
||||
}
|
||||
|
||||
func (s *EmailService) sendTLS(addr string, auth smtp.Auth, to, msg string) error {
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{
|
||||
ServerName: s.cfg.Host,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, s.cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP auth failed: %w", err)
|
||||
}
|
||||
if err = client.Mail(s.cfg.From); err != nil {
|
||||
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
|
||||
}
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP DATA failed: %w", err)
|
||||
}
|
||||
if _, err = w.Write([]byte(msg)); err != nil {
|
||||
return fmt.Errorf("SMTP write failed: %w", err)
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("SMTP close data failed: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
type ExportService struct{}
|
||||
|
||||
func NewExportService() *ExportService {
|
||||
return &ExportService{}
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportHTML(title, content, outputPath string) error {
|
||||
var htmlContent strings.Builder
|
||||
if err := goldmark.Convert([]byte(content), &htmlContent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
htmlTemplate := `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
h1 { border-bottom: 2px solid #eee; padding-bottom: 10px; }
|
||||
code {
|
||||
background-color: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: "Consolas", "Monaco", monospace;
|
||||
}
|
||||
pre {
|
||||
background-color: #f8f8f8;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid #ddd;
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
color: #666;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
a {
|
||||
color: #0066cc;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{.Title}}</h1>
|
||||
{{.Content}}
|
||||
<hr>
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
导出于 {{.ExportTime}} | Kenaito Notebook
|
||||
</p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
tmpl, err := template.New("html").Parse(htmlTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
return tmpl.Execute(file, map[string]string{
|
||||
"Title": title,
|
||||
"Content": htmlContent.String(),
|
||||
"ExportTime": fmt.Sprintf("%s", time.Now().Format("2006-01-02 15:04:05")),
|
||||
})
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportMarkdown(title, content, outputPath string) error {
|
||||
fullContent := fmt.Sprintf("# %s\n\n%s", title, content)
|
||||
return os.WriteFile(outputPath, []byte(fullContent), 0644)
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportFolderMarkdown(folderTitle string, notes []NoteInfo, outputPath string) error {
|
||||
var content strings.Builder
|
||||
content.WriteString(fmt.Sprintf("# %s\n\n", folderTitle))
|
||||
|
||||
for _, note := range notes {
|
||||
content.WriteString(fmt.Sprintf("## %s\n\n", note.Title))
|
||||
content.WriteString(note.Content)
|
||||
content.WriteString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
return os.WriteFile(outputPath, []byte(content.String()), 0644)
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportAllMarkdown(notes []NoteInfo, outputPath string) error {
|
||||
var content strings.Builder
|
||||
content.WriteString("# 所有笔记\n\n")
|
||||
|
||||
for _, note := range notes {
|
||||
content.WriteString(fmt.Sprintf("## %s\n\n", note.Title))
|
||||
content.WriteString(note.Content)
|
||||
content.WriteString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
return os.WriteFile(outputPath, []byte(content.String()), 0644)
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportFolderZip(folderTitle string, notes []*NoteInfo, outputPath string) error {
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
w := zip.NewWriter(file)
|
||||
defer w.Close()
|
||||
|
||||
rootFolder := es.GetSafeFilename(folderTitle) + "/"
|
||||
_, err = w.Create(rootFolder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
es.addNotesToZip(w, notes, rootFolder)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportAllZip(notes []*NoteInfo, outputPath string) error {
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
w := zip.NewWriter(file)
|
||||
defer w.Close()
|
||||
|
||||
rootFolder := "所有笔记/"
|
||||
_, err = w.Create(rootFolder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
es.addNotesToZip(w, notes, rootFolder)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ExportService) addNotesToZip(w *zip.Writer, notes []*NoteInfo, parentPath string) {
|
||||
for _, note := range notes {
|
||||
safeName := es.GetSafeFilename(note.Title)
|
||||
|
||||
if note.IsFolder {
|
||||
folderPath := parentPath + safeName + "/"
|
||||
_, _ = w.Create(folderPath)
|
||||
|
||||
if note.Children != nil {
|
||||
es.addNotesToZip(w, note.Children, folderPath)
|
||||
}
|
||||
} else {
|
||||
filePath := parentPath + safeName + ".md"
|
||||
fullContent := fmt.Sprintf("# %s\n\n%s", note.Title, note.Content)
|
||||
|
||||
f, err := w.Create(filePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, _ = f.Write([]byte(fullContent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type NoteInfo struct {
|
||||
Title string
|
||||
Content string
|
||||
IsFolder bool
|
||||
Children []*NoteInfo
|
||||
}
|
||||
|
||||
func (es *ExportService) GetDefaultExportPath(title, ext string) string {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
exportDir := filepath.Join(homeDir, "Desktop")
|
||||
safeName := es.GetSafeFilename(title)
|
||||
return filepath.Join(exportDir, fmt.Sprintf("%s%s", safeName, ext))
|
||||
}
|
||||
|
||||
func (es *ExportService) GetSafeFilename(title string) string {
|
||||
safeName := strings.ReplaceAll(title, "/", "-")
|
||||
safeName = strings.ReplaceAll(safeName, "\\", "-")
|
||||
safeName = strings.ReplaceAll(safeName, ":", "-")
|
||||
safeName = strings.ReplaceAll(safeName, "*", "")
|
||||
safeName = strings.ReplaceAll(safeName, "?", "")
|
||||
safeName = strings.ReplaceAll(safeName, "\"", "")
|
||||
safeName = strings.ReplaceAll(safeName, "<", "")
|
||||
safeName = strings.ReplaceAll(safeName, ">", "")
|
||||
safeName = strings.ReplaceAll(safeName, "|", "")
|
||||
|
||||
if strings.TrimSpace(safeName) == "" {
|
||||
safeName = "未命名笔记"
|
||||
}
|
||||
|
||||
return safeName
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const MaxFileSize = 6 * 1024 * 1024 // 6MB
|
||||
|
||||
type FileService struct {
|
||||
dataDir string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewFileService(baseURL string) (*FileService, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dataDir := filepath.Join(homeDir, ".kenaito-notebook", "assets")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("[FileService] Assets directory: %s", dataDir)
|
||||
log.Printf("[FileService] Base URL: %s", baseURL)
|
||||
|
||||
return &FileService{
|
||||
dataDir: dataDir,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fs *FileService) GetBaseURL() string {
|
||||
return fs.baseURL
|
||||
}
|
||||
|
||||
func (fs *FileService) SaveImage(base64Data string) (string, error) {
|
||||
parts := strings.Split(base64Data, ",")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid base64 format")
|
||||
}
|
||||
|
||||
header := parts[0]
|
||||
ext := ".png"
|
||||
if strings.Contains(header, "jpeg") || strings.Contains(header, "jpg") {
|
||||
ext = ".jpg"
|
||||
} else if strings.Contains(header, "gif") {
|
||||
ext = ".gif"
|
||||
} else if strings.Contains(header, "webp") {
|
||||
ext = ".webp"
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if int64(len(data)) > MaxFileSize {
|
||||
return "", fmt.Errorf("文件大小超过 6MB 限制")
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s%s", uuid.New().String()[:8], ext)
|
||||
|
||||
dateDir := time.Now().Format("2006/01")
|
||||
dirPath := filepath.Join(fs.dataDir, dateDir)
|
||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dirPath, filename)
|
||||
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/api/files/assets/%s/%s", fs.baseURL, dateDir, filename), nil
|
||||
}
|
||||
|
||||
func (fs *FileService) SaveFile(base64Data, filename string) (string, error) {
|
||||
parts := strings.Split(base64Data, ",")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid base64 format")
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if int64(len(data)) > MaxFileSize {
|
||||
return "", fmt.Errorf("文件大小超过 6MB 限制")
|
||||
}
|
||||
|
||||
ext := filepath.Ext(filename)
|
||||
nameWithoutExt := strings.TrimSuffix(filename, ext)
|
||||
uniqueFilename := fmt.Sprintf("%s_%s%s", nameWithoutExt, uuid.New().String()[:8], ext)
|
||||
|
||||
dateDir := time.Now().Format("2006/01")
|
||||
dirPath := filepath.Join(fs.dataDir, dateDir)
|
||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dirPath, uniqueFilename)
|
||||
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/api/files/assets/%s/%s", fs.baseURL, dateDir, uniqueFilename), nil
|
||||
}
|
||||
|
||||
func (fs *FileService) GetAssetsPath() string {
|
||||
return fs.dataDir
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type verificationEntry struct {
|
||||
code string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type VerificationService struct {
|
||||
mu sync.RWMutex
|
||||
codes map[string]*verificationEntry // key: "email:purpose"
|
||||
times map[string]time.Time // key: "email:purpose" — 上次发送时间
|
||||
}
|
||||
|
||||
func NewVerificationService() *VerificationService {
|
||||
v := &VerificationService{
|
||||
codes: make(map[string]*verificationEntry),
|
||||
times: make(map[string]time.Time),
|
||||
}
|
||||
// 定期清理过期验证码
|
||||
go v.cleanup()
|
||||
return v
|
||||
}
|
||||
|
||||
// GenerateCode 生成 6 位随机验证码
|
||||
func (v *VerificationService) GenerateCode(email, purpose string) string {
|
||||
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
||||
key := email + ":" + purpose
|
||||
|
||||
v.mu.Lock()
|
||||
v.codes[key] = &verificationEntry{
|
||||
code: code,
|
||||
expiresAt: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
v.times[key] = time.Now()
|
||||
v.mu.Unlock()
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// CheckRateLimit 检查是否在 60 秒限频内
|
||||
func (v *VerificationService) CheckRateLimit(email, purpose string) bool {
|
||||
key := email + ":" + purpose
|
||||
v.mu.RLock()
|
||||
last, exists := v.times[key]
|
||||
v.mu.RUnlock()
|
||||
|
||||
if exists && time.Since(last) < 60*time.Second {
|
||||
return false // 被限频
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// VerifyCode 验证验证码(不消耗,仅校验)
|
||||
func (v *VerificationService) VerifyCode(email, code, purpose string) error {
|
||||
key := email + ":" + purpose
|
||||
v.mu.RLock()
|
||||
entry, exists := v.codes[key]
|
||||
v.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("验证码不存在或已过期")
|
||||
}
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return fmt.Errorf("验证码已过期")
|
||||
}
|
||||
if entry.code != code {
|
||||
return fmt.Errorf("验证码错误")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UseCode 验证并消耗验证码(验证通过后删除)
|
||||
func (v *VerificationService) UseCode(email, code, purpose string) error {
|
||||
key := email + ":" + purpose
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
entry, exists := v.codes[key]
|
||||
if !exists {
|
||||
return fmt.Errorf("验证码不存在或已过期")
|
||||
}
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
delete(v.codes, key)
|
||||
return fmt.Errorf("验证码已过期")
|
||||
}
|
||||
if entry.code != code {
|
||||
return fmt.Errorf("验证码错误")
|
||||
}
|
||||
|
||||
delete(v.codes, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanup 定期清理过期验证码
|
||||
func (v *VerificationService) cleanup() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
v.mu.Lock()
|
||||
now := time.Now()
|
||||
for key, entry := range v.codes {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(v.codes, key)
|
||||
}
|
||||
}
|
||||
v.mu.Unlock()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user