13c53fea0a
- 管理员可管理任意用户笔记(读取/修改/删除/回收站/标签/图谱/FTS 全平台) - 普通用户仍数据隔离, 越权返回404 - 新增注册开关: 管理员后台⚙设置可开/关, 支持REGISTRATION_ENABLED环境变量 - 注册关闭时前台/登录页隐藏注册入口, 注册接口返回400 - 冒烟测试扩展到91用例全过
810 lines
22 KiB
Go
810 lines
22 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
|
|
roles RoleChecker // 用户角色判定(管理员可跨租户管理)
|
|
}
|
|
|
|
// RoleChecker 提供用户角色判断,便于解耦(由 UserService 实现)
|
|
type RoleChecker interface {
|
|
IsAdmin(userID uint) bool
|
|
}
|
|
|
|
// ErrNotFound 记录不存在(映射为 HTTP 404)
|
|
var ErrNotFound = errors.New("笔记不存在")
|
|
|
|
// NewNoteService 创建服务实例
|
|
func NewNoteService(repo *repository.NoteRepository, pageSize int) *NoteService {
|
|
return &NoteService{repo: repo, pageSize: pageSize}
|
|
}
|
|
|
|
// RegisterRoleChecker 注入角色判定器(用于管理员跨租户管理)
|
|
func (s *NoteService) RegisterRoleChecker(rc RoleChecker) {
|
|
s.roles = rc
|
|
}
|
|
|
|
// isAdmin 便捷判断
|
|
func (s *NoteService) isAdmin(userID uint) bool {
|
|
return s.roles != nil && s.roles.IsAdmin(userID)
|
|
}
|
|
|
|
// 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) {
|
|
var note *model.Note
|
|
var err error
|
|
if s.isAdmin(userID) {
|
|
note, err = s.repo.GetByID(id) // 管理员直接按 id 读取
|
|
} else {
|
|
note, err = s.repo.GetByIDScoped(userID, id)
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrNotFound
|
|
}
|
|
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) {
|
|
var note *model.Note
|
|
var err error
|
|
if s.isAdmin(userID) {
|
|
note, err = s.repo.GetByID(id)
|
|
} else {
|
|
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 {
|
|
var note *model.Note
|
|
var err error
|
|
if s.isAdmin(userID) {
|
|
note, err = s.repo.GetByID(id)
|
|
} else {
|
|
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) {
|
|
if s.isAdmin(userID) {
|
|
return s.repo.GetAllTreeAll()
|
|
}
|
|
return s.repo.GetAllTree(userID)
|
|
}
|
|
|
|
// GetPublicTree 获取树形结构(登录用户看自己全部;游客看所有公开笔记;管理员看全平台)
|
|
func (s *NoteService) GetPublicTree(userID uint, loggedIn bool) ([]model.NoteListItem, error) {
|
|
if loggedIn && s.isAdmin(userID) {
|
|
return s.repo.GetPublicTreeAll()
|
|
}
|
|
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,
|
|
Admin: loggedIn && s.isAdmin(userID),
|
|
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) {
|
|
if s.isAdmin(userID) {
|
|
return s.repo.GetByParentIDAll(parentID)
|
|
}
|
|
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 {
|
|
if s.isAdmin(userID) {
|
|
items, total, err := s.repo.FTS5SearchAll(keyword, page, pageSize)
|
|
if err != nil {
|
|
items, total, err = s.repo.SearchAll(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.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 && s.isAdmin(userID) {
|
|
return s.repo.GetAllCategories()
|
|
}
|
|
if loggedIn {
|
|
return s.repo.GetCategories(userID)
|
|
}
|
|
// 游客:从公开笔记中提取分类
|
|
return s.repo.GetPublicCategories()
|
|
}
|
|
|
|
// GetTags 获取所有标签(登录用户看自己的;游客看公开笔记的标签;管理员看全平台)
|
|
func (s *NoteService) GetTags(userID uint, loggedIn bool) ([]string, error) {
|
|
if loggedIn && s.isAdmin(userID) {
|
|
return s.repo.GetAllTags()
|
|
}
|
|
if loggedIn {
|
|
return s.repo.GetTags(userID)
|
|
}
|
|
return s.repo.GetPublicTags()
|
|
}
|
|
|
|
// ─────────────── 回收站 ───────────────
|
|
|
|
// ListTrash 获取回收站列表(当前用户;管理员看全平台)
|
|
func (s *NoteService) ListTrash(userID uint) ([]model.NoteListItem, error) {
|
|
if s.isAdmin(userID) {
|
|
return s.repo.ListTrashAll()
|
|
}
|
|
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 !s.isAdmin(userID) && 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 !s.isAdmin(userID) && 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 {
|
|
var trash []model.NoteListItem
|
|
var err error
|
|
if s.isAdmin(userID) {
|
|
trash, err = s.repo.ListTrashAll()
|
|
} else {
|
|
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) {
|
|
if s.isAdmin(userID) {
|
|
_, err := s.repo.GetByID(noteID)
|
|
if err != nil {
|
|
return nil, errors.New("笔记不存在")
|
|
}
|
|
return s.repo.ListVersions(noteID)
|
|
}
|
|
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) {
|
|
var note *model.Note
|
|
var err error
|
|
if s.isAdmin(userID) {
|
|
note, err = s.repo.GetByID(noteID)
|
|
} else {
|
|
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("版本不存在")
|
|
}
|
|
// 保存当前状态为历史版本(防止覆盖)
|
|
_, _ = 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) {
|
|
var note *model.Note
|
|
var err error
|
|
if s.isAdmin(userID) {
|
|
note, err = s.repo.GetByID(noteID)
|
|
} else {
|
|
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 {
|
|
var note *model.Note
|
|
var err error
|
|
if s.isAdmin(userID) {
|
|
note, err = s.repo.GetByID(noteID)
|
|
} else {
|
|
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 {
|
|
var note *model.Note
|
|
var err error
|
|
if s.isAdmin(userID) {
|
|
note, err = s.repo.GetByID(id)
|
|
} else {
|
|
note, err = s.repo.GetByIDScoped(userID, id)
|
|
}
|
|
if err != nil {
|
|
return errors.New("笔记不存在")
|
|
}
|
|
if note.IsFolder {
|
|
return errors.New("目录不支持草稿")
|
|
}
|
|
// 直接更新草稿字段,保持 updated_at 不变(避免与正文保存混淆)
|
|
return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": content})
|
|
}
|
|
|
|
// ClearDraft 清除笔记草稿(保存正文成功后调用,校验归属;管理员可操作任意)
|
|
func (s *NoteService) ClearDraft(userID, id uint) error {
|
|
if s.isAdmin(userID) {
|
|
if _, err := s.repo.GetByID(id); err != nil {
|
|
return errors.New("笔记不存在")
|
|
}
|
|
} else {
|
|
if _, err := s.repo.GetByIDScoped(userID, id); 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 s.isAdmin(userID) {
|
|
var err error
|
|
n, err = s.repo.GetByID(noteID)
|
|
if err != nil {
|
|
return nil, errors.New("笔记不存在")
|
|
}
|
|
} else {
|
|
var err error
|
|
n, err = s.repo.GetByIDScoped(userID, noteID)
|
|
if err != nil {
|
|
return nil, errors.New("笔记不存在")
|
|
}
|
|
}
|
|
title = n.Title
|
|
|
|
var all []model.NoteListItem
|
|
var err error
|
|
if s.isAdmin(userID) {
|
|
all, err = s.repo.GetAllNotesLightAll()
|
|
} else {
|
|
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
|
|
}
|
|
var full *model.Note
|
|
if s.isAdmin(userID) {
|
|
full, _ = s.repo.GetByID(note.ID)
|
|
} else {
|
|
full, _ = s.repo.GetByIDScoped(userID, note.ID)
|
|
}
|
|
if full == 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) {
|
|
var all []struct {
|
|
ID uint `gorm:"column:id"`
|
|
Title string `gorm:"column:title"`
|
|
Content string `gorm:"column:content"`
|
|
}
|
|
var err error
|
|
if s.isAdmin(userID) {
|
|
all, err = s.repo.GetAllContentLightAll()
|
|
} else {
|
|
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
|
|
}
|
|
if s.isAdmin(userID) {
|
|
return s.repo.UpdateTagAllAll(oldTag, newTag)
|
|
}
|
|
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
|
|
}
|
|
if s.isAdmin(userID) {
|
|
return s.repo.UpdateTagAllAll(from, to)
|
|
}
|
|
return s.repo.UpdateTagAll(userID, from, to)
|
|
}
|
|
|
|
// DeleteTag 删除指定标签(当前用户所有笔记中移除;管理员跨全平台)
|
|
func (s *NoteService) DeleteTag(userID uint, tag string) (int64, error) {
|
|
if tag == "" {
|
|
return 0, errors.New("标签名不能为空")
|
|
}
|
|
if s.isAdmin(userID) {
|
|
return s.repo.UpdateTagAllAll(tag, "")
|
|
}
|
|
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)
|
|
var all []model.NoteListItem
|
|
var err error
|
|
if s.isAdmin(userID) {
|
|
all, err = s.repo.GetAllNotesLightAll()
|
|
} else {
|
|
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
|
|
}
|