Files
note-manager/service/note_service.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

609 lines
16 KiB
Go

package service
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"note-manager/model"
"note-manager/repository"
)
// NoteService 笔记业务逻辑层
type NoteService struct {
repo *repository.NoteRepository
pageSize int
}
// NewNoteService 创建服务实例
func NewNoteService(repo *repository.NoteRepository, pageSize int) *NoteService {
return &NoteService{repo: repo, pageSize: pageSize}
}
// CreateNote 创建笔记或目录
func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, error) {
note := &model.Note{
Title: req.Title,
Content: req.Content,
Category: req.Category,
Tags: req.Tags,
IsFolder: req.IsFolder,
SortOrder: req.SortOrder,
IsPublic: true, // 默认公开
}
if req.Password != "" {
note.Password = model.HashPassword(req.Password)
}
if req.IsPinned != nil {
note.IsPinned = *req.IsPinned
}
if req.IsFavorite != nil {
note.IsFavorite = *req.IsFavorite
}
if req.IsPublic != nil {
note.IsPublic = *req.IsPublic
}
if req.ParentID != nil {
note.ParentID = *req.ParentID
}
if err := s.repo.Create(note); err != nil {
return nil, fmt.Errorf("创建笔记失败: %w", err)
}
// 创建时保存第一版历史
_, _ = s.repo.SaveVersion(note)
return note, nil
}
// GetNote 获取单条笔记(后台管理使用,任意笔记)
func (s *NoteService) GetNote(id uint) (*model.Note, error) {
note, err := s.repo.GetByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("笔记不存在")
}
return nil, err
}
return note, nil
}
// GetNoteContent 获取笔记内容(需密码验证,用于前台展示)
// 返回 (note, 是否需要升级密码哈希, err)
func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, bool, error) {
note, err := s.repo.GetByID(id)
if err != nil {
return nil, false, errors.New("笔记不存在")
}
// 检查密码
if note.Password != "" {
ok, upgrade := model.CheckPassword(password, note.Password)
if !ok {
return nil, false, errors.New("密码错误")
}
return note, upgrade, nil
}
return note, false, nil
}
// UpdateNote 更新笔记或目录(保存更新前快照到版本历史)
func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.Note, error) {
note, err := s.repo.GetByID(id)
if err != nil {
return nil, errors.New("笔记不存在")
}
// 记录旧状态,判断是否产生实质内容变化
oldContent := note.Content
oldTitle := note.Title
if req.Title != nil {
note.Title = *req.Title
}
if req.Content != nil {
note.Content = *req.Content
}
if req.Category != nil {
note.Category = *req.Category
}
if req.Tags != nil {
note.Tags = *req.Tags
}
if req.IsPinned != nil {
note.IsPinned = *req.IsPinned
}
if req.IsFavorite != nil {
note.IsFavorite = *req.IsFavorite
}
if req.IsPublic != nil {
note.IsPublic = *req.IsPublic
}
if req.ParentID != nil {
note.ParentID = *req.ParentID
}
if req.IsFolder != nil {
note.IsFolder = *req.IsFolder
}
if req.SortOrder != nil {
note.SortOrder = *req.SortOrder
}
if req.RemovePassword != nil && *req.RemovePassword {
note.Password = ""
} else if req.Password != nil && *req.Password != "" {
note.Password = model.HashPassword(*req.Password)
}
if err := s.repo.Update(note); err != nil {
return nil, fmt.Errorf("更新笔记失败: %w", err)
}
// 若内容或标题发生变化,保存历史版本
if oldContent != note.Content || oldTitle != note.Title {
_, _ = s.repo.SaveVersion(note)
}
return note, nil
}
// DeleteNote 软删除笔记或目录(目录会软删除所有子项)
func (s *NoteService) DeleteNote(id uint) error {
note, err := s.repo.GetByID(id)
if err != nil {
return errors.New("笔记不存在")
}
if note.IsFolder {
return s.repo.DeleteWithChildren(id)
}
return s.repo.Delete(id)
}
// GetAllTree 获取所有笔记和目录的树形结构(管理后台用)
func (s *NoteService) GetAllTree() ([]model.NoteListItem, error) {
return s.repo.GetAllTree()
}
// GetPublicTree 获取公开笔记的树形结构(前台用)
func (s *NoteService) GetPublicTree() ([]model.NoteListItem, error) {
return s.repo.GetPublicTree()
}
// ListNotes 获取笔记列表
func (s *NoteService) ListNotes(pageStr, pageSizeStr, category, tag string, pinned, favorite *bool) ([]model.NoteListItem, int64, int, error) {
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.List(repository.ListQuery{
Page: page,
PageSize: pageSize,
Category: category,
Tag: tag,
Pinned: pinned,
Favorite: favorite,
})
if err != nil {
return nil, 0, 0, fmt.Errorf("获取笔记列表失败: %w", err)
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return items, total, totalPages, nil
}
// GetByParentID 获取指定目录下的所有项目
func (s *NoteService) GetByParentID(parentID uint) ([]model.NoteListItem, error) {
return s.repo.GetByParentID(parentID)
}
// SearchNotes 搜索笔记(优先使用 FTS5 全文索引,含中文分词)
func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model.NoteListItem, int64, int, error) {
if keyword == "" {
return nil, 0, 0, errors.New("搜索关键词不能为空")
}
page := parseInt(pageStr, 1)
pageSize := parseInt(pageSizeStr, s.pageSize)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = s.pageSize
}
// FTS5 的 MATCH 语法:对用户输入做基本转义,避免语法错误
keyword = sanitizeFTS5(keyword)
items, total, err := s.repo.FTS5Search(keyword, page, pageSize)
if err != nil {
// 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
if int(total)%pageSize > 0 {
totalPages++
}
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()
}
// GetTags 获取所有标签
func (s *NoteService) GetTags() ([]string, error) {
return s.repo.GetTags()
}
// ─────────────── 回收站 ───────────────
// ListTrash 获取回收站列表
func (s *NoteService) ListTrash() ([]model.NoteListItem, error) {
return s.repo.ListTrash()
}
// RestoreNote 从回收站恢复笔记或目录(整棵子树)
func (s *NoteService) RestoreNote(id uint) error {
note, err := s.repo.GetByIDIncludingDeleted(id)
if err != nil {
return errors.New("记录不存在")
}
if note.IsFolder {
return s.repo.RestoreSubtree(id)
}
return s.repo.Restore(id)
}
// PurgeNote 彻底删除笔记或目录(不可恢复)
func (s *NoteService) PurgeNote(id uint) error {
note, err := s.repo.GetByIDIncludingDeleted(id)
if err != nil {
return errors.New("记录不存在")
}
if note.IsFolder {
if err := s.repo.HardDeleteWithChildren(id); err != nil {
return err
}
} else {
if err := s.repo.HardDelete(id); err != nil {
return err
}
}
// 清理版本历史
_ = s.repo.DeleteVersions(id)
return nil
}
// EmptyTrash 清空回收站
func (s *NoteService) EmptyTrash() error {
trash, err := s.repo.ListTrash()
if err != nil {
return err
}
for _, item := range trash {
if err := s.PurgeNote(item.ID); err != nil {
return err
}
}
return nil
}
// ─────────────── 版本历史 ───────────────
// ListVersions 获取笔记版本列表
func (s *NoteService) ListVersions(noteID uint) ([]model.NoteVersion, error) {
return s.repo.ListVersions(noteID)
}
// RestoreVersion 将笔记恢复到指定版本
func (s *NoteService) RestoreVersion(noteID, versionID uint) (*model.Note, error) {
version, err := s.repo.GetVersion(versionID)
if err != nil {
return nil, errors.New("版本不存在")
}
note, err := s.repo.GetByID(noteID)
if err != nil {
return nil, errors.New("笔记不存在")
}
// 保存当前状态为历史版本(防止覆盖)
_, _ = s.repo.SaveVersion(note)
// 回滚
note.Title = version.Title
note.Content = version.Content
note.Category = version.Category
note.Tags = version.Tags
if err := s.repo.Update(note); err != nil {
return nil, fmt.Errorf("恢复版本失败: %w", err)
}
return note, nil
}
// ─────────────── 分享 ───────────────
// CreateShare 创建/更新分享令牌
func (s *NoteService) CreateShare(noteID uint, expireHours int) (*model.Note, error) {
note, err := s.repo.GetByID(noteID)
if err != nil {
return nil, errors.New("笔记不存在")
}
if note.IsFolder {
return nil, errors.New("目录不能分享")
}
note.ShareToken = randomToken(32)
if expireHours > 0 {
t := time.Now().Add(time.Duration(expireHours) * time.Hour)
note.ShareExpireAt = &t
} else {
note.ShareExpireAt = nil
}
if err := s.repo.Update(note); err != nil {
return nil, fmt.Errorf("创建分享失败: %w", err)
}
return note, nil
}
// RevokeShare 撤销分享
func (s *NoteService) RevokeShare(noteID uint) error {
note, err := s.repo.GetByID(noteID)
if err != nil {
return errors.New("笔记不存在")
}
note.ShareToken = ""
note.ShareExpireAt = nil
return s.repo.Update(note)
}
// GetSharedNote 通过令牌获取分享笔记(校验过期时间)
func (s *NoteService) GetSharedNote(token string) (*model.Note, error) {
note, err := s.repo.GetByShareToken(token)
if err != nil {
return nil, err
}
if note.ShareExpireAt != nil && time.Now().After(*note.ShareExpireAt) {
return nil, errors.New("分享链接已过期")
}
// 若有密码则需校验(在 handler 层处理)
_ = s.repo.IncrementVisitByToken(token)
return note, nil
}
// UpgradePasswordHash 将旧 SHA-256 密码哈希升级为 bcrypt
func (s *NoteService) UpgradePasswordHash(id uint, password string) error {
note, err := s.repo.GetByID(id)
if err != nil {
return err
}
note.Password = model.HashPassword(password)
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)
if _, err := rand.Read(b); err != nil {
// 兜底使用时间戳(几乎不会发生)
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func parseInt(s string, defaultVal int) int {
if s == "" {
return defaultVal
}
v, err := strconv.Atoi(s)
if err != nil {
return defaultVal
}
return v
}