From 45c24a4bd349c7fad11537f6a4677bcaba07787b Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 19 Sep 2026 16:02:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 15 +++ go.sum | 37 ++++++ internal/api/api.go | 35 ++++++ internal/api/notes.go | 165 ++++++++++++++++++++++++++ internal/api/notes_test.go | 175 ++++++++++++++++++++++++++++ internal/config/config.default.yaml | 2 +- internal/database/database.go | 118 +++++++++++++++++++ internal/database/database_test.go | 93 +++++++++++++++ internal/database/logger.go | 81 +++++++++++++ internal/database/migrate.go | 73 ++++++++++++ internal/model/note.go | 13 +++ main.go | 27 ++++- 12 files changed, 829 insertions(+), 5 deletions(-) create mode 100644 internal/api/api.go create mode 100644 internal/api/notes.go create mode 100644 internal/api/notes_test.go create mode 100644 internal/database/database.go create mode 100644 internal/database/database_test.go create mode 100644 internal/database/logger.go create mode 100644 internal/database/migrate.go create mode 100644 internal/model/note.go diff --git a/go.mod b/go.mod index 7d28e9a..2347683 100644 --- a/go.mod +++ b/go.mod @@ -5,20 +5,30 @@ go 1.26.4 require ( github.com/gin-contrib/cors v1.7.8 github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 github.com/goccy/go-yaml v1.19.2 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.2 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.2 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cloudwego/base64x v0.1.7 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.6 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -28,6 +38,7 @@ require ( github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect @@ -37,4 +48,8 @@ require ( golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/go.sum b/go.sum index 2241211..0a1cb46 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= @@ -9,6 +11,8 @@ github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.8 h1:U0jjyXlWXMEx27hFE6hmdg/TOZviYgsOGGT+kLmUZrc= @@ -17,6 +21,10 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -25,6 +33,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -32,6 +42,14 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= @@ -40,6 +58,8 @@ 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/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -55,6 +75,9 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -90,3 +113,17 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..01fddfb --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,35 @@ +// Package api 提供 HTTP 接口路由。 +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "rill/internal/database" +) + +// RegisterRoutes 注册 API 路由。 +func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB) { + rg.GET("/health", health(db)) + + notes := rg.Group("/notes") + { + notes.GET("", listNotes(db)) + notes.POST("", createNote(db)) + notes.GET("/:id", getNote(db)) + notes.PUT("/:id", updateNote(db)) + notes.DELETE("/:id", deleteNote(db)) + } +} + +func health(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + if err := database.Ping(c.Request.Context(), db); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"status": "error", "error": "数据库不可用"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} diff --git a/internal/api/notes.go b/internal/api/notes.go new file mode 100644 index 0000000..b11649b --- /dev/null +++ b/internal/api/notes.go @@ -0,0 +1,165 @@ +package api + +import ( + "errors" + "log/slog" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "rill/internal/model" +) + +const ( + defaultPageSize = 20 + maxPageSize = 100 +) + +type noteRequest struct { + Title string `json:"title" binding:"required,max=200"` + Content string `json:"content"` +} + +func listNotes(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", strconv.Itoa(defaultPageSize))) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > maxPageSize { + pageSize = defaultPageSize + } + + ctx := c.Request.Context() + var total int64 + if err := db.WithContext(ctx).Model(&model.Note{}).Count(&total).Error; err != nil { + respondDBError(c, err) + return + } + + var notes []model.Note + if err := db.WithContext(ctx). + Order("id DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(¬es).Error; err != nil { + respondDBError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "items": notes, + "total": total, + "page": page, + "page_size": pageSize, + }) + } +} + +func createNote(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var req noteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + return + } + + note := model.Note{Title: req.Title, Content: req.Content} + if err := db.WithContext(c.Request.Context()).Create(¬e).Error; err != nil { + respondDBError(c, err) + return + } + c.JSON(http.StatusCreated, note) + } +} + +func getNote(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + var note model.Note + if err := db.WithContext(c.Request.Context()).First(¬e, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在"}) + return + } + respondDBError(c, err) + return + } + c.JSON(http.StatusOK, note) + } +} + +func updateNote(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + var req noteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + return + } + + ctx := c.Request.Context() + var note model.Note + if err := db.WithContext(ctx).First(¬e, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在"}) + return + } + respondDBError(c, err) + return + } + + note.Title = req.Title + note.Content = req.Content + if err := db.WithContext(ctx).Save(¬e).Error; err != nil { + respondDBError(c, err) + return + } + c.JSON(http.StatusOK, note) + } +} + +func deleteNote(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + result := db.WithContext(c.Request.Context()).Delete(&model.Note{}, id) + if result.Error != nil { + respondDBError(c, result.Error) + return + } + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在"}) + return + } + c.Status(http.StatusNoContent) + } +} + +func parseID(c *gin.Context) (uint, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "id 无效"}) + return 0, false + } + return uint(id), true +} + +func respondDBError(c *gin.Context, err error) { + slog.ErrorContext(c.Request.Context(), "数据库操作失败", "err", err, "path", c.Request.URL.Path) + c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误"}) +} diff --git a/internal/api/notes_test.go b/internal/api/notes_test.go new file mode 100644 index 0000000..ccc9162 --- /dev/null +++ b/internal/api/notes_test.go @@ -0,0 +1,175 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + + "rill/internal/config" + "rill/internal/database" + "rill/internal/model" +) + +func setupRouter(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + + cfg := &config.Config{ + Database: config.DatabaseConfig{ + Driver: "sqlite3", + ConnectTimeout: "5s", + SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "test.db")}, + }, + } + db, err := database.Open(cfg) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + t.Cleanup(func() { + if err := database.Close(db); err != nil { + t.Errorf("关闭测试数据库失败: %v", err) + } + }) + + if err := database.Migrate(context.Background(), db); err != nil { + t.Fatalf("执行测试迁移失败: %v", err) + } + + r := gin.New() + RegisterRoutes(r.Group("/api"), db) + return r +} + +func call(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + + var payload []byte + if body != nil { + var err error + payload, err = json.Marshal(body) + if err != nil { + t.Fatalf("序列化请求体失败: %v", err) + } + } + + req := httptest.NewRequest(method, path, bytes.NewReader(payload)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func decodeNote(t *testing.T, w *httptest.ResponseRecorder) model.Note { + t.Helper() + var note model.Note + if err := json.Unmarshal(w.Body.Bytes(), ¬e); err != nil { + t.Fatalf("解析响应失败: %v, body=%s", err, w.Body.String()) + } + return note +} + +func TestHealth(t *testing.T) { + r := setupRouter(t) + + w := call(t, r, http.MethodGet, "/api/health", nil) + if w.Code != http.StatusOK { + t.Fatalf("状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if resp["status"] != "ok" { + t.Errorf("status = %q, 期望 ok", resp["status"]) + } +} + +func TestNoteCRUD(t *testing.T) { + r := setupRouter(t) + + w := call(t, r, http.MethodPost, "/api/notes", map[string]string{"title": "第一条", "content": "内容"}) + if w.Code != http.StatusCreated { + t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String()) + } + created := decodeNote(t, w) + if created.ID == 0 || created.Title != "第一条" || created.Content != "内容" { + t.Fatalf("创建结果异常: %+v", created) + } + + detailPath := fmt.Sprintf("/api/notes/%d", created.ID) + w = call(t, r, http.MethodGet, detailPath, nil) + if w.Code != http.StatusOK { + t.Fatalf("详情状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + if got := decodeNote(t, w); got.ID != created.ID { + t.Errorf("详情 ID = %d, 期望 %d", got.ID, created.ID) + } + + w = call(t, r, http.MethodPut, detailPath, map[string]string{"title": "已更新", "content": "新内容"}) + if w.Code != http.StatusOK { + t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + updated := decodeNote(t, w) + if updated.Title != "已更新" || updated.Content != "新内容" { + t.Errorf("更新结果异常: %+v", updated) + } + + w = call(t, r, http.MethodGet, "/api/notes?page=1&page_size=10", nil) + if w.Code != http.StatusOK { + t.Fatalf("列表状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + var list struct { + Items []model.Note `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + } + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("解析列表响应失败: %v", err) + } + if list.Total != 1 || len(list.Items) != 1 { + t.Fatalf("列表结果异常: total=%d, items=%d", list.Total, len(list.Items)) + } + if list.Items[0].Title != "已更新" { + t.Errorf("列表项标题 = %q, 期望 已更新", list.Items[0].Title) + } + + w = call(t, r, http.MethodDelete, detailPath, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("删除状态码 = %d, 期望 %d", w.Code, http.StatusNoContent) + } + + w = call(t, r, http.MethodGet, detailPath, nil) + if w.Code != http.StatusNotFound { + t.Errorf("删除后详情状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) + } +} + +func TestNoteValidation(t *testing.T) { + r := setupRouter(t) + + w := call(t, r, http.MethodPost, "/api/notes", map[string]string{"content": "缺少标题"}) + if w.Code != http.StatusBadRequest { + t.Errorf("缺少标题状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) + } + + w = call(t, r, http.MethodGet, "/api/notes/abc", nil) + if w.Code != http.StatusBadRequest { + t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) + } + + w = call(t, r, http.MethodGet, "/api/notes/9999", nil) + if w.Code != http.StatusNotFound { + t.Errorf("不存在记录状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) + } +} diff --git a/internal/config/config.default.yaml b/internal/config/config.default.yaml index 32c694d..b2f10d4 100644 --- a/internal/config/config.default.yaml +++ b/internal/config/config.default.yaml @@ -26,7 +26,7 @@ api: allow_credentials: false max_age: "12h" -# 数据库(当前仅配置,服务暂不建立连接) +# 数据库 database: driver: sqlite3 # sqlite3 / mysql connect_timeout: "10s" # 建立连接超时 diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..118ded7 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,118 @@ +// Package database 负责数据库连接的建立、健康检查与关闭。 +package database + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/driver/mysql" + "gorm.io/gorm" + + "rill/internal/config" +) + +const ( + driverSQLite = "sqlite3" + driverMySQL = "mysql" +) + +// Open 依据配置建立数据库连接,并在 connect_timeout 内完成连通性检查。 +func Open(cfg *config.Config) (*gorm.DB, error) { + timeout, err := time.ParseDuration(cfg.Database.ConnectTimeout) + if err != nil { + return nil, fmt.Errorf("解析 database.connect_timeout 失败: %w", err) + } + + dialector, err := newDialector(cfg) + if err != nil { + return nil, err + } + + db, err := gorm.Open(dialector, &gorm.Config{ + Logger: newLogger(cfg.LogLevel()), + DisableAutomaticPing: true, + TranslateError: true, + DisableForeignKeyConstraintWhenMigrating: cfg.Database.Driver == driverSQLite, + }) + if err != nil { + return nil, fmt.Errorf("打开数据库失败: %w", err) + } + + if err := applyPool(db, cfg); err != nil { + Close(db) + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := Ping(ctx, db); err != nil { + Close(db) + return nil, fmt.Errorf("连接数据库失败: %w", err) + } + return db, nil +} + +func newDialector(cfg *config.Config) (gorm.Dialector, error) { + switch cfg.Database.Driver { + case driverSQLite: + path := filepath.Clean(cfg.Database.SQLite.Path) + if dir := filepath.Dir(path); dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("创建数据库目录 %s 失败: %w", dir, err) + } + } + dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path) + return sqlite.Open(dsn), nil + case driverMySQL: + m := cfg.Database.MySQL + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", + m.User, m.Password, m.Host, m.Port, m.Database, m.Charset) + return mysql.Open(dsn), nil + default: + return nil, fmt.Errorf("不支持的数据库驱动: %q", cfg.Database.Driver) + } +} + +// applyPool 应用连接池配置:SQLite 使用单连接串行化写入,避免并发写触发 SQLITE_BUSY。 +func applyPool(db *gorm.DB, cfg *config.Config) error { + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("获取数据库连接池失败: %w", err) + } + + if cfg.Database.Driver == driverMySQL { + m := cfg.Database.MySQL + sqlDB.SetMaxOpenConns(m.MaxOpenConns) + sqlDB.SetMaxIdleConns(m.MaxIdleConns) + if lifetime, err := time.ParseDuration(m.ConnMaxLifetime); err == nil { + sqlDB.SetConnMaxLifetime(lifetime) + } + return nil + } + + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + return nil +} + +// Ping 检查数据库连通性。 +func Ping(ctx context.Context, db *gorm.DB) error { + sqlDB, err := db.DB() + if err != nil { + return err + } + return sqlDB.PingContext(ctx) +} + +// Close 关闭数据库连接池。 +func Close(db *gorm.DB) error { + sqlDB, err := db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..2d45317 --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,93 @@ +package database + +import ( + "context" + "path/filepath" + "testing" + + "gorm.io/gorm" + + "rill/internal/config" + "rill/internal/model" +) + +func testConfig(t *testing.T) *config.Config { + t.Helper() + return &config.Config{ + Database: config.DatabaseConfig{ + Driver: driverSQLite, + ConnectTimeout: "5s", + SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "test.db")}, + }, + } +} + +func openTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := Open(testConfig(t)) + if err != nil { + t.Fatalf("Open 失败: %v", err) + } + t.Cleanup(func() { + if err := Close(db); err != nil { + t.Errorf("Close 失败: %v", err) + } + }) + return db +} + +func TestOpenAndPing(t *testing.T) { + db := openTestDB(t) + + if err := Ping(context.Background(), db); err != nil { + t.Fatalf("Ping 失败: %v", err) + } +} + +func TestOpenUnsupportedDriver(t *testing.T) { + cfg := testConfig(t) + cfg.Database.Driver = "postgres" + + if _, err := Open(cfg); err == nil { + t.Fatal("期望不支持的驱动返回错误") + } +} + +func TestMigrateIdempotentAndCRUD(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + for i := 1; i <= 2; i++ { + if err := Migrate(ctx, db); err != nil { + t.Fatalf("第 %d 次 Migrate 失败: %v", i, err) + } + } + + if !db.Migrator().HasTable(&model.Note{}) { + t.Error("notes 表未创建") + } + if !db.Migrator().HasTable(&schemaMigration{}) { + t.Error("schema_migrations 表未创建") + } + + var count int64 + if err := db.Model(&schemaMigration{}).Count(&count).Error; err != nil { + t.Fatalf("统计迁移记录失败: %v", err) + } + if count != int64(len(migrations)) { + t.Errorf("迁移记录数 = %d, 期望 %d", count, len(migrations)) + } + + note := model.Note{Title: "标题", Content: "内容"} + if err := db.WithContext(ctx).Create(¬e).Error; err != nil { + t.Fatalf("创建记录失败: %v", err) + } + + var got model.Note + if err := db.WithContext(ctx).First(&got, note.ID).Error; err != nil { + t.Fatalf("查询记录失败: %v", err) + } + if got.Title != note.Title || got.Content != note.Content { + t.Errorf("查询结果 = %+v, 期望 Title=%q Content=%q", got, note.Title, note.Content) + } +} diff --git a/internal/database/logger.go b/internal/database/logger.go new file mode 100644 index 0000000..9293cf6 --- /dev/null +++ b/internal/database/logger.go @@ -0,0 +1,81 @@ +package database + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + gormlogger "gorm.io/gorm/logger" +) + +// slowThreshold 超过该耗时的 SQL 记为慢查询。 +const slowThreshold = 500 * time.Millisecond + +// slogLogger 将 GORM 日志转接到 slog。 +type slogLogger struct { + logger *slog.Logger + level gormlogger.LogLevel +} + +func newLogger(level slog.Level) gormlogger.Interface { + return &slogLogger{ + logger: slog.Default(), + level: gormLogLevel(level), + } +} + +func gormLogLevel(level slog.Level) gormlogger.LogLevel { + switch { + case level <= slog.LevelDebug: + return gormlogger.Info + case level <= slog.LevelWarn: + return gormlogger.Warn + default: + return gormlogger.Error + } +} + +func (l *slogLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { + next := *l + next.level = level + return &next +} + +func (l *slogLogger) Info(ctx context.Context, msg string, args ...any) { + if l.level >= gormlogger.Info { + l.logger.InfoContext(ctx, fmt.Sprintf(msg, args...)) + } +} + +func (l *slogLogger) Warn(ctx context.Context, msg string, args ...any) { + if l.level >= gormlogger.Warn { + l.logger.WarnContext(ctx, fmt.Sprintf(msg, args...)) + } +} + +func (l *slogLogger) Error(ctx context.Context, msg string, args ...any) { + if l.level >= gormlogger.Error { + l.logger.ErrorContext(ctx, fmt.Sprintf(msg, args...)) + } +} + +func (l *slogLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { + if l.level <= gormlogger.Silent { + return + } + + elapsed := time.Since(begin) + sql, rows := fc() + attrs := []any{"elapsed", elapsed, "rows", rows, "sql", sql} + + switch { + case err != nil && l.level >= gormlogger.Error && !errors.Is(err, gormlogger.ErrRecordNotFound): + l.logger.ErrorContext(ctx, "数据库执行出错", append(attrs, "err", err)...) + case elapsed > slowThreshold && l.level >= gormlogger.Warn: + l.logger.WarnContext(ctx, "慢查询", attrs...) + case l.level >= gormlogger.Info: + l.logger.DebugContext(ctx, "数据库执行", attrs...) + } +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..685daf0 --- /dev/null +++ b/internal/database/migrate.go @@ -0,0 +1,73 @@ +package database + +import ( + "context" + "fmt" + "log/slog" + "time" + + "gorm.io/gorm" + + "rill/internal/model" +) + +// Migration 描述一次数据库结构变更,Version 必须唯一且递增。 +type Migration struct { + Version int + Name string + Up func(*gorm.DB) error +} + +// migrations 按 Version 升序登记所有迁移,新增迁移只能追加。 +var migrations = []Migration{ + { + Version: 1, + Name: "create_notes", + Up: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.Note{}) + }, + }, +} + +// schemaMigration 记录已应用的迁移版本。 +type schemaMigration struct { + Version int `gorm:"primaryKey"` + Name string + AppliedAt time.Time +} + +func (schemaMigration) TableName() string { return "schema_migrations" } + +// Migrate 依次执行未应用的迁移,每个迁移在独立事务中完成并记录版本,可重复调用。 +func Migrate(ctx context.Context, db *gorm.DB) error { + tx := db.WithContext(ctx) + if err := tx.AutoMigrate(&schemaMigration{}); err != nil { + return fmt.Errorf("初始化迁移记录表失败: %w", err) + } + + var applied []int + if err := tx.Model(&schemaMigration{}).Pluck("version", &applied).Error; err != nil { + return fmt.Errorf("读取迁移记录失败: %w", err) + } + done := make(map[int]bool, len(applied)) + for _, version := range applied { + done[version] = true + } + + for _, m := range migrations { + if done[m.Version] { + continue + } + err := tx.Transaction(func(tx *gorm.DB) error { + if err := m.Up(tx); err != nil { + return err + } + return tx.Create(&schemaMigration{Version: m.Version, Name: m.Name, AppliedAt: time.Now()}).Error + }) + if err != nil { + return fmt.Errorf("执行迁移 %d_%s 失败: %w", m.Version, m.Name, err) + } + slog.Info("数据库迁移已应用", "version", m.Version, "name", m.Name) + } + return nil +} diff --git a/internal/model/note.go b/internal/model/note.go new file mode 100644 index 0000000..2611774 --- /dev/null +++ b/internal/model/note.go @@ -0,0 +1,13 @@ +// Package model 定义数据库模型。 +package model + +import "time" + +// Note 示例便签。 +type Note struct { + ID uint `gorm:"primaryKey" json:"id"` + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"type:text" json:"content"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/main.go b/main.go index 05d4407..3c3fa44 100644 --- a/main.go +++ b/main.go @@ -19,7 +19,9 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "rill/internal/api" "rill/internal/config" + "rill/internal/database" ) func main() { @@ -52,11 +54,28 @@ func main() { })) } + // 初始化数据库并执行迁移 + db, err := database.Open(cfg) + if err != nil { + slog.Error("初始化数据库失败", "err", err) + os.Exit(1) + } + defer func() { + if err := database.Close(db); err != nil { + slog.Warn("关闭数据库失败", "err", err) + } + }() + + migrateCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err = database.Migrate(migrateCtx, db) + cancel() + if err != nil { + slog.Error("数据库迁移失败", "err", err) + os.Exit(1) + } + // API 路由 - api := r.Group(cfg.API.Prefix) - api.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) + api.RegisterRoutes(r.Group(cfg.API.Prefix), db) // 静态文件服务 fs := http.FileServer(http.Dir(cfg.Static.Dir))