Files
junhong_cmp_fiber/internal/gateway/client.go
break 7891189712
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m52s
feat(代理自充): AUG26-017 代理自充收款方式与线下预存款审批字段
- 受控配置新增代理在线自充允许范围(仅微信/仅支付宝/同时支持),读侧与创建侧取允许范围与可用商户池交集,两侧失败关闭
- 新增允许范围查询与修改端点,读限代理与平台账号、写限超级管理员,复用受控配置写服务留痕
- tb_agent_recharge_record 新增交易流水号、线下收款方式三列快照与其他凭证列(成对迁移 000213)
- 线下申请校验启用的收款方式字典项与必填交易流水号,交易流水号独立于在线渠道交易号、不参与去重
- 扩展 offline_recharge_approval 场景可映射字段白名单与字典引用保护
- 新增付款凭证识别能力与交易流水号预填接口,识别不落库、日志不记录载荷
2026-09-11 15:21:23 +08:00

296 lines
9.3 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/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
}
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) {
// 将参数包装为 {"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, "序列化业务数据失败")
}
// 加密业务数据(加密结果不变,可在重试间复用)
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) {
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)
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
if observer != nil {
if err := observer.BeforeAttempt(ctx, attemptNumber); err != nil {
return nil, err
}
}
result, retryable, err := c.executeHTTPRequest(ctx, path, encryptedData)
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 请求(无重试逻辑)
// 返回值:响应数据、是否可重试、错误
func (c *Client) executeHTTPRequest(ctx context.Context, path string, encryptedData string) (json.RawMessage, bool, error) {
// 每次重试使用新的时间戳和签名
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, errors.Wrap(errors.CodeGatewayError, ctx.Err(), "请求被取消")
}
// Client 超时 — 可重试
if reqCtx.Err() == context.DeadlineExceeded {
return nil, true, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时")
}
// 其他网络错误连接失败、DNS 解析等)— 可重试
return nil, true, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败")
}
defer resp.Body.Close()
// HTTP 状态码错误 — 不可重试
if resp.StatusCode != http.StatusOK {
return nil, false, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d", resp.StatusCode))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, false, errors.Wrap(errors.CodeGatewayInvalidResp, err, "读取响应体失败")
}
var gatewayResp GatewayResponse
if err := sonic.Unmarshal(body, &gatewayResp); err != nil {
return nil, false, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败")
}
// 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),
)
return nil, false, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg))
}
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
}