146 lines
5.2 KiB
Go
146 lines
5.2 KiB
Go
package http
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"hyapp/pkg/idgen"
|
||
"hyapp/pkg/xerr"
|
||
|
||
"google.golang.org/grpc/status"
|
||
)
|
||
|
||
type requestIDContextKey struct{}
|
||
|
||
const (
|
||
codeOK = "OK"
|
||
codeInvalidJSON = "INVALID_JSON"
|
||
codeInvalidArgument = "INVALID_ARGUMENT"
|
||
codeUnauthorized = "UNAUTHORIZED"
|
||
codePermissionDenied = "PERMISSION_DENIED"
|
||
codeProfileRequired = "PROFILE_REQUIRED"
|
||
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) {
|
||
switch reason {
|
||
case xerr.InvalidArgument, xerr.DisplayUserIDInvalid:
|
||
return http.StatusBadRequest, string(reason), "invalid argument"
|
||
case xerr.AuthFailed:
|
||
return http.StatusUnauthorized, string(reason), "authentication failed"
|
||
case xerr.Unauthorized, xerr.SessionExpired, xerr.SessionRevoked:
|
||
return http.StatusUnauthorized, string(reason), "unauthorized"
|
||
case xerr.Conflict, xerr.PasswordAlreadySet, xerr.DisplayUserIDExists:
|
||
return http.StatusConflict, string(reason), "conflict"
|
||
case xerr.DisplayUserIDCooldown, xerr.DisplayUserIDPrettyNotAvailable, xerr.DisplayUserIDPrettyActive, xerr.DisplayUserIDLeaseExpired, xerr.DisplayUserIDPaymentRequired, xerr.CountryChangeCooldown, xerr.RegionCountryConflict, xerr.RegionDisabled:
|
||
return http.StatusConflict, string(reason), "conflict"
|
||
case xerr.ProfileRequired:
|
||
return http.StatusForbidden, codeProfileRequired, "profile required"
|
||
case xerr.UserDisabled, xerr.PermissionDenied:
|
||
return http.StatusForbidden, string(reason), "permission denied"
|
||
case xerr.NotFound, xerr.DisplayUserIDNotFound, xerr.CountryNotFound, xerr.RegionNotFound:
|
||
return http.StatusNotFound, string(reason), "not found"
|
||
case xerr.Unavailable:
|
||
return http.StatusBadGateway, codeUpstreamError, "upstream service error"
|
||
case xerr.Internal:
|
||
return http.StatusInternalServerError, codeInternalError, "internal error"
|
||
default:
|
||
return http.StatusBadGateway, codeUpstreamError, "upstream service error"
|
||
}
|
||
}
|
||
|
||
// 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())
|
||
}
|