2026-05-12 09:53:20 +08:00

159 lines
5.3 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 httpkit
import (
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"google.golang.org/grpc/status"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
)
type requestIDContextKey struct{}
const (
CodeOK = "OK"
CodeInvalidJSON = "INVALID_JSON"
CodeInvalidArgument = "INVALID_ARGUMENT"
CodeUnauthorized = "UNAUTHORIZED"
CodePermissionDenied = "PERMISSION_DENIED"
CodeProfileRequired = "PROFILE_REQUIRED"
CodeAuthLoginBlocked = "AUTH_LOGIN_BLOCKED"
CodeNotFound = "NOT_FOUND"
CodeRateLimited = "RATE_LIMITED"
CodeUpstreamError = "UPSTREAM_ERROR"
CodeInternalError = "INTERNAL_ERROR"
)
// ResponseEnvelope 是 gateway 暴露给客户端的稳定 JSON 外壳。
// HTTP status 表示传输层结果code 表示客户端可依赖的错误语义。
type ResponseEnvelope struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data any `json:"data,omitempty"`
}
// WithRequestID 为每个业务 API 请求建立追踪 ID。
// 客户端传入 X-Request-ID 时沿用,未传时由 gateway 生成,后续 gRPC RequestMeta 复用同一个值。
func WithRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestID := strings.TrimSpace(request.Header.Get("X-Request-ID"))
if requestID == "" {
requestID = idgen.New("req")
}
writer.Header().Set("X-Request-ID", requestID)
ctx := context.WithValue(request.Context(), requestIDContextKey{}, requestID)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
// RequestIDFromContext 读取当前 HTTP 请求的追踪 ID。
// 理论上业务路由都会先经过 WithRequestID兜底生成值只用于防御错误调用。
func RequestIDFromContext(ctx context.Context) string {
requestID, _ := ctx.Value(requestIDContextKey{}).(string)
if requestID != "" {
return requestID
}
return idgen.New("req")
}
// DecodeJSON 隔离 JSON 解码细节,避免 handler 直接依赖 envelope 写法。
func DecodeJSON(reader io.Reader, out any) error {
return json.NewDecoder(reader).Decode(out)
}
// WriteOK 写成功 envelope。
func WriteOK(writer http.ResponseWriter, request *http.Request, data any) {
WriteEnvelope(writer, http.StatusOK, ResponseEnvelope{
Code: CodeOK,
Message: "ok",
RequestID: ResponseRequestID(request),
Data: data,
})
}
// WriteError 写失败 envelope。
// 错误 message 保持稳定、短小,排障依赖 request_id 追日志,而不是把内部错误直接暴露给客户端。
func WriteError(writer http.ResponseWriter, request *http.Request, statusCode int, code string, message string) {
WriteEnvelope(writer, statusCode, ResponseEnvelope{
Code: code,
Message: message,
RequestID: ResponseRequestID(request),
})
}
// WriteRPCError 把内部 gRPC status + ErrorInfo.reason 转成外部稳定 envelope。
func WriteRPCError(writer http.ResponseWriter, request *http.Request, err error) {
if err == nil {
return
}
if _, ok := status.FromError(err); !ok {
WriteError(writer, request, http.StatusBadGateway, CodeUpstreamError, "upstream service error")
return
}
reason := xerr.ReasonFromGRPC(err)
statusCode, code, message := MapReasonToHTTP(reason)
WriteError(writer, request, statusCode, code, message)
}
func MapReasonToHTTP(reason xerr.Code) (int, string, string) {
spec := xerr.SpecOf(reason)
return spec.HTTPStatus, spec.PublicCode, spec.PublicMessage
}
// WriteEnvelope 是 gateway 业务 API 的唯一 JSON 响应出口。
func WriteEnvelope(writer http.ResponseWriter, statusCode int, response ResponseEnvelope) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(statusCode)
if err := json.NewEncoder(writer).Encode(response); err != nil {
return
}
}
// ResponseRequestID 统一确定响应中的 request_id。
func ResponseRequestID(request *http.Request) string {
return RequestIDFromContext(request.Context())
}
// PositiveInt64PathValue 只接受 ServeMux 已命中的单段正整数路径变量。
// 非法路径参数在 transport 层返回 4xx避免把坏 ID 继续透传到内部 gRPC。
func PositiveInt64PathValue(request *http.Request, name string) (int64, bool) {
raw := strings.TrimSpace(request.PathValue(name))
value, err := strconv.ParseInt(raw, 10, 64)
return value, err == nil && value > 0
}
// RequireMethod 在业务 handler 之前固定 HTTP method避免 path-only mux 让错误 method 进入命令链路。
func RequireMethod(method string, next http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if request.Method != method {
WriteError(writer, request, http.StatusMethodNotAllowed, CodeInvalidArgument, "invalid argument")
return
}
next(writer, request)
}
}
// RequireCompletedProfile 在 gateway 层执行外部准入门禁。
// 这里故意不访问 user-service依赖 access token 快照,避免所有高频房间入口同步打用户服务。
func RequireCompletedProfile(next http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if !auth.ProfileCompletedFromContext(request.Context()) {
WriteError(writer, request, http.StatusForbidden, CodeProfileRequired, "profile required")
return
}
next.ServeHTTP(writer, request)
}
}