feat: 初始化 kenaito-notebook 桌面记事本项目

This commit is contained in:
2026-06-13 21:08:00 +08:00
commit 5fafcd28e3
55 changed files with 9722 additions and 0 deletions
+142
View File
@@ -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
}
+581
View File
@@ -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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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
}
+136
View File
@@ -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()
}
+247
View File
@@ -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
}
+122
View File
@@ -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
}
+116
View File
@@ -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()
}
}