Files
note-manager/handler/note_handler.go
T
Your Name d9793300f9 feat: 多租户账号体系 + 前台收藏按钮
- 新增 users 表(user_id 数据隔离,bcrypt 密码)
- 认证: 注册/登录(用户名+密码)/会话绑定用户, 首个用户成为管理员并接管旧数据
- 数据隔离: 笔记/分类/标签/回收站/版本/草稿/图谱/FTS 全部按用户隔离
- 前台: 登录/注册弹窗, 登录后★收藏自己的笔记, 游客只读公开笔记
- 后台: 用户名+密码登录, 每人管理自己的工作区, 越权访问返回404
- 冒烟测试重构+新增多租户隔离用例(78/78)
2026-08-11 12:58:12 +08:00

845 lines
22 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/middleware"
"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) {
userID := middleware.GetUserID(c)
var req model.NoteCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
return
}
note, err := h.svc.CreateNote(userID, 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
}
if middleware.IsLoggedIn(c) {
userID := middleware.GetUserID(c)
note, err := h.svc.GetNote(userID, uint(id))
if err != nil {
fail(c, http.StatusNotFound, err.Error())
return
}
success(c, note)
return
}
// 游客:仅公开且无密码的笔记
note, err := h.svc.GetNotePublic(uint(id))
if err != nil {
fail(c, http.StatusForbidden, err.Error())
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
}
userID := middleware.GetUserID(c)
note, err := h.svc.GetNote(userID, uint(id))
if err != nil {
fail(c, http.StatusNotFound, err.Error())
return
}
success(c, note)
}
// 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) {
userID := middleware.GetUserID(c)
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(userID, uint(id), req)
if err != nil {
if err == service.ErrNotFound {
fail(c, http.StatusNotFound, err.Error())
return
}
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, note)
}
// DeleteNote 删除笔记(软删除,进入回收站,校验归属)
func (h *NoteHandler) DeleteNote(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.DeleteNote(userID, uint(id)); err != nil {
if err == service.ErrNotFound {
fail(c, http.StatusNotFound, err.Error())
return
}
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
}
loggedIn := middleware.IsLoggedIn(c)
userID := middleware.GetUserID(c)
items, total, totalPages, err := h.svc.ListNotes(
userID,
loggedIn,
c.DefaultQuery("page", "1"),
c.DefaultQuery("page_size", ""),
c.Query("category"),
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")
loggedIn := middleware.IsLoggedIn(c)
userID := middleware.GetUserID(c)
items, total, totalPages, err := h.svc.SearchNotes(
userID,
loggedIn,
keyword,
c.DefaultQuery("page", "1"),
c.DefaultQuery("page_size", ""),
)
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) {
loggedIn := middleware.IsLoggedIn(c)
userID := middleware.GetUserID(c)
categories, err := h.svc.GetCategories(userID, loggedIn)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, categories)
}
// GetTags 获取所有标签(登录用户看自己的;游客看公开笔记标签)
func (h *NoteHandler) GetTags(c *gin.Context) {
loggedIn := middleware.IsLoggedIn(c)
userID := middleware.GetUserID(c)
tags, err := h.svc.GetTags(userID, loggedIn)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, tags)
}
// GetTree 获取树形结构(个人管理用)
func (h *NoteHandler) GetTree(c *gin.Context) {
userID := middleware.GetUserID(c)
tree, err := h.svc.GetAllTree(userID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, tree)
}
// GetPublicTree 获取树形结构(登录用户看自己的全部;游客看所有公开笔记)
func (h *NoteHandler) GetPublicTree(c *gin.Context) {
loggedIn := middleware.IsLoggedIn(c)
userID := middleware.GetUserID(c)
tree, err := h.svc.GetPublicTree(userID, loggedIn)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
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) {
userID := middleware.GetUserID(c)
items, err := h.svc.ListTrash(userID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, items)
}
// RestoreNote 恢复笔记(校验归属)
func (h *NoteHandler) RestoreNote(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.RestoreNote(userID, uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// PurgeNote 彻底删除笔记(校验归属)
func (h *NoteHandler) PurgeNote(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.PurgeNote(userID, uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// EmptyTrash 清空回收站(当前用户)
func (h *NoteHandler) EmptyTrash(c *gin.Context) {
userID := middleware.GetUserID(c)
if err := h.svc.EmptyTrash(userID); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// ─────────────── 版本历史 ───────────────
// ListVersions 版本列表(校验归属)
func (h *NoteHandler) ListVersions(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
versions, err := h.svc.ListVersions(userID, uint(id))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, versions)
}
// RestoreVersion 恢复到指定版本(校验归属)
func (h *NoteHandler) RestoreVersion(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
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(userID, 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) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
expireHours, _ := strconv.Atoi(c.DefaultPostForm("expire_hours", "0"))
note, err := h.svc.CreateShare(userID, 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) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.RevokeShare(userID, 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) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
note, err := h.svc.GetNote(userID, 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) {
userID := middleware.GetUserID(c)
tree, err := h.svc.GetAllTree(userID)
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(userID, 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) {
userID := middleware.GetUserID(c)
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(userID, 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) {
userID := middleware.GetUserID(c)
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(userID, uint(id), req.Content); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// DiscardDraft 清除指定笔记的草稿(校验归属)
func (h *NoteHandler) DiscardDraft(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.ClearDraft(userID, uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// ─────────────── 双向链接 / 知识图谱 ───────────────
// GetBacklinks 获取指定笔记的反向链接列表(校验归属)
func (h *NoteHandler) GetBacklinks(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
links, err := h.svc.GetBacklinks(userID, uint(id), "")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, links)
}
// GetKnowledgeGraph 获取当前用户知识图谱数据(节点 + 边)
func (h *NoteHandler) GetKnowledgeGraph(c *gin.Context) {
userID := middleware.GetUserID(c)
graph, err := h.svc.GetKnowledgeGraph(userID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, graph)
}
// ─────────────── 标签管理 ───────────────
// GetTagUsage 获取当前用户标签及使用次数
func (h *NoteHandler) GetTagUsage(c *gin.Context) {
userID := middleware.GetUserID(c)
usage, err := h.svc.GetTagUsage(userID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, usage)
}
// RenameTag 重命名标签
func (h *NoteHandler) RenameTag(c *gin.Context) {
userID := middleware.GetUserID(c)
var req struct {
OldName string `json:"old_name" binding:"required"`
NewName string `json:"new_name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误")
return
}
changed, err := h.svc.RenameTag(userID, 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) {
userID := middleware.GetUserID(c)
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(userID, 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) {
userID := middleware.GetUserID(c)
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(userID, req.Name)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
success(c, gin.H{"changed": changed})
}