159 lines
5.3 KiB
Go
159 lines
5.3 KiB
Go
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)
|
||
}
|
||
}
|