Go 06:后端开发 —— Gin + GORM + JWT(对照 Django / FastAPI)
04 篇用标准库理解了 HTTP 的本质。实际开发 Go 后端服务时,大多数团队会用框架提高效率,
写法和 Flask/FastAPI 非常接近。本篇做一个多用户记账后端:注册登录、JWT 鉴权、分页、统计、
可切换 SQLite / PostgreSQL、Docker Compose 部署、接口测试。
1. 生态对照
| 功能 | Python | Go 常用选择 |
|---|---|---|
| Web 框架 | Django / Flask / FastAPI | Gin(最流行)、Echo、Fiber、Chi(贴近标准库) |
| ORM | Django ORM / SQLAlchemy | GORM(最像 Django ORM)、ent、sqlc(写 SQL 生成代码)、sqlx |
| 迁移 | makemigrations / Alembic |
GORM AutoMigrate(开发用)、golang-migrate、Atlas |
| 校验 | pydantic / DRF serializer | struct tag +go-playground/validator(Gin 内置) |
| 配置 | settings.py / pydantic-settings | 环境变量、viper、koanf |
| 日志 | logging | 标准库log/slog |
| API 文档 | FastAPI 自动 / drf-spectacular | swaggo(注释生成 Swagger) |
| 认证 | session / DRF Token / SimpleJWT | golang-jwt/jwt + 中间件 |
2. 项目结构
accounting-gin/
├── cmd/server/main.go # 入口
├── internal/
│ ├── config/config.go # 配置(≈ settings.py)
│ ├── model/model.go # 数据模型(≈ models.py)
│ ├── auth/jwt.go # Token 签发/解析
│ ├── service/ # 业务逻辑
│ │ ├── auth.go
│ │ └── record.go
│ ├── handler/ # 请求处理(≈ views.py)
│ │ ├── auth.go
│ │ └── record.go
│ ├── middleware/middleware.go # 鉴权、错误处理
│ └── router/router.go # 路由(≈ urls.py)
├── Dockerfile
└── docker-compose.yml
mkdir accounting-gin && cd accounting-gin
go mod init example.com/accounting-gin
go get github.com/gin-gonic/gin gorm.io/gorm gorm.io/driver/postgres \
github.com/glebarez/sqlite github.com/golang-jwt/jwt/v5 golang.org/x/crypto/bcrypt
glebarez/sqlite是纯 Go 实现的 SQLite 驱动,不需要 CGO,交叉编译方便。
3. 配置
// internal/config/config.go
package config
import (
"os"
"time"
)
type Config struct {
Port string
DBDriver string // sqlite | postgres
DSN string
JWTSecret []byte
JWTTTL time.Duration
}
func Load() Config {
ttl, err := time.ParseDuration(getenv("JWT_TTL", "24h"))
if err != nil {
ttl = 24 * time.Hour
}
return Config{
Port: getenv("PORT", "8080"),
DBDriver: getenv("DB_DRIVER", "sqlite"),
DSN: getenv("DB_DSN", "accounting.db"),
JWTSecret: []byte(getenv("JWT_SECRET", "dev-secret-change-me")),
JWTTTL: ttl,
}
}
func getenv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return fallback
}
4. 模型(GORM ≈ Django models)
// internal/model/model.go
package model
import "time"
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Email string `gorm:"uniqueIndex;size:128;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"` // json:"-" 永远不输出到响应
CreatedAt time.Time `json:"createdAt"`
}
type Record struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_user_date;not null" json:"-"`
Type string `gorm:"size:10;not null" json:"type"`
Category string `gorm:"size:20;not null" json:"category"`
AmountCents int64 `gorm:"not null" json:"amountCents"` // 金额用"分"存整数,避免浮点误差
Date string `gorm:"size:10;index:idx_user_date;not null" json:"date"`
Note string `gorm:"size:200" json:"note"`
CreatedAt time.Time `json:"createdAt"`
}
| Django | GORM |
|---|---|
Record.objects.filter(user=u, date__startswith=m) |
db.Where("user_id = ? AND date LIKE ?", uid, m+"%").Find(&list) |
Record.objects.create(...) |
db.Create(&r) |
.get(pk=1) |
db.First(&r, 1)(找不到返回 gorm.ErrRecordNotFound) |
.order_by("-date")[:20] |
.Order("date desc").Limit(20) |
.aggregate(Sum("amount")) |
.Select("SUM(amount_cents)").Scan(&total) |
migrate |
db.AutoMigrate(&User{}, &Record{}) |
5. JWT
// internal/auth/jwt.go
package auth
import (
"strconv"
"time"
"github.com/golang-jwt/jwt/v5"
)
func IssueToken(secret []byte, userID uint, ttl time.Duration) (string, error) {
now := time.Now()
claims := jwt.RegisteredClaims{
Subject: strconv.FormatUint(uint64(userID), 10),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}
func ParseToken(secret []byte, tokenStr string) (uint, error) {
claims := &jwt.RegisteredClaims{}
_, err := jwt.ParseWithClaims(tokenStr, claims,
func(*jwt.Token) (any, error) { return secret, nil },
jwt.WithValidMethods([]string{"HS256"}), // 防止 alg=none 攻击
)
if err != nil {
return 0, err // 过期、签名错误都会在这里返回
}
id, err := strconv.ParseUint(claims.Subject, 10, 64)
return uint(id), err
}
6. 业务层(service)
// internal/service/errors.go
package service
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrEmailTaken = errors.New("email already registered")
ErrBadCredentials = errors.New("invalid email or password")
)
// internal/service/auth.go
package service
import (
"context"
"errors"
"time"
"example.com/accounting-gin/internal/auth"
"example.com/accounting-gin/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type AuthService struct {
DB *gorm.DB
Secret []byte
TTL time.Duration
}
func (s *AuthService) Register(ctx context.Context, email, password string) (*model.User, error) {
var count int64
s.DB.WithContext(ctx).Model(&model.User{}).Where("email = ?", email).Count(&count)
if count > 0 {
return nil, ErrEmailTaken
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
u := &model.User{Email: email, PasswordHash: string(hash)}
if err := s.DB.WithContext(ctx).Create(u).Error; err != nil {
return nil, err
}
return u, nil
}
func (s *AuthService) Login(ctx context.Context, email, password string) (string, error) {
var u model.User
err := s.DB.WithContext(ctx).Where("email = ?", email).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", ErrBadCredentials // 不区分"用户不存在"和"密码错",防止枚举账号
}
if err != nil {
return "", err
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
return "", ErrBadCredentials
}
return auth.IssueToken(s.Secret, u.ID, s.TTL)
}
// internal/service/record.go
package service
import (
"context"
"example.com/accounting-gin/internal/model"
"gorm.io/gorm"
)
type RecordService struct{ DB *gorm.DB }
type Page[T any] struct { // 泛型分页结果
Items []T `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
func (s *RecordService) List(ctx context.Context, userID uint, month string, page, size int) (Page[model.Record], error) {
// Session() 生成可安全复用的查询,Count 和 Find 互不影响
base := s.DB.WithContext(ctx).Model(&model.Record{}).
Where("user_id = ? AND date LIKE ?", userID, month+"%").
Session(&gorm.Session{})
result := Page[model.Record]{Page: page, Size: size, Items: []model.Record{}}
if err := base.Count(&result.Total).Error; err != nil {
return result, err
}
err := base.Order("date desc, id desc").Offset((page - 1) * size).Limit(size).Find(&result.Items).Error
return result, err
}
func (s *RecordService) Create(ctx context.Context, r *model.Record) error {
return s.DB.WithContext(ctx).Create(r).Error
}
func (s *RecordService) Delete(ctx context.Context, userID, id uint) error {
res := s.DB.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&model.Record{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound // 不存在 或 不属于当前用户,统一 404
}
return nil
}
type CategorySum struct {
Type string `json:"type"`
Category string `json:"category"`
TotalCents int64 `json:"totalCents"`
}
func (s *RecordService) Summary(ctx context.Context, userID uint, month string) ([]CategorySum, error) {
out := []CategorySum{}
err := s.DB.WithContext(ctx).Model(&model.Record{}).
Select("type, category, SUM(amount_cents) AS total_cents").
Where("user_id = ? AND date LIKE ?", userID, month+"%").
Group("type, category").
Scan(&out).Error
return out, err
}
7. 中间件
// internal/middleware/middleware.go
package middleware
import (
"errors"
"log/slog"
"net/http"
"strings"
"example.com/accounting-gin/internal/auth"
"example.com/accounting-gin/internal/service"
"github.com/gin-gonic/gin"
)
const UserIDKey = "userID"
// Auth ≈ DRF 的 IsAuthenticated
func Auth(secret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
uid, err := auth.ParseToken(secret, token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set(UserIDKey, uid)
c.Next()
}
}
// ErrorHandler 统一把业务错误映射为 HTTP 状态码(≈ DRF 的 exception_handler)
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 {
return
}
err := c.Errors.Last().Err
switch {
case errors.Is(err, service.ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
case errors.Is(err, service.ErrEmailTaken):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrBadCredentials):
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
default:
slog.Error("unhandled error", "path", c.FullPath(), "err", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
}
}
}
8. Handler(请求绑定 + 校验 ≈ pydantic)
// internal/handler/record.go
package handler
import (
"math"
"net/http"
"strconv"
"time"
"example.com/accounting-gin/internal/middleware"
"example.com/accounting-gin/internal/model"
"example.com/accounting-gin/internal/service"
"github.com/gin-gonic/gin"
)
type RecordHandler struct{ Svc *service.RecordService }
type createRecordReq struct {
Type string `json:"type" binding:"required,oneof=income expense"`
Category string `json:"category" binding:"required,max=20"`
Amount float64 `json:"amount" binding:"required,gt=0,lte=1000000"`
Date string `json:"date" binding:"required,datetime=2006-01-02"`
Note string `json:"note" binding:"max=200"`
}
type listQuery struct {
Month string `form:"month" binding:"omitempty,datetime=2006-01"`
Page int `form:"page,default=1" binding:"min=1"`
Size int `form:"size,default=20" binding:"min=1,max=100"`
}
func (h *RecordHandler) List(c *gin.Context) {
var q listQuery
if err := c.ShouldBindQuery(&q); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if q.Month == "" {
q.Month = time.Now().Format("2006-01")
}
page, err := h.Svc.List(c.Request.Context(), c.GetUint(middleware.UserIDKey), q.Month, q.Page, q.Size)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, page)
}
func (h *RecordHandler) Create(c *gin.Context) {
var req createRecordReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
r := &model.Record{
UserID: c.GetUint(middleware.UserIDKey),
Type: req.Type,
Category: req.Category,
AmountCents: int64(math.Round(req.Amount * 100)),
Date: req.Date,
Note: req.Note,
}
if err := h.Svc.Create(c.Request.Context(), r); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, r)
}
func (h *RecordHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.Svc.Delete(c.Request.Context(), c.GetUint(middleware.UserIDKey), uint(id)); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (h *RecordHandler) Summary(c *gin.Context) {
month := c.DefaultQuery("month", time.Now().Format("2006-01"))
out, err := h.Svc.Summary(c.Request.Context(), c.GetUint(middleware.UserIDKey), month)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, out)
}
// internal/handler/auth.go
package handler
import (
"net/http"
"example.com/accounting-gin/internal/service"
"github.com/gin-gonic/gin"
)
type AuthHandler struct{ Svc *service.AuthService }
type credentials struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}
func (h *AuthHandler) Register(c *gin.Context) {
var req credentials
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, err := h.Svc.Register(c.Request.Context(), req.Email, req.Password)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, u)
}
func (h *AuthHandler) Login(c *gin.Context) {
var req credentials
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
token, err := h.Svc.Login(c.Request.Context(), req.Email, req.Password)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}
9. 路由与入口
// internal/router/router.go
package router
import (
"net/http"
"example.com/accounting-gin/internal/config"
"example.com/accounting-gin/internal/handler"
"example.com/accounting-gin/internal/middleware"
"example.com/accounting-gin/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func New(cfg config.Config, db *gorm.DB) *gin.Engine {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), middleware.ErrorHandler())
ah := &handler.AuthHandler{Svc: &service.AuthService{DB: db, Secret: cfg.JWTSecret, TTL: cfg.JWTTTL}}
rh := &handler.RecordHandler{Svc: &service.RecordService{DB: db}}
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
api := r.Group("/api")
api.POST("/auth/register", ah.Register)
api.POST("/auth/login", ah.Login)
authed := api.Group("", middleware.Auth(cfg.JWTSecret))
authed.GET("/records", rh.List)
authed.POST("/records", rh.Create)
authed.DELETE("/records/:id", rh.Delete)
authed.GET("/records/summary", rh.Summary)
return r
}
// cmd/server/main.go
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"example.com/accounting-gin/internal/config"
"example.com/accounting-gin/internal/model"
"example.com/accounting-gin/internal/router"
"github.com/glebarez/sqlite"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func openDB(cfg config.Config) (*gorm.DB, error) {
var dial gorm.Dialector
switch cfg.DBDriver {
case "postgres":
dial = postgres.Open(cfg.DSN)
default:
dial = sqlite.Open(cfg.DSN)
}
db, err := gorm.Open(dial, &gorm.Config{})
if err != nil {
return nil, err
}
return db, db.AutoMigrate(&model.User{}, &model.Record{})
}
func main() {
cfg := config.Load()
db, err := openDB(cfg)
if err != nil {
slog.Error("open db", "err", err)
os.Exit(1)
}
srv := &http.Server{Addr: ":" + cfg.Port, Handler: router.New(cfg, db)}
go func() {
slog.Info("listening", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server", "err", err)
os.Exit(1)
}
}()
// 优雅关闭(详见 04 篇)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}
go run ./cmd/server
# 手动验证
curl -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
-d '{"email":"a@test.com","password":"12345678"}'
TOKEN=$(curl -s -X POST localhost:8080/api/auth/login -H 'Content-Type: application/json' \
-d '{"email":"a@test.com","password":"12345678"}' | jq -r .token)
curl -X POST localhost:8080/api/records -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"type":"expense","category":"food","amount":12.5,"date":"2026-11-02"}'
curl "localhost:8080/api/records?month=2026-11" -H "Authorization: Bearer $TOKEN"
10. 接口测试(httptest ≈ Django TestClient)
// internal/router/router_test.go
package router
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"testing"
"time"
"example.com/accounting-gin/internal/config"
"example.com/accounting-gin/internal/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func setup(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
// 每个测试一个临时数据库文件,互相隔离(≈ pytest 的 tmp_path)
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Record{}); err != nil {
t.Fatal(err)
}
cfg := config.Config{JWTSecret: []byte("test"), JWTTTL: time.Hour}
return New(cfg, db)
}
func do(r *gin.Engine, method, path, token string, body any) *httptest.ResponseRecorder {
var buf bytes.Buffer
if body != nil {
_ = json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func login(t *testing.T, r *gin.Engine, email string) string {
cred := map[string]string{"email": email, "password": "12345678"}
do(r, "POST", "/api/auth/register", "", cred)
w := do(r, "POST", "/api/auth/login", "", cred)
var resp struct{ Token string }
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Token == "" {
t.Fatalf("login failed: %s", w.Body.String())
}
return resp.Token
}
func TestCreateRecordValidation(t *testing.T) {
r := setup(t)
token := login(t, r, "a@test.com")
cases := []struct {
name string
body map[string]any
want int
}{
{"ok", map[string]any{"type": "expense", "category": "food", "amount": 10, "date": "2026-11-01"}, 201},
{"bad type", map[string]any{"type": "x", "category": "food", "amount": 10, "date": "2026-11-01"}, 400},
{"zero amount", map[string]any{"type": "expense", "category": "food", "amount": 0, "date": "2026-11-01"}, 400},
{"bad date", map[string]any{"type": "expense", "category": "food", "amount": 1, "date": "2026/11/01"}, 400},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if w := do(r, "POST", "/api/records", token, tc.body); w.Code != tc.want {
t.Errorf("got %d, want %d: %s", w.Code, tc.want, w.Body.String())
}
})
}
}
func TestCannotDeleteOthersRecord(t *testing.T) {
r := setup(t)
alice, bob := login(t, r, "alice@test.com"), login(t, r, "bob@test.com")
w := do(r, "POST", "/api/records", alice,
map[string]any{"type": "expense", "category": "food", "amount": 10, "date": "2026-11-01"})
var rec struct{ ID uint }
_ = json.Unmarshal(w.Body.Bytes(), &rec)
if w := do(r, "DELETE", "/api/records/"+itoa(rec.ID), bob, nil); w.Code != http.StatusNotFound {
t.Fatalf("越权删除应返回 404,实际 %d", w.Code)
}
if w := do(r, "GET", "/api/records", "", nil); w.Code != http.StatusUnauthorized {
t.Fatalf("未登录应返回 401,实际 %d", w.Code)
}
}
func itoa(n uint) string { return strconv.FormatUint(uint64(n), 10) }
运行:go test ./... -v -cover。
11. Docker Compose 部署(Go 服务 + PostgreSQL)
# Dockerfile
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
FROM gcr.io/distroless/static
COPY --from=build /app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
# docker-compose.yml
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: accounting
volumes: [pgdata:/var/lib/postgresql/data]
api:
build: .
ports: ["8080:8080"]
environment:
DB_DRIVER: postgres
DB_DSN: host=db user=postgres password=secret dbname=accounting sslmode=disable
JWT_SECRET: please-change-me
depends_on: [db]
volumes:
pgdata:
docker compose up -d --build
最终镜像只有约 15~20 MB,对比 Python 镜像(通常数百 MB)优势明显。
12. 进一步方向
- API 文档:swaggo,在 handler 上写
// @Summary、// @Param注释,swag init生成 Swagger UI。 - 正式迁移:生产环境用 golang-migrate / Atlas 管理 SQL 迁移脚本,不依赖 AutoMigrate。
- 依赖注入:项目大了可以用
google/wire或uber/fx(C# 开发者会觉得熟悉)。 - 前端:直接用 TS 05 篇的 Vue 页面对接(把 Authorization 头加到 API 客户端)。
13. 练习
- 增加
PUT /api/records/:id,同样做越权校验。 - 增加"分类管理":每个用户可自定义分类,新增记录时校验分类存在。
- 用 swaggo 生成接口文档。
- 用 Python
requests + pytest对这个 Go 服务写一套接口测试,体会"跨语言测试"。
文章评论 (0)