2026-06-25 21:30:26 +08:00

369 lines
12 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"
"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"
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_idmeta 只承载链路和租户上下文。
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()),
}
}
// 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 ""
}