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
+12
View File
@@ -741,3 +741,15 @@ await fetch('/api/notes', {
- `note_token` 会话 cookieHttpOnly + SameSite=Lax(防 CSRF),随机 32 字节 token,7 天过期,服务端内存校验绑定用户。
- 密码 bcryptcost 10),兼容旧 SHA-256 自动升级。
### v5 管理员跨租户管理 + 注册开关
- **管理员跨租户管理**`admin` 角色的用户可读取/修改/删除/恢复/分享任意用户的笔记,树/列表/回收站/分类/标签/图谱/FTS 均显示全平台数据;普通用户仍只能操作自己的数据(越权 404)。
- **注册开关**:管理员可在后台「⚙ 设置」中开启/关闭注册。关闭后 `/api/auth/register` 返回 400「注册功能已关闭」,前台与登录页隐藏「注册」入口,仅已有账号可登录。
- 默认通过环境变量 `REGISTRATION_ENABLED`(默认 `true`)设置,运行时可在后台切换并持久化。
- 接口:
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/admin/api/settings/registration` | 获取注册开关状态(管理员) |
| PUT | `/admin/api/settings/registration` | 设置注册开关 `{enabled}`(管理员) |
| GET | `/api/auth/me` | 返回当前用户角色及 `registration_enabled` |
+37 -5
View File
@@ -82,7 +82,9 @@ func (h *AuthHandler) Logout(c *gin.Context) {
func (h *AuthHandler) Me(c *gin.Context) {
uid := middleware.GetUserID(c)
if uid == 0 {
success(c, nil)
success(c, gin.H{
"registration_enabled": h.userSvc.RegistrationEnabled(),
})
return
}
u, err := h.userSvc.GetByID(uid)
@@ -91,9 +93,39 @@ func (h *AuthHandler) Me(c *gin.Context) {
return
}
success(c, gin.H{
"id": u.ID,
"username": u.Username,
"display_name": u.DisplayName,
"role": u.Role,
"id": u.ID,
"username": u.Username,
"display_name": u.DisplayName,
"role": u.Role,
"registration_enabled": h.userSvc.RegistrationEnabled(),
})
}
// GetRegistrationStatus 获取注册开关状态(管理员)
func (h *AuthHandler) GetRegistrationStatus(c *gin.Context) {
if !h.userSvc.IsAdmin(middleware.GetUserID(c)) {
fail(c, http.StatusForbidden, "仅管理员可操作")
return
}
success(c, gin.H{"registration_enabled": h.userSvc.RegistrationEnabled()})
}
// SetRegistration 切换注册开关(管理员)
func (h *AuthHandler) SetRegistration(c *gin.Context) {
if !h.userSvc.IsAdmin(middleware.GetUserID(c)) {
fail(c, http.StatusForbidden, "仅管理员可操作")
return
}
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误")
return
}
if err := h.userSvc.SetRegistrationEnabled(req.Enabled); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, gin.H{"registration_enabled": req.Enabled})
}
+18
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"log"
"os"
"github.com/gin-gonic/gin"
"note-manager/config"
@@ -12,6 +13,14 @@ import (
"note-manager/service"
)
// getDefaultReg 返回注册开关默认值(来自环境变量 REGISTRATION_ENABLED,默认开启)
func getDefaultReg() string {
if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
return v
}
return "true"
}
func main() {
// 加载配置
cfg := config.Load()
@@ -36,6 +45,15 @@ func main() {
log.Fatalf("初始化上传目录失败: %v", err)
}
// 确保系统设置表存在(注册开关等)
if err := userRepo.EnsureSettingsTable(); err != nil {
log.Fatalf("初始化系统设置表失败: %v", err)
}
// 注册开关默认值来自环境变量 REGISTRATION_ENABLED(默认开启)
userService.SetRegistrationDefault(getDefaultReg())
// 注入角色判定,使管理员能跨租户管理笔记
noteService.RegisterRoleChecker(userService)
// 初始化 Gin(设置模板目录)
gin.SetMode(gin.ReleaseMode)
engine := gin.Default()
+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 {
+28
View File
@@ -61,3 +61,31 @@ func (r *UserRepository) GetByID(id uint) (*model.User, error) {
func (r *UserRepository) GetDB() *gorm.DB {
return r.db
}
// ─────────────── 系统设置(key-value,含注册开关等)───────────────
// GetSetting 读取设置项(不存在返回 defaultVal)
func (r *UserRepository) GetSetting(key, defaultVal string) string {
var val string
err := r.db.Raw("SELECT value FROM app_settings WHERE key = ?", key).Scan(&val).Error
if err != nil || val == "" {
return defaultVal
}
return val
}
// SetSetting 写入或更新设置项
func (r *UserRepository) SetSetting(key, val string) error {
return r.db.Exec(`
INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, val).Error
}
// Migration 中创建 app_settings 表
func (r *UserRepository) EnsureSettingsTable() error {
return r.db.Exec(`
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
)`).Error
}
+4
View File
@@ -90,6 +90,10 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, authHandler *handler
adminApi.POST("/tags/rename", noteHandler.RenameTag)
adminApi.POST("/tags/merge", noteHandler.MergeTag)
adminApi.DELETE("/tags", noteHandler.DeleteTag)
// 平台设置(注册开关,管理员)
adminApi.GET("/settings/registration", authHandler.GetRegistrationStatus)
adminApi.PUT("/settings/registration", authHandler.SetRegistration)
}
// ─────────── 后台管理路由 ───────────
+205 -65
View File
@@ -20,6 +20,12 @@ import (
type NoteService struct {
repo *repository.NoteRepository
pageSize int
roles RoleChecker // 用户角色判定(管理员可跨租户管理)
}
// RoleChecker 提供用户角色判断,便于解耦(由 UserService 实现)
type RoleChecker interface {
IsAdmin(userID uint) bool
}
// ErrNotFound 记录不存在(映射为 HTTP 404)
@@ -30,6 +36,16 @@ 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{
@@ -66,12 +82,18 @@ func (s *NoteService) CreateNote(userID uint, req model.NoteCreateRequest) (*mod
return note, nil
}
// GetNote 获取单条笔记(后台/个人管理使用,校验归属
// GetNote 获取单条笔记(后台/个人管理使用,管理员可跨租户读取任意笔记
func (s *NoteService) GetNote(userID, id uint) (*model.Note, error) {
note, err := s.repo.GetByIDScoped(userID, id)
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, errors.New("笔记不存在")
return nil, ErrNotFound
}
return nil, err
}
@@ -108,9 +130,15 @@ func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, boo
return note, false, nil
}
// UpdateNote 更新笔记或目录(保存更新前快照到版本历史,校验归属
// UpdateNote 更新笔记或目录(保存更新前快照到版本历史,管理员可跨租户更新
func (s *NoteService) UpdateNote(userID, id uint, req model.NoteUpdateRequest) (*model.Note, error) {
note, err := s.repo.GetByIDScoped(userID, id)
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
}
@@ -166,9 +194,15 @@ func (s *NoteService) UpdateNote(userID, id uint, req model.NoteUpdateRequest) (
return note, nil
}
// DeleteNote 软删除笔记或目录(目录会软删除所有子项,校验归属
// DeleteNote 软删除笔记或目录(目录会软删除所有子项,管理员可跨租户删除
func (s *NoteService) DeleteNote(userID, id uint) error {
note, err := s.repo.GetByIDScoped(userID, id)
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
}
@@ -178,17 +212,23 @@ func (s *NoteService) DeleteNote(userID, id uint) error {
return s.repo.Delete(id)
}
// GetAllTree 获取所有笔记和目录的树形结构(个人管理用)
// GetAllTree 获取所有笔记和目录的树形结构(个人管理用;管理员看全平台
func (s *NoteService) GetAllTree(userID uint) ([]model.NoteListItem, error) {
if s.isAdmin(userID) {
return s.repo.GetAllTreeAll()
}
return s.repo.GetAllTree(userID)
}
// GetPublicTree 获取树形结构(登录用户看自己全部;游客看所有公开笔记)
// 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 获取笔记列表(登录用户看自己的;游客看公开笔记)
// 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)
@@ -201,14 +241,15 @@ func (s *NoteService) ListNotes(userID uint, loggedIn bool, pageStr, pageSizeStr
}
items, total, err := s.repo.List(repository.ListQuery{
UserID: userID,
LoggedIn: loggedIn,
Page: page,
PageSize: pageSize,
Category: category,
Tag: tag,
Pinned: pinned,
Favorite: favorite,
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)
@@ -222,12 +263,15 @@ func (s *NoteService) ListNotes(userID uint, loggedIn bool, pageStr, pageSizeStr
return items, total, totalPages, nil
}
// GetByParentID 获取指定目录下的所有项目(个人管理用)
// 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 全文索引,含中文分词;按用户隔离
// 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("搜索关键词不能为空")
@@ -246,6 +290,20 @@ func (s *NoteService) SearchNotes(userID uint, loggedIn bool, keyword, pageStr,
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 搜索
@@ -288,8 +346,11 @@ func sanitizeFTS5(q string) string {
return b.String()
}
// GetCategories 获取所有分类(登录用户看自己的;游客看公开笔记的分类)
// 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)
}
@@ -297,8 +358,11 @@ func (s *NoteService) GetCategories(userID uint, loggedIn bool) ([]string, error
return s.repo.GetPublicCategories()
}
// GetTags 获取所有标签(登录用户看自己的;游客看公开笔记的标签)
// 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)
}
@@ -307,18 +371,21 @@ func (s *NoteService) GetTags(userID uint, loggedIn bool) ([]string, error) {
// ─────────────── 回收站 ───────────────
// ListTrash 获取回收站列表(当前用户)
// ListTrash 获取回收站列表(当前用户;管理员看全平台
func (s *NoteService) ListTrash(userID uint) ([]model.NoteListItem, error) {
if s.isAdmin(userID) {
return s.repo.ListTrashAll()
}
return s.repo.ListTrash(userID)
}
// RestoreNote 从回收站恢复笔记或目录(整棵子树,校验归属)
// 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 {
if !s.isAdmin(userID) && note.UserID != userID {
return errors.New("无权操作该记录")
}
if note.IsFolder {
@@ -327,13 +394,13 @@ func (s *NoteService) RestoreNote(userID, id uint) error {
return s.repo.Restore(id)
}
// PurgeNote 彻底删除笔记或目录(不可恢复,校验归属)
// 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 {
if !s.isAdmin(userID) && note.UserID != userID {
return errors.New("无权操作该记录")
}
if note.IsFolder {
@@ -350,9 +417,15 @@ func (s *NoteService) PurgeNote(userID, id uint) error {
return nil
}
// EmptyTrash 清空回收站(当前用户)
// EmptyTrash 清空回收站(当前用户;管理员清空全平台
func (s *NoteService) EmptyTrash(userID uint) error {
trash, err := s.repo.ListTrash(userID)
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
}
@@ -366,8 +439,15 @@ func (s *NoteService) EmptyTrash(userID uint) error {
// ─────────────── 版本历史 ───────────────
// ListVersions 获取笔记版本列表(校验归属)
// 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("笔记不存在")
@@ -376,9 +456,15 @@ func (s *NoteService) ListVersions(userID, noteID uint) ([]model.NoteVersion, er
return s.repo.ListVersions(noteID)
}
// RestoreVersion 将笔记恢复到指定版本(校验归属)
// RestoreVersion 将笔记恢复到指定版本(校验归属;管理员可操作任意
func (s *NoteService) RestoreVersion(userID, noteID, versionID uint) (*model.Note, error) {
note, err := s.repo.GetByIDScoped(userID, noteID)
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("笔记不存在")
}
@@ -386,7 +472,6 @@ func (s *NoteService) RestoreVersion(userID, noteID, versionID uint) (*model.Not
if err != nil {
return nil, errors.New("版本不存在")
}
_ = note
// 保存当前状态为历史版本(防止覆盖)
_, _ = s.repo.SaveVersion(note)
// 回滚
@@ -402,9 +487,15 @@ func (s *NoteService) RestoreVersion(userID, noteID, versionID uint) (*model.Not
// ─────────────── 分享 ───────────────
// CreateShare 创建/更新分享令牌(校验归属)
// CreateShare 创建/更新分享令牌(校验归属;管理员可操作任意
func (s *NoteService) CreateShare(userID, noteID uint, expireHours int) (*model.Note, error) {
note, err := s.repo.GetByIDScoped(userID, noteID)
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("笔记不存在")
}
@@ -424,9 +515,15 @@ func (s *NoteService) CreateShare(userID, noteID uint, expireHours int) (*model.
return note, nil
}
// RevokeShare 撤销分享(校验归属)
// RevokeShare 撤销分享(校验归属;管理员可操作任意
func (s *NoteService) RevokeShare(userID, noteID uint) error {
note, err := s.repo.GetByIDScoped(userID, noteID)
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("笔记不存在")
}
@@ -461,26 +558,35 @@ func (s *NoteService) UpgradePasswordHash(id uint, password string) error {
// ─────────────── 自动保存草稿 ───────────────
// SaveDraft 保存笔记草稿(仅更新草稿字段,不触发版本历史,校验归属)
// 返回是否有未保存草稿被记录
// SaveDraft 保存笔记草稿(仅更新草稿字段,不触发版本历史,校验归属;管理员可操作任意
func (s *NoteService) SaveDraft(userID, id uint, content string) error {
note, err := s.repo.GetByIDScoped(userID, id)
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("目录不支持草稿")
}
_ = note
// 直接更新草稿字段,保持 updated_at 不变(避免与正文保存混淆)
return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": content})
}
// ClearDraft 清除笔记草稿(保存正文成功后调用,校验归属)
// ClearDraft 清除笔记草稿(保存正文成功后调用,校验归属;管理员可操作任意
func (s *NoteService) ClearDraft(userID, id uint) error {
_, err := s.repo.GetByIDScoped(userID, id)
if err != nil {
return errors.New("笔记不存在")
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": ""})
}
@@ -490,27 +596,31 @@ func (s *NoteService) ClearDraft(userID, id uint) error {
// wikiLinkRe 匹配笔记正文中的 [[wiki链接]] 语法
var wikiLinkRe = regexp.MustCompile(`\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]`)
// GetBacklinks 获取指向指定笔记的所有笔记(反向链接,校验归属)
// GetBacklinks 获取指向指定笔记的所有笔记(反向链接,校验归属;管理员可查看任意
func (s *NoteService) GetBacklinks(userID, noteID uint, title string) ([]model.NoteListItem, error) {
var n *model.Note
if title == "" {
// 若未提供标题,先查一下
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("笔记不存在")
}
} 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)
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
}
@@ -521,8 +631,13 @@ func (s *NoteService) GetBacklinks(userID, noteID uint, title string) ([]model.N
if note.ID == noteID {
continue
}
full, err := s.repo.GetByIDScoped(userID, note.ID)
if err != nil {
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+"]]") {
@@ -544,9 +659,19 @@ type GraphEdge struct {
Target uint `json:"target"`
}
// GetKnowledgeGraph 构建当前用户的知识图谱(节点 + [[链接]] 边)
// GetKnowledgeGraph 构建当前用户的知识图谱(节点 + [[链接]] 边;管理员看全平台
func (s *NoteService) GetKnowledgeGraph(userID uint) (map[string]interface{}, error) {
all, err := s.repo.GetAllContentLight(userID)
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
}
@@ -587,7 +712,7 @@ func (s *NoteService) GetKnowledgeGraph(userID uint) (map[string]interface{}, er
// ─────────────── 标签管理 ───────────────
// RenameTag 重命名标签(当前用户所有含该标签的笔记同步更新)
// RenameTag 重命名标签(当前用户所有含该标签的笔记同步更新;管理员跨全平台
func (s *NoteService) RenameTag(userID uint, oldTag, newTag string) (int64, error) {
if oldTag == "" || newTag == "" {
return 0, errors.New("标签名不能为空")
@@ -595,10 +720,13 @@ func (s *NoteService) RenameTag(userID uint, oldTag, newTag string) (int64, erro
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 消失)
// MergeTag 将 from 标签合并到 to 标签(current 用户,from 消失;管理员跨全平台
func (s *NoteService) MergeTag(userID uint, from, to string) (int64, error) {
if from == "" || to == "" {
return 0, errors.New("标签名不能为空")
@@ -606,22 +734,34 @@ func (s *NoteService) MergeTag(userID uint, from, to string) (int64, error) {
if from == to {
return 0, nil
}
if s.isAdmin(userID) {
return s.repo.UpdateTagAllAll(from, to)
}
return s.repo.UpdateTagAll(userID, from, to)
}
// DeleteTag 删除指定标签(当前用户所有笔记中移除)
// 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 获取当前用户每个标签及其使用次数
// 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)
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
}
+51 -2
View File
@@ -11,15 +11,37 @@ import (
// UserService 用户/账号业务逻辑(多租户认证)
type UserService struct {
userRepo *repository.UserRepository
// registrationEnabled 以 env 为准的注册默认开关;DB 设置项可覆盖(运行时切换)
registrationDefault string
}
// NewUserService 创建用户服务
func NewUserService(userRepo *repository.UserRepository) *UserService {
return &UserService{userRepo: userRepo}
return &UserService{userRepo: userRepo, registrationDefault: "true"}
}
// SetRegistrationDefault 设置注册开关的默认值(来自环境变量)
func (s *UserService) SetRegistrationDefault(v string) {
s.registrationDefault = v
}
// RegistrationEnabled 当前注册开关是否开启
func (s *UserService) RegistrationEnabled() bool {
val := s.userRepo.GetSetting("registration_enabled", s.registrationDefault)
return val == "1" || val == "true"
}
// SetRegistrationEnabled 切换注册开关(持久化到 DB)
func (s *UserService) SetRegistrationEnabled(on bool) error {
v := "false"
if on {
v = "true"
}
return s.userRepo.SetSetting("registration_enabled", v)
}
// Register 注册新用户
// 说明:第一个注册的用户自动成为 admin(拥有平台管理权限);其余为普通 user。
// 说明:个注册的用户自动成为 admin(拥有平台管理权限);其余为普通 user。
// 同时把历史遗留(user_id=0)的笔记迁移给首位注册用户。
func (s *UserService) Register(username, password, displayName string) (*model.User, error) {
username = strings.TrimSpace(strings.ToLower(username))
@@ -30,6 +52,9 @@ func (s *UserService) Register(username, password, displayName string) (*model.U
if len(password) < 6 {
return nil, errors.New("密码至少 6 位")
}
if !s.RegistrationEnabled() {
return nil, errors.New("注册功能已关闭")
}
if _, err := s.userRepo.GetByUsername(username); err == nil {
return nil, errors.New("用户名已存在")
}
@@ -66,6 +91,30 @@ func (s *UserService) Register(username, password, displayName string) (*model.U
return u, nil
}
// IsAdmin 判断用户是否为管理员
func (s *UserService) IsAdmin(userID uint) bool {
if userID == 0 {
return false
}
u, err := s.userRepo.GetByID(userID)
if err != nil || u == nil {
return false
}
return u.Role == "admin"
}
// GetRole 返回用户角色(admin/user/空)
func (s *UserService) GetRole(userID uint) string {
if userID == 0 {
return ""
}
u, err := s.userRepo.GetByID(userID)
if err != nil || u == nil {
return ""
}
return u.Role
}
// Login 校验用户名密码,返回用户
func (s *UserService) Login(username, password string) (*model.User, error) {
username = strings.TrimSpace(strings.ToLower(username))
+73 -12
View File
@@ -359,24 +359,31 @@ report("合并后标签去重", "已重命名" in tag_str and "保留" not in ta
st, d = req("DELETE", "/admin/api/tags", {"name": "已重命名"})
report("删除标签", st == 200, f"(got {st})")
# ── 多租户数据隔离 ──
print("\n═══ 12b. 多租户数据隔离 ═══")
# 记住当前测试用户可见的笔记(应只有本脚本创建的 + 自己空间的
st, d = req("GET", "/admin/api/tree")
own_ids = {x["id"] for x in d.get("data", [])}
# ═══ 12b. 多租户数据隔离 + 管理员跨租户管理 ═══
print("\n═══ 12b. 多租户数据隔离 + 管理员跨租户管理 ═══")
# 平台管理员账号(首个注册的 adminprod 已存在 admin/admin123
ADMINU = "admin"
ADMINP = "admin123"
# 注册第二个用户
st, d = req("POST", "/api/auth/register", {"username": "iso_" + _uuid.uuid4().hex[:8], "password": "iso12345"})
# 切到管理员
st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP})
admin_ok = st == 200 and d.get("code") == 0
report("管理员账号可登录", admin_ok, f"(got {st} {d})")
if not admin_ok:
# 若无 admin 账号,则首个用户即管理员;这里直接注册一个新管理员作为降级
st, d = req("POST", "/api/auth/register", {"username": "_adm_" + _uuid.uuid4().hex[:6], "password": "adm12345"})
report("(降级)注册新管理员", st == 200 and d.get("code") == 0, f"(got {st} {d})")
# 注册第二个普通用户
ISOUSER = "iso_" + _uuid.uuid4().hex[:8]
st, d = req("POST", "/api/auth/register", {"username": ISOUSER, "password": "iso12345"})
report("第二个用户注册成功", st == 200 and d.get("code") == 0, f"(got {st})")
# 第二个用户的树必须是空的(看不到第一个用户的笔记)
# 第二个用户的树必须是空的(看不到别人的笔记)
st, d = req("GET", "/admin/api/tree")
other_ids = {x["id"] for x in d.get("data", [])}
report("第二用户树为空(数据隔离)", st == 200 and len(other_ids) == 0, f"(got {other_ids})")
# 两用户笔记 ID 无交集
report("两用户数据无交集", len(own_ids & other_ids) == 0, f"(own={own_ids} other={other_ids})")
# 第二用户无权读取第一用户创建的笔记
st, d = req("GET", f"/admin/api/notes/{note_id}")
report("越权读取他人笔记被拒(404)", st == 404, f"(got {st})")
@@ -391,12 +398,66 @@ st, d = req("GET", f"/api/notes/search?{q}")
other_hits = d.get("data") or []
report("第二用户搜不到他人笔记", len(other_hits) == 0, f"(got {len(other_hits)})")
# 第二用户创建一篇自己的私有笔记(供管理员跨租户操作)
st, d = req("POST", "/admin/api/notes", {"title": "第二用户的私有笔记", "content": "only visible to owner + admin", "is_public": False})
iso_note_id = d.get("data", {}).get("id")
report("第二用户创建私有笔记", st == 200 and iso_note_id, f"(got {st} {d})")
# 管理员跨租户管理:管理员应能读取第二用户的私有笔记
st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP})
report("切回管理员成功", st == 200 and d.get("code") == 0, f"(got {st})")
st, d = req("GET", f"/admin/api/notes/{iso_note_id}")
report("管理员可读取他人(第二用户)笔记", st == 200 and d.get("code") == 0 and d.get("data", {}).get("title") == "第二用户的私有笔记", f"(got {st} {d})")
# 管理员可更新他人笔记(收藏)
st, d = req("PUT", f"/admin/api/notes/{iso_note_id}", {"is_favorite": True})
report("管理员可修改他人笔记", st == 200 and d.get("code") == 0, f"(got {st})")
# 管理员树包含第二用户的笔记(全平台视角)
st, d = req("GET", "/admin/api/tree")
admin_all = {x["id"] for x in d.get("data", [])}
report("管理员树含第二用户笔记(全平台)", iso_note_id in admin_all, f"(got {iso_note_id} in {admin_all})")
# 游客公开接口能读取公开笔记(跨租户展示,需登出为游客)
req("POST", "/api/auth/logout")
st, d = req("GET", f"/api/notes/{note_id}")
report("游客可读公开笔记", st == 200 and d.get("code") == 0, f"(got {st})")
# 重新登录回第一个测试用户(后续清理需要)
# ═══ 12c. 注册开关 ═══
print("\n═══ 12c. 注册开关 ═══")
# 切回管理员
st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP})
report("切回管理员(开关注册测试)", st == 200 and d.get("code") == 0, f"(got {st})")
# 普通用户无权限修改注册开关
st, d = req("POST", "/api/auth/login", {"username": ISOUSER, "password": "iso12345"})
report("切到普通用户", st == 200 and d.get("code") == 0, f"(got {st})")
st, d = req("PUT", "/admin/api/settings/registration", {"enabled": False})
report("普通用户无权改注册开关(403)", st == 403, f"(got {st})")
# 管理员关闭注册
st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP})
st, d = req("PUT", "/admin/api/settings/registration", {"enabled": False})
report("管理员关闭注册", st == 200 and d.get("data", {}).get("registration_enabled") == False, f"(got {st} {d})")
# 关闭后再注册应被拒
st, d = req("POST", "/api/auth/register", {"username": "blocked_" + _uuid.uuid4().hex[:6], "password": "pass12345"})
report("注册关闭后被拒绝", st == 400, f"(got {st} {d})")
# 管理员重新开启注册
st, d = req("PUT", "/admin/api/settings/registration", {"enabled": True})
report("管理员开启注册", st == 200 and d.get("data", {}).get("registration_enabled") == True, f"(got {st} {d})")
# 重新切回第一个测试用户用于清理并删除第二用户的笔记
st, d = req("POST", "/api/auth/login", {"username": TUSER, "password": "smoke123"})
report("切回测试用户", st == 200 and d.get("code") == 0, f"(got {st})")
# 管理员清理第二用户笔记前,先切回管理员
st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP})
st, d = req("POST", f"/admin/api/purge/{iso_note_id}")
report("清理第二用户笔记", st in (200, 500), f"(got {st})")
# 切回第一个测试用户(后续清理需要)
st, d = req("POST", "/api/auth/login", {"username": TUSER, "password": "smoke123"})
report("切回测试用户成功", st == 200 and d.get("code") == 0, f"(got {st})")
+88
View File
@@ -634,6 +634,21 @@
body.dark .tag-row .tag-name { color: #e6e6e6; }
body.dark .tag-row .tag-count { background: #20242e; color: #9aa0aa; }
body.dark .tags-empty { color: #9aa0aa; }
/* 平台设置:注册开关 */
.switch { position: relative; display: inline-block; width: 48px; height: 26px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background: #ccc; transition: .3s; border-radius: 26px;
}
.slider:before {
position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px;
background: #fff; transition: .3s; border-radius: 50%;
}
.switch input:checked + .slider { background: #1976d2; }
.switch input:checked + .slider:before { transform: translateX(22px); }
body.dark .slider { background: #2a3040; }
body.dark .setting-row { border-bottom-color: #232733; }
</style>
</head>
<body>
@@ -649,6 +664,7 @@
<button class="btn btn-secondary" onclick="toggleDarkMode()" title="切换深色模式" id="darkModeBtn">🌙 深色</button>
<button class="btn btn-secondary" onclick="exportAll()" title="批量导出全部笔记为 zip">📦 批量导出</button>
<button class="btn btn-secondary" onclick="openTrash()" title="回收站">🗑 回收站</button>
<button class="btn btn-secondary" id="settingsBtn" onclick="openSettings()" title="平台设置(仅管理员)" style="display:none;">⚙ 设置</button>
<a href="/" class="btn btn-secondary" target="_blank">前台预览</a>
<button class="btn btn-secondary" onclick="logout()">退出登录</button>
</div>
@@ -805,6 +821,27 @@
</div>
</div>
<!-- 平台设置弹窗(仅管理员) -->
<div class="modal" id="settingsModal">
<div class="modal-content" style="min-width: 460px;">
<h3 style="margin-bottom: 16px;">⚙ 平台设置</h3>
<div class="setting-row" style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid #eee;">
<div>
<div style="font-weight:600;">开放注册</div>
<div style="font-size:12px;color:#999;">关闭后新用户无法注册,仅已有账号可登录</div>
</div>
<label class="switch">
<input type="checkbox" id="regSwitch" onchange="toggleRegistration(this)">
<span class="slider"></span>
</label>
</div>
<div class="modal-actions" style="margin-top:16px;">
<button class="btn btn-secondary" onclick="closeSettings()">关闭</button>
</div>
</div>
</div>
<!-- 分享弹窗 -->
<div class="modal" id="shareModal">
<div class="modal-content" style="min-width: 520px;">
@@ -875,11 +912,62 @@
// 初始化
async function init() {
await checkAdmin();
await loadTree();
setupResize();
setupImagePaste();
}
// 检查当前用户是否为管理员(决定是否显示「设置」按钮)
async function checkAdmin() {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
const me = data.data;
if (me && me.role === 'admin') {
const btn = document.getElementById('settingsBtn');
if (btn) btn.style.display = '';
}
} catch (e) { /* 非管理员不显示 */ }
}
// ── 平台设置(注册开关,管理员) ──
async function openSettings() {
document.getElementById('settingsModal').style.display = 'flex';
try {
const res = await fetch(`${ADMIN_API}/settings/registration`);
const data = await res.json();
if (data.code === 0) {
document.getElementById('regSwitch').checked = !!data.data.registration_enabled;
}
} catch (e) {
showToast('读取设置失败', 'error');
}
}
function closeSettings() {
document.getElementById('settingsModal').style.display = 'none';
}
async function toggleRegistration(el) {
const enabled = el.checked;
try {
const res = await fetch(`${ADMIN_API}/settings/registration`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enabled })
});
const data = await res.json();
if (data.code === 0) {
showToast(enabled ? '已开放注册' : '已关闭注册', 'success');
} else {
el.checked = !enabled;
showToast(data.message || '操作失败', 'error');
}
} catch (e) {
el.checked = !enabled;
showToast('操作失败', 'error');
}
}
// 设置粘贴图片监听
function setupImagePaste() {
const textarea = document.getElementById('noteContent');
+11
View File
@@ -166,6 +166,17 @@
}
switchLink.addEventListener('click', () => setMode(!isRegister));
// 若注册已关闭,隐藏「去注册」链接(从 /api/auth/me 读取)
(async () => {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
if (data.data && data.data.registration_enabled === false) {
switchLink.style.display = 'none';
}
} catch (e) { /* 保留默认 */ }
})();
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
errorMsg.style.display = 'none';
+15 -2
View File
@@ -763,6 +763,7 @@
let currentFilter = { type: 'all' };
let expandedNodes = new Set();
let currentUser = null; // 当前登录用户
let registrationEnabled = true; // 是否开放注册
let authMode = 'login';
async function init() {
@@ -776,9 +777,12 @@
try {
const res = await fetch(`${API}/auth/me`);
const data = await res.json();
currentUser = data.code === 0 && data.data ? data.data : null;
const me = data.data;
currentUser = me && me.id ? me : null;
registrationEnabled = !me || me.registration_enabled !== false;
} catch (e) {
currentUser = null;
registrationEnabled = true;
}
renderUserArea();
}
@@ -792,14 +796,23 @@
<button class="auth-link" onclick="logout()">退出</button>
`;
} else {
const regBtn = registrationEnabled
? `<button class="auth-link" onclick="openAuth('register')">注册</button>`
: '';
area.innerHTML = `
<button class="auth-link" onclick="openAuth('login')">登录</button>
<button class="auth-link" onclick="openAuth('register')">注册</button>
${regBtn}
`;
}
// 同步登录弹窗:注册开关关闭时隐藏「去注册」
const switchEl = document.getElementById('authSwitch');
if (switchEl) {
switchEl.style.display = registrationEnabled ? '' : 'none';
}
}
function openAuth(mode) {
if (mode === 'register' && !registrationEnabled) mode = 'login';
authMode = mode;
document.getElementById('authTitle').textContent = mode === 'login' ? '登录' : '注册';
document.getElementById('authDisplayGroup').style.display = mode === 'login' ? 'none' : 'block';