feat: 多租户账号体系 + 前台收藏按钮

- 新增 users 表(user_id 数据隔离,bcrypt 密码)
- 认证: 注册/登录(用户名+密码)/会话绑定用户, 首个用户成为管理员并接管旧数据
- 数据隔离: 笔记/分类/标签/回收站/版本/草稿/图谱/FTS 全部按用户隔离
- 前台: 登录/注册弹窗, 登录后★收藏自己的笔记, 游客只读公开笔记
- 后台: 用户名+密码登录, 每人管理自己的工作区, 越权访问返回404
- 冒烟测试重构+新增多租户隔离用例(78/78)
This commit is contained in:
Your Name
2026-08-11 12:58:12 +08:00
parent 2196189791
commit d9793300f9
16 changed files with 1216 additions and 278 deletions
+25
View File
@@ -716,3 +716,28 @@ await fetch('/api/notes', {
- 可添加到主屏幕离线使用 - 可添加到主屏幕离线使用
> 注:FTS5 需用 `-tags=sqlite_fts5` 编译;`note_search` 虚拟表由 Go 代码维护(手动管理而非 SQL 触发器),保证中文分词正确。 > 注:FTS5 需用 `-tags=sqlite_fts5` 编译;`note_search` 虚拟表由 Go 代码维护(手动管理而非 SQL 触发器),保证中文分词正确。
---
## v4 多租户账号体系
### 认证(用户名 + 密码,bcrypt)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/auth/register` | 注册 `{username,password,display_name}`;**首个用户自动成为 admin 并接管历史遗留(user_id=0)笔记** |
| POST | `/api/auth/login` | 登录 `{username,password}`,写入 `note_token` cookie |
| POST | `/api/auth/logout` | 登出 |
| GET | `/api/auth/me` | 当前登录用户信息(游客返回 null) |
### 多租户数据隔离
- 每篇笔记归属 `user_id`;所有查询(笔记/分类/标签/回收站/版本/草稿/图谱/FTS 搜索)均按当前用户隔离。
- **前台 `/api`**:登录用户看自己的笔记(前台可★收藏自己的笔记);游客只读所有用户的公开无密码笔记。
- **后台 `/admin/api`**:需登录,每个用户只能管理自己的笔记(越权访问他人笔记返回 404)。
- 旧账号固定密码登录已移除;会话与用户绑定,重启后需重新登录。
### 安全
- `note_token` 会话 cookieHttpOnly + SameSite=Lax(防 CSRF),随机 32 字节 token,7 天过期,服务端内存校验绑定用户。
- 密码 bcryptcost 10),兼容旧 SHA-256 自动升级。
+27 -18
View File
@@ -4,20 +4,18 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"note-manager/config"
"note-manager/middleware" "note-manager/middleware"
"note-manager/service" "note-manager/service"
) )
// AdminHandler 后台管理处理器 // AdminHandler 后台管理处理器(登录页与页面跳转,多租户账号认证)
type AdminHandler struct { type AdminHandler struct {
noteSvc *service.NoteService userSvc *service.UserService
config *config.Config
} }
// NewAdminHandler 创建后台管理处理器 // NewAdminHandler 创建后台管理处理器
func NewAdminHandler(noteSvc *service.NoteService, cfg *config.Config) *AdminHandler { func NewAdminHandler(userSvc *service.UserService) *AdminHandler {
return &AdminHandler{noteSvc: noteSvc, config: cfg} return &AdminHandler{userSvc: userSvc}
} }
// LoginPage 登录页面 // LoginPage 登录页面
@@ -27,25 +25,36 @@ func (h *AdminHandler) LoginPage(c *gin.Context) {
}) })
} }
// Login 验证登录 // Login 验证登录(用户名 + 密码,兼容 JSON 与 form
func (h *AdminHandler) Login(c *gin.Context) { func (h *AdminHandler) Login(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password") password := c.PostForm("password")
if password == h.config.AdminPass { if username == "" && password == "" {
// 生成随机会话 token // 尝试 JSON
token, _ := middleware.NewSessionToken() var req struct {
// 通过环境变量判断是否启用 HTTPS(生产建议配置) Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err == nil {
username = req.Username
password = req.Password
}
}
u, err := h.userSvc.Login(username, password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": err.Error(),
})
return
}
token, _ := middleware.NewSession(u.ID)
secure := c.Request.TLS != nil secure := c.Request.TLS != nil
middleware.SetAuthCookie(c, token, secure) middleware.SetAuthCookie(c, token, secure)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 0, "code": 0,
"message": "登录成功", "message": "登录成功",
}) })
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "密码错误",
})
} }
// Logout 登出 // Logout 登出
@@ -80,7 +89,7 @@ func (h *AdminHandler) CheckAuth(c *gin.Context) {
}) })
} }
// IndexPage 后台管理首页 // IndexPage 后台管理首页(需登录,否则跳转登录页)
func (h *AdminHandler) IndexPage(c *gin.Context) { func (h *AdminHandler) IndexPage(c *gin.Context) {
token := middleware.GetAuthToken(c) token := middleware.GetAuthToken(c)
if !middleware.IsValidSession(token) { if !middleware.IsValidSession(token) {
+99
View File
@@ -0,0 +1,99 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"note-manager/middleware"
"note-manager/service"
)
// AuthHandler 认证处理器(多租户账号:注册/登录/登出/当前用户)
type AuthHandler struct {
userSvc *service.UserService
}
// NewAuthHandler 创建认证处理器
func NewAuthHandler(userSvc *service.UserService) *AuthHandler {
return &AuthHandler{userSvc: userSvc}
}
// REGISTER 注册请求
type registerReq struct {
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
}
// Register 注册新用户
// 首个注册用户自动成为 admin,并接管历史遗留笔记。
func (h *AuthHandler) Register(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误")
return
}
u, err := h.userSvc.Register(req.Username, req.Password, req.DisplayName)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
// 注册成功即自动登录
token, _ := middleware.NewSession(u.ID)
secure := c.Request.TLS != nil
middleware.SetAuthCookie(c, token, secure)
success(c, gin.H{"id": u.ID, "username": u.Username, "display_name": u.DisplayName, "role": u.Role})
}
// Login 登录(用户名 + 密码)
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
// 兼容 form 提交(后台登录页)
if err := c.ShouldBindJSON(&req); err != nil {
req.Username = c.PostForm("username")
req.Password = c.PostForm("password")
}
if req.Password == "" || req.Username == "" {
fail(c, http.StatusBadRequest, "请输入用户名和密码")
return
}
u, err := h.userSvc.Login(req.Username, req.Password)
if err != nil {
fail(c, http.StatusUnauthorized, err.Error())
return
}
token, _ := middleware.NewSession(u.ID)
secure := c.Request.TLS != nil
middleware.SetAuthCookie(c, token, secure)
success(c, gin.H{"id": u.ID, "username": u.Username, "display_name": u.DisplayName, "role": u.Role})
}
// Logout 登出
func (h *AuthHandler) Logout(c *gin.Context) {
middleware.RevokeSession(middleware.GetAuthToken(c))
middleware.ClearAuthCookie(c)
success(c, nil)
}
// Me 返回当前登录用户信息(未登录返回 null)
func (h *AuthHandler) Me(c *gin.Context) {
uid := middleware.GetUserID(c)
if uid == 0 {
success(c, nil)
return
}
u, err := h.userSvc.GetByID(uid)
if err != nil {
success(c, nil)
return
}
success(c, gin.H{
"id": u.ID,
"username": u.Username,
"display_name": u.DisplayName,
"role": u.Role,
})
}
+115 -63
View File
@@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"note-manager/middleware"
"note-manager/model" "note-manager/model"
"note-manager/service" "note-manager/service"
) )
@@ -53,15 +54,16 @@ func fail(c *gin.Context, status int, msg string) {
c.JSON(status, Response{Code: -1, Message: msg}) c.JSON(status, Response{Code: -1, Message: msg})
} }
// CreateNote 创建笔记 // CreateNote 创建笔记(归属当前登录用户)
func (h *NoteHandler) CreateNote(c *gin.Context) { func (h *NoteHandler) CreateNote(c *gin.Context) {
userID := middleware.GetUserID(c)
var req model.NoteCreateRequest var req model.NoteCreateRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error()) fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
return return
} }
note, err := h.svc.CreateNote(req) note, err := h.svc.CreateNote(userID, req)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -70,9 +72,8 @@ func (h *NoteHandler) CreateNote(c *gin.Context) {
success(c, note) success(c, note)
} }
// GetNote 获取笔记详情(公开只读接口) // GetNote 获取笔记详情(公开只读接口,游客只看公开笔记
// 安全策略:仅允许返回「公开且无密码」的笔记。有密码或未公开的笔记一律返回需授权提示, // 安全策略:游客仅允许访问「公开且无密码」的笔记;登录用户访问自己的任意笔记。
// 防止通过该接口绕过密码保护读取内容。
func (h *NoteHandler) GetNote(c *gin.Context) { func (h *NoteHandler) GetNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@@ -80,22 +81,27 @@ func (h *NoteHandler) GetNote(c *gin.Context) {
return return
} }
note, err := h.svc.GetNote(uint(id)) if middleware.IsLoggedIn(c) {
userID := middleware.GetUserID(c)
note, err := h.svc.GetNote(userID, uint(id))
if err != nil { if err != nil {
fail(c, http.StatusNotFound, err.Error()) fail(c, http.StatusNotFound, err.Error())
return return
} }
success(c, note)
// 公开接口白名单:仅公开且无密码的笔记返回完整内容
if !note.IsPublic || note.Password != "" {
fail(c, http.StatusForbidden, "该笔记受保护,无法直接访问")
return return
} }
// 游客:仅公开且无密码的笔记
note, err := h.svc.GetNotePublic(uint(id))
if err != nil {
fail(c, http.StatusForbidden, err.Error())
return
}
success(c, note) success(c, note)
} }
// GetAdminNote 获取笔记详情(管理后台用,返回完整内容) // GetAdminNote 获取笔记详情(个人管理用,返回完整内容,校验归属
func (h *NoteHandler) GetAdminNote(c *gin.Context) { func (h *NoteHandler) GetAdminNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@@ -103,7 +109,8 @@ func (h *NoteHandler) GetAdminNote(c *gin.Context) {
return return
} }
note, err := h.svc.GetNote(uint(id)) userID := middleware.GetUserID(c)
note, err := h.svc.GetNote(userID, uint(id))
if err != nil { if err != nil {
fail(c, http.StatusNotFound, err.Error()) fail(c, http.StatusNotFound, err.Error())
return return
@@ -146,8 +153,9 @@ func (h *NoteHandler) AccessNote(c *gin.Context) {
success(c, note) success(c, note)
} }
// UpdateNote 更新笔记 // UpdateNote 更新笔记(校验归属)
func (h *NoteHandler) UpdateNote(c *gin.Context) { func (h *NoteHandler) UpdateNote(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
@@ -160,8 +168,12 @@ func (h *NoteHandler) UpdateNote(c *gin.Context) {
return return
} }
note, err := h.svc.UpdateNote(uint(id), req) note, err := h.svc.UpdateNote(userID, uint(id), req)
if err != nil { if err != nil {
if err == service.ErrNotFound {
fail(c, http.StatusNotFound, err.Error())
return
}
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
@@ -169,15 +181,20 @@ func (h *NoteHandler) UpdateNote(c *gin.Context) {
success(c, note) success(c, note)
} }
// DeleteNote 删除笔记(软删除,进入回收站) // DeleteNote 删除笔记(软删除,进入回收站,校验归属
func (h *NoteHandler) DeleteNote(c *gin.Context) { func (h *NoteHandler) DeleteNote(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
return return
} }
if err := h.svc.DeleteNote(uint(id)); err != nil { if err := h.svc.DeleteNote(userID, uint(id)); err != nil {
if err == service.ErrNotFound {
fail(c, http.StatusNotFound, err.Error())
return
}
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
@@ -185,7 +202,7 @@ func (h *NoteHandler) DeleteNote(c *gin.Context) {
success(c, nil) success(c, nil)
} }
// ListNotes 获取笔记列表 // ListNotes 获取笔记列表(登录用户看自己的;游客看公开笔记)
func (h *NoteHandler) ListNotes(c *gin.Context) { func (h *NoteHandler) ListNotes(c *gin.Context) {
var pinned *bool var pinned *bool
if v := c.Query("pinned"); v != "" { if v := c.Query("pinned"); v != "" {
@@ -198,7 +215,12 @@ func (h *NoteHandler) ListNotes(c *gin.Context) {
favorite = &b favorite = &b
} }
loggedIn := middleware.IsLoggedIn(c)
userID := middleware.GetUserID(c)
items, total, totalPages, err := h.svc.ListNotes( items, total, totalPages, err := h.svc.ListNotes(
userID,
loggedIn,
c.DefaultQuery("page", "1"), c.DefaultQuery("page", "1"),
c.DefaultQuery("page_size", ""), c.DefaultQuery("page_size", ""),
c.Query("category"), c.Query("category"),
@@ -225,10 +247,14 @@ func (h *NoteHandler) ListNotes(c *gin.Context) {
}) })
} }
// SearchNotes 搜索笔记 // SearchNotes 搜索笔记(登录用户搜自己的;游客搜公开笔记)
func (h *NoteHandler) SearchNotes(c *gin.Context) { func (h *NoteHandler) SearchNotes(c *gin.Context) {
keyword := c.Query("q") keyword := c.Query("q")
loggedIn := middleware.IsLoggedIn(c)
userID := middleware.GetUserID(c)
items, total, totalPages, err := h.svc.SearchNotes( items, total, totalPages, err := h.svc.SearchNotes(
userID,
loggedIn,
keyword, keyword,
c.DefaultQuery("page", "1"), c.DefaultQuery("page", "1"),
c.DefaultQuery("page_size", ""), c.DefaultQuery("page_size", ""),
@@ -252,9 +278,11 @@ func (h *NoteHandler) SearchNotes(c *gin.Context) {
}) })
} }
// GetCategories 获取分类列表 // GetCategories 获取分类列表(登录用户看自己的;游客看公开笔记分类)
func (h *NoteHandler) GetCategories(c *gin.Context) { func (h *NoteHandler) GetCategories(c *gin.Context) {
categories, err := h.svc.GetCategories() loggedIn := middleware.IsLoggedIn(c)
userID := middleware.GetUserID(c)
categories, err := h.svc.GetCategories(userID, loggedIn)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -262,9 +290,11 @@ func (h *NoteHandler) GetCategories(c *gin.Context) {
success(c, categories) success(c, categories)
} }
// GetTags 获取所有标签 // GetTags 获取所有标签(登录用户看自己的;游客看公开笔记标签)
func (h *NoteHandler) GetTags(c *gin.Context) { func (h *NoteHandler) GetTags(c *gin.Context) {
tags, err := h.svc.GetTags() loggedIn := middleware.IsLoggedIn(c)
userID := middleware.GetUserID(c)
tags, err := h.svc.GetTags(userID, loggedIn)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -272,9 +302,10 @@ func (h *NoteHandler) GetTags(c *gin.Context) {
success(c, tags) success(c, tags)
} }
// GetTree 获取树形结构(管理后台用) // GetTree 获取树形结构(个人管理用)
func (h *NoteHandler) GetTree(c *gin.Context) { func (h *NoteHandler) GetTree(c *gin.Context) {
tree, err := h.svc.GetAllTree() userID := middleware.GetUserID(c)
tree, err := h.svc.GetAllTree(userID)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -282,9 +313,11 @@ func (h *NoteHandler) GetTree(c *gin.Context) {
success(c, tree) success(c, tree)
} }
// GetPublicTree 获取公开树形结构(前台用 // GetPublicTree 获取树形结构(登录用户看自己的全部;游客看所有公开笔记
func (h *NoteHandler) GetPublicTree(c *gin.Context) { func (h *NoteHandler) GetPublicTree(c *gin.Context) {
tree, err := h.svc.GetPublicTree() loggedIn := middleware.IsLoggedIn(c)
userID := middleware.GetUserID(c)
tree, err := h.svc.GetPublicTree(userID, loggedIn)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -302,9 +335,10 @@ func parseIntDefault(s string, defaultVal int) int {
// ─────────────── 回收站 ─────────────── // ─────────────── 回收站 ───────────────
// ListTrash 回收站列表 // ListTrash 回收站列表(当前用户)
func (h *NoteHandler) ListTrash(c *gin.Context) { func (h *NoteHandler) ListTrash(c *gin.Context) {
items, err := h.svc.ListTrash() userID := middleware.GetUserID(c)
items, err := h.svc.ListTrash(userID)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -312,37 +346,40 @@ func (h *NoteHandler) ListTrash(c *gin.Context) {
success(c, items) success(c, items)
} }
// RestoreNote 恢复笔记 // RestoreNote 恢复笔记(校验归属)
func (h *NoteHandler) RestoreNote(c *gin.Context) { func (h *NoteHandler) RestoreNote(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
return return
} }
if err := h.svc.RestoreNote(uint(id)); err != nil { if err := h.svc.RestoreNote(userID, uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
success(c, nil) success(c, nil)
} }
// PurgeNote 彻底删除笔记 // PurgeNote 彻底删除笔记(校验归属)
func (h *NoteHandler) PurgeNote(c *gin.Context) { func (h *NoteHandler) PurgeNote(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
return return
} }
if err := h.svc.PurgeNote(uint(id)); err != nil { if err := h.svc.PurgeNote(userID, uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
success(c, nil) success(c, nil)
} }
// EmptyTrash 清空回收站 // EmptyTrash 清空回收站(当前用户)
func (h *NoteHandler) EmptyTrash(c *gin.Context) { func (h *NoteHandler) EmptyTrash(c *gin.Context) {
if err := h.svc.EmptyTrash(); err != nil { userID := middleware.GetUserID(c)
if err := h.svc.EmptyTrash(userID); err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
@@ -351,14 +388,15 @@ func (h *NoteHandler) EmptyTrash(c *gin.Context) {
// ─────────────── 版本历史 ─────────────── // ─────────────── 版本历史 ───────────────
// ListVersions 版本列表 // ListVersions 版本列表(校验归属)
func (h *NoteHandler) ListVersions(c *gin.Context) { func (h *NoteHandler) ListVersions(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
return return
} }
versions, err := h.svc.ListVersions(uint(id)) versions, err := h.svc.ListVersions(userID, uint(id))
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -366,8 +404,9 @@ func (h *NoteHandler) ListVersions(c *gin.Context) {
success(c, versions) success(c, versions)
} }
// RestoreVersion 恢复到指定版本 // RestoreVersion 恢复到指定版本(校验归属)
func (h *NoteHandler) RestoreVersion(c *gin.Context) { func (h *NoteHandler) RestoreVersion(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
@@ -378,7 +417,7 @@ func (h *NoteHandler) RestoreVersion(c *gin.Context) {
fail(c, http.StatusBadRequest, "无效的版本 ID") fail(c, http.StatusBadRequest, "无效的版本 ID")
return return
} }
note, err := h.svc.RestoreVersion(uint(id), uint(versionID)) note, err := h.svc.RestoreVersion(userID, uint(id), uint(versionID))
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -388,15 +427,16 @@ func (h *NoteHandler) RestoreVersion(c *gin.Context) {
// ─────────────── 分享 ─────────────── // ─────────────── 分享 ───────────────
// CreateShare 创建分享 // CreateShare 创建分享(校验归属)
func (h *NoteHandler) CreateShare(c *gin.Context) { func (h *NoteHandler) CreateShare(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
return return
} }
expireHours, _ := strconv.Atoi(c.DefaultPostForm("expire_hours", "0")) expireHours, _ := strconv.Atoi(c.DefaultPostForm("expire_hours", "0"))
note, err := h.svc.CreateShare(uint(id), expireHours) note, err := h.svc.CreateShare(userID, uint(id), expireHours)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -409,14 +449,15 @@ func (h *NoteHandler) CreateShare(c *gin.Context) {
}) })
} }
// RevokeShare 撤销分享 // RevokeShare 撤销分享(校验归属)
func (h *NoteHandler) RevokeShare(c *gin.Context) { func (h *NoteHandler) RevokeShare(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
return return
} }
if err := h.svc.RevokeShare(uint(id)); err != nil { if err := h.svc.RevokeShare(userID, uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
@@ -459,15 +500,16 @@ func (h *NoteHandler) SharePage(c *gin.Context) {
}) })
} }
// ExportNote 导出笔记为 Markdown 文件 // ExportNote 导出笔记为 Markdown 文件(校验归属)
func (h *NoteHandler) ExportNote(c *gin.Context) { func (h *NoteHandler) ExportNote(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
return return
} }
note, err := h.svc.GetNote(uint(id)) note, err := h.svc.GetNote(userID, uint(id))
if err != nil { if err != nil {
fail(c, http.StatusNotFound, err.Error()) fail(c, http.StatusNotFound, err.Error())
return return
@@ -482,9 +524,10 @@ func (h *NoteHandler) ExportNote(c *gin.Context) {
c.String(http.StatusOK, content) c.String(http.StatusOK, content)
} }
// ExportAll 批量导出全部笔记为 zip // ExportAll 批量导出当前用户全部笔记为 zip
func (h *NoteHandler) ExportAll(c *gin.Context) { func (h *NoteHandler) ExportAll(c *gin.Context) {
tree, err := h.svc.GetAllTree() userID := middleware.GetUserID(c)
tree, err := h.svc.GetAllTree(userID)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -527,7 +570,7 @@ func (h *NoteHandler) ExportAll(c *gin.Context) {
} }
return nil return nil
} }
note, err := h.svc.GetNote(n.item.ID) note, err := h.svc.GetNote(userID, n.item.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -549,8 +592,9 @@ func (h *NoteHandler) ExportAll(c *gin.Context) {
} }
} }
// ImportNotes 导入 Markdown 文件 // ImportNotes 导入 Markdown 文件(归属当前用户)
func (h *NoteHandler) ImportNotes(c *gin.Context) { func (h *NoteHandler) ImportNotes(c *gin.Context) {
userID := middleware.GetUserID(c)
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "请选择要导入的文件") fail(c, http.StatusBadRequest, "请选择要导入的文件")
@@ -587,7 +631,7 @@ func (h *NoteHandler) ImportNotes(c *gin.Context) {
Content: body, Content: body,
} }
note, err := h.svc.CreateNote(req) note, err := h.svc.CreateNote(userID, req)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -664,8 +708,9 @@ func sanitizeFileName(name string) string {
// ─────────────── 自动保存草稿 ─────────────── // ─────────────── 自动保存草稿 ───────────────
// SaveDraft 保存笔记草稿(自动保存) // SaveDraft 保存笔记草稿(自动保存,校验归属
func (h *NoteHandler) SaveDraft(c *gin.Context) { func (h *NoteHandler) SaveDraft(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
@@ -678,21 +723,22 @@ func (h *NoteHandler) SaveDraft(c *gin.Context) {
fail(c, http.StatusBadRequest, "请求参数错误") fail(c, http.StatusBadRequest, "请求参数错误")
return return
} }
if err := h.svc.SaveDraft(uint(id), req.Content); err != nil { if err := h.svc.SaveDraft(userID, uint(id), req.Content); err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
success(c, nil) success(c, nil)
} }
// DiscardDraft 清除指定笔记的草稿 // DiscardDraft 清除指定笔记的草稿(校验归属)
func (h *NoteHandler) DiscardDraft(c *gin.Context) { func (h *NoteHandler) DiscardDraft(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
return return
} }
if err := h.svc.ClearDraft(uint(id)); err != nil { if err := h.svc.ClearDraft(userID, uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
@@ -701,14 +747,15 @@ func (h *NoteHandler) DiscardDraft(c *gin.Context) {
// ─────────────── 双向链接 / 知识图谱 ─────────────── // ─────────────── 双向链接 / 知识图谱 ───────────────
// GetBacklinks 获取指定笔记的反向链接列表 // GetBacklinks 获取指定笔记的反向链接列表(校验归属)
func (h *NoteHandler) GetBacklinks(c *gin.Context) { func (h *NoteHandler) GetBacklinks(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
return return
} }
links, err := h.svc.GetBacklinks(uint(id), "") links, err := h.svc.GetBacklinks(userID, uint(id), "")
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -716,9 +763,10 @@ func (h *NoteHandler) GetBacklinks(c *gin.Context) {
success(c, links) success(c, links)
} }
// GetKnowledgeGraph 获取知识图谱数据(节点 + 边) // GetKnowledgeGraph 获取当前用户知识图谱数据(节点 + 边)
func (h *NoteHandler) GetKnowledgeGraph(c *gin.Context) { func (h *NoteHandler) GetKnowledgeGraph(c *gin.Context) {
graph, err := h.svc.GetKnowledgeGraph() userID := middleware.GetUserID(c)
graph, err := h.svc.GetKnowledgeGraph(userID)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -728,9 +776,10 @@ func (h *NoteHandler) GetKnowledgeGraph(c *gin.Context) {
// ─────────────── 标签管理 ─────────────── // ─────────────── 标签管理 ───────────────
// GetTagUsage 获取标签及使用次数 // GetTagUsage 获取当前用户标签及使用次数
func (h *NoteHandler) GetTagUsage(c *gin.Context) { func (h *NoteHandler) GetTagUsage(c *gin.Context) {
usage, err := h.svc.GetTagUsage() userID := middleware.GetUserID(c)
usage, err := h.svc.GetTagUsage(userID)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -740,6 +789,7 @@ func (h *NoteHandler) GetTagUsage(c *gin.Context) {
// RenameTag 重命名标签 // RenameTag 重命名标签
func (h *NoteHandler) RenameTag(c *gin.Context) { func (h *NoteHandler) RenameTag(c *gin.Context) {
userID := middleware.GetUserID(c)
var req struct { var req struct {
OldName string `json:"old_name" binding:"required"` OldName string `json:"old_name" binding:"required"`
NewName string `json:"new_name" binding:"required"` NewName string `json:"new_name" binding:"required"`
@@ -748,7 +798,7 @@ func (h *NoteHandler) RenameTag(c *gin.Context) {
fail(c, http.StatusBadRequest, "请求参数错误") fail(c, http.StatusBadRequest, "请求参数错误")
return return
} }
changed, err := h.svc.RenameTag(req.OldName, req.NewName) changed, err := h.svc.RenameTag(userID, req.OldName, req.NewName)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, err.Error()) fail(c, http.StatusBadRequest, err.Error())
return return
@@ -758,6 +808,7 @@ func (h *NoteHandler) RenameTag(c *gin.Context) {
// MergeTag 合并标签(from → to // MergeTag 合并标签(from → to
func (h *NoteHandler) MergeTag(c *gin.Context) { func (h *NoteHandler) MergeTag(c *gin.Context) {
userID := middleware.GetUserID(c)
var req struct { var req struct {
From string `json:"from" binding:"required"` From string `json:"from" binding:"required"`
To string `json:"to" binding:"required"` To string `json:"to" binding:"required"`
@@ -766,7 +817,7 @@ func (h *NoteHandler) MergeTag(c *gin.Context) {
fail(c, http.StatusBadRequest, "请求参数错误") fail(c, http.StatusBadRequest, "请求参数错误")
return return
} }
changed, err := h.svc.MergeTag(req.From, req.To) changed, err := h.svc.MergeTag(userID, req.From, req.To)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, err.Error()) fail(c, http.StatusBadRequest, err.Error())
return return
@@ -774,8 +825,9 @@ func (h *NoteHandler) MergeTag(c *gin.Context) {
success(c, gin.H{"changed": changed}) success(c, gin.H{"changed": changed})
} }
// DeleteTag 删除标签(从所有笔记中移除) // DeleteTag 删除标签(从当前用户所有笔记中移除)
func (h *NoteHandler) DeleteTag(c *gin.Context) { func (h *NoteHandler) DeleteTag(c *gin.Context) {
userID := middleware.GetUserID(c)
var req struct { var req struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
} }
@@ -783,7 +835,7 @@ func (h *NoteHandler) DeleteTag(c *gin.Context) {
fail(c, http.StatusBadRequest, "请求参数错误") fail(c, http.StatusBadRequest, "请求参数错误")
return return
} }
changed, err := h.svc.DeleteTag(req.Name) changed, err := h.svc.DeleteTag(userID, req.Name)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, err.Error()) fail(c, http.StatusBadRequest, err.Error())
return return
+6 -3
View File
@@ -24,8 +24,11 @@ func main() {
// 初始化各层 // 初始化各层
noteService := service.NewNoteService(repo, cfg.PageSize) noteService := service.NewNoteService(repo, cfg.PageSize)
userRepo := repository.NewUserRepository(repo.DB())
userService := service.NewUserService(userRepo)
noteHandler := handler.NewNoteHandler(noteService) noteHandler := handler.NewNoteHandler(noteService)
adminHandler := handler.NewAdminHandler(noteService, cfg) authHandler := handler.NewAuthHandler(userService)
adminHandler := handler.NewAdminHandler(userService)
imageHandler := handler.NewImageHandler(cfg.UploadDir) imageHandler := handler.NewImageHandler(cfg.UploadDir)
// 初始化图片上传目录 // 初始化图片上传目录
@@ -44,7 +47,7 @@ func main() {
engine.Static("/assets", "./web/admin") engine.Static("/assets", "./web/admin")
// 初始化路由 // 初始化路由
router.Setup(engine, noteHandler, adminHandler, imageHandler, cfg) router.Setup(engine, noteHandler, authHandler, adminHandler, imageHandler, cfg)
// 启动服务 // 启动服务
addr := fmt.Sprintf(":%s", cfg.Port) addr := fmt.Sprintf(":%s", cfg.Port)
@@ -52,7 +55,7 @@ func main() {
log.Printf("前台展示: http://localhost%s/", addr) log.Printf("前台展示: http://localhost%s/", addr)
log.Printf("后台管理: http://localhost%s/admin/", addr) log.Printf("后台管理: http://localhost%s/admin/", addr)
log.Printf("图片上传目录: %s", cfg.UploadDir) log.Printf("图片上传目录: %s", cfg.UploadDir)
log.Printf("后台登录密码请通过 ADMIN_PASS 环境变量配置(生产环境务必修改默认密码)") log.Printf("多租户模式:首个注册用户自动成为管理员")
if err := engine.Run(addr); err != nil { if err := engine.Run(addr); err != nil {
log.Fatalf("启动服务失败: %v", err) log.Fatalf("启动服务失败: %v", err)
+67 -16
View File
@@ -10,32 +10,38 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// ---- 服务端会话存储(内存) ---- // ---- 服务端会话存储(内存)----
// 用随机 token 代替之前的固定字符串 cookie,登出/过期即失效。 // 用随机 token 代替固定字符串 cookie,登出/过期即失效。
// 会话绑定到具体用户 ID(多租户)。
// session 会话数据
type session struct {
userID uint
expiry time.Time
}
var ( var (
sessions = make(map[string]time.Time) // token -> 过期时间 sessions = make(map[string]session) // token -> 会话
sessionsMu sync.RWMutex sessionsMu sync.RWMutex
) )
const ( const (
CookieName = "admin_token" CookieName = "note_token" // 会话 cookie
SessionTTL = 7 * 24 * time.Hour // 会话有效期 7 天 SessionTTL = 7 * 24 * time.Hour
CookieMaxAge = 7 * 24 * 3600 // cookie 有效期(秒) CookieMaxAge = 7 * 24 * 3600
sessionCleanT = 10 // 清理过期会话的间隔(分钟) sessionCleanT = 10
) )
// NewSessionToken 生成一个新的会话 token 并注册 // NewSession 创建并绑定一个用户会话
func NewSessionToken() (string, time.Time) { func NewSession(userID uint) (string, time.Time) {
b := make([]byte, 32) b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
// 兜底:用时间戳+纳秒(理论上不会发生)
b = []byte(time.Now().Format("20060102150405.000000000")) b = []byte(time.Now().Format("20060102150405.000000000"))
} }
token := hex.EncodeToString(b) token := hex.EncodeToString(b)
exp := time.Now().Add(SessionTTL) exp := time.Now().Add(SessionTTL)
sessionsMu.Lock() sessionsMu.Lock()
sessions[token] = exp sessions[token] = session{userID: userID, expiry: exp}
sessionsMu.Unlock() sessionsMu.Unlock()
go cleanExpiredSessions() go cleanExpiredSessions()
return token, exp return token, exp
@@ -54,30 +60,58 @@ func IsValidSession(token string) bool {
return false return false
} }
sessionsMu.RLock() sessionsMu.RLock()
exp, ok := sessions[token] s, ok := sessions[token]
sessionsMu.RUnlock() sessionsMu.RUnlock()
if !ok { if !ok {
return false return false
} }
if time.Now().After(exp) { if time.Now().After(s.expiry) {
RevokeSession(token) RevokeSession(token)
return false return false
} }
return true return true
} }
// SessionUserID 获取 token 对应的用户 ID(无效返回 0)
func SessionUserID(token string) uint {
if token == "" {
return 0
}
sessionsMu.RLock()
s, ok := sessions[token]
sessionsMu.RUnlock()
if !ok {
return 0
}
if time.Now().After(s.expiry) {
RevokeSession(token)
return 0
}
return s.userID
}
// cleanExpiredSessions 定期清理过期会话,防止内存泄漏 // cleanExpiredSessions 定期清理过期会话,防止内存泄漏
func cleanExpiredSessions() { func cleanExpiredSessions() {
sessionsMu.Lock() sessionsMu.Lock()
defer sessionsMu.Unlock() defer sessionsMu.Unlock()
for token, exp := range sessions { for token, s := range sessions {
if time.Now().After(exp) { if time.Now().After(s.expiry) {
delete(sessions, token) delete(sessions, token)
} }
} }
} }
// AuthRequired 管理接口认证中间件 // CurrentUser 解析当前登录用户 ID 并写入 context(可为 0 表示游客)。
// 供公开路由/混合路由使用。
func CurrentUser() gin.HandlerFunc {
return func(c *gin.Context) {
uid := SessionUserID(GetAuthToken(c))
c.Set("user_id", uid)
c.Next()
}
}
// AuthRequired 认证中间件:必须登录,否则返回 401。
func AuthRequired() gin.HandlerFunc { func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
token, err := c.Cookie(CookieName) token, err := c.Cookie(CookieName)
@@ -86,10 +120,27 @@ func AuthRequired() gin.HandlerFunc {
c.Abort() c.Abort()
return return
} }
uid := SessionUserID(token)
c.Set("user_id", uid)
c.Next() c.Next()
} }
} }
// GetUserID 从 context 取当前用户 ID(游客为 0)
func GetUserID(c *gin.Context) uint {
if v, ok := c.Get("user_id"); ok {
if uid, ok2 := v.(uint); ok2 {
return uid
}
}
return 0
}
// IsLoggedIn 判断当前请求是否已登录
func IsLoggedIn(c *gin.Context) bool {
return GetUserID(c) != 0
}
// SetAuthCookie 设置认证 cookieSameSite=Lax 防 CSRFhttps 下应启用 Secure // SetAuthCookie 设置认证 cookieSameSite=Lax 防 CSRFhttps 下应启用 Secure
func SetAuthCookie(c *gin.Context, token string, secure bool) { func SetAuthCookie(c *gin.Context, token string, secure bool) {
c.SetSameSite(http.SameSiteLaxMode) c.SetSameSite(http.SameSiteLaxMode)
+1
View File
@@ -57,6 +57,7 @@ func CheckPassword(password, hash string) (bool, bool) {
// Note 笔记模型(也用于目录) // Note 笔记模型(也用于目录)
type Note struct { type Note struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
UserID uint `json:"user_id" gorm:"index;not null;default:0"` // 所属用户(多租户隔离)
Title string `json:"title" gorm:"size:255;not null"` Title string `json:"title" gorm:"size:255;not null"`
Content string `json:"content" gorm:"type:text"` Content string `json:"content" gorm:"type:text"`
DraftContent string `json:"draft_content,omitempty" gorm:"type:text"` // 未保存的草稿(自动保存用) DraftContent string `json:"draft_content,omitempty" gorm:"type:text"` // 未保存的草稿(自动保存用)
+21
View File
@@ -0,0 +1,21 @@
package model
import (
"time"
)
// User 用户(多租户模式下的账号)
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"size:64;uniqueIndex;not null"` // 登录用户名
PasswordHash string `json:"-" gorm:"size:255;not null"` // bcrypt 哈希
DisplayName string `json:"display_name" gorm:"size:64"` // 显示昵称
Role string `json:"role" gorm:"size:16;default:user"` // admin / user
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 指定用户表名
func (User) TableName() string {
return "users"
}
+149 -36
View File
@@ -35,7 +35,7 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) {
db.Exec("PRAGMA foreign_keys = ON") db.Exec("PRAGMA foreign_keys = ON")
// 自动迁移表结构 // 自动迁移表结构
if err := db.AutoMigrate(&model.Note{}, &model.NoteVersion{}); err != nil { if err := db.AutoMigrate(&model.Note{}, &model.NoteVersion{}, &model.User{}); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err) return nil, fmt.Errorf("数据库迁移失败: %w", err)
} }
@@ -138,8 +138,8 @@ func (r *NoteRepository) rebuildAllFTS() error {
} }
// FTS5Search 使用全文索引搜索(同时支持英文与中文短词) // FTS5Search 使用全文索引搜索(同时支持英文与中文短词)
// 返回匹配的笔记列表(排除已删除与目录) // 返回匹配的笔记列表(排除已删除与目录),并按 userID 做数据隔离
func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) { func (r *NoteRepository) FTS5Search(userID uint, keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
// 查询词同样做中文逐字分词,与索引侧保持一致 // 查询词同样做中文逐字分词,与索引侧保持一致
keyword = strings.TrimSpace(segmentCJK(keyword)) keyword = strings.TrimSpace(segmentCJK(keyword))
if keyword == "" { if keyword == "" {
@@ -152,8 +152,9 @@ func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model
SELECT COUNT(*) FROM note_search s SELECT COUNT(*) FROM note_search s
JOIN notes n ON n.id = s.rowid JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0 WHERE note_search MATCH ? AND n.is_folder = 0
AND n.user_id = ?
AND (n.deleted_at IS NULL OR n.deleted_at = '')` AND (n.deleted_at IS NULL OR n.deleted_at = '')`
if err := r.db.Raw(countQuery, keyword).Scan(&total).Error; err != nil { if err := r.db.Raw(countQuery, keyword, userID).Scan(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -167,10 +168,11 @@ func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model
FROM note_search s FROM note_search s
JOIN notes n ON n.id = s.rowid JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0 WHERE note_search MATCH ? AND n.is_folder = 0
AND n.user_id = ?
AND (n.deleted_at IS NULL OR n.deleted_at = '') AND (n.deleted_at IS NULL OR n.deleted_at = '')
ORDER BY bm25(note_search), n.updated_at DESC ORDER BY bm25(note_search), n.updated_at DESC
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
keyword, pageSize, offset, keyword, userID, pageSize, offset,
).Scan(&items).Error ).Scan(&items).Error
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -180,7 +182,7 @@ func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model
// Create 创建笔记 // Create 创建笔记
func (r *NoteRepository) Create(note *model.Note) error { func (r *NoteRepository) Create(note *model.Note) error {
res := r.db.Select("Title", "Content", "DraftContent", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note) res := r.db.Select("UserID", "Title", "Content", "DraftContent", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
@@ -191,7 +193,7 @@ func (r *NoteRepository) Create(note *model.Note) error {
return nil return nil
} }
// GetByID 根据 ID 获取笔记(排除已删除) // GetByID 根据 ID 获取笔记(排除已删除,不校验归属
func (r *NoteRepository) GetByID(id uint) (*model.Note, error) { func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
var note model.Note var note model.Note
err := r.db.First(&note, id).Error err := r.db.First(&note, id).Error
@@ -201,7 +203,17 @@ func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
return &note, nil return &note, nil
} }
// GetByIDIncludingDeleted 获取笔记(包含已软删除的,用于回收站恢复 // GetByIDScoped 根据 ID 获取笔记并校验归属(多租户隔离,防止跨用户访问
func (r *NoteRepository) GetByIDScoped(userID, id uint) (*model.Note, error) {
var note model.Note
err := r.db.Where("user_id = ?", userID).First(&note, id).Error
if err != nil {
return nil, err
}
return &note, nil
}
// GetByIDIncludingDeleted 获取笔记(包含已软删除的,用于回收站恢复,不校验归属)
func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) { func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
var note model.Note var note model.Note
err := r.db.Unscoped().First(&note, id).Error err := r.db.Unscoped().First(&note, id).Error
@@ -211,6 +223,16 @@ func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
return &note, nil return &note, nil
} }
// Scoped 返回带用户隔离的查询(供需要复用 db 的场景)
func (r *NoteRepository) Scoped(userID uint) *gorm.DB {
return r.db.Where("user_id = ?", userID)
}
// DB 暴露底层数据库连接(供其它仓库复用)
func (r *NoteRepository) DB() *gorm.DB {
return r.db
}
// Update 更新笔记 // Update 更新笔记
func (r *NoteRepository) Update(note *model.Note) error { func (r *NoteRepository) Update(note *model.Note) error {
if err := r.db.Save(note).Error; err != nil { if err := r.db.Save(note).Error; err != nil {
@@ -251,6 +273,8 @@ func (r *NoteRepository) Delete(id uint) error {
// ListQuery 列表查询参数 // ListQuery 列表查询参数
type ListQuery struct { type ListQuery struct {
UserID uint
LoggedIn bool // 是否已登录(false 时仅返回公开笔记,跨租户展示)
Page int Page int
PageSize int PageSize int
Category string Category string
@@ -267,6 +291,14 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error)
query := r.db.Model(&model.Note{}) query := r.db.Model(&model.Note{})
if q.LoggedIn {
// 登录用户:数据隔离,只看自己的
query = query.Where("user_id = ?", q.UserID)
} else {
// 游客:只看所有公开无密码的笔记(跨租户展示)
query = query.Where("is_public = ?", true).Where("(password IS NULL OR password = '')")
}
if q.Category != "" { if q.Category != "" {
query = query.Where("category = ?", q.Category) query = query.Where("category = ?", q.Category)
} }
@@ -297,30 +329,40 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error)
return items, total, err return items, total, err
} }
// GetAllTree 获取所有笔记的树形结构(含分享信息) // GetAllTree 获取所有笔记的树形结构(含分享信息,当前用户
func (r *NoteRepository) GetAllTree() ([]model.NoteListItem, error) { func (r *NoteRepository) GetAllTree(userID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem var items []model.NoteListItem
err := r.db.Model(&model.Note{}). err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, share_token, share_expire_at, visit_count, created_at, updated_at"). 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"). Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error Find(&items).Error
return items, err return items, err
} }
// GetPublicTree 获取公开可见的树形结构(前台用,不含分享令牌 // GetPublicTree 获取公开可见的树形结构(多租户下:登录用户看自己全部笔记;游客看所有公开笔记
func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) { func (r *NoteRepository) GetPublicTree(userID uint, loggedIn bool) ([]model.NoteListItem, error) {
var items []model.NoteListItem var items []model.NoteListItem
err := r.db.Model(&model.Note{}). q := r.db.Model(&model.Note{})
if loggedIn {
// 登录用户:展示自己名下所有未删除笔记(含私有,用于其个人笔记空间)
q = q.Where("user_id = ?", userID)
} else {
// 游客:仅展示跨租户的全部公开无密码笔记
q = q.Where("is_public = ?", true).Where("(password IS NULL OR password = '')")
}
err := q.
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at"). 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"). Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error Find(&items).Error
return items, err return items, err
} }
// GetByParentID 获取指定父目录下的所有项目(排除已删除) // GetByParentID 获取指定父目录下的所有项目(排除已删除,按用户隔离
func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, error) { func (r *NoteRepository) GetByParentID(userID, parentID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem var items []model.NoteListItem
err := r.db.Model(&model.Note{}). err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("parent_id = ?", parentID). 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"). 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"). Order("is_folder DESC, sort_order ASC, title ASC").
@@ -328,10 +370,10 @@ func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, err
return items, err return items, err
} }
// GetChildrenCount 获取子项数量(排除已删除) // GetChildrenCount 获取子项数量(排除已删除,按用户隔离
func (r *NoteRepository) GetChildrenCount(parentID uint) (int64, error) { func (r *NoteRepository) GetChildrenCount(userID, parentID uint) (int64, error) {
var count int64 var count int64
err := r.db.Model(&model.Note{}).Where("parent_id = ?", parentID).Count(&count).Error err := r.db.Model(&model.Note{}).Where("user_id = ?", userID).Where("parent_id = ?", parentID).Count(&count).Error
return count, err return count, err
} }
@@ -367,10 +409,11 @@ func (r *NoteRepository) collectDescendants(id uint) []uint {
return ids return ids
} }
// ListTrash 回收站列表(只含已软删除项) // ListTrash 回收站列表(只含已软删除项,按用户隔离
func (r *NoteRepository) ListTrash() ([]model.NoteListItem, error) { func (r *NoteRepository) ListTrash(userID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem var items []model.NoteListItem
err := r.db.Unscoped().Model(&model.Note{}). err := r.db.Unscoped().Model(&model.Note{}).
Where("user_id = ?", userID).
Where("deleted_at IS NOT NULL"). 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"). 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"). Order("deleted_at DESC").
@@ -445,13 +488,15 @@ func (r *NoteRepository) collectDescendantsIncludingDeleted(id uint) []uint {
return ids return ids
} }
// Search 搜索笔记(按标题和内容) // Search 搜索笔记(按标题和内容,按用户隔离
func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) { func (r *NoteRepository) Search(userID uint, keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
var items []model.NoteListItem var items []model.NoteListItem
var total int64 var total int64
like := "%" + keyword + "%" like := "%" + keyword + "%"
query := r.db.Model(&model.Note{}).Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false) query := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false)
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
@@ -467,20 +512,47 @@ func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.Not
return items, total, err return items, total, err
} }
// GetCategories 获取所有分类(排除已删除和目录 // SearchPublic 游客搜索(仅搜索所有公开无密码笔记
func (r *NoteRepository) GetCategories() ([]string, error) { func (r *NoteRepository) SearchPublic(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
var items []model.NoteListItem
var total int64
like := "%" + keyword + "%"
query := r.db.Model(&model.Note{}).
Where("is_public = ?", true).
Where("(password IS NULL OR password = '')").
Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("is_pinned DESC, updated_at DESC").
Offset(offset).
Limit(pageSize).
Find(&items).Error
return items, total, err
}
// GetCategories 获取所有分类(排除已删除和目录,按用户隔离)
func (r *NoteRepository) GetCategories(userID uint) ([]string, error) {
var categories []string var categories []string
err := r.db.Model(&model.Note{}). err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Distinct("category"). Distinct("category").
Where("category != '' AND is_folder = ?", false). Where("category != '' AND is_folder = ?", false).
Pluck("category", &categories).Error Pluck("category", &categories).Error
return categories, err return categories, err
} }
// GetTags 获取所有标签(排除已删除和目录) // GetTags 获取所有标签(排除已删除和目录,按用户隔离
func (r *NoteRepository) GetTags() ([]string, error) { func (r *NoteRepository) GetTags(userID uint) ([]string, error) {
var tagsJSON []string var tagsJSON []string
err := r.db.Model(&model.Note{}). err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false). Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false).
Pluck("tags", &tagsJSON).Error Pluck("tags", &tagsJSON).Error
if err != nil { if err != nil {
@@ -501,11 +573,49 @@ func (r *NoteRepository) GetTags() ([]string, error) {
// ─────────────── 标签管理 ─────────────── // ─────────────── 标签管理 ───────────────
// UpdateTagAll 将所有笔记中出现的指定标签重命名/合并/删除。 // GetPublicCategories 游客获取所有公开笔记的分类
func (r *NoteRepository) GetPublicCategories() ([]string, error) {
var categories []string
err := r.db.Model(&model.Note{}).
Where("is_public = ?", true).
Distinct("category").
Where("category != '' AND is_folder = ?", false).
Pluck("category", &categories).Error
return categories, err
}
// GetPublicTags 游客获取所有公开笔记的标签
func (r *NoteRepository) GetPublicTags() ([]string, error) {
var tagsJSON []string
err := r.db.Model(&model.Note{}).
Where("is_public = ?", true).
Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false).
Pluck("tags", &tagsJSON).Error
if err != nil {
return nil, err
}
return dedupeTags(tagsJSON), nil
}
// dedupeTags 去重并拼接标签 JSON 为字符串切片
func dedupeTags(tagsJSON []string) []string {
seen := make(map[string]bool)
var result []string
for _, t := range tagsJSON {
if !seen[t] {
seen[t] = true
result = append(result, t)
}
}
return result
}
// UpdateTagAll 将所有笔记中出现的指定标签重命名/合并/删除(按用户隔离)。
// oldTag 为要操作的旧标签;newTag 传入新名称实现重命名,传空字符串则删除该标签。 // oldTag 为要操作的旧标签;newTag 传入新名称实现重命名,传空字符串则删除该标签。
func (r *NoteRepository) UpdateTagAll(oldTag, newTag string) (int64, error) { func (r *NoteRepository) UpdateTagAll(userID uint, oldTag, newTag string) (int64, error) {
var notes []model.Note var notes []model.Note
if err := r.db.Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", oldTag)). if err := r.db.Where("user_id = ?", userID).
Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", oldTag)).
Where("is_folder = ?", false). Where("is_folder = ?", false).
Find(&notes).Error; err != nil { Find(&notes).Error; err != nil {
return 0, err return 0, err
@@ -547,18 +657,19 @@ func (r *NoteRepository) UpdateTagAll(oldTag, newTag string) (int64, error) {
// ─────────────── 双向链接 / 知识图谱 ─────────────── // ─────────────── 双向链接 / 知识图谱 ───────────────
// GetAllNotesLight 获取所有未删除笔记的 id、标题 与 标签(用于解析 [[wiki链接]] 与标签统计) // GetAllNotesLight 获取所有未删除笔记的 id、标题 与 标签(用于解析 [[wiki链接]] 与标签统计,按用户隔离
func (r *NoteRepository) GetAllNotesLight() ([]model.NoteListItem, error) { func (r *NoteRepository) GetAllNotesLight(userID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem var items []model.NoteListItem
err := r.db.Model(&model.Note{}). err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("is_folder = ?", false). Where("is_folder = ?", false).
Select("id, title, tags").Find(&items).Error Select("id, title, tags").Find(&items).Error
return items, err return items, err
} }
// GetAllLinks 获取所有未删除笔记的 id、标题、内容(用于扫描双向链接与构建图谱) // GetAllLinks 获取所有未删除笔记的 id、标题、内容(用于扫描双向链接与构建图谱,按用户隔离
// 仅返回轻量字段以降低内存占用 // 仅返回轻量字段以降低内存占用
func (r *NoteRepository) GetAllContentLight() ([]struct { func (r *NoteRepository) GetAllContentLight(userID uint) ([]struct {
ID uint `gorm:"column:id"` ID uint `gorm:"column:id"`
Title string `gorm:"column:title"` Title string `gorm:"column:title"`
Content string `gorm:"column:content"` Content string `gorm:"column:content"`
@@ -569,18 +680,20 @@ func (r *NoteRepository) GetAllContentLight() ([]struct {
Content string `gorm:"column:content"` Content string `gorm:"column:content"`
} }
err := r.db.Model(&model.Note{}). err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("is_folder = ?", false). Where("is_folder = ?", false).
Select("id, title, content").Find(&items).Error Select("id, title, content").Find(&items).Error
return items, err return items, err
} }
// GetByIDs 批量获取笔记(用于解析链接指向的笔记是否存在) // GetByIDs 批量获取笔记(用于解析链接指向的笔记是否存在,按用户隔离
func (r *NoteRepository) GetByIDs(ids []uint) ([]model.NoteListItem, error) { func (r *NoteRepository) GetByIDs(userID uint, ids []uint) ([]model.NoteListItem, error) {
if len(ids) == 0 { if len(ids) == 0 {
return nil, nil return nil, nil
} }
var items []model.NoteListItem var items []model.NoteListItem
err := r.db.Model(&model.Note{}). err := r.db.Model(&model.Note{}).
Where("user_id = ?", userID).
Where("id IN ?", ids). Where("id IN ?", ids).
Select("id, title").Find(&items).Error Select("id, title").Find(&items).Error
return items, err return items, err
+63
View File
@@ -0,0 +1,63 @@
package repository
import (
"errors"
"gorm.io/gorm"
"note-manager/model"
)
// UserRepository 用户数据访问层(多租户账号)
type UserRepository struct {
db *gorm.DB
}
// NewUserRepository 创建用户仓库(复用已有数据库连接)
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
// AutoMigrate 确保 users 表存在
func (r *UserRepository) AutoMigrate() error {
return r.db.AutoMigrate(&model.User{})
}
// Count 统计用户总数
func (r *UserRepository) Count() (int64, error) {
var n int64
err := r.db.Model(&model.User{}).Count(&n).Error
return n, err
}
// Create 创建用户(用户名冲突返回错误)
func (r *UserRepository) Create(u *model.User) error {
return r.db.Create(u).Error
}
// GetByUsername 通过用户名查询(不分大小写,统一小写存储/匹配)
func (r *UserRepository) GetByUsername(username string) (*model.User, error) {
var u model.User
err := r.db.Where("username = ?", username).First(&u).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("用户不存在")
}
return nil, err
}
return &u, nil
}
// GetByID 通过 ID 查询用户
func (r *UserRepository) GetByID(id uint) (*model.User, error) {
var u model.User
err := r.db.First(&u, id).Error
if err != nil {
return nil, err
}
return &u, nil
}
// GetDB 暴露底层连接(供需要跨表事务/迁移的场景)
func (r *UserRepository) GetDB() *gorm.DB {
return r.db
}
+15 -5
View File
@@ -8,33 +8,43 @@ import (
) )
// Setup 初始化路由 // Setup 初始化路由
func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handler.AdminHandler, imageHandler *handler.ImageHandler, cfg *config.Config) *gin.Engine { func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, authHandler *handler.AuthHandler, adminHandler *handler.AdminHandler, imageHandler *handler.ImageHandler, cfg *config.Config) *gin.Engine {
// 全局中间件 // 全局中间件
r.Use(middleware.CORS()) r.Use(middleware.CORS())
r.Use(middleware.CurrentUser()) // 解析当前用户 ID(游客为 0),供公开/混合路由使用
// 静态文件服务(图片) // 静态文件服务(图片)
r.Static("/uploads", cfg.UploadDir) r.Static("/uploads", cfg.UploadDir)
// ─────────── 公开 API ─────────── // ─────────── 认证接口(多租户账号)───────────
auth := r.Group("/api/auth")
{
auth.POST("/register", authHandler.Register) // 首个用户自动成为 admin
auth.POST("/login", authHandler.Login)
auth.POST("/logout", authHandler.Logout)
auth.GET("/me", authHandler.Me)
}
// ─────────── 公开 API(登录用户看自己的;游客看公开笔记)───────────
api := r.Group("/api") api := r.Group("/api")
{ {
notes := api.Group("/notes") notes := api.Group("/notes")
{ {
notes.GET("", noteHandler.ListNotes) notes.GET("", noteHandler.ListNotes)
notes.GET("/search", noteHandler.SearchNotes) notes.GET("/search", noteHandler.SearchNotes)
notes.GET("/:id", noteHandler.GetNote) // 公开安全版:公开无密码的笔记 notes.GET("/:id", noteHandler.GetNote) // 公开安全版:游客只看公开无密码;登录用户看自己
notes.POST("/:id/access", noteHandler.AccessNote) // 密码验证访问 notes.POST("/:id/access", noteHandler.AccessNote) // 密码验证访问
} }
api.GET("/categories", noteHandler.GetCategories) api.GET("/categories", noteHandler.GetCategories)
api.GET("/tags", noteHandler.GetTags) api.GET("/tags", noteHandler.GetTags)
api.GET("/tree", noteHandler.GetPublicTree) // 前台公开 api.GET("/tree", noteHandler.GetPublicTree) // 前台树(登录=自己,游客=公开
// 分享 JSON 接口(公开) // 分享 JSON 接口(公开)
api.GET("/share/:token", noteHandler.GetSharedNote) api.GET("/share/:token", noteHandler.GetSharedNote)
} }
// ─────────── 管理后台 API(需认证)─────────── // ─────────── 管理(个人工作区)API(需登录)───────────
adminApi := r.Group("/admin/api") adminApi := r.Group("/admin/api")
adminApi.Use(middleware.AuthRequired()) adminApi.Use(middleware.AuthRequired())
{ {
+152 -91
View File
@@ -22,14 +22,18 @@ type NoteService struct {
pageSize int pageSize int
} }
// ErrNotFound 记录不存在(映射为 HTTP 404)
var ErrNotFound = errors.New("笔记不存在")
// NewNoteService 创建服务实例 // NewNoteService 创建服务实例
func NewNoteService(repo *repository.NoteRepository, pageSize int) *NoteService { func NewNoteService(repo *repository.NoteRepository, pageSize int) *NoteService {
return &NoteService{repo: repo, pageSize: pageSize} return &NoteService{repo: repo, pageSize: pageSize}
} }
// CreateNote 创建笔记或目录 // CreateNote 创建笔记或目录(归属当前用户)
func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, error) { func (s *NoteService) CreateNote(userID uint, req model.NoteCreateRequest) (*model.Note, error) {
note := &model.Note{ note := &model.Note{
UserID: userID,
Title: req.Title, Title: req.Title,
Content: req.Content, Content: req.Content,
Category: req.Category, Category: req.Category,
@@ -62,9 +66,9 @@ func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, erro
return note, nil return note, nil
} }
// GetNote 获取单条笔记(后台管理使用,任意笔记 // GetNote 获取单条笔记(后台/个人管理使用,校验归属
func (s *NoteService) GetNote(id uint) (*model.Note, error) { func (s *NoteService) GetNote(userID, id uint) (*model.Note, error) {
note, err := s.repo.GetByID(id) note, err := s.repo.GetByIDScoped(userID, id)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("笔记不存在") return nil, errors.New("笔记不存在")
@@ -74,6 +78,18 @@ func (s *NoteService) GetNote(id uint) (*model.Note, error) {
return note, nil return note, nil
} }
// GetNotePublic 获取公开可见的笔记(游客只读公开且无密码的,用于前台)
func (s *NoteService) GetNotePublic(id uint) (*model.Note, error) {
note, err := s.repo.GetByID(id)
if err != nil {
return nil, errors.New("笔记不存在")
}
if !note.IsPublic || note.Password != "" {
return nil, errors.New("该笔记受保护,无法直接访问")
}
return note, nil
}
// GetNoteContent 获取笔记内容(需密码验证,用于前台展示) // GetNoteContent 获取笔记内容(需密码验证,用于前台展示)
// 返回 (note, 是否需要升级密码哈希, err) // 返回 (note, 是否需要升级密码哈希, err)
func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, bool, error) { func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, bool, error) {
@@ -92,11 +108,11 @@ func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, boo
return note, false, nil return note, false, nil
} }
// UpdateNote 更新笔记或目录(保存更新前快照到版本历史) // UpdateNote 更新笔记或目录(保存更新前快照到版本历史,校验归属
func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.Note, error) { func (s *NoteService) UpdateNote(userID, id uint, req model.NoteUpdateRequest) (*model.Note, error) {
note, err := s.repo.GetByID(id) note, err := s.repo.GetByIDScoped(userID, id)
if err != nil { if err != nil {
return nil, errors.New("笔记不存在") return nil, ErrNotFound
} }
// 记录旧状态,判断是否产生实质内容变化 // 记录旧状态,判断是否产生实质内容变化
@@ -150,11 +166,11 @@ func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.N
return note, nil return note, nil
} }
// DeleteNote 软删除笔记或目录(目录会软删除所有子项) // DeleteNote 软删除笔记或目录(目录会软删除所有子项,校验归属
func (s *NoteService) DeleteNote(id uint) error { func (s *NoteService) DeleteNote(userID, id uint) error {
note, err := s.repo.GetByID(id) note, err := s.repo.GetByIDScoped(userID, id)
if err != nil { if err != nil {
return errors.New("笔记不存在") return ErrNotFound
} }
if note.IsFolder { if note.IsFolder {
return s.repo.DeleteWithChildren(id) return s.repo.DeleteWithChildren(id)
@@ -162,18 +178,18 @@ func (s *NoteService) DeleteNote(id uint) error {
return s.repo.Delete(id) return s.repo.Delete(id)
} }
// GetAllTree 获取所有笔记和目录的树形结构(管理后台用) // GetAllTree 获取所有笔记和目录的树形结构(个人管理用)
func (s *NoteService) GetAllTree() ([]model.NoteListItem, error) { func (s *NoteService) GetAllTree(userID uint) ([]model.NoteListItem, error) {
return s.repo.GetAllTree() return s.repo.GetAllTree(userID)
} }
// GetPublicTree 获取公开笔记的树形结构(前台用 // GetPublicTree 获取树形结构(登录用户看自己全部;游客看所有公开笔记)
func (s *NoteService) GetPublicTree() ([]model.NoteListItem, error) { func (s *NoteService) GetPublicTree(userID uint, loggedIn bool) ([]model.NoteListItem, error) {
return s.repo.GetPublicTree() return s.repo.GetPublicTree(userID, loggedIn)
} }
// ListNotes 获取笔记列表 // ListNotes 获取笔记列表(登录用户看自己的;游客看公开笔记)
func (s *NoteService) ListNotes(pageStr, pageSizeStr, category, tag string, pinned, favorite *bool) ([]model.NoteListItem, int64, int, error) { 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) page := parseInt(pageStr, 1)
pageSize := parseInt(pageSizeStr, s.pageSize) pageSize := parseInt(pageSizeStr, s.pageSize)
@@ -185,6 +201,8 @@ func (s *NoteService) ListNotes(pageStr, pageSizeStr, category, tag string, pinn
} }
items, total, err := s.repo.List(repository.ListQuery{ items, total, err := s.repo.List(repository.ListQuery{
UserID: userID,
LoggedIn: loggedIn,
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
Category: category, Category: category,
@@ -204,13 +222,13 @@ func (s *NoteService) ListNotes(pageStr, pageSizeStr, category, tag string, pinn
return items, total, totalPages, nil return items, total, totalPages, nil
} }
// GetByParentID 获取指定目录下的所有项目 // GetByParentID 获取指定目录下的所有项目(个人管理用)
func (s *NoteService) GetByParentID(parentID uint) ([]model.NoteListItem, error) { func (s *NoteService) GetByParentID(userID, parentID uint) ([]model.NoteListItem, error) {
return s.repo.GetByParentID(parentID) return s.repo.GetByParentID(userID, parentID)
} }
// SearchNotes 搜索笔记(优先使用 FTS5 全文索引,含中文分词) // SearchNotes 搜索笔记(优先使用 FTS5 全文索引,含中文分词;按用户隔离
func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model.NoteListItem, int64, int, error) { func (s *NoteService) SearchNotes(userID uint, loggedIn bool, keyword, pageStr, pageSizeStr string) ([]model.NoteListItem, int64, int, error) {
if keyword == "" { if keyword == "" {
return nil, 0, 0, errors.New("搜索关键词不能为空") return nil, 0, 0, errors.New("搜索关键词不能为空")
} }
@@ -227,20 +245,31 @@ func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model
// FTS5 的 MATCH 语法:对用户输入做基本转义,避免语法错误 // FTS5 的 MATCH 语法:对用户输入做基本转义,避免语法错误
keyword = sanitizeFTS5(keyword) keyword = sanitizeFTS5(keyword)
items, total, err := s.repo.FTS5Search(keyword, page, pageSize) if loggedIn {
items, total, err := s.repo.FTS5Search(userID, keyword, page, pageSize)
if err != nil { if err != nil {
// FTS5 失败时回退到传统 LIKE 搜索 // FTS5 失败时回退到传统 LIKE 搜索
items, total, err = s.repo.Search(keyword, page, pageSize) items, total, err = s.repo.Search(userID, keyword, page, pageSize)
if err != nil { if err != nil {
return nil, 0, 0, fmt.Errorf("搜索笔记失败: %w", err) return nil, 0, 0, fmt.Errorf("搜索笔记失败: %w", err)
} }
} }
totalPages := int(total) / pageSize totalPages := int(total) / pageSize
if int(total)%pageSize > 0 { if int(total)%pageSize > 0 {
totalPages++ totalPages++
} }
return items, total, totalPages, nil
}
// 游客:仅搜索公开笔记
items, total, err := s.repo.SearchPublic(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 return items, total, totalPages, nil
} }
@@ -259,41 +288,54 @@ func sanitizeFTS5(q string) string {
return b.String() return b.String()
} }
// GetCategories 获取所有分类 // GetCategories 获取所有分类(登录用户看自己的;游客看公开笔记的分类)
func (s *NoteService) GetCategories() ([]string, error) { func (s *NoteService) GetCategories(userID uint, loggedIn bool) ([]string, error) {
return s.repo.GetCategories() if loggedIn {
return s.repo.GetCategories(userID)
}
// 游客:从公开笔记中提取分类
return s.repo.GetPublicCategories()
} }
// GetTags 获取所有标签 // GetTags 获取所有标签(登录用户看自己的;游客看公开笔记的标签)
func (s *NoteService) GetTags() ([]string, error) { func (s *NoteService) GetTags(userID uint, loggedIn bool) ([]string, error) {
return s.repo.GetTags() if loggedIn {
return s.repo.GetTags(userID)
}
return s.repo.GetPublicTags()
} }
// ─────────────── 回收站 ─────────────── // ─────────────── 回收站 ───────────────
// ListTrash 获取回收站列表 // ListTrash 获取回收站列表(当前用户)
func (s *NoteService) ListTrash() ([]model.NoteListItem, error) { func (s *NoteService) ListTrash(userID uint) ([]model.NoteListItem, error) {
return s.repo.ListTrash() return s.repo.ListTrash(userID)
} }
// RestoreNote 从回收站恢复笔记或目录(整棵子树) // RestoreNote 从回收站恢复笔记或目录(整棵子树,校验归属
func (s *NoteService) RestoreNote(id uint) error { func (s *NoteService) RestoreNote(userID, id uint) error {
note, err := s.repo.GetByIDIncludingDeleted(id) note, err := s.repo.GetByIDIncludingDeleted(id)
if err != nil { if err != nil {
return errors.New("记录不存在") return errors.New("记录不存在")
} }
if note.UserID != userID {
return errors.New("无权操作该记录")
}
if note.IsFolder { if note.IsFolder {
return s.repo.RestoreSubtree(id) return s.repo.RestoreSubtree(id)
} }
return s.repo.Restore(id) return s.repo.Restore(id)
} }
// PurgeNote 彻底删除笔记或目录(不可恢复) // PurgeNote 彻底删除笔记或目录(不可恢复,校验归属
func (s *NoteService) PurgeNote(id uint) error { func (s *NoteService) PurgeNote(userID, id uint) error {
note, err := s.repo.GetByIDIncludingDeleted(id) note, err := s.repo.GetByIDIncludingDeleted(id)
if err != nil { if err != nil {
return errors.New("记录不存在") return errors.New("记录不存在")
} }
if note.UserID != userID {
return errors.New("无权操作该记录")
}
if note.IsFolder { if note.IsFolder {
if err := s.repo.HardDeleteWithChildren(id); err != nil { if err := s.repo.HardDeleteWithChildren(id); err != nil {
return err return err
@@ -308,14 +350,14 @@ func (s *NoteService) PurgeNote(id uint) error {
return nil return nil
} }
// EmptyTrash 清空回收站 // EmptyTrash 清空回收站(当前用户)
func (s *NoteService) EmptyTrash() error { func (s *NoteService) EmptyTrash(userID uint) error {
trash, err := s.repo.ListTrash() trash, err := s.repo.ListTrash(userID)
if err != nil { if err != nil {
return err return err
} }
for _, item := range trash { for _, item := range trash {
if err := s.PurgeNote(item.ID); err != nil { if err := s.PurgeNote(userID, item.ID); err != nil {
return err return err
} }
} }
@@ -324,21 +366,27 @@ func (s *NoteService) EmptyTrash() error {
// ─────────────── 版本历史 ─────────────── // ─────────────── 版本历史 ───────────────
// ListVersions 获取笔记版本列表 // ListVersions 获取笔记版本列表(校验归属)
func (s *NoteService) ListVersions(noteID uint) ([]model.NoteVersion, error) { func (s *NoteService) ListVersions(userID, noteID uint) ([]model.NoteVersion, error) {
note, err := s.repo.GetByIDScoped(userID, noteID)
if err != nil {
return nil, errors.New("笔记不存在")
}
_ = note
return s.repo.ListVersions(noteID) return s.repo.ListVersions(noteID)
} }
// RestoreVersion 将笔记恢复到指定版本 // RestoreVersion 将笔记恢复到指定版本(校验归属)
func (s *NoteService) RestoreVersion(noteID, versionID uint) (*model.Note, error) { func (s *NoteService) RestoreVersion(userID, noteID, versionID uint) (*model.Note, error) {
note, err := s.repo.GetByIDScoped(userID, noteID)
if err != nil {
return nil, errors.New("笔记不存在")
}
version, err := s.repo.GetVersion(versionID) version, err := s.repo.GetVersion(versionID)
if err != nil { if err != nil {
return nil, errors.New("版本不存在") return nil, errors.New("版本不存在")
} }
note, err := s.repo.GetByID(noteID) _ = note
if err != nil {
return nil, errors.New("笔记不存在")
}
// 保存当前状态为历史版本(防止覆盖) // 保存当前状态为历史版本(防止覆盖)
_, _ = s.repo.SaveVersion(note) _, _ = s.repo.SaveVersion(note)
// 回滚 // 回滚
@@ -354,9 +402,9 @@ func (s *NoteService) RestoreVersion(noteID, versionID uint) (*model.Note, error
// ─────────────── 分享 ─────────────── // ─────────────── 分享 ───────────────
// CreateShare 创建/更新分享令牌 // CreateShare 创建/更新分享令牌(校验归属)
func (s *NoteService) CreateShare(noteID uint, expireHours int) (*model.Note, error) { func (s *NoteService) CreateShare(userID, noteID uint, expireHours int) (*model.Note, error) {
note, err := s.repo.GetByID(noteID) note, err := s.repo.GetByIDScoped(userID, noteID)
if err != nil { if err != nil {
return nil, errors.New("笔记不存在") return nil, errors.New("笔记不存在")
} }
@@ -376,9 +424,9 @@ func (s *NoteService) CreateShare(noteID uint, expireHours int) (*model.Note, er
return note, nil return note, nil
} }
// RevokeShare 撤销分享 // RevokeShare 撤销分享(校验归属)
func (s *NoteService) RevokeShare(noteID uint) error { func (s *NoteService) RevokeShare(userID, noteID uint) error {
note, err := s.repo.GetByID(noteID) note, err := s.repo.GetByIDScoped(userID, noteID)
if err != nil { if err != nil {
return errors.New("笔记不存在") return errors.New("笔记不存在")
} }
@@ -413,23 +461,27 @@ func (s *NoteService) UpgradePasswordHash(id uint, password string) error {
// ─────────────── 自动保存草稿 ─────────────── // ─────────────── 自动保存草稿 ───────────────
// SaveDraft 保存笔记草稿(仅更新草稿字段,不触发版本历史) // SaveDraft 保存笔记草稿(仅更新草稿字段,不触发版本历史,校验归属
// 返回是否有未保存草稿被记录 // 返回是否有未保存草稿被记录
func (s *NoteService) SaveDraft(id uint, content string) error { func (s *NoteService) SaveDraft(userID, id uint, content string) error {
note, err := s.repo.GetByID(id) note, err := s.repo.GetByIDScoped(userID, id)
if err != nil { if err != nil {
return errors.New("笔记不存在") return errors.New("笔记不存在")
} }
if note.IsFolder { if note.IsFolder {
return errors.New("目录不支持草稿") return errors.New("目录不支持草稿")
} }
note.DraftContent = content _ = note
// 直接更新草稿字段,保持 updated_at 不变(避免与正文保存混淆) // 直接更新草稿字段,保持 updated_at 不变(避免与正文保存混淆)
return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": content}) return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": content})
} }
// ClearDraft 清除笔记草稿(保存正文成功后调用) // ClearDraft 清除笔记草稿(保存正文成功后调用,校验归属
func (s *NoteService) ClearDraft(id uint) error { func (s *NoteService) ClearDraft(userID, id uint) error {
_, err := s.repo.GetByIDScoped(userID, id)
if err != nil {
return errors.New("笔记不存在")
}
return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": ""}) return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": ""})
} }
@@ -438,34 +490,43 @@ func (s *NoteService) ClearDraft(id uint) error {
// wikiLinkRe 匹配笔记正文中的 [[wiki链接]] 语法 // wikiLinkRe 匹配笔记正文中的 [[wiki链接]] 语法
var wikiLinkRe = regexp.MustCompile(`\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]`) var wikiLinkRe = regexp.MustCompile(`\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]`)
// GetBacklinks 获取指向指定笔记的所有笔记(反向链接) // GetBacklinks 获取指向指定笔记的所有笔记(反向链接,校验归属
func (s *NoteService) GetBacklinks(noteID uint, title string) ([]model.NoteListItem, error) { func (s *NoteService) GetBacklinks(userID, noteID uint, title string) ([]model.NoteListItem, error) {
var n *model.Note
if title == "" { if title == "" {
// 若未提供标题,先查一下 // 若未提供标题,先查一下
n, err := s.repo.GetByID(noteID) var err error
n, err = s.repo.GetByIDScoped(userID, noteID)
if err != nil { if err != nil {
return nil, errors.New("笔记不存在") return nil, errors.New("笔记不存在")
} }
title = n.Title } 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() all, err := s.repo.GetAllNotesLight(userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 找出所有包含 [[title]] 链接的笔记 ID // 找出所有包含 [[title]] 链接的笔记 ID
var result []model.NoteListItem var result []model.NoteListItem
for _, n := range all { for _, note := range all {
if n.ID == noteID { if note.ID == noteID {
continue continue
} }
full, err := s.repo.GetByID(n.ID) full, err := s.repo.GetByIDScoped(userID, note.ID)
if err != nil { if err != nil {
continue continue
} }
if wikiLinkRe.MatchString(full.Content) && strings.Contains(full.Content, "[["+title+"]]") { if strings.Contains(full.Content, "[["+title+"]]") {
result = append(result, n) result = append(result, note)
} }
} }
return result, nil return result, nil
@@ -483,9 +544,9 @@ type GraphEdge struct {
Target uint `json:"target"` Target uint `json:"target"`
} }
// GetKnowledgeGraph 构建完整知识图谱(节点 + [[链接]] 边) // GetKnowledgeGraph 构建当前用户的知识图谱(节点 + [[链接]] 边)
func (s *NoteService) GetKnowledgeGraph() (map[string]interface{}, error) { func (s *NoteService) GetKnowledgeGraph(userID uint) (map[string]interface{}, error) {
all, err := s.repo.GetAllContentLight() all, err := s.repo.GetAllContentLight(userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -526,41 +587,41 @@ func (s *NoteService) GetKnowledgeGraph() (map[string]interface{}, error) {
// ─────────────── 标签管理 ─────────────── // ─────────────── 标签管理 ───────────────
// RenameTag 重命名标签(所有含该标签的笔记同步更新) // RenameTag 重命名标签(当前用户所有含该标签的笔记同步更新)
func (s *NoteService) RenameTag(oldTag, newTag string) (int64, error) { func (s *NoteService) RenameTag(userID uint, oldTag, newTag string) (int64, error) {
if oldTag == "" || newTag == "" { if oldTag == "" || newTag == "" {
return 0, errors.New("标签名不能为空") return 0, errors.New("标签名不能为空")
} }
if oldTag == newTag { if oldTag == newTag {
return 0, nil return 0, nil
} }
return s.repo.UpdateTagAll(oldTag, newTag) return s.repo.UpdateTagAll(userID, oldTag, newTag)
} }
// MergeTag 将 from 标签合并到 to 标签(from 消失) // MergeTag 将 from 标签合并到 to 标签(current 用户,from 消失)
func (s *NoteService) MergeTag(from, to string) (int64, error) { func (s *NoteService) MergeTag(userID uint, from, to string) (int64, error) {
if from == "" || to == "" { if from == "" || to == "" {
return 0, errors.New("标签名不能为空") return 0, errors.New("标签名不能为空")
} }
if from == to { if from == to {
return 0, nil return 0, nil
} }
return s.repo.UpdateTagAll(from, to) return s.repo.UpdateTagAll(userID, from, to)
} }
// DeleteTag 删除指定标签(所有笔记中移除) // DeleteTag 删除指定标签(当前用户所有笔记中移除)
func (s *NoteService) DeleteTag(tag string) (int64, error) { func (s *NoteService) DeleteTag(userID uint, tag string) (int64, error) {
if tag == "" { if tag == "" {
return 0, errors.New("标签名不能为空") return 0, errors.New("标签名不能为空")
} }
return s.repo.UpdateTagAll(tag, "") return s.repo.UpdateTagAll(userID, tag, "")
} }
// GetTagUsage 获取每个标签及其使用次数 // GetTagUsage 获取当前用户每个标签及其使用次数
func (s *NoteService) GetTagUsage() ([]model.TagUsage, error) { func (s *NoteService) GetTagUsage(userID uint) ([]model.TagUsage, error) {
var result []model.TagUsage var result []model.TagUsage
counts := make(map[string]int) counts := make(map[string]int)
all, err := s.repo.GetAllNotesLight() all, err := s.repo.GetAllNotesLight(userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+86
View File
@@ -0,0 +1,86 @@
package service
import (
"errors"
"strings"
"note-manager/model"
"note-manager/repository"
)
// UserService 用户/账号业务逻辑(多租户认证)
type UserService struct {
userRepo *repository.UserRepository
}
// NewUserService 创建用户服务
func NewUserService(userRepo *repository.UserRepository) *UserService {
return &UserService{userRepo: userRepo}
}
// Register 注册新用户
// 说明:第一个注册的用户自动成为 admin(拥有平台管理权限);其余为普通 user。
// 同时把历史遗留(user_id=0)的笔记迁移给首位注册用户。
func (s *UserService) Register(username, password, displayName string) (*model.User, error) {
username = strings.TrimSpace(strings.ToLower(username))
displayName = strings.TrimSpace(displayName)
if username == "" {
return nil, errors.New("用户名不能为空")
}
if len(password) < 6 {
return nil, errors.New("密码至少 6 位")
}
if _, err := s.userRepo.GetByUsername(username); err == nil {
return nil, errors.New("用户名已存在")
}
count, err := s.userRepo.Count()
if err != nil {
return nil, err
}
role := "user"
if count == 0 {
role = "admin"
}
if displayName == "" {
displayName = username
}
u := &model.User{
Username: username,
PasswordHash: model.HashPassword(password),
DisplayName: displayName,
Role: role,
}
if err := s.userRepo.Create(u); err != nil {
return nil, err
}
// 首位用户:接管历史遗留(user_id=0)的笔记
if role == "admin" {
if err := s.userRepo.GetDB().Model(&model.Note{}).
Where("user_id = ?", 0).Update("user_id", u.ID).Error; err != nil {
return nil, err
}
}
return u, nil
}
// Login 校验用户名密码,返回用户
func (s *UserService) Login(username, password string) (*model.User, error) {
username = strings.TrimSpace(strings.ToLower(username))
u, err := s.userRepo.GetByUsername(username)
if err != nil {
return nil, errors.New("用户名或密码错误")
}
ok, _ := model.CheckPassword(password, u.PasswordHash)
if !ok {
return nil, errors.New("用户名或密码错误")
}
return u, nil
}
// GetByID 获取用户信息
func (s *UserService) GetByID(id uint) (*model.User, error) {
return s.userRepo.GetByID(id)
}
+74 -15
View File
@@ -55,7 +55,7 @@ def req(method, path, data=None, raw=False, headers=None):
except Exception as ex: except Exception as ex:
return -1, {"error": str(ex)} return -1, {"error": str(ex)}
print("═══ 1. 认证安全 ═══") print("═══ 1. 认证安全(多租户用户名+密码) ═══")
# 未登录访问管理 API -> 401 # 未登录访问管理 API -> 401
st, d = req("GET", "/admin/api/tree") st, d = req("GET", "/admin/api/tree")
@@ -68,23 +68,38 @@ st, d = req("POST", "/admin/notes")
report("未登录(错误路径)不泄露", st in (401, 404), f"(got {st})") report("未登录(错误路径)不泄露", st in (401, 404), f"(got {st})")
# 错误密码登录 # 错误密码登录
st, d = req("POST", "/admin/login", "password=wrongpass") st, d = req("POST", "/admin/login", "username=smoketest&password=wrongpass")
report("错误密码登录返回401", st == 401, f"(got {st})") report("错误密码登录返回401", st == 401, f"(got {st})")
# 正确登录 # 用随机用户名注册一个专属测试管理员(保证可重复运行)
st, d = req("POST", "/admin/login", "password=admin123") import uuid as _uuid
report("正确密码登录成功", st == 200 and d.get("code") == 0, f"(got {st} {d})") TUSER = "smoke_" + _uuid.uuid4().hex[:8]
st, d = req("POST", "/api/auth/register", {"username": TUSER, "password": "smoke123", "display_name": "冒烟测试"})
report("注册测试用户成功", st == 200 and d.get("code") == 0, f"(got {st} {d})")
# 注册后自动登录 -> 可访问管理 API
st, d = req("GET", "/admin/api/tree")
report("注册后自动登录(管理API可用)", st == 200 and d.get("code") == 0, f"(got {st})")
# 退出登录
st, d = req("POST", "/api/auth/logout")
report("登出成功", st == 200 and d.get("code") == 0, 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} {d})")
# 登录后可访问 # 登录后可访问
st, d = req("GET", "/admin/api/tree") st, d = req("GET", "/admin/api/tree")
report("登录后访问管理API成功", st == 200 and d.get("code") == 0, f"(got {st})") report("登录后访问管理API成功", st == 200 and d.get("code") == 0, f"(got {st})")
# 重复注册同一用户名 -> 失败
st, d = req("POST", "/api/auth/register", {"username": TUSER, "password": "smoke123"})
report("重复注册同名用户被拒", st == 400, f"(got {st} {d})")
# 旧固定 cookie 不再有效 # 旧固定 cookie 不再有效
class TmpOpener:
pass
# 手动构造请求带旧的 fixed cookie
import urllib.request as u import urllib.request as u
reqf = urllib.request.Request(BASE + "/admin/api/tree", headers={"Cookie": "admin_token=authenticated"}) reqf = urllib.request.Request(BASE + "/admin/api/tree", headers={"Cookie": "note_token=authenticated"})
try: try:
r = u.urlopen(reqf, timeout=10) r = u.urlopen(reqf, timeout=10)
st_old = r.status st_old = r.status
@@ -124,21 +139,24 @@ st, d = req("POST", "/admin/api/notes", {
protected_id = d.get("data", {}).get("id") protected_id = d.get("data", {}).get("id")
report("创建带密码笔记成功", st == 200 and protected_id, f"(got {st})") report("创建带密码笔记成功", st == 200 and protected_id, f"(got {st})")
# 通过公开接口 GET /api/notes/:id 访问 -> 应被拒 # 登出后(游客身份)通过公开接口 GET /api/notes/:id 访问 -> 应被拒
req("POST", "/api/auth/logout")
st, d = req("GET", f"/api/notes/{protected_id}") st, d = req("GET", f"/api/notes/{protected_id}")
report("公开接口拒绝带密码笔记", st in (401, 403), f"(got {st})") report("游客公开接口拒绝带密码笔记", st in (401, 403), f"(got {st})")
# 密码验证接口应能正确返回 # 密码验证接口(公开,游客)应能正确返回
st, d = req("POST", f"/api/notes/{protected_id}/access", {"password": "secret123"}) st, d = req("POST", f"/api/notes/{protected_id}/access", {"password": "secret123"})
report("密码验证访问成功", st == 200 and "秘密内容" in d.get("data", {}).get("content", ""), f"(got {st})") report("密码验证访问成功", st == 200 and "秘密内容" in d.get("data", {}).get("content", ""), f"(got {st})")
st, d = req("POST", f"/api/notes/{protected_id}/access", {"password": "wrong"}) st, d = req("POST", f"/api/notes/{protected_id}/access", {"password": "wrong"})
report("错误密码访问被拒", st == 401, f"(got {st})") report("错误密码访问被拒", st == 401, f"(got {st})")
# 公开无密码笔记 # 公开无密码笔记(游客)
public_ok = False
st, d = req("GET", f"/api/notes/{note_id}") st, d = req("GET", f"/api/notes/{note_id}")
report("公开接口读取无密码笔记成功", st == 200 and d.get("code") == 0, f"(got {st})") report("游客公开接口读取无密码笔记成功", st == 200 and d.get("code") == 0, f"(got {st})")
# 重新登录回测试用户
req("POST", "/api/auth/login", {"username": TUSER, "password": "smoke123"})
print("═══ 4. 回收站(软删除/恢复/清空) ═══") print("═══ 4. 回收站(软删除/恢复/清空) ═══")
@@ -341,6 +359,47 @@ report("合并后标签去重", "已重命名" in tag_str and "保留" not in ta
st, d = req("DELETE", "/admin/api/tags", {"name": "已重命名"}) st, d = req("DELETE", "/admin/api/tags", {"name": "已重命名"})
report("删除标签", st == 200, f"(got {st})") 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", [])}
# 注册第二个用户
st, d = req("POST", "/api/auth/register", {"username": "iso_" + _uuid.uuid4().hex[:8], "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})")
# 第二用户无权收藏/删除他人笔记
st, d = req("PUT", f"/admin/api/notes/{note_id}", {"is_favorite": True})
report("越权修改他人笔记被拒(404)", st == 404, f"(got {st})")
# 第二用户 FTS 搜索不到第一用户的笔记
q = urllib.parse.urlencode({"q": "测试笔记A"})
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)})")
# 游客公开接口能读取公开笔记(跨租户展示,需登出为游客)
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})")
# 重新登录回第一个测试用户(后续清理需要)
st, d = req("POST", "/api/auth/login", {"username": TUSER, "password": "smoke123"})
report("切回测试用户成功", st == 200 and d.get("code") == 0, f"(got {st})")
# ── 清理测试数据(彻底删除本脚本创建的所有笔记及其版本)── # ── 清理测试数据(彻底删除本脚本创建的所有笔记及其版本)──
print("\n═══ 13. 清理测试数据 ═══") print("\n═══ 13. 清理测试数据 ═══")
test_ids = [x for x in (note_id, protected_id, tmp_id, fts_id, linkA_id, linkB_id) if x] test_ids = [x for x in (note_id, protected_id, tmp_id, fts_id, linkA_id, linkB_id) if x]
+103 -19
View File
@@ -81,58 +81,142 @@
color: #667eea; color: #667eea;
text-decoration: none; text-decoration: none;
} }
.switch-link {
display: block;
text-align: center;
margin-top: 12px;
color: #764ba2;
text-decoration: none;
font-size: 14px;
cursor: pointer;
}
.switch-link:hover { text-decoration: underline; }
</style> </style>
</head> </head>
<body> <body>
<div class="login-box"> <div class="login-box">
<h1>后台管理登录</h1> <h1 id="boxTitle">后台管理登录</h1>
<!-- 登录表单 -->
<form id="loginForm"> <form id="loginForm">
<div class="form-group"> <div class="form-group">
<label>管理密码</label> <label>用户名</label>
<input type="password" id="password" placeholder="请输入管理密码" required> <input type="text" id="username" placeholder="请输入用户名" autocomplete="username" required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="password" placeholder="请输入密码" autocomplete="current-password" required>
</div> </div>
<button type="submit" class="btn-login" id="loginBtn">登 录</button> <button type="submit" class="btn-login" id="loginBtn">登 录</button>
<p class="error-msg" id="errorMsg"></p> <p class="error-msg" id="errorMsg"></p>
</form> </form>
<!-- 注册表单 -->
<form id="registerForm" style="display:none;">
<div class="form-group">
<label>用户名</label>
<input type="text" id="regUsername" placeholder="设置用户名" autocomplete="username" required>
</div>
<div class="form-group">
<label>昵称(可选)</label>
<input type="text" id="regDisplay" placeholder="显示昵称" autocomplete="nickname">
</div>
<div class="form-group">
<label>密码(至少 6 位)</label>
<input type="password" id="regPassword" placeholder="设置密码" autocomplete="new-password" required>
</div>
<button type="submit" class="btn-login" id="regBtn">注 册</button>
<p class="error-msg" id="regErrorMsg"></p>
</form>
<a href="javascript:void(0)" class="switch-link" id="switchLink">没有账号?去注册</a>
<a href="/" class="back-link">返回前台</a> <a href="/" class="back-link">返回前台</a>
</div> </div>
<script> <script>
const form = document.getElementById('loginForm'); const loginForm = document.getElementById('loginForm');
const registerForm = document.getElementById('registerForm');
const username = document.getElementById('username');
const password = document.getElementById('password'); const password = document.getElementById('password');
const regUsername = document.getElementById('regUsername');
const regDisplay = document.getElementById('regDisplay');
const regPassword = document.getElementById('regPassword');
const loginBtn = document.getElementById('loginBtn'); const loginBtn = document.getElementById('loginBtn');
const regBtn = document.getElementById('regBtn');
const errorMsg = document.getElementById('errorMsg'); const errorMsg = document.getElementById('errorMsg');
const regErrorMsg = document.getElementById('regErrorMsg');
const switchLink = document.getElementById('switchLink');
const boxTitle = document.getElementById('boxTitle');
let isRegister = false;
form.addEventListener('submit', async (e) => { function submit(btn, text) {
btn.disabled = true;
btn.textContent = text;
}
function reset(btn, text) {
btn.disabled = false;
btn.textContent = text;
}
function setMode(reg) {
isRegister = reg;
loginForm.style.display = reg ? 'none' : 'block';
registerForm.style.display = reg ? 'block' : 'none';
boxTitle.textContent = reg ? '注册账号' : '后台管理登录';
switchLink.textContent = reg ? '已有账号?去登录' : '没有账号?去注册';
errorMsg.style.display = 'none';
regErrorMsg.style.display = 'none';
}
switchLink.addEventListener('click', () => setMode(!isRegister));
loginForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
errorMsg.style.display = 'none'; errorMsg.style.display = 'none';
loginBtn.disabled = true; submit(loginBtn, '登录中...');
loginBtn.textContent = '登录中...';
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('username', username.value);
formData.append('password', password.value); formData.append('password', password.value);
const res = await fetch('/admin/login', { method: 'POST', body: formData });
const res = await fetch('/admin/login', {
method: 'POST',
body: formData
});
const data = await res.json(); const data = await res.json();
if (data.code === 0) { if (data.code === 0) {
window.location.href = '/admin/'; window.location.href = '/admin/';
} else { } else {
errorMsg.textContent = data.message; errorMsg.textContent = data.message;
errorMsg.style.display = 'block'; errorMsg.style.display = 'block';
loginBtn.disabled = false; reset(loginBtn, '登 录');
loginBtn.textContent = '登 录';
} }
} catch (err) { } catch (err) {
errorMsg.textContent = '登录失败,请重试'; errorMsg.textContent = '登录失败,请重试';
errorMsg.style.display = 'block'; errorMsg.style.display = 'block';
loginBtn.disabled = false; reset(loginBtn, '登 录');
loginBtn.textContent = '登 录'; }
});
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
regErrorMsg.style.display = 'none';
submit(regBtn, '注册中...');
try {
const body = {
username: regUsername.value,
password: regPassword.value,
display_name: regDisplay.value
};
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.code === 0) {
window.location.href = '/admin/';
} else {
regErrorMsg.textContent = data.message;
regErrorMsg.style.display = 'block';
reset(regBtn, '注 册');
}
} catch (err) {
regErrorMsg.textContent = '注册失败,请重试';
regErrorMsg.style.display = 'block';
reset(regBtn, '注 册');
} }
}); });
</script> </script>
+201
View File
@@ -610,6 +610,54 @@
.header-right .search-box { max-width: none; } .header-right .search-box { max-width: none; }
.wrap-text { word-break: break-word; } .wrap-text { word-break: break-word; }
} }
/* 登录/注册弹窗 */
.auth-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center; z-index: 9999;
}
.auth-box {
background: #fff; border-radius: 12px; padding: 30px; width: 92%; max-width: 380px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3); position: relative;
}
body.dark .auth-box { background: #1a1e26; color: #e8eaed; }
.auth-box h3 { margin: 0 0 20px; text-align: center; color: #333; }
body.dark .auth-box h3 { color: #e8eaed; }
.auth-form-group { margin-bottom: 14px; }
.auth-form-group label { display: block; margin-bottom: 6px; font-size: 13px; color: #666; }
body.dark .auth-form-group label { color: #9aa0aa; }
.auth-form-group input {
width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px;
}
body.dark .auth-form-group input { background: #11141a; border-color: #2a3040; color: #e8eaed; }
.auth-error { color: #dc3545; font-size: 13px; margin: 8px 0; }
.auth-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
.auth-btn {
padding: 11px; background: #1976d2; color: #fff; border: none; border-radius: 6px;
font-size: 15px; cursor: pointer;
}
.auth-switch {
padding: 8px; background: none; border: none; color: #1976d2; cursor: pointer; font-size: 13px;
}
.auth-close {
position: absolute; top: 12px; right: 14px; background: none; border: none;
font-size: 16px; cursor: pointer; color: #888;
}
body.dark .auth-close { color: #9aa0aa; }
.auth-link { background: none; border: 1px solid #1976d2; color: #1976d2; border-radius: 16px; padding: 4px 12px; font-size: 13px; cursor: pointer; margin-right: 4px; }
.auth-user { font-size: 13px; color: #666; }
body.dark .auth-user { color: #c9ced8; }
.auth-user b { color: #1976d2; }
/* 收藏按钮 */
.fav-btn {
margin-left: auto; display: inline-flex; align-items: center; gap: 4px;
background: none; border: 1px solid #ddd; border-radius: 16px; padding: 3px 12px;
font-size: 13px; cursor: pointer; color: #666;
}
.fav-btn:hover { border-color: #f5a623; }
.fav-btn.on { border-color: #f5a623; color: #f5a623; }
body.dark .fav-btn { border-color: #2a3040; color: #c9ced8; }
body.dark .fav-btn.on { border-color: #f5a623; color: #f5a623; }
</style> </style>
</head> </head>
<body> <body>
@@ -623,11 +671,28 @@
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg> <svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="searchInput" placeholder="搜索笔记..." onkeyup="handleSearch(event)"> <input type="text" id="searchInput" placeholder="搜索笔记..." onkeyup="handleSearch(event)">
</div> </div>
<span id="userArea" style="display:flex;align-items:center;gap:8px;"></span>
<a href="/admin/" style="color: #666; text-decoration: none; font-size: 14px;">管理</a> <a href="/admin/" style="color: #666; text-decoration: none; font-size: 14px;">管理</a>
<button onclick="toggleSiteDark()" id="darkBtn" style="background:none;border:1px solid #ccc;border-radius:20px;padding:5px 12px;font-size:13px;cursor:pointer;color:#666;">🌙 深色</button> <button onclick="toggleSiteDark()" id="darkBtn" style="background:none;border:1px solid #ccc;border-radius:20px;padding:5px 12px;font-size:13px;cursor:pointer;color:#666;">🌙 深色</button>
</div> </div>
</header> </header>
<!-- 登录/注册弹窗 -->
<div class="auth-modal" id="authModal" style="display:none;">
<div class="auth-box">
<h3 id="authTitle">登录</h3>
<div class="auth-form-group"><label>用户名</label><input type="text" id="authUsername" autocomplete="username"></div>
<div class="auth-form-group" id="authDisplayGroup" style="display:none;"><label>昵称(可选)</label><input type="text" id="authDisplay" autocomplete="nickname"></div>
<div class="auth-form-group"><label>密码</label><input type="password" id="authPassword" autocomplete="current-password"></div>
<p class="auth-error" id="authError" style="display:none;"></p>
<div class="auth-actions">
<button class="auth-btn" id="authSubmit">登 录</button>
<button class="auth-switch" id="authSwitch" type="button">没有账号?注册</button>
</div>
<button class="auth-close" id="authClose" type="button"></button>
</div>
</div>
<div class="main"> <div class="main">
<div class="sidebar"> <div class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
@@ -673,6 +738,10 @@
<span id="noteDate"></span> <span id="noteDate"></span>
<span id="noteStats" style="color:#999;font-size:12px;margin-left:8px;"></span> <span id="noteStats" style="color:#999;font-size:12px;margin-left:8px;"></span>
<div class="tags" id="noteTags"></div> <div class="tags" id="noteTags"></div>
<button id="favBtn" class="fav-btn" style="display:none;" onclick="toggleFavoritePublic()">
<span id="favStar" style="color:#ccc;"></span>
<span id="favText">收藏</span>
</button>
</div> </div>
<div class="note-content" id="noteContent"></div> <div class="note-content" id="noteContent"></div>
</div> </div>
@@ -693,12 +762,141 @@
let currentNote = null; let currentNote = null;
let currentFilter = { type: 'all' }; let currentFilter = { type: 'all' };
let expandedNodes = new Set(); let expandedNodes = new Set();
let currentUser = null; // 当前登录用户
let authMode = 'login';
async function init() { async function init() {
await checkAuth();
await loadTree(); await loadTree();
await loadCategories(); await loadCategories();
} }
// ─────────── 认证 ───────────
async function checkAuth() {
try {
const res = await fetch(`${API}/auth/me`);
const data = await res.json();
currentUser = data.code === 0 && data.data ? data.data : null;
} catch (e) {
currentUser = null;
}
renderUserArea();
}
function renderUserArea() {
const area = document.getElementById('userArea');
if (!area) return;
if (currentUser) {
area.innerHTML = `
<span class="auth-user">你好,<b>${escapeHtml(currentUser.display_name || currentUser.username)}</b></span>
<button class="auth-link" onclick="logout()">退出</button>
`;
} else {
area.innerHTML = `
<button class="auth-link" onclick="openAuth('login')">登录</button>
<button class="auth-link" onclick="openAuth('register')">注册</button>
`;
}
}
function openAuth(mode) {
authMode = mode;
document.getElementById('authTitle').textContent = mode === 'login' ? '登录' : '注册';
document.getElementById('authDisplayGroup').style.display = mode === 'login' ? 'none' : 'block';
document.getElementById('authSwitch').textContent = mode === 'login' ? '没有账号?注册' : '已有账号?登录';
document.getElementById('authSubmit').textContent = mode === 'login' ? '登 录' : '注 册';
document.getElementById('authError').style.display = 'none';
document.getElementById('authModal').style.display = 'flex';
}
function closeAuth() {
document.getElementById('authModal').style.display = 'none';
}
document.getElementById('authClose').addEventListener('click', closeAuth);
document.getElementById('authSwitch').addEventListener('click', () => {
openAuth(authMode === 'login' ? 'register' : 'login');
});
document.getElementById('authSubmit').addEventListener('click', async () => {
const username = document.getElementById('authUsername').value.trim();
const password = document.getElementById('authPassword').value;
const displayName = document.getElementById('authDisplay').value.trim();
const errEl = document.getElementById('authError');
errEl.style.display = 'none';
if (!username || !password) {
errEl.textContent = '请输入用户名和密码';
errEl.style.display = 'block';
return;
}
try {
const url = authMode === 'login' ? `${API}/auth/login` : `${API}/auth/register`;
const body = authMode === 'login'
? { username, password }
: { username, password, display_name: displayName };
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.code === 0) {
closeAuth();
await checkAuth();
await loadTree(); // 登录后树变为自己的笔记
await loadCategories();
if (currentFilter.type === 'favorites') await filterFavorites();
} else {
errEl.textContent = data.message;
errEl.style.display = 'block';
}
} catch (e) {
errEl.textContent = '请求失败,请重试';
errEl.style.display = 'block';
}
});
async function logout() {
await fetch(`${API}/auth/logout`, { method: 'POST' });
currentUser = null;
renderUserArea();
await loadTree(); // 登出后树变为公开笔记
await loadCategories();
if (currentFilter.type === 'favorites') await filterFavorites();
}
// 前台收藏按钮:登录用户可收藏/取消收藏自己的笔记
async function toggleFavoritePublic() {
if (!currentUser || !currentNote) {
showToast('请先登录', 'error');
return;
}
const newVal = !(currentNote.is_favorite === true);
try {
const res = await fetch(`/admin/api/notes/${currentNote.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_favorite: newVal })
});
const data = await res.json();
if (data.code !== 0) {
showToast(data.message || '操作失败', 'error');
return;
}
currentNote.is_favorite = newVal;
updateFavUI();
showToast(newVal ? '已收藏' : '已取消收藏', 'success');
} catch (e) {
showToast('操作失败', 'error');
}
}
function updateFavUI() {
const btn = document.getElementById('favBtn');
if (!btn) return;
const fav = currentNote && currentNote.is_favorite === true;
btn.style.display = currentUser ? 'inline-flex' : 'none';
btn.classList.toggle('on', !!fav);
document.getElementById('favStar').textContent = fav ? '★' : '☆';
document.getElementById('favText').textContent = fav ? '已收藏' : '收藏';
document.getElementById('favStar').style.color = fav ? '#f5a623' : '#ccc';
}
// 加载分类并在筛选区渲染 chips // 加载分类并在筛选区渲染 chips
async function loadCategories() { async function loadCategories() {
try { try {
@@ -920,6 +1118,9 @@
// 渲染 mermaid 图表(异步) // 渲染 mermaid 图表(异步)
setTimeout(renderMermaid, 50); setTimeout(renderMermaid, 50);
// 更新收藏按钮状态
updateFavUI();
} }
function generateTOC(content) { function generateTOC(content) {