feat: 管理员跨租户管理 + 注册开关
- 管理员可管理任意用户笔记(读取/修改/删除/回收站/标签/图谱/FTS 全平台) - 普通用户仍数据隔离, 越权返回404 - 新增注册开关: 管理员后台⚙设置可开/关, 支持REGISTRATION_ENABLED环境变量 - 注册关闭时前台/登录页隐藏注册入口, 注册接口返回400 - 冒烟测试扩展到91用例全过
This commit is contained in:
@@ -741,3 +741,15 @@ await fetch('/api/notes', {
|
||||
|
||||
- `note_token` 会话 cookie:HttpOnly + SameSite=Lax(防 CSRF),随机 32 字节 token,7 天过期,服务端内存校验绑定用户。
|
||||
- 密码 bcrypt(cost 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` |
|
||||
|
||||
+33
-1
@@ -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)
|
||||
@@ -95,5 +97,35 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
||||
"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})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -275,6 +313,7 @@ func (r *NoteRepository) Delete(id uint) error {
|
||||
type ListQuery struct {
|
||||
UserID uint
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ─────────── 后台管理路由 ───────────
|
||||
|
||||
+196
-56
@@ -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)
|
||||
@@ -203,6 +243,7 @@ func (s *NoteService) ListNotes(userID uint, loggedIn bool, pageStr, pageSizeStr
|
||||
items, total, err := s.repo.List(repository.ListQuery{
|
||||
UserID: userID,
|
||||
LoggedIn: loggedIn,
|
||||
Admin: loggedIn && s.isAdmin(userID),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Category: category,
|
||||
@@ -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,27 +558,36 @@ 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 {
|
||||
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
@@ -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
@@ -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. 多租户数据隔离 + 管理员跨租户管理 ═══")
|
||||
# 平台管理员账号(首个注册的 admin,prod 已存在 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})")
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user