Files
junhong_cmp_fiber/internal/gateway/client.go
邹进 94cbf2adab feat: 采集记录成功失败分表并保存网关原始数据,到期归档 csv.gz,新增记录分页查询接口
- 网关客户端新增原始数据收集器,记录最后一次尝试的明文请求、HTTP 状态、响应体与 code/msg/trace_id
- tb_card_traffic_collection 只写成功读数,新增 carrier_id 与原始数据列;新增 tb_card_traffic_collection_failure,失败不判断有无套餐并带错误详情
- 采集记录在线保留当月加最近 2 个整月,到期分区摘出、导出 csv.gz、校验行数与 SHA-256 后删除,本地保留 3 个月
- 新增采集成功/失败、卡日流量、套餐日流量详单、冲正流水、退款后处理尝试列表接口:可查单个也可查全部,分页与条件筛选
- 停复机记录支持资产标识、场景、原因、指令号筛选;单卡历史支持动作、结果、来源筛选
- 单卡采集记录支持筛选与合并失败记录;套餐日流量详单支持分页;退款列表支持后处理状态筛选
- 测试环境 api、worker 日志分目录,保留 40 个压缩备份;worker 挂载 archive 目录
- 迁移 000272、000273 需维护者在测试库执行后再部署

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:47:00 +09:00

355 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 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": <JSON>} 格式
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<<uint(attempt-1))
if delay > 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
}