package httpkit import ( "context" "encoding/json" "io" "net" "net/http" "net/netip" "strconv" "strings" "time" "google.golang.org/grpc/status" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" "hyapp/services/gateway-service/internal/auth" activityv1 "hyapp.local/api/proto/activity/v1" luckygiftv1 "hyapp.local/api/proto/luckygift/v1" roomv1 "hyapp.local/api/proto/room/v1" userv1 "hyapp.local/api/proto/user/v1" ) 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) } // Decode 统一处理 HTTP JSON 入参解析失败分支。 // gateway 在这里拒绝非法 JSON,不把脏请求继续透传到内部 gRPC。 func Decode(writer http.ResponseWriter, request *http.Request, out any) bool { if err := DecodeJSON(request.Body, out); err != nil { WriteError(writer, request, http.StatusBadRequest, CodeInvalidJSON, "invalid request body") return false } return true } // 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 } st, ok := status.FromError(err) if !ok { WriteError(writer, request, http.StatusBadGateway, CodeUpstreamError, "upstream service error") return } reason := xerr.ReasonFromGRPC(err) statusCode, code, message := MapReasonToHTTP(reason) if reason == xerr.DeviceAlreadyRegistered { // 设备注册上限错误需要把 user-service 组装的账号数量和短号列表透给客户端。 if detail := strings.TrimSpace(st.Message()); detail != "" { message = detail } } WriteError(writer, request, statusCode, code, message) } // Write 统一把内部 gRPC 调用结果转换为 HTTP JSON 响应。 func Write(writer http.ResponseWriter, request *http.Request, body any, err error) { if err != nil { WriteRPCError(writer, request, err) return } WriteOK(writer, request, body) } 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 } func UserIDString(userID int64) string { if userID > 0 { return strconv.FormatInt(userID, 10) } return "" } func PositiveInt32Query(request *http.Request, name string, fallback int32) (int32, bool) { raw := strings.TrimSpace(request.URL.Query().Get(name)) if raw == "" { return fallback, true } value, err := strconv.ParseInt(raw, 10, 32) if err != nil || value <= 0 { return 0, false } return int32(value), true } func FirstQueryOrHeader(request *http.Request, queryKey string, headerNames ...string) string { if value := strings.TrimSpace(request.URL.Query().Get(queryKey)); value != "" { return value } return FirstHeader(request, headerNames...) } func FirstHeader(request *http.Request, headerNames ...string) string { for _, name := range headerNames { if value := strings.TrimSpace(request.Header.Get(name)); value != "" { return value } } return "" } // 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) } } // RoomMeta 把外部 HTTP 请求上下文转换成 room-service 命令元信息。 // request_id 只做链路追踪;command_id 必须来自客户端用户动作,gateway 只裁剪空白。 func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.RequestMeta { sessionID := auth.SessionIDFromContext(request.Context()) if sessionID == "" { sessionID = idgen.New("sess") } return &roomv1.RequestMeta{ RequestId: RequestIDFromContext(request.Context()), CommandId: strings.TrimSpace(commandID), ActorUserId: auth.UserIDFromContext(request.Context()), RoomId: roomID, AppCode: appcode.FromContext(request.Context()), GatewayNodeId: "gateway-local", SessionId: sessionID, SentAtMs: time.Now().UnixMilli(), } } // UserMeta 生成 user-service RPC 的统一追踪和审计元信息。 func UserMeta(request *http.Request, deviceID string) *userv1.RequestMeta { return UserMetaWithLoginContext(request, deviceID, "", "", "") } func UserMetaWithLoginContext(request *http.Request, deviceID string, platform string, language string, timezone string) *userv1.RequestMeta { return &userv1.RequestMeta{ RequestId: RequestIDFromContext(request.Context()), Caller: "gateway-service", GatewayNodeId: "gateway-local", SentAtMs: time.Now().UnixMilli(), DeviceId: deviceID, ClientIp: ClientIP(request), UserAgent: request.UserAgent(), CountryByIp: CountryByIP(request), SessionId: auth.SessionIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), Platform: strings.ToLower(strings.TrimSpace(platform)), Language: strings.TrimSpace(language), Timezone: strings.TrimSpace(timezone), } } // ActivityMeta 生成 activity-service RPC 的统一追踪元信息。 // 业务 actor 仍由各请求显式传 user_id,meta 只承载链路和租户上下文。 func ActivityMeta(request *http.Request) *activityv1.RequestMeta { return &activityv1.RequestMeta{ RequestId: RequestIDFromContext(request.Context()), Caller: "gateway-service", GatewayNodeId: "gateway-local", SentAtMs: time.Now().UnixMilli(), AppCode: appcode.FromContext(request.Context()), } } // LuckyGiftMeta 生成 lucky-gift-service RPC 的追踪元信息。 // request_id 仍只用于链路排查,送礼和外部抽奖的幂等键必须由各自业务命令字段承载。 func LuckyGiftMeta(request *http.Request) *luckygiftv1.RequestMeta { return &luckygiftv1.RequestMeta{ RequestId: RequestIDFromContext(request.Context()), Caller: "gateway-service", GatewayNodeId: "gateway-local", SentAtMs: time.Now().UnixMilli(), AppCode: appcode.FromContext(request.Context()), } } // ClientIP 提取 gateway 看到的客户端地址;X-Real-IP 在线上可能是入口代理地址,所以只在 XFF 没有公网段时兜底使用。 func ClientIP(request *http.Request) string { if ip := NormalizeIP(request.Header.Get("X-Trusted-Client-IP")); ip != "" { return ip } if ip := FirstPublicForwardedIP(request.Header.Get("X-Forwarded-For")); ip != "" { return ip } if ip := PublicHeaderIP(request.Header.Get("X-Real-IP")); ip != "" { return ip } host, _, err := net.SplitHostPort(request.RemoteAddr) if err != nil { return NormalizeIP(request.RemoteAddr) } return host } func FirstPublicForwardedIP(forwarded string) string { forwarded = strings.TrimSpace(forwarded) if forwarded == "" { return "" } for part := range strings.SplitSeq(forwarded, ",") { ip := NormalizeIP(part) if ip == "" { continue } if addr, err := netip.ParseAddr(ip); err == nil && isPublicClientAddr(addr) { return ip } } return "" } func PublicHeaderIP(value string) string { ip := NormalizeIP(value) if ip == "" { return "" } if addr, err := netip.ParseAddr(ip); err == nil && isPublicClientAddr(addr) { return ip } return "" } func NormalizeIP(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if host, _, err := net.SplitHostPort(value); err == nil { value = host } if addr, err := netip.ParseAddr(value); err == nil { return addr.String() } return value } func isPublicClientAddr(addr netip.Addr) bool { return addr.IsValid() && addr.IsGlobalUnicast() && !addr.IsPrivate() && !addr.IsLoopback() && !addr.IsLinkLocalUnicast() && !addr.IsLinkLocalMulticast() && !addr.IsMulticast() && !addr.IsUnspecified() } func CountryByIP(request *http.Request) string { // country_by_ip 不接受 JSON body;只采信 gateway/边缘层写入的地域头。 for _, header := range []string{"CF-IPCountry", "CloudFront-Viewer-Country", "X-Vercel-IP-Country", "X-AppEngine-Country", "X-Country-Code"} { country := strings.ToUpper(strings.TrimSpace(request.Header.Get(header))) if country == "" || country == "XX" || country == "UNKNOWN" { continue } if len(country) >= 2 && len(country) <= 3 { return country } } return "" }