编程语言

Go教程(六)后端开发 —— Gin + GORM + JWT(对照 Django / FastAPI)

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. 练习

  1. 增加 PUT /api/records/:id,同样做越权校验。
  2. 增加"分类管理":每个用户可自定义分类,新增记录时校验分类存在。
  3. 用 swaggo 生成接口文档。
  4. 用 Python requests + pytest 对这个 Go 服务写一套接口测试,体会"跨语言测试"。

文章评论 (0)