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 }