b93c6f1e16
- FTS5中文分词搜索(需-tags=sqlite_fts5) - [[wiki链接]]反向链接+SVG知识图谱 - 自动保存草稿(不触发版本历史) - 标签重命名/合并/删除+使用统计 - 后台编辑实时预览+格式工具栏 - PWA manifest+service worker+移动端适配 - 冒烟测试扩至66例全通过
793 lines
20 KiB
Go
793 lines
20 KiB
Go
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})
|
||
}
|