feat: 管理员跨租户管理 + 注册开关

- 管理员可管理任意用户笔记(读取/修改/删除/回收站/标签/图谱/FTS 全平台)
- 普通用户仍数据隔离, 越权返回404
- 新增注册开关: 管理员后台⚙设置可开/关, 支持REGISTRATION_ENABLED环境变量
- 注册关闭时前台/登录页隐藏注册入口, 注册接口返回400
- 冒烟测试扩展到91用例全过
This commit is contained in:
Your Name
2026-08-11 13:07:56 +08:00
parent d9793300f9
commit 13c53fea0a
12 changed files with 741 additions and 89 deletions
+199 -3
View File
@@ -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(&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链接]] 与标签统计,按用户隔离)
@@ -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 {