feat: 多租户账号体系 + 前台收藏按钮
- 新增 users 表(user_id 数据隔离,bcrypt 密码) - 认证: 注册/登录(用户名+密码)/会话绑定用户, 首个用户成为管理员并接管旧数据 - 数据隔离: 笔记/分类/标签/回收站/版本/草稿/图谱/FTS 全部按用户隔离 - 前台: 登录/注册弹窗, 登录后★收藏自己的笔记, 游客只读公开笔记 - 后台: 用户名+密码登录, 每人管理自己的工作区, 越权访问返回404 - 冒烟测试重构+新增多租户隔离用例(78/78)
This commit is contained in:
@@ -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` 会话 cookie:HttpOnly + SameSite=Lax(防 CSRF),随机 32 字节 token,7 天过期,服务端内存校验绑定用户。
|
||||||
|
- 密码 bcrypt(cost 10),兼容旧 SHA-256 自动升级。
|
||||||
|
|||||||
+27
-18
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+66
-15
@@ -11,31 +11,37 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ---- 服务端会话存储(内存)----
|
// ---- 服务端会话存储(内存)----
|
||||||
// 用随机 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 设置认证 cookie(SameSite=Lax 防 CSRF;https 下应启用 Secure)
|
// SetAuthCookie 设置认证 cookie(SameSite=Lax 防 CSRF;https 下应启用 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)
|
||||||
|
|||||||
@@ -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"` // 未保存的草稿(自动保存用)
|
||||||
|
|||||||
@@ -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
@@ -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(¬e, id).Error
|
err := r.db.First(¬e, id).Error
|
||||||
@@ -201,7 +203,17 @@ func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
|
|||||||
return ¬e, nil
|
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) {
|
func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
|
||||||
var note model.Note
|
var note model.Note
|
||||||
err := r.db.Unscoped().First(¬e, id).Error
|
err := r.db.Unscoped().First(¬e, id).Error
|
||||||
@@ -211,6 +223,16 @@ func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
|
|||||||
return ¬e, nil
|
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 更新笔记
|
// 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(¬es).Error; err != nil {
|
Find(¬es).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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user