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 } // ErrNotFound 记录不存在(映射为 HTTP 404) var ErrNotFound = errors.New("笔记不存在") // NewNoteService 创建服务实例 func NewNoteService(repo *repository.NoteRepository, pageSize int) *NoteService { return &NoteService{repo: repo, pageSize: pageSize} } // CreateNote 创建笔记或目录(归属当前用户) func (s *NoteService) CreateNote(userID uint, req model.NoteCreateRequest) (*model.Note, error) { note := &model.Note{ UserID: userID, 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(userID, id uint) (*model.Note, error) { note, err := s.repo.GetByIDScoped(userID, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("笔记不存在") } return nil, err } return note, nil } // GetNotePublic 获取公开可见的笔记(游客只读公开且无密码的,用于前台) func (s *NoteService) GetNotePublic(id uint) (*model.Note, error) { note, err := s.repo.GetByID(id) if err != nil { return nil, errors.New("笔记不存在") } if !note.IsPublic || note.Password != "" { return nil, errors.New("该笔记受保护,无法直接访问") } 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(userID, id uint, req model.NoteUpdateRequest) (*model.Note, error) { note, err := s.repo.GetByIDScoped(userID, id) if err != nil { return nil, ErrNotFound } // 记录旧状态,判断是否产生实质内容变化 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(userID, id uint) error { note, err := s.repo.GetByIDScoped(userID, id) if err != nil { return ErrNotFound } if note.IsFolder { return s.repo.DeleteWithChildren(id) } return s.repo.Delete(id) } // GetAllTree 获取所有笔记和目录的树形结构(个人管理用) func (s *NoteService) GetAllTree(userID uint) ([]model.NoteListItem, error) { return s.repo.GetAllTree(userID) } // GetPublicTree 获取树形结构(登录用户看自己全部;游客看所有公开笔记) func (s *NoteService) GetPublicTree(userID uint, loggedIn bool) ([]model.NoteListItem, error) { return s.repo.GetPublicTree(userID, loggedIn) } // ListNotes 获取笔记列表(登录用户看自己的;游客看公开笔记) func (s *NoteService) ListNotes(userID uint, loggedIn bool, 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{ UserID: userID, LoggedIn: loggedIn, 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(userID, parentID uint) ([]model.NoteListItem, error) { return s.repo.GetByParentID(userID, parentID) } // SearchNotes 搜索笔记(优先使用 FTS5 全文索引,含中文分词;按用户隔离) func (s *NoteService) SearchNotes(userID uint, loggedIn bool, 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) if loggedIn { items, total, err := s.repo.FTS5Search(userID, keyword, page, pageSize) if err != nil { // FTS5 失败时回退到传统 LIKE 搜索 items, total, err = s.repo.Search(userID, 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 } // 游客:仅搜索公开笔记 items, total, err := s.repo.SearchPublic(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(userID uint, loggedIn bool) ([]string, error) { if loggedIn { return s.repo.GetCategories(userID) } // 游客:从公开笔记中提取分类 return s.repo.GetPublicCategories() } // GetTags 获取所有标签(登录用户看自己的;游客看公开笔记的标签) func (s *NoteService) GetTags(userID uint, loggedIn bool) ([]string, error) { if loggedIn { return s.repo.GetTags(userID) } return s.repo.GetPublicTags() } // ─────────────── 回收站 ─────────────── // ListTrash 获取回收站列表(当前用户) func (s *NoteService) ListTrash(userID uint) ([]model.NoteListItem, error) { return s.repo.ListTrash(userID) } // RestoreNote 从回收站恢复笔记或目录(整棵子树,校验归属) func (s *NoteService) RestoreNote(userID, id uint) error { note, err := s.repo.GetByIDIncludingDeleted(id) if err != nil { return errors.New("记录不存在") } if note.UserID != userID { return errors.New("无权操作该记录") } if note.IsFolder { return s.repo.RestoreSubtree(id) } return s.repo.Restore(id) } // PurgeNote 彻底删除笔记或目录(不可恢复,校验归属) func (s *NoteService) PurgeNote(userID, id uint) error { note, err := s.repo.GetByIDIncludingDeleted(id) if err != nil { return errors.New("记录不存在") } if note.UserID != userID { 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(userID uint) error { trash, err := s.repo.ListTrash(userID) if err != nil { return err } for _, item := range trash { if err := s.PurgeNote(userID, item.ID); err != nil { return err } } return nil } // ─────────────── 版本历史 ─────────────── // ListVersions 获取笔记版本列表(校验归属) func (s *NoteService) ListVersions(userID, noteID uint) ([]model.NoteVersion, error) { note, err := s.repo.GetByIDScoped(userID, noteID) if err != nil { return nil, errors.New("笔记不存在") } _ = note return s.repo.ListVersions(noteID) } // RestoreVersion 将笔记恢复到指定版本(校验归属) func (s *NoteService) RestoreVersion(userID, noteID, versionID uint) (*model.Note, error) { note, err := s.repo.GetByIDScoped(userID, noteID) if err != nil { return nil, errors.New("笔记不存在") } version, err := s.repo.GetVersion(versionID) if err != nil { return nil, errors.New("版本不存在") } _ = note // 保存当前状态为历史版本(防止覆盖) _, _ = 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(userID, noteID uint, expireHours int) (*model.Note, error) { note, err := s.repo.GetByIDScoped(userID, 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(userID, noteID uint) error { note, err := s.repo.GetByIDScoped(userID, 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(userID, id uint, content string) error { note, err := s.repo.GetByIDScoped(userID, id) if err != nil { return errors.New("笔记不存在") } if note.IsFolder { return errors.New("目录不支持草稿") } _ = note // 直接更新草稿字段,保持 updated_at 不变(避免与正文保存混淆) return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": content}) } // ClearDraft 清除笔记草稿(保存正文成功后调用,校验归属) func (s *NoteService) ClearDraft(userID, id uint) error { _, err := s.repo.GetByIDScoped(userID, id) if err != nil { return errors.New("笔记不存在") } return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": ""}) } // ─────────────── 双向链接 / 知识图谱 ─────────────── // wikiLinkRe 匹配笔记正文中的 [[wiki链接]] 语法 var wikiLinkRe = regexp.MustCompile(`\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]`) // GetBacklinks 获取指向指定笔记的所有笔记(反向链接,校验归属) func (s *NoteService) GetBacklinks(userID, noteID uint, title string) ([]model.NoteListItem, error) { var n *model.Note if title == "" { // 若未提供标题,先查一下 var err error n, err = s.repo.GetByIDScoped(userID, noteID) if err != nil { return nil, errors.New("笔记不存在") } } else { // 校验归属(避免越权读取他人笔记反链) scoped, err := s.repo.GetByIDScoped(userID, noteID) if err != nil { return nil, errors.New("笔记不存在") } n = scoped } title = n.Title all, err := s.repo.GetAllNotesLight(userID) if err != nil { return nil, err } // 找出所有包含 [[title]] 链接的笔记 ID var result []model.NoteListItem for _, note := range all { if note.ID == noteID { continue } full, err := s.repo.GetByIDScoped(userID, note.ID) if err != nil { continue } if strings.Contains(full.Content, "[["+title+"]]") { result = append(result, note) } } 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(userID uint) (map[string]interface{}, error) { all, err := s.repo.GetAllContentLight(userID) 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(userID uint, oldTag, newTag string) (int64, error) { if oldTag == "" || newTag == "" { return 0, errors.New("标签名不能为空") } if oldTag == newTag { return 0, nil } return s.repo.UpdateTagAll(userID, oldTag, newTag) } // MergeTag 将 from 标签合并到 to 标签(current 用户,from 消失) func (s *NoteService) MergeTag(userID uint, from, to string) (int64, error) { if from == "" || to == "" { return 0, errors.New("标签名不能为空") } if from == to { return 0, nil } return s.repo.UpdateTagAll(userID, from, to) } // DeleteTag 删除指定标签(当前用户所有笔记中移除) func (s *NoteService) DeleteTag(userID uint, tag string) (int64, error) { if tag == "" { return 0, errors.New("标签名不能为空") } return s.repo.UpdateTagAll(userID, tag, "") } // GetTagUsage 获取当前用户每个标签及其使用次数 func (s *NoteService) GetTagUsage(userID uint) ([]model.TagUsage, error) { var result []model.TagUsage counts := make(map[string]int) all, err := s.repo.GetAllNotesLight(userID) 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 }