feat(bookmark): 实现书签和文件夹的增删改查功能

- 添加 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 模式
- 统一服务监听地址为变量,并增加启动日志提示
```
This commit is contained in:
zzy
2025-09-21 15:45:37 +08:00
parent 7ff8591be8
commit 36b311ff86
7 changed files with 295 additions and 99 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
.*/
dist/
gen/
bin/
*.exe
*.sqlite3

View File

@ -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"]
}
}
```

1
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

@ -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)
}

81
magefile.go Normal file
View File

@ -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")
}

18
main.go
View File

@ -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))
}