338 lines
11 KiB
Go
338 lines
11 KiB
Go
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
|
||
}
|
||
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)
|
||
}
|
||
|
||
// 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 {
|
||
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: idgen.New("sess"),
|
||
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()),
|
||
}
|
||
}
|
||
|
||
// ClientIP 提取 gateway 看到的客户端地址。
|
||
func ClientIP(request *http.Request) string {
|
||
for _, header := range []string{"X-Trusted-Client-IP", "X-Real-IP"} {
|
||
if ip := NormalizeIP(request.Header.Get(header)); ip != "" {
|
||
return ip
|
||
}
|
||
}
|
||
forwarded := strings.TrimSpace(request.Header.Get("X-Forwarded-For"))
|
||
if forwarded != "" {
|
||
for part := range strings.SplitSeq(forwarded, ",") {
|
||
ip := NormalizeIP(part)
|
||
if ip == "" {
|
||
continue
|
||
}
|
||
if addr, err := netip.ParseAddr(ip); err == nil && isPublicClientAddr(addr) {
|
||
return ip
|
||
}
|
||
}
|
||
}
|
||
|
||
host, _, err := net.SplitHostPort(request.RemoteAddr)
|
||
if err != nil {
|
||
return NormalizeIP(request.RemoteAddr)
|
||
}
|
||
|
||
return host
|
||
}
|
||
|
||
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 ""
|
||
}
|