feat: 管理员跨租户管理 + 注册开关
- 管理员可管理任意用户笔记(读取/修改/删除/回收站/标签/图谱/FTS 全平台) - 普通用户仍数据隔离, 越权返回404 - 新增注册开关: 管理员后台⚙设置可开/关, 支持REGISTRATION_ENABLED环境变量 - 注册关闭时前台/登录页隐藏注册入口, 注册接口返回400 - 冒烟测试扩展到91用例全过
This commit is contained in:
@@ -180,6 +180,44 @@ func (r *NoteRepository) FTS5Search(userID uint, keyword string, page, pageSize
|
||||
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)
|
||||
@@ -274,7 +312,8 @@ func (r *NoteRepository) Delete(id uint) error {
|
||||
// ListQuery 列表查询参数
|
||||
type ListQuery struct {
|
||||
UserID uint
|
||||
LoggedIn bool // 是否已登录(false 时仅返回公开笔记,跨租户展示)
|
||||
LoggedIn bool // 是否已登录(false 时仅返回公开笔记,跨租户展示)
|
||||
Admin bool // 管理员是否(true 时忽略 user_id 隔离,返回全平台笔记)
|
||||
Page int
|
||||
PageSize int
|
||||
Category string
|
||||
@@ -291,10 +330,13 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error)
|
||||
|
||||
query := r.db.Model(&model.Note{})
|
||||
|
||||
if q.LoggedIn {
|
||||
switch {
|
||||
case q.Admin:
|
||||
// 管理员:全平台所有笔记
|
||||
case q.LoggedIn:
|
||||
// 登录用户:数据隔离,只看自己的
|
||||
query = query.Where("user_id = ?", q.UserID)
|
||||
} else {
|
||||
default:
|
||||
// 游客:只看所有公开无密码的笔记(跨租户展示)
|
||||
query = query.Where("is_public = ?", true).Where("(password IS NULL OR password = '')")
|
||||
}
|
||||
@@ -340,6 +382,16 @@ func (r *NoteRepository) GetAllTree(userID uint) ([]model.NoteListItem, 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
|
||||
@@ -358,6 +410,16 @@ func (r *NoteRepository) GetPublicTree(userID uint, loggedIn bool) ([]model.Note
|
||||
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
|
||||
@@ -370,6 +432,17 @@ func (r *NoteRepository) GetByParentID(userID, parentID uint) ([]model.NoteListI
|
||||
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
|
||||
@@ -421,6 +494,17 @@ func (r *NoteRepository) ListTrash(userID uint) ([]model.NoteListItem, 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 {
|
||||
// 恢复自身
|
||||
@@ -537,6 +621,29 @@ func (r *NoteRepository) SearchPublic(keyword string, page, pageSize int) ([]mod
|
||||
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
|
||||
@@ -571,6 +678,18 @@ func (r *NoteRepository) GetTags(userID uint) ([]string, error) {
|
||||
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 游客获取所有公开笔记的分类
|
||||
@@ -584,6 +703,16 @@ func (r *NoteRepository) GetPublicCategories() ([]string, 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
|
||||
@@ -655,6 +784,47 @@ func (r *NoteRepository) UpdateTagAll(userID uint, oldTag, newTag string) (int64
|
||||
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(¬es).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链接]] 与标签统计,按用户隔离)
|
||||
@@ -667,6 +837,15 @@ func (r *NoteRepository) GetAllNotesLight(userID uint) ([]model.NoteListItem, er
|
||||
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 {
|
||||
@@ -686,6 +865,23 @@ func (r *NoteRepository) GetAllContentLight(userID uint) ([]struct {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user