Files
note-manager/handler/note_handler.go
T
Your Name b93c6f1e16 feat: v3 增强 - FTS5全文搜索+双向链接/知识图谱+自动保存草稿+标签管理+实时预览编辑器+PWA
- FTS5中文分词搜索(需-tags=sqlite_fts5)
- [[wiki链接]]反向链接+SVG知识图谱
- 自动保存草稿(不触发版本历史)
- 标签重命名/合并/删除+使用统计
- 后台编辑实时预览+格式工具栏
- PWA manifest+service worker+移动端适配
- 冒烟测试扩至66例全通过
2026-08-11 12:38:41 +08:00

793 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"note-manager/model"
"note-manager/service"
)
// NoteHandler 笔记请求处理器
type NoteHandler struct {
svc *service.NoteService
}
// NewNoteHandler 创建处理器实例
func NewNoteHandler(svc *service.NoteService) *NoteHandler {
return &NoteHandler{svc: svc}
}
// Response 通用响应结构
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// PageResponse 分页响应结构
type PageResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
func success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{Code: 0, Message: "success", Data: data})
}
func fail(c *gin.Context, status int, msg string) {
c.JSON(status, Response{Code: -1, Message: msg})
}
// CreateNote 创建笔记
func (h *NoteHandler) CreateNote(c *gin.Context) {
var req model.NoteCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
return
}
note, err := h.svc.CreateNote(req)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, note)
}
// GetNote 获取笔记详情(公开只读接口)
// 安全策略:仅允许返回「公开且无密码」的笔记。有密码或未公开的笔记一律返回需授权提示,
// 防止通过该接口绕过密码保护读取内容。
func (h *NoteHandler) GetNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
note, err := h.svc.GetNote(uint(id))
if err != nil {
fail(c, http.StatusNotFound, err.Error())
return
}
// 公开接口白名单:仅公开且无密码的笔记返回完整内容
if !note.IsPublic || note.Password != "" {
fail(c, http.StatusForbidden, "该笔记受保护,无法直接访问")
return
}
success(c, note)
}
// GetAdminNote 获取笔记详情(管理后台用,返回完整内容)
func (h *NoteHandler) GetAdminNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
note, err := h.svc.GetNote(uint(id))
if err != nil {
fail(c, http.StatusNotFound, err.Error())
return
}
success(c, note)
}
// AccessNote 验证密码后获取笔记内容
func (h *NoteHandler) AccessNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
var req struct {
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
// 没有密码参数,尝试从 URL 参数获取
req.Password = c.Query("password")
}
note, upgrade, err := h.svc.GetNoteContent(uint(id), req.Password)
if err != nil {
if err.Error() == "密码错误" {
fail(c, http.StatusUnauthorized, err.Error())
return
}
fail(c, http.StatusNotFound, err.Error())
return
}
// 若旧 SHA-256 哈希命中,自动升级为 bcrypt
if upgrade {
_ = h.svc.UpgradePasswordHash(note.ID, req.Password)
}
success(c, note)
}
// UpdateNote 更新笔记
func (h *NoteHandler) UpdateNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
var req model.NoteUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
return
}
note, err := h.svc.UpdateNote(uint(id), req)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, note)
}
// DeleteNote 删除笔记(软删除,进入回收站)
func (h *NoteHandler) DeleteNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.DeleteNote(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// ListNotes 获取笔记列表
func (h *NoteHandler) ListNotes(c *gin.Context) {
var pinned *bool
if v := c.Query("pinned"); v != "" {
b := v == "true" || v == "1"
pinned = &b
}
var favorite *bool
if v := c.Query("favorite"); v != "" {
b := v == "true" || v == "1"
favorite = &b
}
items, total, totalPages, err := h.svc.ListNotes(
c.DefaultQuery("page", "1"),
c.DefaultQuery("page_size", ""),
c.Query("category"),
c.Query("tag"),
pinned,
favorite,
)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
page := parseIntDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntDefault(c.DefaultQuery("page_size", "20"), 20)
c.JSON(http.StatusOK, PageResponse{
Code: 0,
Message: "success",
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
})
}
// SearchNotes 搜索笔记
func (h *NoteHandler) SearchNotes(c *gin.Context) {
keyword := c.Query("q")
items, total, totalPages, err := h.svc.SearchNotes(
keyword,
c.DefaultQuery("page", "1"),
c.DefaultQuery("page_size", ""),
)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
page := parseIntDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntDefault(c.DefaultQuery("page_size", "20"), 20)
c.JSON(http.StatusOK, PageResponse{
Code: 0,
Message: "success",
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
})
}
// GetCategories 获取分类列表
func (h *NoteHandler) GetCategories(c *gin.Context) {
categories, err := h.svc.GetCategories()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, categories)
}
// GetTags 获取所有标签
func (h *NoteHandler) GetTags(c *gin.Context) {
tags, err := h.svc.GetTags()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, tags)
}
// GetTree 获取树形结构(管理后台用)
func (h *NoteHandler) GetTree(c *gin.Context) {
tree, err := h.svc.GetAllTree()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, tree)
}
// GetPublicTree 获取公开树形结构(前台用)
func (h *NoteHandler) GetPublicTree(c *gin.Context) {
tree, err := h.svc.GetPublicTree()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, tree)
}
func parseIntDefault(s string, defaultVal int) int {
v, err := strconv.Atoi(s)
if err != nil {
return defaultVal
}
return v
}
// ─────────────── 回收站 ───────────────
// ListTrash 回收站列表
func (h *NoteHandler) ListTrash(c *gin.Context) {
items, err := h.svc.ListTrash()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, items)
}
// RestoreNote 恢复笔记
func (h *NoteHandler) RestoreNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.RestoreNote(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// PurgeNote 彻底删除笔记
func (h *NoteHandler) PurgeNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.PurgeNote(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// EmptyTrash 清空回收站
func (h *NoteHandler) EmptyTrash(c *gin.Context) {
if err := h.svc.EmptyTrash(); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// ─────────────── 版本历史 ───────────────
// ListVersions 版本列表
func (h *NoteHandler) ListVersions(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
versions, err := h.svc.ListVersions(uint(id))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, versions)
}
// RestoreVersion 恢复到指定版本
func (h *NoteHandler) RestoreVersion(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
versionID, err := strconv.ParseUint(c.PostForm("version_id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的版本 ID")
return
}
note, err := h.svc.RestoreVersion(uint(id), uint(versionID))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, note)
}
// ─────────────── 分享 ───────────────
// CreateShare 创建分享
func (h *NoteHandler) CreateShare(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
expireHours, _ := strconv.Atoi(c.DefaultPostForm("expire_hours", "0"))
note, err := h.svc.CreateShare(uint(id), expireHours)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, gin.H{
"id": note.ID,
"share_token": note.ShareToken,
"url": "/share/" + note.ShareToken,
"expire_at": note.ShareExpireAt,
})
}
// RevokeShare 撤销分享
func (h *NoteHandler) RevokeShare(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.RevokeShare(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// GetSharedNote 通过令牌获取分享笔记的 JSON 接口
// 分享的笔记若设置了访问密码,则需通过 password 参数/请求体校验
func (h *NoteHandler) GetSharedNote(c *gin.Context) {
token := c.Param("token")
var req struct {
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
req.Password = c.Query("password")
}
note, err := h.svc.GetSharedNote(token)
if err != nil {
fail(c, http.StatusNotFound, err.Error())
return
}
// 若分享的笔记有访问密码,校验
if note.Password != "" {
ok, _ := model.CheckPassword(req.Password, note.Password)
if !ok {
fail(c, http.StatusUnauthorized, "密码错误")
return
}
}
success(c, note)
}
// SharePage 分享阅读页(简单 HTML,展示分享的笔记内容)
func (h *NoteHandler) SharePage(c *gin.Context) {
c.HTML(http.StatusOK, "share.html", gin.H{
"token": c.Param("token"),
})
}
// ExportNote 导出笔记为 Markdown 文件
func (h *NoteHandler) ExportNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
note, err := h.svc.GetNote(uint(id))
if err != nil {
fail(c, http.StatusNotFound, err.Error())
return
}
// 构造 Markdown(带 YAML front matter
content := buildMarkdown(note)
filename := sanitizeFileName(note.Title) + ".md"
c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"")
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.String(http.StatusOK, content)
}
// ExportAll 批量导出全部笔记为 zip
func (h *NoteHandler) ExportAll(c *gin.Context) {
tree, err := h.svc.GetAllTree()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
// 构建节点 map 与根节点列表
type node struct {
item model.NoteListItem
children []*node
}
nodeMap := make(map[uint]*node)
for i := range tree {
nodeMap[tree[i].ID] = &node{item: tree[i]}
}
var roots []*node
for i := range tree {
n := nodeMap[tree[i].ID]
if tree[i].ParentID == 0 {
roots = append(roots, n)
} else if p, ok := nodeMap[tree[i].ParentID]; ok {
p.children = append(p.children, n)
} else {
roots = append(roots, n)
}
}
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", "attachment; filename=notes-export.zip")
zw := zip.NewWriter(c.Writer)
defer zw.Close()
var zipNotes func(n *node, path string) error
zipNotes = func(n *node, path string) error {
if n.item.IsFolder {
dirPath := filepath.Join(path, sanitizeFileName(n.item.Title))
for _, ch := range n.children {
if err := zipNotes(ch, dirPath); err != nil {
return err
}
}
return nil
}
note, err := h.svc.GetNote(n.item.ID)
if err != nil {
return err
}
body := buildMarkdown(note)
fname := filepath.Join(path, sanitizeFileName(note.Title)+".md")
fw, err := zw.Create(fname)
if err != nil {
return err
}
_, err = fw.Write([]byte(body))
return err
}
for _, root := range roots {
if err := zipNotes(root, ""); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
}
// ImportNotes 导入 Markdown 文件
func (h *NoteHandler) ImportNotes(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
fail(c, http.StatusBadRequest, "请选择要导入的文件")
return
}
if !strings.HasSuffix(strings.ToLower(file.Filename), ".md") {
fail(c, http.StatusBadRequest, "仅支持导入 .md 文件")
return
}
// 读取文件内容
src, err := file.Open()
if err != nil {
fail(c, http.StatusInternalServerError, "读取文件失败")
return
}
defer src.Close()
contentBytes, err := io.ReadAll(src)
if err != nil {
fail(c, http.StatusInternalServerError, "读取文件失败")
return
}
// 解析 front matter
title, body := parseFrontMatter(string(contentBytes))
if title == "" {
title = strings.TrimSuffix(file.Filename, ".md")
}
// 创建笔记
req := model.NoteCreateRequest{
Title: title,
Content: body,
}
note, err := h.svc.CreateNote(req)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, note)
}
// parseFrontMatter 解析 YAML front matter
func parseFrontMatter(content string) (title string, body string) {
if !strings.HasPrefix(content, "---") {
return "", content
}
parts := strings.SplitN(content, "---", 3)
if len(parts) < 3 {
return "", content
}
frontMatter := parts[1]
body = strings.TrimSpace(parts[2])
// 解析 title
for _, line := range strings.Split(frontMatter, "\n") {
if strings.HasPrefix(line, "title:") {
title = strings.TrimSpace(strings.TrimPrefix(line, "title:"))
break
}
}
return title, body
}
// urlEncode URL 编码(RFC 3986
func urlEncode(s string) string {
return url.QueryEscape(s)
}
// buildMarkdown 将笔记构造为带 YAML front matter 的 Markdown
func buildMarkdown(note *model.Note) string {
var b strings.Builder
b.WriteString("---\n")
b.WriteString("title: " + note.Title + "\n")
if note.Category != "" {
b.WriteString("category: " + note.Category + "\n")
}
// tags 是 JSON 数组字符串,转成 YAML 列表
if note.Tags != "" {
var tags []string
if err := json.Unmarshal([]byte(note.Tags), &tags); err == nil && len(tags) > 0 {
b.WriteString("tags:\n")
for _, t := range tags {
b.WriteString(" - " + t + "\n")
}
}
}
b.WriteString("created: " + note.CreatedAt.Format("2006-01-02 15:04:05") + "\n")
b.WriteString("updated: " + note.UpdatedAt.Format("2006-01-02 15:04:05") + "\n")
b.WriteString("---\n\n")
b.WriteString(note.Content)
return b.String()
}
// sanitizeFileName 清理文件名中的非法字符
func sanitizeFileName(name string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
name = replacer.Replace(name)
name = strings.TrimSpace(name)
if name == "" {
return fmt.Sprintf("note-%d", time.Now().Unix())
}
return name
}
// ─────────────── 自动保存草稿 ───────────────
// SaveDraft 保存笔记草稿(自动保存)
func (h *NoteHandler) SaveDraft(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
var req struct {
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误")
return
}
if err := h.svc.SaveDraft(uint(id), req.Content); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// DiscardDraft 清除指定笔记的草稿
func (h *NoteHandler) DiscardDraft(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.ClearDraft(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// ─────────────── 双向链接 / 知识图谱 ───────────────
// GetBacklinks 获取指定笔记的反向链接列表
func (h *NoteHandler) GetBacklinks(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
links, err := h.svc.GetBacklinks(uint(id), "")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, links)
}
// GetKnowledgeGraph 获取知识图谱数据(节点 + 边)
func (h *NoteHandler) GetKnowledgeGraph(c *gin.Context) {
graph, err := h.svc.GetKnowledgeGraph()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, graph)
}
// ─────────────── 标签管理 ───────────────
// GetTagUsage 获取标签及使用次数
func (h *NoteHandler) GetTagUsage(c *gin.Context) {
usage, err := h.svc.GetTagUsage()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, usage)
}
// RenameTag 重命名标签
func (h *NoteHandler) RenameTag(c *gin.Context) {
var req struct {
OldName string `json:"old_name" binding:"required"`
NewName string `json:"new_name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误")
return
}
changed, err := h.svc.RenameTag(req.OldName, req.NewName)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
success(c, gin.H{"changed": changed})
}
// MergeTag 合并标签(from → to
func (h *NoteHandler) MergeTag(c *gin.Context) {
var req struct {
From string `json:"from" binding:"required"`
To string `json:"to" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误")
return
}
changed, err := h.svc.MergeTag(req.From, req.To)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
success(c, gin.H{"changed": changed})
}
// DeleteTag 删除标签(从所有笔记中移除)
func (h *NoteHandler) DeleteTag(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误")
return
}
changed, err := h.svc.DeleteTag(req.Name)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
success(c, gin.H{"changed": changed})
}