Files
note-manager/repository/note_repository.go
T
Your Name 74fa759274 feat: 云笔记增强 - 安全加固 + 回收站/版本历史/分享/批量导出 + 前端优化
- 安全: 认证改随机token会话(弃固定cookie), 笔记密码SHA256升级为bcrypt(自动迁移),
  堵住GET /api/notes/:id泄露带密码笔记, CORS收紧+SameSite防CSRF, 上传图片内容嗅探
- 回收站: 软删除(deleted_at), 列表/恢复/彻底删除/清空, 目录子树连删连恢复
- 版本历史: note_versions表存快照, 每次保存自动留档, 支持查看/回滚
- 分享: 生成随机token分享链接, 支持过期时间, 公开阅读页share.html
- 批量导出: 全部笔记打包zip(按目录结构+front matter)
- 前端: 深色模式, Mermaid图表, 待办清单checkbox, 字数统计;
  后台新增回收站/历史/分享面板和批量导出按钮
- 新增deploy/note-manager.service systemd单元与smoke_test.py
2026-08-11 11:21:29 +08:00

399 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"errors"
"fmt"
"os"
"path/filepath"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"note-manager/model"
)
// NoteRepository 笔记数据访问层
type NoteRepository struct {
db *gorm.DB
}
// NewNoteRepository 创建数据仓库实例,初始化数据库
func NewNoteRepository(dbPath string) (*NoteRepository, error) {
// 确保数据库目录存在
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败: %w", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
// 开启外键约束(SQLite 默认关闭)
db.Exec("PRAGMA foreign_keys = ON")
// 自动迁移表结构
if err := db.AutoMigrate(&model.Note{}, &model.NoteVersion{}); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
return &NoteRepository{db: db}, nil
}
// Create 创建笔记
func (r *NoteRepository) Create(note *model.Note) error {
res := r.db.Select("Title", "Content", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
return res.Error
}
// GetByID 根据 ID 获取笔记(排除已删除)
func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
var note model.Note
err := r.db.First(&note, id).Error
if err != nil {
return nil, err
}
return &note, nil
}
// GetByIDIncludingDeleted 获取笔记(包含已软删除的,用于回收站恢复)
func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
var note model.Note
err := r.db.Unscoped().First(&note, id).Error
if err != nil {
return nil, err
}
return &note, nil
}
// Update 更新笔记
func (r *NoteRepository) Update(note *model.Note) error {
return r.db.Save(note).Error
}
// UpdateFields 按字段更新(避免 Save 覆盖所有字段)
func (r *NoteRepository) UpdateFields(id uint, fields map[string]interface{}) error {
return r.db.Model(&model.Note{}).Where("id = ?", id).Updates(fields).Error
}
// Delete 软删除笔记
func (r *NoteRepository) Delete(id uint) error {
return r.db.Delete(&model.Note{}, id).Error
}
// ListQuery 列表查询参数
type ListQuery struct {
Page int
PageSize int
Category string
Tag string
Pinned *bool
Favorite *bool
ParentID *uint // 父目录 IDnil 表示所有
}
// List 获取笔记列表(分页)
func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error) {
var items []model.NoteListItem
var total int64
query := r.db.Model(&model.Note{})
if q.Category != "" {
query = query.Where("category = ?", q.Category)
}
if q.Tag != "" {
query = query.Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", q.Tag))
}
if q.Pinned != nil {
query = query.Where("is_pinned = ?", *q.Pinned)
}
if q.Favorite != nil {
query = query.Where("is_favorite = ?", *q.Favorite)
}
if q.ParentID != nil {
query = query.Where("parent_id = ?", *q.ParentID)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (q.Page - 1) * q.PageSize
err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, updated_at DESC").
Offset(offset).
Limit(q.PageSize).
Find(&items).Error
return items, total, err
}
// GetAllTree 获取所有笔记的树形结构(含分享信息)
func (r *NoteRepository) GetAllTree() ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, share_token, share_expire_at, visit_count, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error
return items, err
}
// GetPublicTree 获取公开可见的树形结构(前台用,不含分享令牌)
func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error
return items, err
}
// GetByParentID 获取指定父目录下的所有项目(排除已删除)
func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("parent_id = ?", parentID).
Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error
return items, err
}
// GetChildrenCount 获取子项数量(排除已删除)
func (r *NoteRepository) GetChildrenCount(parentID uint) (int64, error) {
var count int64
err := r.db.Model(&model.Note{}).Where("parent_id = ?", parentID).Count(&count).Error
return count, err
}
// DeleteWithChildren 软删除目录及其下所有内容
func (r *NoteRepository) DeleteWithChildren(id uint) error {
// 递归收集所有后代 id
ids := r.collectDescendants(id)
var err error
if len(ids) > 0 {
err = r.db.Delete(&model.Note{}, ids).Error
} else {
err = r.db.Delete(&model.Note{}, id).Error
}
return err
}
// collectDescendants 收集目录的所有后代 ID(含自身)
func (r *NoteRepository) collectDescendants(id uint) []uint {
ids := []uint{id}
queue := []uint{id}
for len(queue) > 0 {
parent := queue[0]
queue = queue[1:]
var children []uint
if err := r.db.Model(&model.Note{}).Where("parent_id = ? AND is_folder = ?", parent, true).Pluck("id", &children).Error; err != nil {
continue
}
for _, c := range children {
ids = append(ids, c)
queue = append(queue, c)
}
}
return ids
}
// ListTrash 回收站列表(只含已软删除项)
func (r *NoteRepository) ListTrash() ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Unscoped().Model(&model.Note{}).
Where("deleted_at IS NOT NULL").
Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("deleted_at DESC").
Find(&items).Error
return items, err
}
// Restore 从回收站恢复(连带恢复已被软删除的父目录路径不需要特殊处理)
func (r *NoteRepository) Restore(id uint) error {
// 恢复自身
if err := r.db.Unscoped().Model(&model.Note{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
return err
}
// 若其祖先目录也被软删除,一并恢复
var note model.Note
if err := r.db.Unscoped().First(&note, id).Error; err == nil && note.ParentID != 0 {
var parent model.Note
if err := r.db.Unscoped().First(&parent, note.ParentID).Error; err == nil && !parent.DeletedAt.Time.IsZero() {
_ = r.db.Unscoped().Model(&model.Note{}).Where("id = ?", parent.ID).Update("deleted_at", nil).Error
}
}
return nil
}
// RestoreSubtree 恢复整个子树(含所有已软删除后代),返回恢复的节点数
func (r *NoteRepository) RestoreSubtree(rootID uint) error {
// 收集整棵子树所有节点 id(Unscoped,含已删除)
var ids []uint
queue := []uint{rootID}
for len(queue) > 0 {
parent := queue[0]
queue = queue[1:]
var children []uint
if err := r.db.Unscoped().Model(&model.Note{}).Where("parent_id = ?", parent).Pluck("id", &children).Error; err != nil {
return err
}
ids = append(ids, children...)
queue = append(queue, children...)
}
// 统一恢复所有节点(含自身)
ids = append(ids, rootID)
return r.db.Unscoped().Model(&model.Note{}).Where("id IN ?", ids).Update("deleted_at", nil).Error
}
// HardDelete 彻底删除(不可恢复)
func (r *NoteRepository) HardDelete(id uint) error {
return r.db.Unscoped().Delete(&model.Note{}, id).Error
}
// HardDeleteWithChildren 彻底删除目录及所有后代
func (r *NoteRepository) HardDeleteWithChildren(id uint) error {
ids := r.collectDescendantsIncludingDeleted(id)
ids = append(ids, id)
return r.db.Unscoped().Delete(&model.Note{}, ids).Error
}
func (r *NoteRepository) collectDescendantsIncludingDeleted(id uint) []uint {
var ids []uint
queue := []uint{id}
for len(queue) > 0 {
parent := queue[0]
queue = queue[1:]
var children []uint
if err := r.db.Unscoped().Model(&model.Note{}).Where("parent_id = ? AND is_folder = ?", parent, true).Pluck("id", &children).Error; err != nil {
continue
}
for _, c := range children {
ids = append(ids, c)
queue = append(queue, c)
}
}
return ids
}
// Search 搜索笔记(按标题和内容)
func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
var items []model.NoteListItem
var total int64
like := "%" + keyword + "%"
query := r.db.Model(&model.Note{}).Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("is_pinned DESC, updated_at DESC").
Offset(offset).
Limit(pageSize).
Find(&items).Error
return items, total, err
}
// GetCategories 获取所有分类(排除已删除和目录)
func (r *NoteRepository) GetCategories() ([]string, error) {
var categories []string
err := r.db.Model(&model.Note{}).
Distinct("category").
Where("category != '' AND is_folder = ?", false).
Pluck("category", &categories).Error
return categories, err
}
// GetTags 获取所有标签(排除已删除和目录)
func (r *NoteRepository) GetTags() ([]string, error) {
var tagsJSON []string
err := r.db.Model(&model.Note{}).
Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false).
Pluck("tags", &tagsJSON).Error
if err != nil {
return nil, err
}
// 去重
seen := make(map[string]bool)
var result []string
for _, t := range tagsJSON {
if !seen[t] {
seen[t] = true
result = append(result, t)
}
}
return result, nil
}
// ─────────────── 版本历史 ───────────────
// SaveVersion 保存笔记新版本快照
func (r *NoteRepository) SaveVersion(note *model.Note) (*model.NoteVersion, error) {
v := &model.NoteVersion{
NoteID: note.ID,
Title: note.Title,
Content: note.Content,
Category: note.Category,
Tags: note.Tags,
}
if err := r.db.Create(v).Error; err != nil {
return nil, err
}
return v, nil
}
// ListVersions 获取笔记的所有版本(按时间倒序)
func (r *NoteRepository) ListVersions(noteID uint) ([]model.NoteVersion, error) {
var versions []model.NoteVersion
err := r.db.Where("note_id = ?", noteID).Order("created_at DESC").Find(&versions).Error
return versions, err
}
// GetVersion 获取指定版本
func (r *NoteRepository) GetVersion(id uint) (*model.NoteVersion, error) {
var v model.NoteVersion
err := r.db.First(&v, id).Error
if err != nil {
return nil, err
}
return &v, nil
}
// DeleteVersions 删除某笔记的全部版本
func (r *NoteRepository) DeleteVersions(noteID uint) error {
return r.db.Where("note_id = ?", noteID).Delete(&model.NoteVersion{}).Error
}
// ─────────────── 分享 ───────────────
// GetByShareToken 通过分享令牌获取笔记(含已删除?分享的笔记不应是已删除的,排除)
func (r *NoteRepository) GetByShareToken(token string) (*model.Note, error) {
var note model.Note
err := r.db.Where("share_token = ?", token).First(&note).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("分享链接无效或已失效")
}
return nil, err
}
return &note, nil
}
// IncrementVisit 增加浏览次数
func (r *NoteRepository) IncrementVisit(id uint) error {
return r.db.Model(&model.Note{}).Where("id = ?", id).
UpdateColumn("visit_count", gorm.Expr("visit_count + 1")).Error
}
// IncrementVisitByToken 通过分享令牌增加浏览次数
func (r *NoteRepository) IncrementVisitByToken(token string) error {
return r.db.Model(&model.Note{}).Where("share_token = ?", token).
UpdateColumn("visit_count", gorm.Expr("visit_count + 1")).Error
}