186 lines
4.9 KiB
Go
186 lines
4.9 KiB
Go
// 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
|
||
}
|