增加数据库模块

This commit is contained in:
2026-09-19 16:02:30 +08:00
parent 6697240a8e
commit 45c24a4bd3
12 files changed
+829 -5

No files matched your search

+15
View File
@@ -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
)
+37
View File
@@ -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=
+35
View File
@@ -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"})
}
}
+165
View File
@@ -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(&notes).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(&note).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(&note, 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(&note, 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(&note).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": "服务器内部错误"})
}
+175
View File
@@ -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(), &note); 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)
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ api:
allow_credentials: false
max_age: "12h"
# 数据库(当前仅配置,服务暂不建立连接)
# 数据库
database:
driver: sqlite3 # sqlite3 / mysql
connect_timeout: "10s" # 建立连接超时
+118
View File
@@ -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()
}
+93
View File
@@ -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(&note).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)
}
}
+81
View File
@@ -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...)
}
}
+73
View File
@@ -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
}
+13
View File
@@ -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"`
}
+23 -4
View File
@@ -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))