Files
zhuangang/topics/backend/gin/examples/unified-api/api.go
T

186 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package unifiedapi 演示企业后台接口规范:统一响应、业务错误码、请求 ID、参数校验中文提示、panic 兜底。
package unifiedapi
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"reflect"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
// AppError 业务错误:HTTP 状态码 + 业务错误码 + 给用户看的提示
type AppError struct {
Status int
Code int
Message string
}
func (e *AppError) Error() string { return e.Message }
var (
ErrNotFound = &AppError{http.StatusNotFound, 40400, "资源不存在"}
ErrInternal = &AppError{http.StatusInternalServerError, 50000, "服务器开小差了,请稍后再试"}
)
func Invalid(msg string) *AppError { return &AppError{http.StatusBadRequest, 40000, msg} }
// Body 统一响应格式
type Body struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
RequestID string `json:"request_id"`
}
func OK(c *gin.Context, status int, data any) {
c.JSON(status, Body{Code: 0, Message: "ok", Data: data, RequestID: c.GetString("requestID")})
}
func writeErr(c *gin.Context, e *AppError) {
c.AbortWithStatusJSON(e.Status, Body{Code: e.Code, Message: e.Message, RequestID: c.GetString("requestID")})
}
// RequestID 沿用客户端传来的 X-Request-ID,没有就生成一个,并写回响应头
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader("X-Request-ID")
if id == "" || len(id) > 64 {
b := make([]byte, 8)
_, _ = rand.Read(b)
id = hex.EncodeToString(b)
}
c.Set("requestID", id)
c.Header("X-Request-ID", id)
c.Next()
}
}
// Errors 统一把处理函数里 c.Error(err) 记录的错误和 panic 转成统一响应;内部错误不把细节暴露给用户
func Errors(log *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
log.Error("panic", "request_id", c.GetString("requestID"), "panic", r)
writeErr(c, ErrInternal)
}
}()
c.Next()
if len(c.Errors) == 0 || c.Writer.Written() {
return
}
err := c.Errors.Last().Err
var ae *AppError
if errors.As(err, &ae) {
writeErr(c, ae)
return
}
log.Error("internal error", "request_id", c.GetString("requestID"), "err", err)
writeErr(c, ErrInternal)
}
}
var setupOnce sync.Once
// useJSONFieldNames 让校验错误里显示 JSON 字段名(title)而不是结构体字段名(Title)
func useJSONFieldNames() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterTagNameFunc(func(f reflect.StructField) string {
name := strings.Split(f.Tag.Get("json"), ",")[0]
if name == "" || name == "-" {
return f.Name
}
return name
})
}
}
// Bind 解析并校验 JSON,失败时记录一个用户能看懂的错误
func Bind(c *gin.Context, req any) bool {
err := c.ShouldBindJSON(req)
if err == nil {
return true
}
var ve validator.ValidationErrors
if errors.As(err, &ve) {
msgs := make([]string, 0, len(ve))
for _, fe := range ve {
switch fe.Tag() {
case "required":
msgs = append(msgs, fmt.Sprintf("%s 不能为空", fe.Field()))
case "max":
msgs = append(msgs, fmt.Sprintf("%s 长度不能超过 %s", fe.Field(), fe.Param()))
case "oneof":
msgs = append(msgs, fmt.Sprintf("%s 只能是 %s 之一", fe.Field(), fe.Param()))
default:
msgs = append(msgs, fmt.Sprintf("%s 不合法", fe.Field()))
}
}
_ = c.Error(Invalid(strings.Join(msgs, ";")))
} else {
_ = c.Error(Invalid("请求体格式错误"))
}
return false
}
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Priority string `json:"priority"`
}
type CreateTask struct {
Title string `json:"title" binding:"required,max=50"`
Priority string `json:"priority" binding:"omitempty,oneof=low normal high"`
}
// New 组装路由
func New(log *slog.Logger) *gin.Engine {
setupOnce.Do(useJSONFieldNames)
var mu sync.Mutex
tasks := map[int]Task{}
next := 1
r := gin.New()
r.Use(RequestID(), Errors(log))
api := r.Group("/api")
api.POST("/tasks", func(c *gin.Context) {
var req CreateTask
if !Bind(c, &req) {
return
}
mu.Lock()
t := Task{ID: next, Title: req.Title, Priority: req.Priority}
tasks[t.ID] = t
next++
mu.Unlock()
OK(c, http.StatusCreated, t)
})
api.GET("/tasks/:id", func(c *gin.Context) {
var id int
if _, err := fmt.Sscan(c.Param("id"), &id); err != nil {
_ = c.Error(Invalid("id 必须是整数"))
return
}
mu.Lock()
t, ok := tasks[id]
mu.Unlock()
if !ok {
_ = c.Error(ErrNotFound)
return
}
OK(c, http.StatusOK, t)
})
api.GET("/boom", func(c *gin.Context) { panic("数据库连接串 postgres://secret 泄漏测试") })
api.GET("/db-error", func(c *gin.Context) { _ = c.Error(errors.New("pq: relation \"tasks\" does not exist")) })
return r
}