Files
note-manager/repository/note_repository.go
T
Your Name b93c6f1e16 feat: v3 增强 - FTS5全文搜索+双向链接/知识图谱+自动保存草稿+标签管理+实时预览编辑器+PWA
- FTS5中文分词搜索(需-tags=sqlite_fts5)
- [[wiki链接]]反向链接+SVG知识图谱
- 自动保存草稿(不触发版本历史)
- 标签重命名/合并/删除+使用统计
- 后台编辑实时预览+格式工具栏
- PWA manifest+service worker+移动端适配
- 冒烟测试扩至66例全通过
2026-08-11 12:38:41 +08:00

654 lines
21 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 (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"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)
}
repo := &NoteRepository{db: db}
// 初始化 FTS5 全文搜索(含中文 trigram 分词)
if err := repo.initFTS5(); err != nil {
return nil, fmt.Errorf("初始化全文搜索失败: %w", err)
}
return repo, nil
}
// ─────────────── FTS5 全文搜索 ───────────────
// initFTS5 创建 FTS5 虚拟表并全量重建索引。
// 采用 unicode61 分词器 + 在 Go 层对中文做逐字分词(segmentCJK),
// 使中文搜索支持 1~2 个字符(trigram 只支持 3 字以上,不适合中文短词)。
func (r *NoteRepository) initFTS5() error {
// 独立 FTS5 表(不依赖外部内容表,由 Go 代码维护,便于中文分词)
createSQL := `
CREATE VIRTUAL TABLE IF NOT EXISTS note_search USING fts5(
title, content, category, tags,
tokenize='unicode61 remove_diacritics 2'
);`
if err := r.db.Exec(createSQL).Error; err != nil {
return err
}
// 启动时全量重建索引(低成本,保证索引与数据一致)
return r.rebuildAllFTS()
}
// segmentCJK 在中文/日文/韩文等连写字符之间插入空格,并在 CJK 与 ASCII 之间也插入空格,
// 使 unicode61 分词器能把每个汉字作为独立词元索引(支持短词),同时保留英文单词。
func segmentCJK(s string) string {
if s == "" {
return ""
}
isCJKChar := func(r rune) bool {
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK 统一表意文字
(r >= 0x3040 && r <= 0x30FF) || // 日文平假名/片假名
(r >= 0xAC00 && r <= 0xD7AF) || // 韩文
(r >= 0x3400 && r <= 0x4DBF) // CJK 扩展 A
}
var b strings.Builder
first := true
var prevIsCJK bool
for _, r := range s {
cur := isCJKChar(r)
if !first && cur != prevIsCJK {
b.WriteRune(' ')
} else if !first && cur && prevIsCJK {
// 连续 CJK 字符之间也加空格,使每个字独立
b.WriteRune(' ')
}
b.WriteRune(r)
prevIsCJK = cur
first = false
}
return b.String()
}
// rebuildNoteFTS 重建某篇笔记的全文索引行(先删后插)
func (r *NoteRepository) rebuildNoteFTS(id uint) error {
var note model.Note
if err := r.db.Unscoped().First(&note, id).Error; err != nil {
// 笔记不存在则清除索引行
_ = r.db.Exec(`DELETE FROM note_search WHERE rowid = ?`, id).Error
return nil
}
sTitle := segmentCJK(note.Title)
sContent := segmentCJK(note.Content)
sCategory := segmentCJK(note.Category)
sTags := segmentCJK(note.Tags)
if err := r.db.Exec(`DELETE FROM note_search WHERE rowid = ?`, id).Error; err != nil {
return err
}
return r.db.Exec(`
INSERT INTO note_search(rowid, title, content, category, tags)
VALUES (?, ?, ?, ?, ?)`,
id, sTitle, sContent, sCategory, sTags).Error
}
// rebuildAllFTS 全量重建所有未删除笔记的索引(先清空再重建)
func (r *NoteRepository) rebuildAllFTS() error {
// 常规 FTS5 表用 DELETE 清空所有行
if err := r.db.Exec(`DELETE FROM note_search`).Error; err != nil {
return err
}
var ids []uint
if err := r.db.Model(&model.Note{}).Where("is_folder = ?", false).Pluck("id", &ids).Error; err != nil {
return err
}
for _, id := range ids {
if err := r.rebuildNoteFTS(id); err != nil {
return err
}
}
return nil
}
// FTS5Search 使用全文索引搜索(同时支持英文与中文短词)
// 返回匹配的笔记列表(排除已删除与目录)
func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
// 查询词同样做中文逐字分词,与索引侧保持一致
keyword = strings.TrimSpace(segmentCJK(keyword))
if keyword == "" {
return nil, 0, nil
}
var total int64
// 先统计匹配总数(通过 FTS 表 join 到 notes 过滤目录与已删除)
countQuery := `
SELECT COUNT(*) FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND (n.deleted_at IS NULL OR n.deleted_at = '')`
if err := r.db.Raw(countQuery, keyword).Scan(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
var items []model.NoteListItem
err := r.db.Raw(`
SELECT n.id, n.title, n.category, n.tags,
CASE WHEN n.password != '' THEN 1 ELSE 0 END AS has_password,
n.is_pinned, n.is_favorite, n.is_public, n.parent_id, n.is_folder,
n.sort_order, n.visit_count, n.created_at, n.updated_at
FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND (n.deleted_at IS NULL OR n.deleted_at = '')
ORDER BY bm25(note_search), n.updated_at DESC
LIMIT ? OFFSET ?`,
keyword, pageSize, offset,
).Scan(&items).Error
if err != nil {
return nil, 0, err
}
return items, total, nil
}
// Create 创建笔记
func (r *NoteRepository) Create(note *model.Note) error {
res := r.db.Select("Title", "Content", "DraftContent", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
if res.Error != nil {
return res.Error
}
// 建立全文索引(目录不索引)
if !note.IsFolder {
return r.rebuildNoteFTS(note.ID)
}
return nil
}
// 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 {
if err := r.db.Save(note).Error; err != nil {
return err
}
// 更新全文索引(目录不索引)
if !note.IsFolder {
return r.rebuildNoteFTS(note.ID)
}
return nil
}
// UpdateFields 按字段更新(避免 Save 覆盖所有字段)
func (r *NoteRepository) UpdateFields(id uint, fields map[string]interface{}) error {
if err := r.db.Model(&model.Note{}).Where("id = ?", id).Updates(fields).Error; err != nil {
return err
}
// 若更新涉及可索引字段(标题/内容/分类/标签),同步重建索引
if _, ok := fields["Title"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Content"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Category"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Tags"]; ok {
return r.rebuildNoteFTS(id)
}
return nil
}
// 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
}
// ─────────────── 标签管理 ───────────────
// UpdateTagAll 将所有笔记中出现的指定标签重命名/合并/删除。
// oldTag 为要操作的旧标签;newTag 传入新名称实现重命名,传空字符串则删除该标签。
func (r *NoteRepository) UpdateTagAll(oldTag, newTag string) (int64, error) {
var notes []model.Note
if err := r.db.Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", oldTag)).
Where("is_folder = ?", false).
Find(&notes).Error; err != nil {
return 0, err
}
changed := int64(0)
for i := range notes {
var tagSlice []string
if json.Unmarshal([]byte(notes[i].Tags), &tagSlice) != nil {
continue
}
// 过滤掉旧标签;若 newTag 非空则加入新标签(避免重复)
seen := make(map[string]bool)
var newSlice []string
for _, t := range tagSlice {
if t == oldTag {
changed++
if newTag != "" && !seen[newTag] {
newSlice = append(newSlice, newTag)
seen[newTag] = true
}
continue
}
if !seen[t] {
newSlice = append(newSlice, t)
seen[t] = true
}
}
newJSON, _ := json.Marshal(newSlice)
if err := r.db.Model(&model.Note{}).Where("id = ?", notes[i].ID).
Update("tags", string(newJSON)).Error; err != nil {
return changed, err
}
// 标签变化,同步全文索引
_ = r.rebuildNoteFTS(notes[i].ID)
}
return changed, nil
}
// ─────────────── 双向链接 / 知识图谱 ───────────────
// GetAllNotesLight 获取所有未删除笔记的 id、标题 与 标签(用于解析 [[wiki链接]] 与标签统计)
func (r *NoteRepository) GetAllNotesLight() ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("is_folder = ?", false).
Select("id, title, tags").Find(&items).Error
return items, err
}
// GetAllLinks 获取所有未删除笔记的 id、标题、内容(用于扫描双向链接与构建图谱)
// 仅返回轻量字段以降低内存占用
func (r *NoteRepository) GetAllContentLight() ([]struct {
ID uint `gorm:"column:id"`
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
}, error) {
var items []struct {
ID uint `gorm:"column:id"`
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
}
err := r.db.Model(&model.Note{}).
Where("is_folder = ?", false).
Select("id, title, content").Find(&items).Error
return items, err
}
// GetByIDs 批量获取笔记(用于解析链接指向的笔记是否存在)
func (r *NoteRepository) GetByIDs(ids []uint) ([]model.NoteListItem, error) {
if len(ids) == 0 {
return nil, nil
}
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("id IN ?", ids).
Select("id, title").Find(&items).Error
return items, err
}
// ─────────────── 版本历史 ───────────────
// 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
}