// Package gateway 提供 Gateway API 的统一客户端封装 // 实现 AES-128-ECB 加密 + MD5 签名认证机制,支持请求日志和网络级错误重试 package gateway import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/bytedance/sonic" "go.uber.org/zap" ) const ( defaultTimeout = 60 * time.Second maxIdleConns = 100 maxIdleConnsPerHost = 10 idleConnTimeout = 90 * time.Second contentTypeJSON = "application/json;charset=utf-8" gatewaySuccessCode = 200 defaultMaxRetries = 2 retryBaseDelay = 100 * time.Millisecond ) // Client 是 Gateway API 的 HTTP 客户端 type Client struct { baseURL string appID string appSecret string httpClient *http.Client timeout time.Duration logger *zap.Logger maxRetries int guard CallGuard // 卡网关调用守卫(限流与统计),为 nil 时不生效 // readonlyChecker 判断请求卡号是否为只读资产(测试环境只读模式),为 nil 时不生效。 readonlyChecker ReadonlyChecker } type attemptObserverKey struct{} // AttemptObserver 记录一次 Gateway HTTP 尝试的开始和结果。 type AttemptObserver interface { BeforeAttempt(ctx context.Context, attempt int) error AfterAttempt(ctx context.Context, attempt int, callErr error) error } // WithAttemptObserver 为当前 Gateway 调用注入逐次 HTTP 尝试观察器。 func WithAttemptObserver(ctx context.Context, observer AttemptObserver) context.Context { if observer == nil { return ctx } return context.WithValue(ctx, attemptObserverKey{}, observer) } // requestWrapper 用于将请求参数包装为 Gateway 的 {"params": ...} 格式 type requestWrapper struct { Params interface{} `json:"params"` } // NewClient 创建 Gateway 客户端实例 // baseURL: Gateway 服务基础地址 // appID: 应用 ID // appSecret: 应用密钥(用于加密和签名) // logger: Zap 日志记录器 func NewClient(baseURL, appID, appSecret string, logger *zap.Logger) *Client { return &Client{ baseURL: baseURL, appID: appID, appSecret: appSecret, httpClient: &http.Client{ Transport: &http.Transport{ MaxIdleConns: maxIdleConns, MaxIdleConnsPerHost: maxIdleConnsPerHost, IdleConnTimeout: idleConnTimeout, }, }, timeout: defaultTimeout, logger: logger, maxRetries: defaultMaxRetries, } } // WithTimeout 设置请求超时时间(支持链式调用) func (c *Client) WithTimeout(timeout time.Duration) *Client { c.timeout = timeout return c } // WithRetry 设置最大重试次数(支持链式调用) // maxRetries=0 表示不重试,maxRetries=2 表示最多重试 2 次(共 3 次尝试) func (c *Client) WithRetry(maxRetries int) *Client { c.maxRetries = maxRetries return c } // doRequest 执行 Gateway API 请求的统一方法 // 流程:包装参数 → 序列化 → 加密 → 签名 → HTTP POST(带重试)→ 解析响应 → 检查业务状态码 // params: 请求参数结构体,内部自动包装为 {"params": } 格式 func (c *Client) doRequest(ctx context.Context, path string, params interface{}) (json.RawMessage, error) { if err := c.rejectReadonlyWrite(ctx, path, params); err != nil { return nil, err } // 将参数包装为 {"params": ...} 格式后序列化 wrapper := requestWrapper{Params: params} dataBytes, err := sonic.Marshal(wrapper) c.logger.Info("发送gateway请求前结构:", zap.ByteString("body", dataBytes)) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败") } if capture := rawCaptureFrom(ctx); capture != nil { capture.Request = append(json.RawMessage(nil), dataBytes...) } // 加密业务数据(加密结果不变,可在重试间复用) encryptedData, err := aesEncrypt(dataBytes, c.appSecret) if err != nil { return nil, err } return c.executeWithRetry(ctx, path, encryptedData, true) } // doRequestWithoutPayloadLog 执行 Gateway 请求,但不记录请求体与响应体。 // 仅用于载荷含敏感内容(如付款凭证图片)的能力;成功与失败都只记录路径、耗时与结果摘要。 func (c *Client) doRequestWithoutPayloadLog(ctx context.Context, path string, params interface{}) (json.RawMessage, error) { if err := c.rejectReadonlyWrite(ctx, path, params); err != nil { return nil, err } dataBytes, err := sonic.Marshal(requestWrapper{Params: params}) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败") } encryptedData, err := aesEncrypt(dataBytes, c.appSecret) if err != nil { return nil, err } return c.executeWithRetry(ctx, path, encryptedData, false) } // executeWithRetry 按现有重试语义发送一次已加密请求。 // logPayload 为 false 时不记录响应体,只记录路径、耗时与结果字节数摘要。 func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData string, logPayload bool) (json.RawMessage, error) { startTime := time.Now() // 带重试的 HTTP 请求 var lastErr error observer, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver) call, isCardCall := cardCallInfo(ctx) for attempt := 0; attempt <= c.maxRetries; attempt++ { if attempt > 0 { // 检查用户 Context 是否已取消 if ctx.Err() != nil { break } // 指数退避等待:100ms → 200ms → 300ms(封顶 3 倍基础延迟) delay := retryBaseDelay * time.Duration(1< retryBaseDelay*3 { delay = retryBaseDelay * 3 } c.logger.Warn("Gateway 请求重试", zap.String("path", path), zap.Int("attempt", attempt+1), zap.Duration("delay", delay), ) time.Sleep(delay) } attemptNumber := attempt + 1 // 卡网关操作每次 HTTP 尝试前各取一次限流名额,内部重试同样计量;拿不到名额时本次调用立即结束。 if isCardCall && c.guard != nil { call.Attempt = attemptNumber if err := c.guard.BeforeAttempt(ctx, call); err != nil { if attempt > 0 { c.logger.Warn("Gateway 重试被限流中止", zap.String("path", path), zap.Int("attempt", attemptNumber), zap.NamedError("last_error", lastErr)) } return nil, err } } if observer != nil { if err := observer.BeforeAttempt(ctx, attemptNumber); err != nil { return nil, err } } capture := rawCaptureFrom(ctx) if capture != nil { capture.resetResponse() capture.Attempts = attemptNumber } attemptStartedAt := time.Now() result, retryable, err := c.executeHTTPRequest(ctx, path, encryptedData, capture != nil && logPayload) attemptDuration := time.Since(attemptStartedAt) if gwErr, ok := AsGatewayError(err); ok { gwErr.Duration = attemptDuration } if isCardCall && c.guard != nil { c.guard.AfterAttempt(ctx, call, err, attemptDuration) } if observer != nil { if observeErr := observer.AfterAttempt(ctx, attemptNumber, err); observeErr != nil { return nil, observeErr } } if err != nil { lastErr = err // 仅对网络级错误重试 if retryable && ctx.Err() == nil { continue } break } // 成功 duration := time.Since(startTime) if logPayload { c.logger.Debug("Gateway 请求成功", zap.String("path", path), zap.Duration("duration", duration), zap.Any("result", result), ) } else { c.logger.Debug("Gateway 请求成功", zap.String("path", path), zap.Duration("duration", duration), zap.Int("result_bytes", len(result)), ) } return result, nil } // 所有尝试都失败 duration := time.Since(startTime) c.logger.Error("Gateway 请求失败", zap.String("path", path), zap.Duration("duration", duration), zap.Error(lastErr), ) return nil, lastErr } // executeHTTPRequest 执行单次 HTTP 请求(无重试逻辑) // 返回值:响应数据、是否可重试、错误 // captureRaw 为 true 时把状态码、响应体与外层字段写入上下文上的原始数据收集器。 func (c *Client) executeHTTPRequest(ctx context.Context, path string, encryptedData string, captureRaw bool) (json.RawMessage, bool, error) { var capture *RawCapture if captureRaw { capture = rawCaptureFrom(ctx) } // 每次重试使用新的时间戳和签名 timestamp := time.Now().Unix() sign := generateSign(c.appID, encryptedData, timestamp, c.appSecret) reqBody := map[string]interface{}{ "appId": c.appID, "data": encryptedData, "sign": sign, "timestamp": timestamp, } reqBodyBytes, err := sonic.Marshal(reqBody) if err != nil { return nil, false, errors.Wrap(errors.CodeInternalError, err, "序列化请求体失败") } reqCtx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.baseURL+path, bytes.NewReader(reqBodyBytes)) if err != nil { return nil, false, errors.Wrap(errors.CodeGatewayError, err, "创建 HTTP 请求失败") } req.Header.Set("Content-Type", contentTypeJSON) resp, err := c.httpClient.Do(req) if err != nil { // 用户 Context 已取消 — 不可重试 if ctx.Err() != nil { return nil, false, newGatewayError(constants.GatewayErrorNetwork, errors.Wrap(errors.CodeGatewayError, ctx.Err(), "请求被取消")) } // Client 超时 — 可重试 if reqCtx.Err() == context.DeadlineExceeded { return nil, true, newGatewayError(constants.GatewayErrorTimeout, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时")) } // 其他网络错误(连接失败、DNS 解析等)— 可重试 return nil, true, newGatewayError(constants.GatewayErrorNetwork, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败")) } defer resp.Body.Close() if capture != nil { capture.HTTPStatus = resp.StatusCode } // HTTP 状态码错误 — 不可重试 if resp.StatusCode != http.StatusOK { gwErr := newGatewayError(constants.GatewayErrorHTTP, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d", resp.StatusCode))) gwErr.HTTPStatus = resp.StatusCode return nil, false, gwErr } body, err := io.ReadAll(resp.Body) if err != nil { return nil, false, newGatewayError(constants.GatewayErrorInvalidResponse, errors.Wrap(errors.CodeGatewayInvalidResp, err, "读取响应体失败")) } if capture != nil { capture.Response = body } var gatewayResp GatewayResponse if err := sonic.Unmarshal(body, &gatewayResp); err != nil { return nil, false, newGatewayError(constants.GatewayErrorInvalidResponse, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败")) } if capture != nil { code := gatewayResp.Code capture.GatewayCode, capture.GatewayMsg, capture.TraceID = &code, gatewayResp.Msg, gatewayResp.TraceID } // Gateway 业务错误 — 不可重试 if gatewayResp.Code != gatewaySuccessCode { c.logger.Warn("Gateway 业务错误", zap.String("path", path), zap.Int("gateway_code", gatewayResp.Code), zap.String("gateway_msg", gatewayResp.Msg), ) gwErr := newGatewayError(constants.GatewayErrorUpstream, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg))) gwErr.ProviderCode = gatewayResp.Code gwErr.ProviderMessage = truncateProviderMessage(gatewayResp.Msg) return nil, false, gwErr } return gatewayResp.Data, false, nil } // doRequestWithResponse 执行 Gateway API 请求并自动反序列化响应为目标类型 func doRequestWithResponse[T any](c *Client, ctx context.Context, path string, params interface{}) (*T, error) { data, err := c.doRequest(ctx, path, params) if err != nil { return nil, err } c.logger.Info("Gateway 原始响应数据", zap.ByteString("data", data)) var result T if err := sonic.Unmarshal(data, &result); err != nil { return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败") } return &result, nil }