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 不作为可信输入,避免客户端把追踪字段误用成跨请求幂等锚点。 func WithRequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 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) } }