From 36b311ff86e723f40ff823e34d2f0b481d0d2588 Mon Sep 17 00:00:00 2001 From: zzy <2450266535@qq.com> Date: Sun, 21 Sep 2025 15:45:37 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(bookmark):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=B9=A6=E7=AD=BE=E5=92=8C=E6=96=87=E4=BB=B6=E5=A4=B9=E7=9A=84?= =?UTF-8?q?=E5=A2=9E=E5=88=A0=E6=94=B9=E6=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 GetFolderDefaultRoot 方法用于获取默认根文件夹 - 实现 bookmark 和 folder 的请求/模型/响应转换函数 - 完善 CreateBookmark、CreateFolder、DeleteBookmark、DeleteFolder 等接口逻辑 - 支持删除空文件夹,非空文件夹禁止删除 - 修复根文件夹创建逻辑并添加错误处理 - 删除未实现的示例路由和无用代码 build: 引入 mage 构建工具支持多平台编译 - 添加 magefile.go 实现跨平台构建脚本 - 更新 go.mod 和 go.sum 引入 github.com/magefile/mage 依赖 - 在 README 中补充 mage 使用说明和 VSCode 配置 docs: 更新 README 并添加 mage 使用示例 - 添加 govulncheck 和 mage 构建相关文档 - 补充 VSCode gopls 配置说明 ci: 更新 .gitignore 忽略 bin 目录和可执行文件 - 添加 bin/ 到忽略列表 - 统一忽略 *.exe 和 *.sqlite3 文件 refactor(main): 优化主函数启动逻辑与日志输出 - 移除示例 helloworld 路由 - 设置 gin 为 ReleaseMode 模式 - 统一服务监听地址为变量,并增加启动日志提示 ``` --- .gitignore | 1 + README.md | 38 +++++ go.mod | 1 + go.sum | 2 + internal/handlers/bookmark.go | 253 ++++++++++++++++++++++------------ magefile.go | 81 +++++++++++ main.go | 18 +-- 7 files changed, 295 insertions(+), 99 deletions(-) create mode 100644 magefile.go diff --git a/.gitignore b/.gitignore index 730d3b4..a7074d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .*/ dist/ gen/ +bin/ *.exe *.sqlite3 diff --git a/README.md b/README.md index c385c0e..1f52383 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,41 @@ go get -tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest #go:generate go tool oapi-codegen -config cfg.yaml ../../api.yaml go tool oapi-codegen -config cfg.yaml api.yaml ``` + +```shell +# https://go-lang.org.cn/doc/tutorial/govulncheck +go install golang.org/x/vuln/cmd/govulncheck@latest +govulncheck ./... + +go install github.com/magefile/mage@latest +mage -init + +mage build +``` + +```go +//go:build mage + +package main + +import ( + "github.com/magefile/mage/sh" +) + +// Runs go mod download and then installs the binary. +func Build() error { + if err := sh.Run("go", "mod", "download"); err != nil { + return err + } + return sh.Run("go", "install", "./...") +} +``` + +```json +// on vscode settings.json +{ + "gopls": { + "buildFlags": ["-tags=mage"] + } +} +``` diff --git a/go.mod b/go.mod index 17d890a..d3a54e4 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.1 require ( github.com/gin-gonic/gin v1.10.1 + github.com/magefile/mage v1.15.0 github.com/mattn/go-sqlite3 v1.14.32 github.com/oapi-codegen/runtime v1.1.2 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 46e6412..4fe2596 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/internal/handlers/bookmark.go b/internal/handlers/bookmark.go index 3d35691..962e5b4 100644 --- a/internal/handlers/bookmark.go +++ b/internal/handlers/bookmark.go @@ -3,6 +3,7 @@ package handlers import ( + "fmt" "net/http" "git.zzyxyz.com/zzy/zzyxyz_go_api/gen/api" @@ -17,6 +18,71 @@ type BookMarksImpl struct { db *gorm.DB } +const forlder_root_id = 1 + +func (b *BookMarksImpl) GetFolderDefaultRoot(folderID *int64) (*models.Folder, error) { + var db *gorm.DB = b.db + var real_root_id int64 = forlder_root_id + + // 设置默认父文件夹ID为根目录(1) + parentID := real_root_id + if folderID != nil && *folderID != 0 { + parentID = *folderID + } + + // 检查文件夹是否存在(Find 不会在找不到记录时返回错误) + var parentFolder models.Folder + result := db.Limit(1).Find(&parentFolder, parentID) + if result.Error != nil { + return nil, result.Error + } + + // 检查是否找到了记录 + if result.RowsAffected == 0 { + return nil, nil + } + + return &parentFolder, nil +} + +func bookmarkReq2Model(req api.BookmarkRequest, parentID int64) models.Bookmark { + return models.Bookmark{ + Name: req.Name, + Detail: *req.Detail, + Description: *req.Description, + ParentPathID: parentID, + } +} + +func bookmarkModel2Res(bookmark models.Bookmark) api.BookmarkResponse { + return api.BookmarkResponse{ + Id: bookmark.ID, + Name: bookmark.Name, + Link: &bookmark.Link, + Detail: &bookmark.Detail, + Description: &bookmark.Description, + ParentPathId: bookmark.ParentPathID, + CreatedAt: bookmark.CreatedAt, + } +} + +func folderReq2Model(req api.FolderRequest, parentID int64) models.Folder { + return models.Folder{ + Name: req.Name, + ParentPathID: parentID, + } +} + +func folderModel2Res(folder models.Folder) api.FolderResponse { + return api.FolderResponse{ + Id: folder.ID, + Name: folder.Name, + ParentPathId: folder.ParentPathID, + CreatedAt: folder.CreatedAt, + UpdatedAt: folder.UpdatedAt, + } +} + func NewBookMarks(dbPath string) (*BookMarksImpl, error) { var err error var db *gorm.DB @@ -33,15 +99,21 @@ func NewBookMarks(dbPath string) (*BookMarksImpl, error) { // 创建根文件夹(如果不存在) var rootFolder models.Folder - result := db.First(&rootFolder, 1) + result := db.Limit(1).Find(&rootFolder, forlder_root_id) if result.Error != nil { + return nil, result.Error + } + + if result.RowsAffected == 0 { // 根文件夹不存在,创建它 rootFolder = models.Folder{ - ID: 1, + ID: forlder_root_id, Name: "Root", - ParentPathID: 1, // 根目录指向自己 + ParentPathID: forlder_root_id, // 根目录指向自己 + } + if err := db.Create(&rootFolder).Error; err != nil { + return nil, fmt.Errorf("failed to create root folder: %w", err) } - db.Create(&rootFolder) } return &BookMarksImpl{db: db}, nil @@ -59,29 +131,18 @@ func (b *BookMarksImpl) CreateBookmark(c *gin.Context) { return } - // 设置默认父文件夹ID为根目录(1) - parentID := int64(1) - if req.ParentPathId != nil && *req.ParentPathId != 0 { - parentID = *req.ParentPathId - } - - // 检查父文件夹是否存在 - var parentFolder models.Folder - if err := db.First(&parentFolder, parentID).Error; err != nil { + var parentID int64 + if folder, err := b.GetFolderDefaultRoot(req.ParentPathId); err != nil || folder == nil { c.JSON(http.StatusNotFound, api.Error{ Errtype: "NotFoundError", Message: "Parent folder not found", }) return + } else { + parentID = folder.ID } - // 创建书签 - bookmark := models.Bookmark{ - Name: req.Name, - Detail: *req.Detail, - Description: *req.Description, - ParentPathID: parentID, - } + bookmark := bookmarkReq2Model(req, parentID) if err := db.Create(&bookmark).Error; err != nil { c.JSON(http.StatusInternalServerError, api.Error{ @@ -91,17 +152,7 @@ func (b *BookMarksImpl) CreateBookmark(c *gin.Context) { return } - // 构造响应 - response := api.BookmarkResponse{ - Id: bookmark.ID, - Name: bookmark.Name, - Detail: &bookmark.Detail, - Description: &bookmark.Description, - ParentPathId: bookmark.ParentPathID, - CreatedAt: bookmark.CreatedAt, - UpdatedAt: bookmark.UpdatedAt, - } - + response := bookmarkModel2Res(bookmark) c.JSON(http.StatusCreated, response) } @@ -117,27 +168,19 @@ func (b *BookMarksImpl) CreateFolder(c *gin.Context) { return } - // 设置默认父文件夹ID为根目录(1) - parentID := int64(1) - if req.ParentPathId != nil && *req.ParentPathId != 0 { - parentID = *req.ParentPathId - } - - // 检查父文件夹是否存在 - var parentFolder models.Folder - if err := db.First(&parentFolder, parentID).Error; err != nil { + var parentID int64 + if folder, err := b.GetFolderDefaultRoot(req.ParentPathId); err != nil || folder == nil { c.JSON(http.StatusNotFound, api.Error{ Errtype: "NotFoundError", Message: "Parent folder not found", }) return + } else { + parentID = folder.ID } // 创建文件夹 - folder := models.Folder{ - Name: req.Name, - ParentPathID: parentID, - } + folder := folderReq2Model(req, parentID) if err := db.Create(&folder).Error; err != nil { c.JSON(http.StatusInternalServerError, api.Error{ @@ -147,31 +190,94 @@ func (b *BookMarksImpl) CreateFolder(c *gin.Context) { return } - // 构造响应 - response := api.FolderResponse{ - Id: folder.ID, - Name: folder.Name, - ParentPathId: folder.ParentPathID, - CreatedAt: folder.CreatedAt, - UpdatedAt: folder.UpdatedAt, - } - + response := folderModel2Res(folder) c.JSON(http.StatusCreated, response) } // DeleteBookmark implements api.ServerInterface. func (b *BookMarksImpl) DeleteBookmark(c *gin.Context, id int64) { - panic("unimplemented") + var db = b.db + var bookmark models.Bookmark + + // 查询书签是否存在 + if err := db.First(&bookmark, id).Error; err != nil { + // FIXME maybe use 204 means already deleted status is same as delete + c.JSON(http.StatusNotFound, api.Error{ + Errtype: "NotFoundError", + Message: "Bookmark not found", + }) + return + } + + // 删除书签 + if err := db.Delete(&bookmark).Error; err != nil { + c.JSON(http.StatusInternalServerError, api.Error{ + Errtype: "DatabaseError", + Message: "Failed to delete bookmark", + }) + return + } + + c.Status(http.StatusNoContent) } // DeleteFolder implements api.ServerInterface. func (b *BookMarksImpl) DeleteFolder(c *gin.Context, id int64) { - panic("unimplemented") + var db = b.db + + var folder models.Folder + + // 查询文件夹是否存在 + if err := db.First(&folder, id).Error; err != nil { + c.JSON(http.StatusNotFound, api.Error{ + Errtype: "NotFoundError", + Message: "Folder not found", + }) + return + } + + if folder.ID == folder.ParentPathID { + c.JSON(http.StatusBadRequest, api.Error{ + Errtype: "ParameterError", + Message: "Cannot delete root folder", + }) + return + } + + // 检查文件夹是否为空 + var subFolderCount int64 + db.Model(&models.Folder{}).Where("parent_path_id = ?", id).Count(&subFolderCount) + + var bookmarkCount int64 + db.Model(&models.Bookmark{}).Where("parent_path_id = ?", id).Count(&bookmarkCount) + + // 如果文件夹不为空,拒绝删除 + if subFolderCount > 0 || bookmarkCount > 0 { + c.JSON(http.StatusBadRequest, api.Error{ + Errtype: "ParameterError", + Message: "Cannot delete non-empty folder, please delete contents first", + }) + return + } + + // 删除空文件夹 + if err := db.Delete(&folder).Error; err != nil { + c.JSON(http.StatusInternalServerError, api.Error{ + Errtype: "DatabaseError", + Message: "Failed to delete folder", + }) + return + } + + c.Status(http.StatusNoContent) } // DeleteFolderContent implements api.ServerInterface. func (b *BookMarksImpl) DeleteFolderContent(c *gin.Context, id int64, params api.DeleteFolderContentParams) { - panic("unimplemented") + c.JSON(http.StatusNotImplemented, api.Error{ + Errtype: "error", + Message: "Not implemented", + }) } // GetBookmark implements api.ServerInterface. @@ -189,17 +295,7 @@ func (b *BookMarksImpl) GetBookmark(c *gin.Context, id int64) { } // 构造响应 - response := api.BookmarkResponse{ - Id: bookmark.ID, - Name: bookmark.Name, - Link: &bookmark.Link, - Detail: &bookmark.Detail, - Description: &bookmark.Description, - ParentPathId: bookmark.ParentPathID, - CreatedAt: bookmark.CreatedAt, - UpdatedAt: bookmark.UpdatedAt, - } - + response := bookmarkModel2Res(bookmark) c.JSON(http.StatusOK, response) } @@ -349,17 +445,7 @@ func (b *BookMarksImpl) UpdateBookmark(c *gin.Context, id int64) { } // 构造响应 - response := api.BookmarkResponse{ - Id: bookmark.ID, - Name: bookmark.Name, - Link: &bookmark.Link, - Detail: &bookmark.Detail, - Description: &bookmark.Description, - ParentPathId: bookmark.ParentPathID, - CreatedAt: bookmark.CreatedAt, - UpdatedAt: bookmark.UpdatedAt, - } - + response := bookmarkModel2Res(bookmark) c.JSON(http.StatusOK, response) } @@ -432,16 +518,7 @@ func (b *BookMarksImpl) UpdateFolder(c *gin.Context, id int64) { db.Model(&models.Bookmark{}).Where("parent_path_id = ?", id).Count(&bookmarkCount) // 构造响应 - response := api.FolderResponse{ - Id: folder.ID, - Name: folder.Name, - ParentPathId: folder.ParentPathID, - CreatedAt: folder.CreatedAt, - UpdatedAt: folder.UpdatedAt, - SubFolderCount: int(subFolderCount), - BookmarkCount: int(bookmarkCount), - } - + response := folderModel2Res(folder) c.JSON(http.StatusOK, response) } diff --git a/magefile.go b/magefile.go new file mode 100644 index 0000000..c917ac3 --- /dev/null +++ b/magefile.go @@ -0,0 +1,81 @@ +//go:build mage +// +build mage + +package main + +import ( + "fmt" + "os" + "os/exec" + + "github.com/magefile/mage/mg" // mg contains helpful utility functions, like Deps +) + +// Default target to run when none is specified +// If not set, running mage will list available targets +// var Default = Build + +// A build step that requires additional params, or platform specific steps for example +// Build builds the application for multiple platforms. +func Build() error { + mg.Deps(InstallDeps) + + // Define target platforms + platforms := []struct { + OS string + Arch string + }{ + {"linux", "amd64"}, + {"linux", "arm64"}, + {"darwin", "amd64"}, + {"darwin", "arm64"}, + {"windows", "amd64"}, + } + + for _, p := range platforms { + fmt.Printf("Building for %s/%s...\n", p.OS, p.Arch) + + // Set environment variables for cross-compilation + env := append(os.Environ(), + fmt.Sprintf("GOOS=%s", p.OS), + fmt.Sprintf("GOARCH=%s", p.Arch)) + + // Determine output name + outputName := fmt.Sprintf("./bin/zzyxyz_go_api-%s-%s", p.OS, p.Arch) + if p.OS == "windows" { + outputName += ".exe" + } + + // Run build command + cmd := exec.Command("go", "build", "-o", outputName, ".") + cmd.Env = env + + if err := cmd.Run(); err != nil { + return err + } + } + + return nil +} + +// A custom install step if you need your bin someplace other than go/bin +func Install() error { + mg.Deps(Build) + // fmt.Println("Installing...") + // return os.Rename("./MyApp", "/usr/bin/MyApp") + return nil +} + +// Manage your deps, or running package managers. +func InstallDeps() error { + fmt.Println("Installing Deps...") + // cmd := exec.Command("go", "get", "github.com/stretchr/piglatin") + // return cmd.Run() + return nil +} + +// Clean up after yourself +func Clean() { + fmt.Println("Cleaning...") + // os.RemoveAll("MyApp") +} diff --git a/main.go b/main.go index 0837bfd..3c97146 100644 --- a/main.go +++ b/main.go @@ -16,11 +16,8 @@ import ( //go:embed config/api.yaml dist/* var staticFiles embed.FS -func Helloworld(g *gin.Context) { - g.JSON(http.StatusOK, "helloworld") -} - func main() { + gin.SetMode(gin.ReleaseMode) router := gin.Default() router.GET("/ping", func(c *gin.Context) { @@ -39,14 +36,9 @@ func main() { api.RegisterHandlers(api_router, server) } - eg := api_router.Group("/example") - { - eg.GET("/helloworld", Helloworld) - } + handlers.TodoHandler(api_router) } - handlers.TodoHandler(api_router) - // FIXME 可能有更好的方式实现这个代码 // 提供嵌入的静态文件访问 - OpenAPI YAML 文件和 dist 目录 router.GET("/swagger.yaml", func(c *gin.Context) { @@ -66,5 +58,9 @@ func main() { // 恢复原始路径 r.URL.Path = originalPath }) - log.Fatal(router.Run("127.0.0.1:8080")) + + var listener = "127.0.0.1:8080" + log.Printf("Starting server at http://%s", listener) + log.Printf("Swagger UI: http://%s/swagger/index.html", listener) + log.Fatal(router.Run(listener)) }