feat: v3 增强 - FTS5全文搜索+双向链接/知识图谱+自动保存草稿+标签管理+实时预览编辑器+PWA

- FTS5中文分词搜索(需-tags=sqlite_fts5)
- [[wiki链接]]反向链接+SVG知识图谱
- 自动保存草稿(不触发版本历史)
- 标签重命名/合并/删除+使用统计
- 后台编辑实时预览+格式工具栏
- PWA manifest+service worker+移动端适配
- 冒烟测试扩至66例全通过
This commit is contained in:
Your Name
2026-08-11 12:38:41 +08:00
parent cbfaac3d4f
commit b93c6f1e16
14 changed files with 1720 additions and 16 deletions
+209 -3
View File
@@ -3,9 +3,12 @@ package service
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"gorm.io/gorm"
@@ -206,7 +209,7 @@ func (s *NoteService) GetByParentID(parentID uint) ([]model.NoteListItem, error)
return s.repo.GetByParentID(parentID)
}
// SearchNotes 搜索笔记
// SearchNotes 搜索笔记(优先使用 FTS5 全文索引,含中文分词)
func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model.NoteListItem, int64, int, error) {
if keyword == "" {
return nil, 0, 0, errors.New("搜索关键词不能为空")
@@ -214,10 +217,23 @@ func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model
page := parseInt(pageStr, 1)
pageSize := parseInt(pageSizeStr, s.pageSize)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = s.pageSize
}
items, total, err := s.repo.Search(keyword, page, pageSize)
// FTS5 的 MATCH 语法:对用户输入做基本转义,避免语法错误
keyword = sanitizeFTS5(keyword)
items, total, err := s.repo.FTS5Search(keyword, page, pageSize)
if err != nil {
return nil, 0, 0, fmt.Errorf("搜索笔记失败: %w", err)
// FTS5 失败时回退到传统 LIKE 搜索
items, total, err = s.repo.Search(keyword, page, pageSize)
if err != nil {
return nil, 0, 0, fmt.Errorf("搜索笔记失败: %w", err)
}
}
totalPages := int(total) / pageSize
@@ -228,6 +244,21 @@ func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model
return items, total, totalPages, nil
}
// sanitizeFTS5 对 FTS5 查询做安全转义,处理用户输入中的特殊字符(引号、操作符等)
// 简单方案:去掉可能破坏 MATCH 语法的字符,将内容包裹为短语。
func sanitizeFTS5(q string) string {
// 移除 FTS5 特殊语法字符
var b strings.Builder
skip := map[rune]bool{'"': true, '^': true, '*': true, '(': true, ')': true, '{': true, '}': true, '[': true, ']': true, ':': true, '+': true, '-': true, '~': true}
for _, r := range q {
if skip[r] {
continue
}
b.WriteRune(r)
}
return b.String()
}
// GetCategories 获取所有分类
func (s *NoteService) GetCategories() ([]string, error) {
return s.repo.GetCategories()
@@ -380,6 +411,181 @@ func (s *NoteService) UpgradePasswordHash(id uint, password string) error {
return s.repo.Update(note)
}
// ─────────────── 自动保存草稿 ───────────────
// SaveDraft 保存笔记草稿(仅更新草稿字段,不触发版本历史)
// 返回是否有未保存草稿被记录
func (s *NoteService) SaveDraft(id uint, content string) error {
note, err := s.repo.GetByID(id)
if err != nil {
return errors.New("笔记不存在")
}
if note.IsFolder {
return errors.New("目录不支持草稿")
}
note.DraftContent = content
// 直接更新草稿字段,保持 updated_at 不变(避免与正文保存混淆)
return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": content})
}
// ClearDraft 清除笔记草稿(保存正文成功后调用)
func (s *NoteService) ClearDraft(id uint) error {
return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": ""})
}
// ─────────────── 双向链接 / 知识图谱 ───────────────
// wikiLinkRe 匹配笔记正文中的 [[wiki链接]] 语法
var wikiLinkRe = regexp.MustCompile(`\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]`)
// GetBacklinks 获取指向指定笔记的所有笔记(反向链接)
func (s *NoteService) GetBacklinks(noteID uint, title string) ([]model.NoteListItem, error) {
if title == "" {
// 若未提供标题,先查一下
n, err := s.repo.GetByID(noteID)
if err != nil {
return nil, errors.New("笔记不存在")
}
title = n.Title
}
all, err := s.repo.GetAllNotesLight()
if err != nil {
return nil, err
}
// 找出所有包含 [[title]] 链接的笔记 ID
var result []model.NoteListItem
for _, n := range all {
if n.ID == noteID {
continue
}
full, err := s.repo.GetByID(n.ID)
if err != nil {
continue
}
if wikiLinkRe.MatchString(full.Content) && strings.Contains(full.Content, "[["+title+"]]") {
result = append(result, n)
}
}
return result, nil
}
// GraphNode 知识图谱节点
type GraphNode struct {
ID uint `json:"id"`
Title string `json:"title"`
}
// GraphEdge 知识图谱边
type GraphEdge struct {
Source uint `json:"source"`
Target uint `json:"target"`
}
// GetKnowledgeGraph 构建完整知识图谱(节点 + [[链接]] 边)
func (s *NoteService) GetKnowledgeGraph() (map[string]interface{}, error) {
all, err := s.repo.GetAllContentLight()
if err != nil {
return nil, err
}
// 标题 → ID 映射,用于把 [[标题]] 解析为具体笔记
titleToID := make(map[string]uint)
nodes := make([]GraphNode, 0, len(all))
for _, n := range all {
titleToID[n.Title] = n.ID
nodes = append(nodes, GraphNode{ID: n.ID, Title: n.Title})
}
// 构建边(去重)
type edgeKey struct{ src, dst uint }
seen := make(map[edgeKey]bool)
var edges []GraphEdge
for _, n := range all {
// 提取当前笔记中的所有 [[链接]]
matches := wikiLinkRe.FindAllStringSubmatch(n.Content, -1)
for _, m := range matches {
// m[1] 是链接目标(标题或路径,取第一个 [[..]] 内容作为标题)
link := strings.TrimSpace(m[1])
if targetID, ok := titleToID[link]; ok && targetID != n.ID {
key := edgeKey{n.ID, targetID}
if !seen[key] {
seen[key] = true
edges = append(edges, GraphEdge{Source: n.ID, Target: targetID})
}
}
}
}
return map[string]interface{}{
"nodes": nodes,
"edges": edges,
}, nil
}
// ─────────────── 标签管理 ───────────────
// RenameTag 重命名标签(所有含该标签的笔记同步更新)
func (s *NoteService) RenameTag(oldTag, newTag string) (int64, error) {
if oldTag == "" || newTag == "" {
return 0, errors.New("标签名不能为空")
}
if oldTag == newTag {
return 0, nil
}
return s.repo.UpdateTagAll(oldTag, newTag)
}
// MergeTag 将 from 标签合并到 to 标签(from 消失)
func (s *NoteService) MergeTag(from, to string) (int64, error) {
if from == "" || to == "" {
return 0, errors.New("标签名不能为空")
}
if from == to {
return 0, nil
}
return s.repo.UpdateTagAll(from, to)
}
// DeleteTag 删除指定标签(从所有笔记中移除)
func (s *NoteService) DeleteTag(tag string) (int64, error) {
if tag == "" {
return 0, errors.New("标签名不能为空")
}
return s.repo.UpdateTagAll(tag, "")
}
// GetTagUsage 获取每个标签及其使用次数
func (s *NoteService) GetTagUsage() ([]model.TagUsage, error) {
var result []model.TagUsage
counts := make(map[string]int)
all, err := s.repo.GetAllNotesLight()
if err != nil {
return nil, err
}
for _, n := range all {
var tags []string
if json.Unmarshal([]byte(n.Tags), &tags) == nil {
for _, t := range tags {
counts[t]++
}
}
}
for tag, count := range counts {
result = append(result, model.TagUsage{Name: tag, Count: count})
}
// 按使用次数降序
for i := range result {
for j := i + 1; j < len(result); j++ {
if result[j].Count > result[i].Count {
result[i], result[j] = result[j], result[i]
}
}
}
return result, nil
}
// randomToken 生成安全的随机令牌
func randomToken(bytesLen int) string {
b := make([]byte, bytesLen)