Files
note-manager/repository/note_repository.go
T
Your Name 13c53fea0a feat: 管理员跨租户管理 + 注册开关
- 管理员可管理任意用户笔记(读取/修改/删除/回收站/标签/图谱/FTS 全平台)
- 普通用户仍数据隔离, 越权返回404
- 新增注册开关: 管理员后台⚙设置可开/关, 支持REGISTRATION_ENABLED环境变量
- 注册关闭时前台/登录页隐藏注册入口, 注册接口返回400
- 冒烟测试扩展到91用例全过
2026-08-11 13:07:56 +08:00

963 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"note-manager/model"
)
// NoteRepository 笔记数据访问层
type NoteRepository struct {
db *gorm.DB
}
// NewNoteRepository 创建数据仓库实例,初始化数据库
func NewNoteRepository(dbPath string) (*NoteRepository, error) {
// 确保数据库目录存在
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败: %w", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
// 开启外键约束(SQLite 默认关闭)
db.Exec("PRAGMA foreign_keys = ON")
// 自动迁移表结构
if err := db.AutoMigrate(&model.Note{}, &model.NoteVersion{}, &model.User{}); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
repo := &NoteRepository{db: db}
// 初始化 FTS5 全文搜索(含中文 trigram 分词)
if err := repo.initFTS5(); err != nil {
return nil, fmt.Errorf("初始化全文搜索失败: %w", err)
}
return repo, nil
}
// ─────────────── FTS5 全文搜索 ───────────────
// initFTS5 创建 FTS5 虚拟表并全量重建索引。
// 采用 unicode61 分词器 + 在 Go 层对中文做逐字分词(segmentCJK),
// 使中文搜索支持 1~2 个字符(trigram 只支持 3 字以上,不适合中文短词)。
func (r *NoteRepository) initFTS5() error {
// 独立 FTS5 表(不依赖外部内容表,由 Go 代码维护,便于中文分词)
createSQL := `
CREATE VIRTUAL TABLE IF NOT EXISTS note_search USING fts5(
title, content, category, tags,
tokenize='unicode61 remove_diacritics 2'
);`
if err := r.db.Exec(createSQL).Error; err != nil {
return err
}
// 启动时全量重建索引(低成本,保证索引与数据一致)
return r.rebuildAllFTS()
}
// segmentCJK 在中文/日文/韩文等连写字符之间插入空格,并在 CJK 与 ASCII 之间也插入空格,
// 使 unicode61 分词器能把每个汉字作为独立词元索引(支持短词),同时保留英文单词。
func segmentCJK(s string) string {
if s == "" {
return ""
}
isCJKChar := func(r rune) bool {
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK 统一表意文字
(r >= 0x3040 && r <= 0x30FF) || // 日文平假名/片假名
(r >= 0xAC00 && r <= 0xD7AF) || // 韩文
(r >= 0x3400 && r <= 0x4DBF) // CJK 扩展 A
}
var b strings.Builder
first := true
var prevIsCJK bool
for _, r := range s {
cur := isCJKChar(r)
if !first && cur != prevIsCJK {
b.WriteRune(' ')
} else if !first && cur && prevIsCJK {
// 连续 CJK 字符之间也加空格,使每个字独立
b.WriteRune(' ')
}
b.WriteRune(r)
prevIsCJK = cur
first = false
}
return b.String()
}
// rebuildNoteFTS 重建某篇笔记的全文索引行(先删后插)
func (r *NoteRepository) rebuildNoteFTS(id uint) error {
var note model.Note
if err := r.db.Unscoped().First(&note, id).Error; err != nil {
// 笔记不存在则清除索引行
_ = r.db.Exec(`DELETE FROM note_search WHERE rowid = ?`, id).Error
return nil
}
sTitle := segmentCJK(note.Title)
sContent := segmentCJK(note.Content)
sCategory := segmentCJK(note.Category)
sTags := segmentCJK(note.Tags)
if err := r.db.Exec(`DELETE FROM note_search WHERE rowid = ?`, id).Error; err != nil {
return err
}
return r.db.Exec(`
INSERT INTO note_search(rowid, title, content, category, tags)
VALUES (?, ?, ?, ?, ?)`,
id, sTitle, sContent, sCategory, sTags).Error
}
// rebuildAllFTS 全量重建所有未删除笔记的索引(先清空再重建)
func (r *NoteRepository) rebuildAllFTS() error {
// 常规 FTS5 表用 DELETE 清空所有行
if err := r.db.Exec(`DELETE FROM note_search`).Error; err != nil {
return err
}
var ids []uint
if err := r.db.Model(&model.Note{}).Where("is_folder = ?", false).Pluck("id", &ids).Error; err != nil {
return err
}
for _, id := range ids {
if err := r.rebuildNoteFTS(id); err != nil {
return err
}
}
return nil
}
// FTS5Search 使用全文索引搜索(同时支持英文与中文短词)
// 返回匹配的笔记列表(排除已删除与目录),并按 userID 做数据隔离
func (r *NoteRepository) FTS5Search(userID uint, keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
// 查询词同样做中文逐字分词,与索引侧保持一致
keyword = strings.TrimSpace(segmentCJK(keyword))
if keyword == "" {
return nil, 0, nil
}
var total int64
// 先统计匹配总数(通过 FTS 表 join 到 notes 过滤目录与已删除)
countQuery := `
SELECT COUNT(*) FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND n.user_id = ?
AND (n.deleted_at IS NULL OR n.deleted_at = '')`
if err := r.db.Raw(countQuery, keyword, userID).Scan(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
var items []model.NoteListItem
err := r.db.Raw(`
SELECT n.id, n.title, n.category, n.tags,
CASE WHEN n.password != '' THEN 1 ELSE 0 END AS has_password,
n.is_pinned, n.is_favorite, n.is_public, n.parent_id, n.is_folder,
n.sort_order, n.visit_count, n.created_at, n.updated_at
FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND n.user_id = ?
AND (n.deleted_at IS NULL OR n.deleted_at = '')
ORDER BY bm25(note_search), n.updated_at DESC
LIMIT ? OFFSET ?`,
keyword, userID, pageSize, offset,
).Scan(&items).Error
if err != nil {
return nil, 0, err
}
return items, total, nil
}
// FTS5SearchAll 管理员跨租户全文搜索全部笔记
func (r *NoteRepository) FTS5SearchAll(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
keyword = strings.TrimSpace(segmentCJK(keyword))
if keyword == "" {
return nil, 0, nil
}
var total int64
countQuery := `
SELECT COUNT(*) FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND (n.deleted_at IS NULL OR n.deleted_at = '')`
if err := r.db.Raw(countQuery, keyword).Scan(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
var items []model.NoteListItem
err := r.db.Raw(`
SELECT n.id, n.title, n.category, n.tags,
CASE WHEN n.password != '' THEN 1 ELSE 0 END AS has_password,
n.is_pinned, n.is_favorite, n.is_public, n.parent_id, n.is_folder,
n.sort_order, n.visit_count, n.created_at, n.updated_at
FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND (n.deleted_at IS NULL OR n.deleted_at = '')
ORDER BY bm25(note_search), n.updated_at DESC
LIMIT ? OFFSET ?`,
keyword, pageSize, offset,
).Scan(&items).Error
if err != nil {
return nil, 0, err
}
return items, total, nil
}
// Create 创建笔记
func (r *NoteRepository) Create(note *model.Note) error {
res := r.db.Select("UserID", "Title", "Content", "DraftContent", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
if res.Error != nil {
return res.Error
}
// 建立全文索引(目录不索引)
if !note.IsFolder {
return r.rebuildNoteFTS(note.ID)
}
return nil
}
// GetByID 根据 ID 获取笔记(排除已删除,不校验归属)
func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
var note model.Note
err := r.db.First(&note, id).Error
if err != nil {
return nil, err
}
return &note, nil
}
// GetByIDScoped 根据 ID 获取笔记并校验归属(多租户隔离,防止跨用户访问)
func (r *NoteRepository) GetByIDScoped(userID, id uint) (*model.Note, error) {
var note model.Note
err := r.db.Where("user_id = ?", userID).First(&note, id).Error
if err != nil {
return nil, err
}
return &note, nil
}
// GetByIDIncludingDeleted 获取笔记(包含已软删除的,用于回收站恢复,不校验归属)
func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
var note model.Note
err := r.db.Unscoped().First(&note, id).Error
if err != nil {
return nil, err
}
return &note, nil
}
// Scoped 返回带用户隔离的查询(供需要复用 db 的场景)
func (r *NoteRepository) Scoped(userID uint) *gorm.DB {
return r.db.Where("user_id = ?", userID)
}
// DB 暴露底层数据库连接(供其它仓库复用)
func (r *NoteRepository) DB() *gorm.DB {
return r.db
}
// Update 更新笔记
func (r *NoteRepository) Update(note *model.Note) error {
if err := r.db.Save(note).Error; err != nil {
return err
}
// 更新全文索引(目录不索引)
if !note.IsFolder {
return r.rebuildNoteFTS(note.ID)
}
return nil
}
// UpdateFields 按字段更新(避免 Save 覆盖所有字段)
func (r *NoteRepository) UpdateFields(id uint, fields map[string]interface{}) error {
if err := r.db.Model(&model.Note{}).Where("id = ?", id).Updates(fields).Error; err != nil {
return err
}
// 若更新涉及可索引字段(标题/内容/分类/标签),同步重建索引
if _, ok := fields["Title"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Content"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Category"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Tags"]; ok {
return r.rebuildNoteFTS(id)
}
return nil
}
// Delete 软删除笔记
func (r *NoteRepository) Delete(id uint) error {
return r.db.Delete(&model.Note{}, id).Error
}
// ListQuery 列表查询参数
type ListQuery struct {
UserID uint
LoggedIn bool // 是否已登录(false 时仅返回公开笔记,跨租户展示)
Admin bool // 管理员是否(true 时忽略 user_id 隔离,返回全平台笔记)
Page int
PageSize int
Category string
Tag string
Pinned *bool
Favorite *bool
ParentID *uint // 父目录 IDnil 表示所有
}
// List 获取笔记列表(分页)
func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error) {
var items []model.NoteListItem
var total int64
query := r.db.Model(&model.Note{})
switch {
case q.Admin:
// 管理员:全平台所有笔记
case q.LoggedIn:
// 登录用户:数据隔离,只看自己的
query = query.Where("user_id = ?", q.UserID)
default:
// 游客:只看所有公开无密码的笔记(跨租户展示)
query = query.Where("is_public = ?", true).Where("(password IS NULL OR password = '')")
}
if q.Category != "" {
query = query.Where("category = ?", q.Category)
}
if q.Tag != "" {
query = query.Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", q.Tag))
}
if q.Pinned != nil {
query = query.Where("is_pinned = ?", *q.Pinned)
}
if q.Favorite != nil {
query = query.Where("is_favorite = ?", *q.Favorite)
}
if q.ParentID != nil {
query = query.Where("parent_id = ?", *q.ParentID)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (q.Page - 1) * q.PageSize
err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, updated_at DESC").
Offset(offset).
Limit(q.PageSize).
Find(&items).Error
return items, total, err
}
// GetAllTree 获取所有笔记的树形结构(含分享信息,当前用户)
func (r *NoteRepository) GetAllTree(userID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, share_token, share_expire_at, visit_count, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error
return items, err
}
// GetAllTreeAll 获取全平台所有笔记的树形结构(管理员用)
func (r *NoteRepository) GetAllTreeAll() ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, share_token, share_expire_at, visit_count, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error
return items, err
}
// GetPublicTree 获取公开可见的树形结构(多租户下:登录用户看自己全部笔记;游客看所有公开笔记)
func (r *NoteRepository) GetPublicTree(userID uint, loggedIn bool) ([]model.NoteListItem, error) {
var items []model.NoteListItem
q := r.db.Model(&model.Note{})
if loggedIn {
// 登录用户:展示自己名下所有未删除笔记(含私有,用于其个人笔记空间)
q = q.Where("user_id = ?", userID)
} else {
// 游客:仅展示跨租户的全部公开无密码笔记
q = q.Where("is_public = ?", true).Where("(password IS NULL OR password = '')")
}
err := q.
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error
return items, err
}
// GetPublicTreeAll 获取全平台所有笔记的树形结构(管理员只看全平台,含公开与私有)
func (r *NoteRepository) GetPublicTreeAll() ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error
return items, err
}
// GetByParentID 获取指定父目录下的所有项目(排除已删除,按用户隔离)
func (r *NoteRepository) GetByParentID(userID, parentID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("parent_id = ?", parentID).
Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error
return items, err
}
// GetByParentIDAll 获取指定父目录下的所有项目(管理员跨租户)
func (r *NoteRepository) GetByParentIDAll(parentID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("parent_id = ?", parentID).
Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error
return items, err
}
// GetChildrenCount 获取子项数量(排除已删除,按用户隔离)
func (r *NoteRepository) GetChildrenCount(userID, parentID uint) (int64, error) {
var count int64
err := r.db.Model(&model.Note{}).Where("user_id = ?", userID).Where("parent_id = ?", parentID).Count(&count).Error
return count, err
}
// DeleteWithChildren 软删除目录及其下所有内容
func (r *NoteRepository) DeleteWithChildren(id uint) error {
// 递归收集所有后代 id
ids := r.collectDescendants(id)
var err error
if len(ids) > 0 {
err = r.db.Delete(&model.Note{}, ids).Error
} else {
err = r.db.Delete(&model.Note{}, id).Error
}
return err
}
// collectDescendants 收集目录的所有后代 ID(含自身)
func (r *NoteRepository) collectDescendants(id uint) []uint {
ids := []uint{id}
queue := []uint{id}
for len(queue) > 0 {
parent := queue[0]
queue = queue[1:]
var children []uint
if err := r.db.Model(&model.Note{}).Where("parent_id = ? AND is_folder = ?", parent, true).Pluck("id", &children).Error; err != nil {
continue
}
for _, c := range children {
ids = append(ids, c)
queue = append(queue, c)
}
}
return ids
}
// ListTrash 回收站列表(只含已软删除项,按用户隔离)
func (r *NoteRepository) ListTrash(userID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Unscoped().Model(&model.Note{}).
Where("user_id = ?", userID).
Where("deleted_at IS NOT NULL").
Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("deleted_at DESC").
Find(&items).Error
return items, err
}
// ListTrashAll 管理员跨租户回收站列表
func (r *NoteRepository) ListTrashAll() ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Unscoped().Model(&model.Note{}).
Where("deleted_at IS NOT NULL").
Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("deleted_at DESC").
Find(&items).Error
return items, err
}
// Restore 从回收站恢复(连带恢复已被软删除的父目录路径不需要特殊处理)
func (r *NoteRepository) Restore(id uint) error {
// 恢复自身
if err := r.db.Unscoped().Model(&model.Note{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
return err
}
// 若其祖先目录也被软删除,一并恢复
var note model.Note
if err := r.db.Unscoped().First(&note, id).Error; err == nil && note.ParentID != 0 {
var parent model.Note
if err := r.db.Unscoped().First(&parent, note.ParentID).Error; err == nil && !parent.DeletedAt.Time.IsZero() {
_ = r.db.Unscoped().Model(&model.Note{}).Where("id = ?", parent.ID).Update("deleted_at", nil).Error
}
}
return nil
}
// RestoreSubtree 恢复整个子树(含所有已软删除后代),返回恢复的节点数
func (r *NoteRepository) RestoreSubtree(rootID uint) error {
// 收集整棵子树所有节点 id(Unscoped,含已删除)
var ids []uint
queue := []uint{rootID}
for len(queue) > 0 {
parent := queue[0]
queue = queue[1:]
var children []uint
if err := r.db.Unscoped().Model(&model.Note{}).Where("parent_id = ?", parent).Pluck("id", &children).Error; err != nil {
return err
}
ids = append(ids, children...)
queue = append(queue, children...)
}
// 统一恢复所有节点(含自身)
ids = append(ids, rootID)
return r.db.Unscoped().Model(&model.Note{}).Where("id IN ?", ids).Update("deleted_at", nil).Error
}
// HardDelete 彻底删除(不可恢复)
func (r *NoteRepository) HardDelete(id uint) error {
return r.db.Unscoped().Delete(&model.Note{}, id).Error
}
// HardDeleteWithChildren 彻底删除目录及所有后代
func (r *NoteRepository) HardDeleteWithChildren(id uint) error {
ids := r.collectDescendantsIncludingDeleted(id)
ids = append(ids, id)
return r.db.Unscoped().Delete(&model.Note{}, ids).Error
}
func (r *NoteRepository) collectDescendantsIncludingDeleted(id uint) []uint {
var ids []uint
queue := []uint{id}
for len(queue) > 0 {
parent := queue[0]
queue = queue[1:]
var children []uint
if err := r.db.Unscoped().Model(&model.Note{}).Where("parent_id = ? AND is_folder = ?", parent, true).Pluck("id", &children).Error; err != nil {
continue
}
for _, c := range children {
ids = append(ids, c)
queue = append(queue, c)
}
}
return ids
}
// Search 搜索笔记(按标题和内容,按用户隔离)
func (r *NoteRepository) Search(userID uint, keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
var items []model.NoteListItem
var total int64
like := "%" + keyword + "%"
query := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("is_pinned DESC, updated_at DESC").
Offset(offset).
Limit(pageSize).
Find(&items).Error
return items, total, err
}
// SearchPublic 游客搜索(仅搜索所有公开无密码笔记)
func (r *NoteRepository) SearchPublic(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
var items []model.NoteListItem
var total int64
like := "%" + keyword + "%"
query := r.db.Model(&model.Note{}).
Where("is_public = ?", true).
Where("(password IS NULL OR password = '')").
Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("is_pinned DESC, updated_at DESC").
Offset(offset).
Limit(pageSize).
Find(&items).Error
return items, total, err
}
// SearchAll 管理员跨租户搜索全部笔记(LIKE 回退)
func (r *NoteRepository) SearchAll(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
var items []model.NoteListItem
var total int64
like := "%" + keyword + "%"
query := r.db.Model(&model.Note{}).
Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("is_pinned DESC, updated_at DESC").
Offset(offset).
Limit(pageSize).
Find(&items).Error
return items, total, err
}
// GetCategories 获取所有分类(排除已删除和目录,按用户隔离)
func (r *NoteRepository) GetCategories(userID uint) ([]string, error) {
var categories []string
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Distinct("category").
Where("category != '' AND is_folder = ?", false).
Pluck("category", &categories).Error
return categories, err
}
// GetTags 获取所有标签(排除已删除和目录,按用户隔离)
func (r *NoteRepository) GetTags(userID uint) ([]string, error) {
var tagsJSON []string
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false).
Pluck("tags", &tagsJSON).Error
if err != nil {
return nil, err
}
// 去重
seen := make(map[string]bool)
var result []string
for _, t := range tagsJSON {
if !seen[t] {
seen[t] = true
result = append(result, t)
}
}
return result, nil
}
// GetAllTags 管理员跨租户获取全部标签
func (r *NoteRepository) GetAllTags() ([]string, error) {
var tagsJSON []string
err := r.db.Model(&model.Note{}).
Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false).
Pluck("tags", &tagsJSON).Error
if err != nil {
return nil, err
}
return dedupeTags(tagsJSON), nil
}
// ─────────────── 标签管理 ───────────────
// GetPublicCategories 游客获取所有公开笔记的分类
func (r *NoteRepository) GetPublicCategories() ([]string, error) {
var categories []string
err := r.db.Model(&model.Note{}).
Where("is_public = ?", true).
Distinct("category").
Where("category != '' AND is_folder = ?", false).
Pluck("category", &categories).Error
return categories, err
}
// GetAllCategories 管理员跨租户获取全部分类
func (r *NoteRepository) GetAllCategories() ([]string, error) {
var categories []string
err := r.db.Model(&model.Note{}).
Distinct("category").
Where("category != '' AND is_folder = ?", false).
Pluck("category", &categories).Error
return categories, err
}
// GetPublicTags 游客获取所有公开笔记的标签
func (r *NoteRepository) GetPublicTags() ([]string, error) {
var tagsJSON []string
err := r.db.Model(&model.Note{}).
Where("is_public = ?", true).
Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false).
Pluck("tags", &tagsJSON).Error
if err != nil {
return nil, err
}
return dedupeTags(tagsJSON), nil
}
// dedupeTags 去重并拼接标签 JSON 为字符串切片
func dedupeTags(tagsJSON []string) []string {
seen := make(map[string]bool)
var result []string
for _, t := range tagsJSON {
if !seen[t] {
seen[t] = true
result = append(result, t)
}
}
return result
}
// UpdateTagAll 将所有笔记中出现的指定标签重命名/合并/删除(按用户隔离)。
// oldTag 为要操作的旧标签;newTag 传入新名称实现重命名,传空字符串则删除该标签。
func (r *NoteRepository) UpdateTagAll(userID uint, oldTag, newTag string) (int64, error) {
var notes []model.Note
if err := r.db.Where("user_id = ?", userID).
Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", oldTag)).
Where("is_folder = ?", false).
Find(&notes).Error; err != nil {
return 0, err
}
changed := int64(0)
for i := range notes {
var tagSlice []string
if json.Unmarshal([]byte(notes[i].Tags), &tagSlice) != nil {
continue
}
// 过滤掉旧标签;若 newTag 非空则加入新标签(避免重复)
seen := make(map[string]bool)
var newSlice []string
for _, t := range tagSlice {
if t == oldTag {
changed++
if newTag != "" && !seen[newTag] {
newSlice = append(newSlice, newTag)
seen[newTag] = true
}
continue
}
if !seen[t] {
newSlice = append(newSlice, t)
seen[t] = true
}
}
newJSON, _ := json.Marshal(newSlice)
if err := r.db.Model(&model.Note{}).Where("id = ?", notes[i].ID).
Update("tags", string(newJSON)).Error; err != nil {
return changed, err
}
// 标签变化,同步全文索引
_ = r.rebuildNoteFTS(notes[i].ID)
}
return changed, nil
}
// UpdateTagAllAll 管理员跨租户重命名/合并/删除标签(作用于全平台所有笔记)
func (r *NoteRepository) UpdateTagAllAll(oldTag, newTag string) (int64, error) {
var notes []model.Note
if err := r.db.Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", oldTag)).
Where("is_folder = ?", false).
Find(&notes).Error; err != nil {
return 0, err
}
changed := int64(0)
for i := range notes {
var tagSlice []string
if json.Unmarshal([]byte(notes[i].Tags), &tagSlice) != nil {
continue
}
seen := make(map[string]bool)
var newSlice []string
for _, t := range tagSlice {
if t == oldTag {
changed++
if newTag != "" && !seen[newTag] {
newSlice = append(newSlice, newTag)
seen[newTag] = true
}
continue
}
if !seen[t] {
newSlice = append(newSlice, t)
seen[t] = true
}
}
newJSON, _ := json.Marshal(newSlice)
if err := r.db.Model(&model.Note{}).Where("id = ?", notes[i].ID).
Update("tags", string(newJSON)).Error; err != nil {
return changed, err
}
_ = r.rebuildNoteFTS(notes[i].ID)
}
return changed, nil
}
// ─────────────── 双向链接 / 知识图谱 ───────────────
// GetAllNotesLight 获取所有未删除笔记的 id、标题 与 标签(用于解析 [[wiki链接]] 与标签统计,按用户隔离)
func (r *NoteRepository) GetAllNotesLight(userID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("is_folder = ?", false).
Select("id, title, tags").Find(&items).Error
return items, err
}
// GetAllNotesLightAll 管理员跨租户获取所有未删除笔记的 id、标题 与 标签
func (r *NoteRepository) GetAllNotesLightAll() ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("is_folder = ?", false).
Select("id, title, tags").Find(&items).Error
return items, err
}
// GetAllLinks 获取所有未删除笔记的 id、标题、内容(用于扫描双向链接与构建图谱,按用户隔离)
// 仅返回轻量字段以降低内存占用
func (r *NoteRepository) GetAllContentLight(userID uint) ([]struct {
ID uint `gorm:"column:id"`
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
}, error) {
var items []struct {
ID uint `gorm:"column:id"`
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
}
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("is_folder = ?", false).
Select("id, title, content").Find(&items).Error
return items, err
}
// GetAllContentLightAll 管理员跨租户获取所有未删除笔记的 id、标题、内容
func (r *NoteRepository) GetAllContentLightAll() ([]struct {
ID uint `gorm:"column:id"`
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
}, error) {
var items []struct {
ID uint `gorm:"column:id"`
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
}
err := r.db.Model(&model.Note{}).
Where("is_folder = ?", false).
Select("id, title, content").Find(&items).Error
return items, err
}
// GetByIDs 批量获取笔记(用于解析链接指向的笔记是否存在,按用户隔离)
func (r *NoteRepository) GetByIDs(userID uint, ids []uint) ([]model.NoteListItem, error) {
if len(ids) == 0 {
return nil, nil
}
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("id IN ?", ids).
Select("id, title").Find(&items).Error
return items, err
}
// ─────────────── 版本历史 ───────────────
// SaveVersion 保存笔记新版本快照
func (r *NoteRepository) SaveVersion(note *model.Note) (*model.NoteVersion, error) {
v := &model.NoteVersion{
NoteID: note.ID,
Title: note.Title,
Content: note.Content,
Category: note.Category,
Tags: note.Tags,
}
if err := r.db.Create(v).Error; err != nil {
return nil, err
}
return v, nil
}
// ListVersions 获取笔记的所有版本(按时间倒序)
func (r *NoteRepository) ListVersions(noteID uint) ([]model.NoteVersion, error) {
var versions []model.NoteVersion
err := r.db.Where("note_id = ?", noteID).Order("created_at DESC").Find(&versions).Error
return versions, err
}
// GetVersion 获取指定版本
func (r *NoteRepository) GetVersion(id uint) (*model.NoteVersion, error) {
var v model.NoteVersion
err := r.db.First(&v, id).Error
if err != nil {
return nil, err
}
return &v, nil
}
// DeleteVersions 删除某笔记的全部版本
func (r *NoteRepository) DeleteVersions(noteID uint) error {
return r.db.Where("note_id = ?", noteID).Delete(&model.NoteVersion{}).Error
}
// ─────────────── 分享 ───────────────
// GetByShareToken 通过分享令牌获取笔记(含已删除?分享的笔记不应是已删除的,排除)
func (r *NoteRepository) GetByShareToken(token string) (*model.Note, error) {
var note model.Note
err := r.db.Where("share_token = ?", token).First(&note).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("分享链接无效或已失效")
}
return nil, err
}
return &note, nil
}
// IncrementVisit 增加浏览次数
func (r *NoteRepository) IncrementVisit(id uint) error {
return r.db.Model(&model.Note{}).Where("id = ?", id).
UpdateColumn("visit_count", gorm.Expr("visit_count + 1")).Error
}
// IncrementVisitByToken 通过分享令牌增加浏览次数
func (r *NoteRepository) IncrementVisitByToken(token string) error {
return r.db.Model(&model.Note{}).Where("share_token = ?", token).
UpdateColumn("visit_count", gorm.Expr("visit_count + 1")).Error
}