// 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 }