diff --git a/API.md b/API.md index 774b297..54d3728 100644 --- a/API.md +++ b/API.md @@ -716,3 +716,28 @@ await fetch('/api/notes', { - 可添加到主屏幕离线使用 > 注: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` 会话 cookie:HttpOnly + SameSite=Lax(防 CSRF),随机 32 字节 token,7 天过期,服务端内存校验绑定用户。 +- 密码 bcrypt(cost 10),兼容旧 SHA-256 自动升级。 diff --git a/handler/admin_handler.go b/handler/admin_handler.go index fb67dd1..0df2647 100644 --- a/handler/admin_handler.go +++ b/handler/admin_handler.go @@ -4,20 +4,18 @@ import ( "net/http" "github.com/gin-gonic/gin" - "note-manager/config" "note-manager/middleware" "note-manager/service" ) -// AdminHandler 后台管理处理器 +// AdminHandler 后台管理处理器(登录页与页面跳转,多租户账号认证) type AdminHandler struct { - noteSvc *service.NoteService - config *config.Config + userSvc *service.UserService } // NewAdminHandler 创建后台管理处理器 -func NewAdminHandler(noteSvc *service.NoteService, cfg *config.Config) *AdminHandler { - return &AdminHandler{noteSvc: noteSvc, config: cfg} +func NewAdminHandler(userSvc *service.UserService) *AdminHandler { + return &AdminHandler{userSvc: userSvc} } // LoginPage 登录页面 @@ -27,24 +25,35 @@ func (h *AdminHandler) LoginPage(c *gin.Context) { }) } -// Login 验证登录 +// Login 验证登录(用户名 + 密码,兼容 JSON 与 form) func (h *AdminHandler) Login(c *gin.Context) { + username := c.PostForm("username") password := c.PostForm("password") - if password == h.config.AdminPass { - // 生成随机会话 token - token, _ := middleware.NewSessionToken() - // 通过环境变量判断是否启用 HTTPS(生产建议配置) - secure := c.Request.TLS != nil - middleware.SetAuthCookie(c, token, secure) - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "message": "登录成功", + if username == "" && password == "" { + // 尝试 JSON + var req struct { + 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 } - c.JSON(http.StatusUnauthorized, gin.H{ - "code": 401, - "message": "密码错误", + token, _ := middleware.NewSession(u.ID) + secure := c.Request.TLS != nil + middleware.SetAuthCookie(c, token, secure) + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "message": "登录成功", }) } @@ -80,7 +89,7 @@ func (h *AdminHandler) CheckAuth(c *gin.Context) { }) } -// IndexPage 后台管理首页 +// IndexPage 后台管理首页(需登录,否则跳转登录页) func (h *AdminHandler) IndexPage(c *gin.Context) { token := middleware.GetAuthToken(c) if !middleware.IsValidSession(token) { diff --git a/handler/auth_handler.go b/handler/auth_handler.go new file mode 100644 index 0000000..9646955 --- /dev/null +++ b/handler/auth_handler.go @@ -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, + }) +} diff --git a/handler/note_handler.go b/handler/note_handler.go index db28cef..f4386c5 100644 --- a/handler/note_handler.go +++ b/handler/note_handler.go @@ -13,6 +13,7 @@ import ( "time" "github.com/gin-gonic/gin" + "note-manager/middleware" "note-manager/model" "note-manager/service" ) @@ -53,15 +54,16 @@ func fail(c *gin.Context, status int, msg string) { c.JSON(status, Response{Code: -1, Message: msg}) } -// CreateNote 创建笔记 +// CreateNote 创建笔记(归属当前登录用户) func (h *NoteHandler) CreateNote(c *gin.Context) { + userID := middleware.GetUserID(c) var req model.NoteCreateRequest if err := c.ShouldBindJSON(&req); err != nil { fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error()) return } - note, err := h.svc.CreateNote(req) + note, err := h.svc.CreateNote(userID, req) if err != nil { fail(c, http.StatusInternalServerError, err.Error()) return @@ -70,9 +72,8 @@ func (h *NoteHandler) CreateNote(c *gin.Context) { success(c, note) } -// GetNote 获取笔记详情(公开只读接口) -// 安全策略:仅允许返回「公开且无密码」的笔记。有密码或未公开的笔记一律返回需授权提示, -// 防止通过该接口绕过密码保护读取内容。 +// GetNote 获取笔记详情(公开只读接口,游客只看公开笔记) +// 安全策略:游客仅允许访问「公开且无密码」的笔记;登录用户访问自己的任意笔记。 func (h *NoteHandler) GetNote(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { @@ -80,22 +81,27 @@ func (h *NoteHandler) GetNote(c *gin.Context) { 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 { + fail(c, http.StatusNotFound, err.Error()) + return + } + success(c, note) + return + } + + // 游客:仅公开且无密码的笔记 + note, err := h.svc.GetNotePublic(uint(id)) if err != nil { - fail(c, http.StatusNotFound, err.Error()) + fail(c, http.StatusForbidden, err.Error()) return } - - // 公开接口白名单:仅公开且无密码的笔记返回完整内容 - if !note.IsPublic || note.Password != "" { - fail(c, http.StatusForbidden, "该笔记受保护,无法直接访问") - return - } - success(c, note) } -// GetAdminNote 获取笔记详情(管理后台用,返回完整内容) +// GetAdminNote 获取笔记详情(个人管理用,返回完整内容,校验归属) func (h *NoteHandler) GetAdminNote(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { @@ -103,7 +109,8 @@ func (h *NoteHandler) GetAdminNote(c *gin.Context) { return } - note, err := h.svc.GetNote(uint(id)) + userID := middleware.GetUserID(c) + note, err := h.svc.GetNote(userID, uint(id)) if err != nil { fail(c, http.StatusNotFound, err.Error()) return @@ -146,8 +153,9 @@ func (h *NoteHandler) AccessNote(c *gin.Context) { success(c, note) } -// UpdateNote 更新笔记 +// UpdateNote 更新笔记(校验归属) func (h *NoteHandler) UpdateNote(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") @@ -160,8 +168,12 @@ func (h *NoteHandler) UpdateNote(c *gin.Context) { return } - note, err := h.svc.UpdateNote(uint(id), req) + note, err := h.svc.UpdateNote(userID, uint(id), req) if err != nil { + if err == service.ErrNotFound { + fail(c, http.StatusNotFound, err.Error()) + return + } fail(c, http.StatusInternalServerError, err.Error()) return } @@ -169,15 +181,20 @@ func (h *NoteHandler) UpdateNote(c *gin.Context) { success(c, note) } -// DeleteNote 删除笔记(软删除,进入回收站) +// DeleteNote 删除笔记(软删除,进入回收站,校验归属) func (h *NoteHandler) DeleteNote(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") 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()) return } @@ -185,7 +202,7 @@ func (h *NoteHandler) DeleteNote(c *gin.Context) { success(c, nil) } -// ListNotes 获取笔记列表 +// ListNotes 获取笔记列表(登录用户看自己的;游客看公开笔记) func (h *NoteHandler) ListNotes(c *gin.Context) { var pinned *bool if v := c.Query("pinned"); v != "" { @@ -198,7 +215,12 @@ func (h *NoteHandler) ListNotes(c *gin.Context) { favorite = &b } + loggedIn := middleware.IsLoggedIn(c) + userID := middleware.GetUserID(c) + items, total, totalPages, err := h.svc.ListNotes( + userID, + loggedIn, c.DefaultQuery("page", "1"), c.DefaultQuery("page_size", ""), c.Query("category"), @@ -225,10 +247,14 @@ func (h *NoteHandler) ListNotes(c *gin.Context) { }) } -// SearchNotes 搜索笔记 +// SearchNotes 搜索笔记(登录用户搜自己的;游客搜公开笔记) func (h *NoteHandler) SearchNotes(c *gin.Context) { keyword := c.Query("q") + loggedIn := middleware.IsLoggedIn(c) + userID := middleware.GetUserID(c) items, total, totalPages, err := h.svc.SearchNotes( + userID, + loggedIn, keyword, c.DefaultQuery("page", "1"), c.DefaultQuery("page_size", ""), @@ -252,9 +278,11 @@ func (h *NoteHandler) SearchNotes(c *gin.Context) { }) } -// GetCategories 获取分类列表 +// GetCategories 获取分类列表(登录用户看自己的;游客看公开笔记分类) 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 { fail(c, http.StatusInternalServerError, err.Error()) return @@ -262,9 +290,11 @@ func (h *NoteHandler) GetCategories(c *gin.Context) { success(c, categories) } -// GetTags 获取所有标签 +// GetTags 获取所有标签(登录用户看自己的;游客看公开笔记标签) 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 { fail(c, http.StatusInternalServerError, err.Error()) return @@ -272,9 +302,10 @@ func (h *NoteHandler) GetTags(c *gin.Context) { success(c, tags) } -// GetTree 获取树形结构(管理后台用) +// GetTree 获取树形结构(个人管理用) 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 { fail(c, http.StatusInternalServerError, err.Error()) return @@ -282,9 +313,11 @@ func (h *NoteHandler) GetTree(c *gin.Context) { success(c, tree) } -// GetPublicTree 获取公开树形结构(前台用) +// GetPublicTree 获取树形结构(登录用户看自己的全部;游客看所有公开笔记) 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 { fail(c, http.StatusInternalServerError, err.Error()) return @@ -302,9 +335,10 @@ func parseIntDefault(s string, defaultVal int) int { // ─────────────── 回收站 ─────────────── -// ListTrash 回收站列表 +// ListTrash 回收站列表(当前用户) 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 { fail(c, http.StatusInternalServerError, err.Error()) return @@ -312,37 +346,40 @@ func (h *NoteHandler) ListTrash(c *gin.Context) { success(c, items) } -// RestoreNote 恢复笔记 +// RestoreNote 恢复笔记(校验归属) func (h *NoteHandler) RestoreNote(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") 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()) return } success(c, nil) } -// PurgeNote 彻底删除笔记 +// PurgeNote 彻底删除笔记(校验归属) func (h *NoteHandler) PurgeNote(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") 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()) return } success(c, nil) } -// EmptyTrash 清空回收站 +// EmptyTrash 清空回收站(当前用户) 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()) return } @@ -351,14 +388,15 @@ func (h *NoteHandler) EmptyTrash(c *gin.Context) { // ─────────────── 版本历史 ─────────────── -// ListVersions 版本列表 +// ListVersions 版本列表(校验归属) func (h *NoteHandler) ListVersions(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") return } - versions, err := h.svc.ListVersions(uint(id)) + versions, err := h.svc.ListVersions(userID, uint(id)) if err != nil { fail(c, http.StatusInternalServerError, err.Error()) return @@ -366,8 +404,9 @@ func (h *NoteHandler) ListVersions(c *gin.Context) { success(c, versions) } -// RestoreVersion 恢复到指定版本 +// RestoreVersion 恢复到指定版本(校验归属) func (h *NoteHandler) RestoreVersion(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") @@ -378,7 +417,7 @@ func (h *NoteHandler) RestoreVersion(c *gin.Context) { fail(c, http.StatusBadRequest, "无效的版本 ID") return } - note, err := h.svc.RestoreVersion(uint(id), uint(versionID)) + note, err := h.svc.RestoreVersion(userID, uint(id), uint(versionID)) if err != nil { fail(c, http.StatusInternalServerError, err.Error()) return @@ -388,35 +427,37 @@ func (h *NoteHandler) RestoreVersion(c *gin.Context) { // ─────────────── 分享 ─────────────── -// CreateShare 创建分享 +// CreateShare 创建分享(校验归属) func (h *NoteHandler) CreateShare(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") return } 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 { fail(c, http.StatusInternalServerError, err.Error()) return } success(c, gin.H{ - "id": note.ID, + "id": note.ID, "share_token": note.ShareToken, - "url": "/share/" + note.ShareToken, - "expire_at": note.ShareExpireAt, + "url": "/share/" + note.ShareToken, + "expire_at": note.ShareExpireAt, }) } -// RevokeShare 撤销分享 +// RevokeShare 撤销分享(校验归属) func (h *NoteHandler) RevokeShare(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") 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()) return } @@ -459,15 +500,16 @@ func (h *NoteHandler) SharePage(c *gin.Context) { }) } -// ExportNote 导出笔记为 Markdown 文件 +// ExportNote 导出笔记为 Markdown 文件(校验归属) func (h *NoteHandler) ExportNote(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") return } - note, err := h.svc.GetNote(uint(id)) + note, err := h.svc.GetNote(userID, uint(id)) if err != nil { fail(c, http.StatusNotFound, err.Error()) return @@ -482,9 +524,10 @@ func (h *NoteHandler) ExportNote(c *gin.Context) { c.String(http.StatusOK, content) } -// ExportAll 批量导出全部笔记为 zip +// ExportAll 批量导出当前用户全部笔记为 zip 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 { fail(c, http.StatusInternalServerError, err.Error()) return @@ -527,7 +570,7 @@ func (h *NoteHandler) ExportAll(c *gin.Context) { } return nil } - note, err := h.svc.GetNote(n.item.ID) + note, err := h.svc.GetNote(userID, n.item.ID) if err != nil { return err } @@ -549,8 +592,9 @@ func (h *NoteHandler) ExportAll(c *gin.Context) { } } -// ImportNotes 导入 Markdown 文件 +// ImportNotes 导入 Markdown 文件(归属当前用户) func (h *NoteHandler) ImportNotes(c *gin.Context) { + userID := middleware.GetUserID(c) file, err := c.FormFile("file") if err != nil { fail(c, http.StatusBadRequest, "请选择要导入的文件") @@ -587,7 +631,7 @@ func (h *NoteHandler) ImportNotes(c *gin.Context) { Content: body, } - note, err := h.svc.CreateNote(req) + note, err := h.svc.CreateNote(userID, req) if err != nil { fail(c, http.StatusInternalServerError, err.Error()) return @@ -664,8 +708,9 @@ func sanitizeFileName(name string) string { // ─────────────── 自动保存草稿 ─────────────── -// SaveDraft 保存笔记草稿(自动保存) +// SaveDraft 保存笔记草稿(自动保存,校验归属) func (h *NoteHandler) SaveDraft(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") @@ -678,21 +723,22 @@ func (h *NoteHandler) SaveDraft(c *gin.Context) { fail(c, http.StatusBadRequest, "请求参数错误") 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()) return } success(c, nil) } -// DiscardDraft 清除指定笔记的草稿 +// DiscardDraft 清除指定笔记的草稿(校验归属) func (h *NoteHandler) DiscardDraft(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") 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()) return } @@ -701,14 +747,15 @@ func (h *NoteHandler) DiscardDraft(c *gin.Context) { // ─────────────── 双向链接 / 知识图谱 ─────────────── -// GetBacklinks 获取指定笔记的反向链接列表 +// GetBacklinks 获取指定笔记的反向链接列表(校验归属) func (h *NoteHandler) GetBacklinks(c *gin.Context) { + userID := middleware.GetUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") return } - links, err := h.svc.GetBacklinks(uint(id), "") + links, err := h.svc.GetBacklinks(userID, uint(id), "") if err != nil { fail(c, http.StatusInternalServerError, err.Error()) return @@ -716,9 +763,10 @@ func (h *NoteHandler) GetBacklinks(c *gin.Context) { success(c, links) } -// GetKnowledgeGraph 获取知识图谱数据(节点 + 边) +// GetKnowledgeGraph 获取当前用户知识图谱数据(节点 + 边) 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 { fail(c, http.StatusInternalServerError, err.Error()) return @@ -728,9 +776,10 @@ func (h *NoteHandler) GetKnowledgeGraph(c *gin.Context) { // ─────────────── 标签管理 ─────────────── -// GetTagUsage 获取标签及使用次数 +// GetTagUsage 获取当前用户标签及使用次数 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 { fail(c, http.StatusInternalServerError, err.Error()) return @@ -740,6 +789,7 @@ func (h *NoteHandler) GetTagUsage(c *gin.Context) { // RenameTag 重命名标签 func (h *NoteHandler) RenameTag(c *gin.Context) { + userID := middleware.GetUserID(c) var req struct { OldName string `json:"old_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, "请求参数错误") return } - changed, err := h.svc.RenameTag(req.OldName, req.NewName) + changed, err := h.svc.RenameTag(userID, req.OldName, req.NewName) if err != nil { fail(c, http.StatusBadRequest, err.Error()) return @@ -758,6 +808,7 @@ func (h *NoteHandler) RenameTag(c *gin.Context) { // MergeTag 合并标签(from → to) func (h *NoteHandler) MergeTag(c *gin.Context) { + userID := middleware.GetUserID(c) var req struct { From string `json:"from" binding:"required"` To string `json:"to" binding:"required"` @@ -766,7 +817,7 @@ func (h *NoteHandler) MergeTag(c *gin.Context) { fail(c, http.StatusBadRequest, "请求参数错误") return } - changed, err := h.svc.MergeTag(req.From, req.To) + changed, err := h.svc.MergeTag(userID, req.From, req.To) if err != nil { fail(c, http.StatusBadRequest, err.Error()) return @@ -774,8 +825,9 @@ func (h *NoteHandler) MergeTag(c *gin.Context) { success(c, gin.H{"changed": changed}) } -// DeleteTag 删除标签(从所有笔记中移除) +// DeleteTag 删除标签(从当前用户所有笔记中移除) func (h *NoteHandler) DeleteTag(c *gin.Context) { + userID := middleware.GetUserID(c) var req struct { Name string `json:"name" binding:"required"` } @@ -783,7 +835,7 @@ func (h *NoteHandler) DeleteTag(c *gin.Context) { fail(c, http.StatusBadRequest, "请求参数错误") return } - changed, err := h.svc.DeleteTag(req.Name) + changed, err := h.svc.DeleteTag(userID, req.Name) if err != nil { fail(c, http.StatusBadRequest, err.Error()) return diff --git a/main.go b/main.go index 3f6f56a..0e0e319 100644 --- a/main.go +++ b/main.go @@ -24,8 +24,11 @@ func main() { // 初始化各层 noteService := service.NewNoteService(repo, cfg.PageSize) + userRepo := repository.NewUserRepository(repo.DB()) + userService := service.NewUserService(userRepo) noteHandler := handler.NewNoteHandler(noteService) - adminHandler := handler.NewAdminHandler(noteService, cfg) + authHandler := handler.NewAuthHandler(userService) + adminHandler := handler.NewAdminHandler(userService) imageHandler := handler.NewImageHandler(cfg.UploadDir) // 初始化图片上传目录 @@ -44,7 +47,7 @@ func main() { 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) @@ -52,7 +55,7 @@ func main() { log.Printf("前台展示: http://localhost%s/", addr) log.Printf("后台管理: http://localhost%s/admin/", addr) log.Printf("图片上传目录: %s", cfg.UploadDir) - log.Printf("后台登录密码请通过 ADMIN_PASS 环境变量配置(生产环境务必修改默认密码)") + log.Printf("多租户模式:首个注册用户自动成为管理员") if err := engine.Run(addr); err != nil { log.Fatalf("启动服务失败: %v", err) diff --git a/middleware/session.go b/middleware/session.go index bc70926..d7cf965 100644 --- a/middleware/session.go +++ b/middleware/session.go @@ -10,32 +10,38 @@ import ( "github.com/gin-gonic/gin" ) -// ---- 服务端会话存储(内存) ---- -// 用随机 token 代替之前的固定字符串 cookie,登出/过期即失效。 +// ---- 服务端会话存储(内存)---- +// 用随机 token 代替固定字符串 cookie,登出/过期即失效。 +// 会话绑定到具体用户 ID(多租户)。 + +// session 会话数据 +type session struct { + userID uint + expiry time.Time +} var ( - sessions = make(map[string]time.Time) // token -> 过期时间 + sessions = make(map[string]session) // token -> 会话 sessionsMu sync.RWMutex ) const ( - CookieName = "admin_token" - SessionTTL = 7 * 24 * time.Hour // 会话有效期 7 天 - CookieMaxAge = 7 * 24 * 3600 // cookie 有效期(秒) - sessionCleanT = 10 // 清理过期会话的间隔(分钟) + CookieName = "note_token" // 会话 cookie + SessionTTL = 7 * 24 * time.Hour + CookieMaxAge = 7 * 24 * 3600 + sessionCleanT = 10 ) -// NewSessionToken 生成一个新的会话 token 并注册 -func NewSessionToken() (string, time.Time) { +// NewSession 创建并绑定一个用户会话 +func NewSession(userID uint) (string, time.Time) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { - // 兜底:用时间戳+纳秒(理论上不会发生) b = []byte(time.Now().Format("20060102150405.000000000")) } token := hex.EncodeToString(b) exp := time.Now().Add(SessionTTL) sessionsMu.Lock() - sessions[token] = exp + sessions[token] = session{userID: userID, expiry: exp} sessionsMu.Unlock() go cleanExpiredSessions() return token, exp @@ -54,30 +60,58 @@ func IsValidSession(token string) bool { return false } sessionsMu.RLock() - exp, ok := sessions[token] + s, ok := sessions[token] sessionsMu.RUnlock() if !ok { return false } - if time.Now().After(exp) { + if time.Now().After(s.expiry) { RevokeSession(token) return false } 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 定期清理过期会话,防止内存泄漏 func cleanExpiredSessions() { sessionsMu.Lock() defer sessionsMu.Unlock() - for token, exp := range sessions { - if time.Now().After(exp) { + for token, s := range sessions { + if time.Now().After(s.expiry) { 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 { return func(c *gin.Context) { token, err := c.Cookie(CookieName) @@ -86,10 +120,27 @@ func AuthRequired() gin.HandlerFunc { c.Abort() return } + uid := SessionUserID(token) + c.Set("user_id", uid) 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 设置认证 cookie(SameSite=Lax 防 CSRF;https 下应启用 Secure) func SetAuthCookie(c *gin.Context, token string, secure bool) { c.SetSameSite(http.SameSiteLaxMode) diff --git a/model/note.go b/model/note.go index 0178809..0196723 100644 --- a/model/note.go +++ b/model/note.go @@ -57,6 +57,7 @@ func CheckPassword(password, hash string) (bool, bool) { // Note 笔记模型(也用于目录) type Note struct { 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"` Content string `json:"content" gorm:"type:text"` DraftContent string `json:"draft_content,omitempty" gorm:"type:text"` // 未保存的草稿(自动保存用) diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000..d3e397b --- /dev/null +++ b/model/user.go @@ -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" +} diff --git a/repository/note_repository.go b/repository/note_repository.go index 5c24934..df61769 100644 --- a/repository/note_repository.go +++ b/repository/note_repository.go @@ -35,7 +35,7 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) { 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) } @@ -138,8 +138,8 @@ func (r *NoteRepository) rebuildAllFTS() error { } // FTS5Search 使用全文索引搜索(同时支持英文与中文短词) -// 返回匹配的笔记列表(排除已删除与目录) -func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) { +// 返回匹配的笔记列表(排除已删除与目录),并按 userID 做数据隔离 +func (r *NoteRepository) FTS5Search(userID uint, keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) { // 查询词同样做中文逐字分词,与索引侧保持一致 keyword = strings.TrimSpace(segmentCJK(keyword)) if keyword == "" { @@ -152,8 +152,9 @@ func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model SELECT COUNT(*) FROM note_search s JOIN notes n ON n.id = s.rowid WHERE note_search MATCH ? AND n.is_folder = 0 + AND n.user_id = ? 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 } @@ -167,10 +168,11 @@ func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model FROM note_search s JOIN notes n ON n.id = s.rowid WHERE note_search MATCH ? AND n.is_folder = 0 + AND n.user_id = ? AND (n.deleted_at IS NULL OR n.deleted_at = '') ORDER BY bm25(note_search), n.updated_at DESC LIMIT ? OFFSET ?`, - keyword, pageSize, offset, + keyword, userID, pageSize, offset, ).Scan(&items).Error if err != nil { return nil, 0, err @@ -180,7 +182,7 @@ func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model // Create 创建笔记 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 { return res.Error } @@ -191,7 +193,7 @@ func (r *NoteRepository) Create(note *model.Note) error { return nil } -// GetByID 根据 ID 获取笔记(排除已删除) +// GetByID 根据 ID 获取笔记(排除已删除,不校验归属) func (r *NoteRepository) GetByID(id uint) (*model.Note, error) { var note model.Note err := r.db.First(¬e, id).Error @@ -201,7 +203,17 @@ func (r *NoteRepository) GetByID(id uint) (*model.Note, error) { return ¬e, 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(¬e, id).Error + if err != nil { + return nil, err + } + return ¬e, nil +} + +// GetByIDIncludingDeleted 获取笔记(包含已软删除的,用于回收站恢复,不校验归属) func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) { var note model.Note err := r.db.Unscoped().First(¬e, id).Error @@ -211,6 +223,16 @@ func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) { return ¬e, 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 更新笔记 func (r *NoteRepository) Update(note *model.Note) error { if err := r.db.Save(note).Error; err != nil { @@ -251,6 +273,8 @@ func (r *NoteRepository) Delete(id uint) error { // ListQuery 列表查询参数 type ListQuery struct { + UserID uint + LoggedIn bool // 是否已登录(false 时仅返回公开笔记,跨租户展示) Page int PageSize int Category string @@ -267,6 +291,14 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error) 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 != "" { query = query.Where("category = ?", q.Category) } @@ -297,30 +329,40 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error) return items, total, err } -// GetAllTree 获取所有笔记的树形结构(含分享信息) -func (r *NoteRepository) GetAllTree() ([]model.NoteListItem, error) { +// GetAllTree 获取所有笔记的树形结构(含分享信息,当前用户) +func (r *NoteRepository) GetAllTree(userID uint) ([]model.NoteListItem, error) { var items []model.NoteListItem 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"). Order("is_folder DESC, sort_order ASC, title ASC"). Find(&items).Error return items, err } -// GetPublicTree 获取公开可见的树形结构(前台用,不含分享令牌) -func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) { +// GetPublicTree 获取公开可见的树形结构(多租户下:登录用户看自己全部笔记;游客看所有公开笔记) +func (r *NoteRepository) GetPublicTree(userID uint, loggedIn bool) ([]model.NoteListItem, error) { 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"). Order("is_folder DESC, sort_order ASC, title ASC"). Find(&items).Error return items, err } -// GetByParentID 获取指定父目录下的所有项目(排除已删除) -func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, error) { +// GetByParentID 获取指定父目录下的所有项目(排除已删除,按用户隔离) +func (r *NoteRepository) GetByParentID(userID, parentID uint) ([]model.NoteListItem, error) { var items []model.NoteListItem err := r.db.Model(&model.Note{}). + Where("user_id = ?", userID). Where("parent_id = ?", parentID). Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at"). Order("is_folder DESC, sort_order ASC, title ASC"). @@ -328,10 +370,10 @@ func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, err return items, err } -// GetChildrenCount 获取子项数量(排除已删除) -func (r *NoteRepository) GetChildrenCount(parentID uint) (int64, error) { +// GetChildrenCount 获取子项数量(排除已删除,按用户隔离) +func (r *NoteRepository) GetChildrenCount(userID, parentID uint) (int64, error) { 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 } @@ -367,10 +409,11 @@ func (r *NoteRepository) collectDescendants(id uint) []uint { return ids } -// ListTrash 回收站列表(只含已软删除项) -func (r *NoteRepository) ListTrash() ([]model.NoteListItem, error) { +// ListTrash 回收站列表(只含已软删除项,按用户隔离) +func (r *NoteRepository) ListTrash(userID uint) ([]model.NoteListItem, error) { var items []model.NoteListItem err := r.db.Unscoped().Model(&model.Note{}). + Where("user_id = ?", userID). Where("deleted_at IS NOT NULL"). Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at"). Order("deleted_at DESC"). @@ -445,13 +488,15 @@ func (r *NoteRepository) collectDescendantsIncludingDeleted(id uint) []uint { return ids } -// Search 搜索笔记(按标题和内容) -func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) { +// Search 搜索笔记(按标题和内容,按用户隔离) +func (r *NoteRepository) Search(userID uint, keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) { var items []model.NoteListItem var total int64 like := "%" + keyword + "%" - query := r.db.Model(&model.Note{}).Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false) + 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 { return nil, 0, err @@ -467,20 +512,47 @@ func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.Not return items, total, err } -// GetCategories 获取所有分类(排除已删除和目录) -func (r *NoteRepository) GetCategories() ([]string, error) { +// SearchPublic 游客搜索(仅搜索所有公开无密码笔记) +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 err := r.db.Model(&model.Note{}). + Where("user_id = ?", userID). Distinct("category"). Where("category != '' AND is_folder = ?", false). Pluck("category", &categories).Error return categories, err } -// GetTags 获取所有标签(排除已删除和目录) -func (r *NoteRepository) GetTags() ([]string, error) { +// GetTags 获取所有标签(排除已删除和目录,按用户隔离) +func (r *NoteRepository) GetTags(userID uint) ([]string, error) { var tagsJSON []string err := r.db.Model(&model.Note{}). + Where("user_id = ?", userID). Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false). Pluck("tags", &tagsJSON).Error 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 传入新名称实现重命名,传空字符串则删除该标签。 -func (r *NoteRepository) UpdateTagAll(oldTag, newTag string) (int64, error) { +func (r *NoteRepository) UpdateTagAll(userID uint, oldTag, newTag string) (int64, error) { 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). Find(¬es).Error; err != nil { return 0, err @@ -547,18 +657,19 @@ func (r *NoteRepository) UpdateTagAll(oldTag, newTag string) (int64, error) { // ─────────────── 双向链接 / 知识图谱 ─────────────── -// GetAllNotesLight 获取所有未删除笔记的 id、标题 与 标签(用于解析 [[wiki链接]] 与标签统计) -func (r *NoteRepository) GetAllNotesLight() ([]model.NoteListItem, error) { +// GetAllNotesLight 获取所有未删除笔记的 id、标题 与 标签(用于解析 [[wiki链接]] 与标签统计,按用户隔离) +func (r *NoteRepository) GetAllNotesLight(userID uint) ([]model.NoteListItem, error) { var items []model.NoteListItem err := r.db.Model(&model.Note{}). + Where("user_id = ?", userID). Where("is_folder = ?", false). Select("id, title, tags").Find(&items).Error return items, err } -// GetAllLinks 获取所有未删除笔记的 id、标题、内容(用于扫描双向链接与构建图谱) +// GetAllLinks 获取所有未删除笔记的 id、标题、内容(用于扫描双向链接与构建图谱,按用户隔离) // 仅返回轻量字段以降低内存占用 -func (r *NoteRepository) GetAllContentLight() ([]struct { +func (r *NoteRepository) GetAllContentLight(userID uint) ([]struct { ID uint `gorm:"column:id"` Title string `gorm:"column:title"` Content string `gorm:"column:content"` @@ -569,18 +680,20 @@ func (r *NoteRepository) GetAllContentLight() ([]struct { Content string `gorm:"column:content"` } err := r.db.Model(&model.Note{}). + Where("user_id = ?", userID). Where("is_folder = ?", false). Select("id, title, content").Find(&items).Error return items, err } -// GetByIDs 批量获取笔记(用于解析链接指向的笔记是否存在) -func (r *NoteRepository) GetByIDs(ids []uint) ([]model.NoteListItem, error) { +// GetByIDs 批量获取笔记(用于解析链接指向的笔记是否存在,按用户隔离) +func (r *NoteRepository) GetByIDs(userID uint, ids []uint) ([]model.NoteListItem, error) { if len(ids) == 0 { return nil, nil } var items []model.NoteListItem err := r.db.Model(&model.Note{}). + Where("user_id = ?", userID). Where("id IN ?", ids). Select("id, title").Find(&items).Error return items, err diff --git a/repository/user_repository.go b/repository/user_repository.go new file mode 100644 index 0000000..76f9d51 --- /dev/null +++ b/repository/user_repository.go @@ -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 +} diff --git a/router/router.go b/router/router.go index 153221c..76b2ad5 100644 --- a/router/router.go +++ b/router/router.go @@ -8,33 +8,43 @@ import ( ) // 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.CurrentUser()) // 解析当前用户 ID(游客为 0),供公开/混合路由使用 // 静态文件服务(图片) 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") { notes := api.Group("/notes") { notes.GET("", noteHandler.ListNotes) notes.GET("/search", noteHandler.SearchNotes) - notes.GET("/:id", noteHandler.GetNote) // 公开安全版:仅公开且无密码的笔记 + notes.GET("/:id", noteHandler.GetNote) // 公开安全版:游客只看公开无密码;登录用户看自己的 notes.POST("/:id/access", noteHandler.AccessNote) // 密码验证访问 } api.GET("/categories", noteHandler.GetCategories) api.GET("/tags", noteHandler.GetTags) - api.GET("/tree", noteHandler.GetPublicTree) // 前台公开树 + api.GET("/tree", noteHandler.GetPublicTree) // 前台树(登录=自己,游客=公开) // 分享 JSON 接口(公开) api.GET("/share/:token", noteHandler.GetSharedNote) } - // ─────────── 管理后台 API(需认证)─────────── + // ─────────── 管理(个人工作区)API(需登录)─────────── adminApi := r.Group("/admin/api") adminApi.Use(middleware.AuthRequired()) { diff --git a/service/note_service.go b/service/note_service.go index 14264df..81b9b86 100644 --- a/service/note_service.go +++ b/service/note_service.go @@ -22,14 +22,18 @@ type NoteService struct { pageSize int } +// ErrNotFound 记录不存在(映射为 HTTP 404) +var ErrNotFound = errors.New("笔记不存在") + // NewNoteService 创建服务实例 func NewNoteService(repo *repository.NoteRepository, pageSize int) *NoteService { return &NoteService{repo: repo, pageSize: pageSize} } -// CreateNote 创建笔记或目录 -func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, error) { +// CreateNote 创建笔记或目录(归属当前用户) +func (s *NoteService) CreateNote(userID uint, req model.NoteCreateRequest) (*model.Note, error) { note := &model.Note{ + UserID: userID, Title: req.Title, Content: req.Content, Category: req.Category, @@ -62,9 +66,9 @@ func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, erro return note, nil } -// GetNote 获取单条笔记(后台管理使用,任意笔记) -func (s *NoteService) GetNote(id uint) (*model.Note, error) { - note, err := s.repo.GetByID(id) +// GetNote 获取单条笔记(后台/个人管理使用,校验归属) +func (s *NoteService) GetNote(userID, id uint) (*model.Note, error) { + note, err := s.repo.GetByIDScoped(userID, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("笔记不存在") @@ -74,6 +78,18 @@ func (s *NoteService) GetNote(id uint) (*model.Note, error) { 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 获取笔记内容(需密码验证,用于前台展示) // 返回 (note, 是否需要升级密码哈希, err) 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 } -// UpdateNote 更新笔记或目录(保存更新前快照到版本历史) -func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.Note, error) { - note, err := s.repo.GetByID(id) +// UpdateNote 更新笔记或目录(保存更新前快照到版本历史,校验归属) +func (s *NoteService) UpdateNote(userID, id uint, req model.NoteUpdateRequest) (*model.Note, error) { + note, err := s.repo.GetByIDScoped(userID, id) 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 } -// DeleteNote 软删除笔记或目录(目录会软删除所有子项) -func (s *NoteService) DeleteNote(id uint) error { - note, err := s.repo.GetByID(id) +// DeleteNote 软删除笔记或目录(目录会软删除所有子项,校验归属) +func (s *NoteService) DeleteNote(userID, id uint) error { + note, err := s.repo.GetByIDScoped(userID, id) if err != nil { - return errors.New("笔记不存在") + return ErrNotFound } if note.IsFolder { return s.repo.DeleteWithChildren(id) @@ -162,18 +178,18 @@ func (s *NoteService) DeleteNote(id uint) error { return s.repo.Delete(id) } -// GetAllTree 获取所有笔记和目录的树形结构(管理后台用) -func (s *NoteService) GetAllTree() ([]model.NoteListItem, error) { - return s.repo.GetAllTree() +// GetAllTree 获取所有笔记和目录的树形结构(个人管理用) +func (s *NoteService) GetAllTree(userID uint) ([]model.NoteListItem, error) { + return s.repo.GetAllTree(userID) } -// GetPublicTree 获取公开笔记的树形结构(前台用) -func (s *NoteService) GetPublicTree() ([]model.NoteListItem, error) { - return s.repo.GetPublicTree() +// GetPublicTree 获取树形结构(登录用户看自己全部;游客看所有公开笔记) +func (s *NoteService) GetPublicTree(userID uint, loggedIn bool) ([]model.NoteListItem, error) { + return s.repo.GetPublicTree(userID, loggedIn) } -// ListNotes 获取笔记列表 -func (s *NoteService) ListNotes(pageStr, pageSizeStr, category, tag string, pinned, favorite *bool) ([]model.NoteListItem, int64, int, error) { +// ListNotes 获取笔记列表(登录用户看自己的;游客看公开笔记) +func (s *NoteService) ListNotes(userID uint, loggedIn bool, pageStr, pageSizeStr, category, tag string, pinned, favorite *bool) ([]model.NoteListItem, int64, int, error) { page := parseInt(pageStr, 1) pageSize := parseInt(pageSizeStr, s.pageSize) @@ -185,6 +201,8 @@ func (s *NoteService) ListNotes(pageStr, pageSizeStr, category, tag string, pinn } items, total, err := s.repo.List(repository.ListQuery{ + UserID: userID, + LoggedIn: loggedIn, Page: page, PageSize: pageSize, Category: category, @@ -204,13 +222,13 @@ func (s *NoteService) ListNotes(pageStr, pageSizeStr, category, tag string, pinn return items, total, totalPages, nil } -// GetByParentID 获取指定目录下的所有项目 -func (s *NoteService) GetByParentID(parentID uint) ([]model.NoteListItem, error) { - return s.repo.GetByParentID(parentID) +// GetByParentID 获取指定目录下的所有项目(个人管理用) +func (s *NoteService) GetByParentID(userID, parentID uint) ([]model.NoteListItem, error) { + return s.repo.GetByParentID(userID, parentID) } -// SearchNotes 搜索笔记(优先使用 FTS5 全文索引,含中文分词) -func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model.NoteListItem, int64, int, error) { +// SearchNotes 搜索笔记(优先使用 FTS5 全文索引,含中文分词;按用户隔离) +func (s *NoteService) SearchNotes(userID uint, loggedIn bool, keyword, pageStr, pageSizeStr string) ([]model.NoteListItem, int64, int, error) { if keyword == "" { return nil, 0, 0, errors.New("搜索关键词不能为空") } @@ -227,20 +245,31 @@ func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model // FTS5 的 MATCH 语法:对用户输入做基本转义,避免语法错误 keyword = sanitizeFTS5(keyword) - items, total, err := s.repo.FTS5Search(keyword, page, pageSize) - if err != nil { - // FTS5 失败时回退到传统 LIKE 搜索 - items, total, err = s.repo.Search(keyword, page, pageSize) + if loggedIn { + items, total, err := s.repo.FTS5Search(userID, keyword, page, pageSize) if err != nil { - return nil, 0, 0, fmt.Errorf("搜索笔记失败: %w", err) + // FTS5 失败时回退到传统 LIKE 搜索 + items, total, err = s.repo.Search(userID, keyword, page, pageSize) + if err != nil { + return nil, 0, 0, fmt.Errorf("搜索笔记失败: %w", err) + } } + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + return items, total, totalPages, nil } + // 游客:仅搜索公开笔记 + items, total, err := s.repo.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 } @@ -259,41 +288,54 @@ func sanitizeFTS5(q string) string { return b.String() } -// GetCategories 获取所有分类 -func (s *NoteService) GetCategories() ([]string, error) { - return s.repo.GetCategories() +// GetCategories 获取所有分类(登录用户看自己的;游客看公开笔记的分类) +func (s *NoteService) GetCategories(userID uint, loggedIn bool) ([]string, error) { + if loggedIn { + return s.repo.GetCategories(userID) + } + // 游客:从公开笔记中提取分类 + return s.repo.GetPublicCategories() } -// GetTags 获取所有标签 -func (s *NoteService) GetTags() ([]string, error) { - return s.repo.GetTags() +// GetTags 获取所有标签(登录用户看自己的;游客看公开笔记的标签) +func (s *NoteService) GetTags(userID uint, loggedIn bool) ([]string, error) { + if loggedIn { + return s.repo.GetTags(userID) + } + return s.repo.GetPublicTags() } // ─────────────── 回收站 ─────────────── -// ListTrash 获取回收站列表 -func (s *NoteService) ListTrash() ([]model.NoteListItem, error) { - return s.repo.ListTrash() +// ListTrash 获取回收站列表(当前用户) +func (s *NoteService) ListTrash(userID uint) ([]model.NoteListItem, error) { + return s.repo.ListTrash(userID) } -// RestoreNote 从回收站恢复笔记或目录(整棵子树) -func (s *NoteService) RestoreNote(id uint) error { +// RestoreNote 从回收站恢复笔记或目录(整棵子树,校验归属) +func (s *NoteService) RestoreNote(userID, id uint) error { note, err := s.repo.GetByIDIncludingDeleted(id) if err != nil { return errors.New("记录不存在") } + if note.UserID != userID { + return errors.New("无权操作该记录") + } if note.IsFolder { return s.repo.RestoreSubtree(id) } return s.repo.Restore(id) } -// PurgeNote 彻底删除笔记或目录(不可恢复) -func (s *NoteService) PurgeNote(id uint) error { +// PurgeNote 彻底删除笔记或目录(不可恢复,校验归属) +func (s *NoteService) PurgeNote(userID, id uint) error { note, err := s.repo.GetByIDIncludingDeleted(id) if err != nil { return errors.New("记录不存在") } + if note.UserID != userID { + return errors.New("无权操作该记录") + } if note.IsFolder { if err := s.repo.HardDeleteWithChildren(id); err != nil { return err @@ -308,14 +350,14 @@ func (s *NoteService) PurgeNote(id uint) error { return nil } -// EmptyTrash 清空回收站 -func (s *NoteService) EmptyTrash() error { - trash, err := s.repo.ListTrash() +// EmptyTrash 清空回收站(当前用户) +func (s *NoteService) EmptyTrash(userID uint) error { + trash, err := s.repo.ListTrash(userID) if err != nil { return err } for _, item := range trash { - if err := s.PurgeNote(item.ID); err != nil { + if err := s.PurgeNote(userID, item.ID); err != nil { return err } } @@ -324,21 +366,27 @@ func (s *NoteService) EmptyTrash() error { // ─────────────── 版本历史 ─────────────── -// ListVersions 获取笔记版本列表 -func (s *NoteService) ListVersions(noteID uint) ([]model.NoteVersion, error) { +// ListVersions 获取笔记版本列表(校验归属) +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) } -// RestoreVersion 将笔记恢复到指定版本 -func (s *NoteService) RestoreVersion(noteID, versionID uint) (*model.Note, error) { +// RestoreVersion 将笔记恢复到指定版本(校验归属) +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) if err != nil { return nil, errors.New("版本不存在") } - note, err := s.repo.GetByID(noteID) - if err != nil { - return nil, errors.New("笔记不存在") - } + _ = note // 保存当前状态为历史版本(防止覆盖) _, _ = s.repo.SaveVersion(note) // 回滚 @@ -354,9 +402,9 @@ func (s *NoteService) RestoreVersion(noteID, versionID uint) (*model.Note, error // ─────────────── 分享 ─────────────── -// CreateShare 创建/更新分享令牌 -func (s *NoteService) CreateShare(noteID uint, expireHours int) (*model.Note, error) { - note, err := s.repo.GetByID(noteID) +// CreateShare 创建/更新分享令牌(校验归属) +func (s *NoteService) CreateShare(userID, noteID uint, expireHours int) (*model.Note, error) { + note, err := s.repo.GetByIDScoped(userID, noteID) if err != nil { return nil, errors.New("笔记不存在") } @@ -376,9 +424,9 @@ func (s *NoteService) CreateShare(noteID uint, expireHours int) (*model.Note, er return note, nil } -// RevokeShare 撤销分享 -func (s *NoteService) RevokeShare(noteID uint) error { - note, err := s.repo.GetByID(noteID) +// RevokeShare 撤销分享(校验归属) +func (s *NoteService) RevokeShare(userID, noteID uint) error { + note, err := s.repo.GetByIDScoped(userID, noteID) if err != nil { 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 { - note, err := s.repo.GetByID(id) +func (s *NoteService) SaveDraft(userID, id uint, content string) error { + note, err := s.repo.GetByIDScoped(userID, id) if err != nil { return errors.New("笔记不存在") } if note.IsFolder { return errors.New("目录不支持草稿") } - note.DraftContent = content + _ = note // 直接更新草稿字段,保持 updated_at 不变(避免与正文保存混淆) return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": content}) } -// ClearDraft 清除笔记草稿(保存正文成功后调用) -func (s *NoteService) ClearDraft(id uint) error { +// ClearDraft 清除笔记草稿(保存正文成功后调用,校验归属) +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": ""}) } @@ -438,34 +490,43 @@ func (s *NoteService) ClearDraft(id uint) error { // wikiLinkRe 匹配笔记正文中的 [[wiki链接]] 语法 var wikiLinkRe = regexp.MustCompile(`\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]`) -// GetBacklinks 获取指向指定笔记的所有笔记(反向链接) -func (s *NoteService) GetBacklinks(noteID uint, title string) ([]model.NoteListItem, error) { +// GetBacklinks 获取指向指定笔记的所有笔记(反向链接,校验归属) +func (s *NoteService) GetBacklinks(userID, noteID uint, title string) ([]model.NoteListItem, error) { + var n *model.Note if title == "" { // 若未提供标题,先查一下 - n, err := s.repo.GetByID(noteID) + var err error + n, err = s.repo.GetByIDScoped(userID, noteID) if err != nil { 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 { return nil, err } // 找出所有包含 [[title]] 链接的笔记 ID var result []model.NoteListItem - for _, n := range all { - if n.ID == noteID { + for _, note := range all { + if note.ID == noteID { continue } - full, err := s.repo.GetByID(n.ID) + full, err := s.repo.GetByIDScoped(userID, note.ID) if err != nil { continue } - if wikiLinkRe.MatchString(full.Content) && strings.Contains(full.Content, "[["+title+"]]") { - result = append(result, n) + if strings.Contains(full.Content, "[["+title+"]]") { + result = append(result, note) } } return result, nil @@ -483,9 +544,9 @@ type GraphEdge struct { Target uint `json:"target"` } -// GetKnowledgeGraph 构建完整知识图谱(节点 + [[链接]] 边) -func (s *NoteService) GetKnowledgeGraph() (map[string]interface{}, error) { - all, err := s.repo.GetAllContentLight() +// GetKnowledgeGraph 构建当前用户的知识图谱(节点 + [[链接]] 边) +func (s *NoteService) GetKnowledgeGraph(userID uint) (map[string]interface{}, error) { + all, err := s.repo.GetAllContentLight(userID) if err != nil { return nil, err } @@ -526,41 +587,41 @@ func (s *NoteService) GetKnowledgeGraph() (map[string]interface{}, error) { // ─────────────── 标签管理 ─────────────── -// RenameTag 重命名标签(所有含该标签的笔记同步更新) -func (s *NoteService) RenameTag(oldTag, newTag string) (int64, error) { +// RenameTag 重命名标签(当前用户所有含该标签的笔记同步更新) +func (s *NoteService) RenameTag(userID uint, oldTag, newTag string) (int64, error) { if oldTag == "" || newTag == "" { return 0, errors.New("标签名不能为空") } if oldTag == newTag { return 0, nil } - return s.repo.UpdateTagAll(oldTag, newTag) + return s.repo.UpdateTagAll(userID, oldTag, newTag) } -// MergeTag 将 from 标签合并到 to 标签(from 消失) -func (s *NoteService) MergeTag(from, to string) (int64, error) { +// MergeTag 将 from 标签合并到 to 标签(current 用户,from 消失) +func (s *NoteService) MergeTag(userID uint, from, to string) (int64, error) { if from == "" || to == "" { return 0, errors.New("标签名不能为空") } if from == to { return 0, nil } - return s.repo.UpdateTagAll(from, to) + return s.repo.UpdateTagAll(userID, from, to) } -// DeleteTag 删除指定标签(从所有笔记中移除) -func (s *NoteService) DeleteTag(tag string) (int64, error) { +// DeleteTag 删除指定标签(当前用户所有笔记中移除) +func (s *NoteService) DeleteTag(userID uint, tag string) (int64, error) { if tag == "" { return 0, errors.New("标签名不能为空") } - return s.repo.UpdateTagAll(tag, "") + return s.repo.UpdateTagAll(userID, tag, "") } -// GetTagUsage 获取每个标签及其使用次数 -func (s *NoteService) GetTagUsage() ([]model.TagUsage, error) { +// GetTagUsage 获取当前用户每个标签及其使用次数 +func (s *NoteService) GetTagUsage(userID uint) ([]model.TagUsage, error) { var result []model.TagUsage counts := make(map[string]int) - all, err := s.repo.GetAllNotesLight() + all, err := s.repo.GetAllNotesLight(userID) if err != nil { return nil, err } diff --git a/service/user_service.go b/service/user_service.go new file mode 100644 index 0000000..042fd92 --- /dev/null +++ b/service/user_service.go @@ -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) +} diff --git a/smoke_test.py b/smoke_test.py index 935a443..839cda6 100644 --- a/smoke_test.py +++ b/smoke_test.py @@ -55,7 +55,7 @@ def req(method, path, data=None, raw=False, headers=None): except Exception as ex: return -1, {"error": str(ex)} -print("═══ 1. 认证安全 ═══") +print("═══ 1. 认证安全(多租户用户名+密码) ═══") # 未登录访问管理 API -> 401 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})") # 错误密码登录 -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})") -# 正确登录 -st, d = req("POST", "/admin/login", "password=admin123") -report("正确密码登录成功", st == 200 and d.get("code") == 0, f"(got {st} {d})") +# 用随机用户名注册一个专属测试管理员(保证可重复运行) +import uuid as _uuid +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") 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 不再有效 -class TmpOpener: - pass -# 手动构造请求带旧的 fixed cookie 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: r = u.urlopen(reqf, timeout=10) st_old = r.status @@ -124,21 +139,24 @@ st, d = req("POST", "/admin/api/notes", { protected_id = d.get("data", {}).get("id") 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}") -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"}) 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"}) report("错误密码访问被拒", st == 401, f"(got {st})") -# 公开无密码笔记 -public_ok = False +# 公开无密码笔记(游客) 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. 回收站(软删除/恢复/清空) ═══") @@ -341,6 +359,47 @@ report("合并后标签去重", "已重命名" in tag_str and "保留" not in ta st, d = req("DELETE", "/admin/api/tags", {"name": "已重命名"}) report("删除标签", st == 200, f"(got {st})") +# ── 多租户数据隔离 ── +print("\n═══ 12b. 多租户数据隔离 ═══") +# 记住当前测试用户可见的笔记(应只有本脚本创建的 + 自己空间的) +st, d = req("GET", "/admin/api/tree") +own_ids = {x["id"] for x in d.get("data", [])} + +# 注册第二个用户 +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. 清理测试数据 ═══") test_ids = [x for x in (note_id, protected_id, tmp_id, fts_id, linkA_id, linkB_id) if x] diff --git a/web/admin/login.html b/web/admin/login.html index 48ede71..5a34230 100644 --- a/web/admin/login.html +++ b/web/admin/login.html @@ -81,58 +81,142 @@ color: #667eea; 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; }
-

后台管理登录

+

后台管理登录

+
- - + + +
+
+ +

+ + + 没有账号?去注册 返回前台
diff --git a/web/index.html b/web/index.html index 98ebb95..2f4ae23 100644 --- a/web/index.html +++ b/web/index.html @@ -610,6 +610,54 @@ .header-right .search-box { max-width: none; } .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; } @@ -623,11 +671,28 @@ + 管理 + + +
@@ -693,12 +762,141 @@ let currentNote = null; let currentFilter = { type: 'all' }; let expandedNodes = new Set(); + let currentUser = null; // 当前登录用户 + let authMode = 'login'; async function init() { + await checkAuth(); await loadTree(); 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 = ` + 你好,${escapeHtml(currentUser.display_name || currentUser.username)} + + `; + } else { + area.innerHTML = ` + + + `; + } + } + + 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 async function loadCategories() { try { @@ -920,6 +1118,9 @@ // 渲染 mermaid 图表(异步) setTimeout(renderMermaid, 50); + + // 更新收藏按钮状态 + updateFavUI(); } function generateTOC(content) {