Files
ProjectAGiPrompt/1-Vue3-Dev/common-runtime.md
2026-07-01 16:31:30 +08:00

9.4 KiB
Raw Permalink Blame History

自包含 Common Runtime

当目标项目没有等价的本地基础能力时,使用这些模板。不要依赖外部 common 仓库或某台机器上的固定路径。应用项目优先使用 internal/common;只有当模块确实需要向其他模块暴露这些辅助函数时,才使用 pkg/common

包结构

internal/common/
  app_error.go
  codes.go
  response.go
  time.go
  logging.go
  request_context.go

错误码与 AppError

package common

import (
    "errors"
    "fmt"
)

const (
    CodeSuccess = 0

    CodeParamError     = 1001
    CodeValidationFail = 1002
    CodeUnauthorized   = 1003
    CodeForbidden      = 1004
    CodeNotFound       = 1005
    CodeTimeout        = 1006
    CodeServerError    = 1007
    CodeDuplicate      = 1008
    CodeOperationFail  = 1009

    CodeBusinessError      = 2001
    CodeDataNotReady       = 2002
    CodeStatusInvalid      = 2003
    CodeDependencyError    = 2004
    CodeExternalAPIError   = 2005
    CodeResourceLocked     = 2006
    CodeQuotaExceeded      = 2007
    CodeConcurrentConflict = 2008
)

var CodeMessage = map[int]string{
    CodeSuccess:            "success",
    CodeParamError:         "参数错误",
    CodeValidationFail:     "数据验证失败",
    CodeUnauthorized:       "未授权,请先登录",
    CodeForbidden:          "权限不足,禁止访问",
    CodeNotFound:           "资源不存在",
    CodeTimeout:            "请求超时",
    CodeServerError:        "服务器内部错误",
    CodeDuplicate:          "数据重复",
    CodeOperationFail:      "操作失败",
    CodeBusinessError:      "业务处理失败",
    CodeDataNotReady:       "数据未就绪",
    CodeStatusInvalid:      "状态不合法",
    CodeDependencyError:    "依赖服务错误",
    CodeExternalAPIError:   "外部服务调用失败",
    CodeResourceLocked:     "资源被锁定",
    CodeQuotaExceeded:      "配额超限",
    CodeConcurrentConflict: "并发冲突",
}

// AppError 是 service 层向 handler 层暴露的稳定错误契约。
// DAO 返回底层错误service 负责转换为 AppError。
type AppError struct {
    Code    int
    Message string
    Cause   error
    Fields  map[string]any
}

func NewAppError(code int, message string) *AppError {
    if message == "" {
        message = CodeMessage[code]
    }
    return &AppError{Code: code, Message: message}
}

func WrapAppError(code int, message string, cause error) *AppError {
    appErr := NewAppError(code, message)
    appErr.Cause = cause
    return appErr
}

func (e *AppError) Error() string {
    if e == nil {
        return ""
    }
    if e.Cause == nil {
        return fmt.Sprintf("%d:%s", e.Code, e.Message)
    }
    return fmt.Sprintf("%d:%s: %v", e.Code, e.Message, e.Cause)
}

func (e *AppError) Unwrap() error {
    if e == nil {
        return nil
    }
    return e.Cause
}

func AsAppError(err error) (*AppError, bool) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        return appErr, true
    }
    return nil, false
}

func ToAppError(err error) *AppError {
    if err == nil {
        return nil
    }
    if appErr, ok := AsAppError(err); ok {
        return appErr
    }
    return WrapAppError(CodeServerError, CodeMessage[CodeServerError], err)
}

统一响应

package common

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

// Response 是所有 API 的统一响应体。失败时 Data 必须为 nil。
type Response struct {
    Code      int    `json:"code"`
    Message   string `json:"message"`
    Data      any    `json:"data"`
    Timestamp string `json:"timestamp"`
    RequestID string `json:"request_id"`
}

type PageResponse struct {
    List     any   `json:"list"`
    Total    int64 `json:"total"`
    Page     int   `json:"page"`
    PageSize int   `json:"page_size"`
}

func ResponseSuccess(c *gin.Context, data any) {
    respond(c, http.StatusOK, CodeSuccess, CodeMessage[CodeSuccess], data)
}

func ResponseSuccessWithMessage(c *gin.Context, data any, message string) {
    respond(c, http.StatusOK, CodeSuccess, message, data)
}

func ResponseError(c *gin.Context, code int, message string) {
    respond(c, httpStatusByCode(code), code, message, nil)
}

func ResponseAppError(c *gin.Context, err error) {
    appErr := ToAppError(err)
    Error(c.Request.Context(), appErr.Message, "code", appErr.Code, "error", appErr.Cause)
    respond(c, httpStatusByCode(appErr.Code), appErr.Code, appErr.Message, nil)
}

func respond(c *gin.Context, httpStatus int, code int, message string, data any) {
    if message == "" {
        message = CodeMessage[code]
    }
    c.JSON(httpStatus, Response{
        Code:      code,
        Message:   message,
        Data:      data,
        Timestamp: FormatTime(Now()),
        RequestID: RequestIDFromGin(c),
    })
}

func httpStatusByCode(code int) int {
    switch code {
    case CodeUnauthorized:
        return http.StatusUnauthorized
    case CodeForbidden:
        return http.StatusForbidden
    case CodeParamError, CodeValidationFail:
        return http.StatusBadRequest
    case CodeNotFound:
        return http.StatusNotFound
    default:
        return http.StatusOK
    }
}

请求上下文

package common

import (
    "context"

    "github.com/gin-gonic/gin"
)

type contextKey string

const (
    ContextKeyRequestID contextKey = "request_id"
    ContextKeyUserID    contextKey = "user_id"
    ContextKeyUsername  contextKey = "username"
    ContextKeyRole      contextKey = "role"
)

func WithRequestID(ctx context.Context, requestID string) context.Context {
    return context.WithValue(ctx, ContextKeyRequestID, requestID)
}

func RequestIDFromContext(ctx context.Context) string {
    if value, ok := ctx.Value(ContextKeyRequestID).(string); ok {
        return value
    }
    return ""
}

func RequestIDFromGin(c *gin.Context) string {
    if value, exists := c.Get(string(ContextKeyRequestID)); exists {
        if requestID, ok := value.(string); ok {
            return requestID
        }
    }
    return RequestIDFromContext(c.Request.Context())
}

func UserIDFromContext(ctx context.Context) int64 {
    if value, ok := ctx.Value(ContextKeyUserID).(int64); ok {
        return value
    }
    return 0
}

Asia/Shanghai 时间

package common

import "time"

const TimeFormat = time.RFC3339

var shanghaiLocation = mustLoadShanghaiLocation()

func mustLoadShanghaiLocation() *time.Location {
    location, err := time.LoadLocation("Asia/Shanghai")
    if err != nil {
        return time.FixedZone("Asia/Shanghai", 8*60*60)
    }
    return location
}

// Now 返回东八区当前时间。只有 common/time.go 允许直接调用 time.Now。
func Now() time.Time {
    return time.Now().In(shanghaiLocation)
}

func FormatTime(t time.Time) string {
    return t.In(shanghaiLocation).Format(TimeFormat)
}

func ParseTime(value string) (time.Time, error) {
    parsed, err := time.Parse(TimeFormat, value)
    if err != nil {
        return time.Time{}, err
    }
    return parsed.In(shanghaiLocation), nil
}

func StartOfDay(t time.Time) time.Time {
    local := t.In(shanghaiLocation)
    return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
}

func EndOfDay(t time.Time) time.Time {
    return StartOfDay(t).Add(24*time.Hour - time.Nanosecond)
}

结构化日志

package common

import (
    "context"
    "fmt"
    "log/slog"
    "os"
    "strings"
)

var logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))

func InitLogger(debug bool) {
    level := slog.LevelInfo
    if debug {
        level = slog.LevelDebug
    }
    logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
}

func Debug(ctx context.Context, message string, fields ...any) {
    logger.DebugContext(ctx, message, appendContextFields(ctx, sanitizeFields(fields)...)...)
}

func Info(ctx context.Context, message string, fields ...any) {
    logger.InfoContext(ctx, message, appendContextFields(ctx, sanitizeFields(fields)...)...)
}

func Warn(ctx context.Context, message string, fields ...any) {
    logger.WarnContext(ctx, message, appendContextFields(ctx, sanitizeFields(fields)...)...)
}

func Error(ctx context.Context, message string, fields ...any) {
    logger.ErrorContext(ctx, message, appendContextFields(ctx, sanitizeFields(fields)...)...)
}

func appendContextFields(ctx context.Context, fields ...any) []any {
    if requestID := RequestIDFromContext(ctx); requestID != "" {
        fields = append(fields, "request_id", requestID)
    }
    if userID := UserIDFromContext(ctx); userID > 0 {
        fields = append(fields, "user_id", userID)
    }
    return fields
}

func sanitizeFields(fields []any) []any {
    sanitized := make([]any, 0, len(fields))
    for i := 0; i < len(fields); i += 2 {
        key := fields[i]
        if i+1 >= len(fields) {
            sanitized = append(sanitized, key)
            break
        }
        value := fields[i+1]
        if isSensitiveKey(key) {
            value = "***REDACTED***"
        }
        sanitized = append(sanitized, key, value)
    }
    return sanitized
}

func isSensitiveKey(key any) bool {
    name := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(fmt.Sprint(key), "_", "")))
    sensitive := []string{"password", "passwd", "token", "authorization", "secret", "privatekey", "apikey", "cookie"}
    for _, item := range sensitive {
        if strings.Contains(name, item) {
            return true
        }
    }
    return false
}