Files
junhong_cmp_fiber/openspec/changes/gateway-integration/design.md
huang 4856a88d41
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 43s
docs: 新增 Gateway 集成和微信公众号支付集成的 OpenSpec 规划文档
2026-01-30 16:09:32 +08:00

15 KiB
Raw Blame History

设计文档Gateway API 统一封装

架构设计

文件组织

internal/gateway/
├── client.go          # Gateway 客户端主体Client 结构体 + doRequest
├── crypto.go          # 加密/签名工具函数AES + MD5
├── flow_card.go       # 流量卡 7 个 API 方法封装
├── device.go          # 设备 7 个 API 方法封装
├── models.go          # 请求/响应 DTO
└── client_test.go     # 单元测试和集成测试

设计理由

  • 按功能职责拆分,清晰易维护
  • 单文件长度控制在 100 行以内
  • 符合 Go 惯用法的扁平化包结构

客户端设计

// Client Gateway API 客户端
type Client struct {
	baseURL    string
	appID      string
	appSecret  string
	httpClient *http.Client
	timeout    time.Duration
}

// NewClient 创建 Gateway 客户端
func NewClient(baseURL, appID, appSecret string) *Client

// WithTimeout 设置请求超时时间
func (c *Client) WithTimeout(timeout time.Duration) *Client

// doRequest 统一处理请求(加密、签名、发送、解密)
func (c *Client) doRequest(ctx context.Context, path string, businessData interface{}) ([]byte, error)

核心方法

  • doRequest统一封装加密、签名、HTTP 请求、响应解析
  • 14 个 API 方法复用 doRequest

加密/签名机制

1. AES-128-ECB 加密

// aesEncrypt 使用 AES-128-ECB 模式加密数据
// 密钥MD5(appSecret) 的原始字节数组16字节
// 填充PKCS5Padding
// 编码Base64
func aesEncrypt(data []byte, appSecret string) (string, error) {
	// 1. 生成密钥MD5(appSecret)
	h := md5.New()
	h.Write([]byte(appSecret))
	key := h.Sum(nil)  // 16 字节
	
	// 2. 创建 AES 加密器
	block, err := aes.NewCipher(key)
	if err != nil {
		return "", errors.Wrap(errors.CodeGatewayEncryptError, err)
	}
	
	// 3. PKCS5 填充
	padding := block.BlockSize() - len(data)%block.BlockSize()
	padText := bytes.Repeat([]byte{byte(padding)}, padding)
	data = append(data, padText...)
	
	// 4. ECB 模式加密
	encrypted := make([]byte, len(data))
	size := block.BlockSize()
	for bs, be := 0, size; bs < len(data); bs, be = bs+size, be+size {
		block.Encrypt(encrypted[bs:be], data[bs:be])
	}
	
	// 5. Base64 编码
	return base64.StdEncoding.EncodeToString(encrypted), nil
}

2. MD5 签名

// generateSign 生成 MD5 签名
// 参数排序appId、data、timestamp 按字母序
// 格式appId=xxx&data=xxx&timestamp=xxx&key=appSecret
// 输出:大写十六进制字符串
func generateSign(appID, encryptedData string, timestamp int64, appSecret string) string {
	// 1. 构建签名字符串(参数按字母序)
	signStr := fmt.Sprintf("appId=%s&data=%s&timestamp=%d&key=%s",
		appID, encryptedData, timestamp, appSecret)
	
	// 2. MD5 加密
	h := md5.New()
	h.Write([]byte(signStr))
	
	// 3. 转大写十六进制
	return strings.ToUpper(hex.EncodeToString(h.Sum(nil)))
}

请求流程

业务数据Go struct
      ↓ JSON 序列化
业务数据JSON string
      ↓ AES 加密
加密数据Base64 string
      ↓ 生成签名
签名MD5 大写)
      ↓ 构建请求
{
  "appId": "...",
  "data": "...",
  "sign": "...",
  "timestamp": ...
}
      ↓ HTTP POST
Gateway API
      ↓ 响应
{
  "code": 200,
  "msg": "成功",
  "data": {...},
  "trace_id": "..."
}
      ↓ 解析响应
返回业务数据

API 封装示例

流量卡状态查询

// QueryCardStatus 查询流量卡状态
func (c *Client) QueryCardStatus(ctx context.Context, req *CardStatusReq) (*CardStatusResp, error) {
	// 1. 构建业务数据
	businessData := map[string]interface{}{
		"params": map[string]interface{}{
			"cardNo": req.CardNo,
		},
	}
	
	// 2. 调用统一请求方法
	resp, err := c.doRequest(ctx, "/flow-card/status", businessData)
	if err != nil {
		return nil, err
	}
	
	// 3. 解析响应
	var result CardStatusResp
	if err := sonic.Unmarshal(resp, &result); err != nil {
		return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析卡状态响应失败")
	}
	
	return &result, nil
}

流量卡停机

// StopCard 流量卡停机
func (c *Client) StopCard(ctx context.Context, req *CardOperationReq) error {
	businessData := map[string]interface{}{
		"params": map[string]interface{}{
			"cardNo": req.CardNo,
		},
	}
	
	_, err := c.doRequest(ctx, "/flow-card/cardStop", businessData)
	return err
}

数据模型设计

请求 DTO

// CardStatusReq 卡状态查询请求
type CardStatusReq struct {
	CardNo string `json:"cardNo" validate:"required"` // 物联网卡号ICCID
}

// CardOperationReq 卡操作请求(停机、复机)
type CardOperationReq struct {
	CardNo string `json:"cardNo" validate:"required"` // 物联网卡号ICCID
	Extend string `json:"extend,omitempty"`           // 扩展参数(广电国网)
}

// FlowQueryReq 流量查询请求
type FlowQueryReq struct {
	CardNo string `json:"cardNo" validate:"required"` // 物联网卡号ICCID
}

// DeviceInfoReq 设备信息查询请求
type DeviceInfoReq struct {
	CardNo   string `json:"cardNo,omitempty"`   // 物联网卡号(与 DeviceID 二选一)
	DeviceID string `json:"deviceId,omitempty"` // 设备编号IMEI
}

响应 DTO

// GatewayResponse Gateway 通用响应
type GatewayResponse struct {
	Code    int             `json:"code"`    // 业务状态码200 = 成功)
	Msg     string          `json:"msg"`     // 业务提示信息
	Data    json.RawMessage `json:"data"`    // 业务数据(原始 JSON
	TraceID string          `json:"trace_id"` // 链路追踪 ID
}

// CardStatusResp 卡状态查询响应
type CardStatusResp struct {
	ICCID      string `json:"iccid"`       // 卡号
	CardStatus string `json:"cardStatus"`  // 卡状态(准备、正常、停机)
	Extend     string `json:"extend"`      // 扩展响应字段(广电国网)
}

// FlowUsageResp 流量使用查询响应
type FlowUsageResp struct {
	ICCID string  `json:"iccid"` // 卡号
	Used  float64 `json:"used"`  // 已使用流量MB
	Unit  string  `json:"unit"`  // 单位MB
}

// DeviceInfoResp 设备信息响应
type DeviceInfoResp struct {
	EquipmentID   string `json:"equipmentId"`   // 设备标识IMEI
	OnlineStatus  string `json:"onlineStatus"`  // 在线状态
	ClientNumber  int    `json:"clientNumber"`  // 连接客户端数
	RSSI          int    `json:"rssi"`          // 信号强度
	SSIDName      string `json:"ssidName"`      // WiFi 名称
	SSIDPassword  string `json:"ssidPassword"`  // WiFi 密码
	MAC           string `json:"mac"`           // MAC 地址
	UploadSpeed   int    `json:"uploadSpeed"`   // 上行速率
	DownloadSpeed int    `json:"downloadSpeed"` // 下行速率
	Version       string `json:"version"`       // 软件版本
	IP            string `json:"ip"`            // IP 地址
	WanIP         string `json:"wanIp"`         // 外网 IP
}

错误处理设计

错误码定义

pkg/errors/codes.go 中添加:

// Gateway 相关错误1110-1119
const (
	CodeGatewayError        = 1110 // Gateway 通用错误
	CodeGatewayEncryptError = 1111 // 数据加密失败
	CodeGatewaySignError    = 1112 // 签名生成失败
	CodeGatewayTimeout      = 1113 // 请求超时
	CodeGatewayInvalidResp  = 1114 // 响应格式错误
)

errorMessages 中添加:

errorMessages = map[int]string{
	// ...
	CodeGatewayError:        "Gateway 请求失败",
	CodeGatewayEncryptError: "数据加密失败",
	CodeGatewaySignError:    "签名生成失败",
	CodeGatewayTimeout:      "Gateway 请求超时",
	CodeGatewayInvalidResp:  "Gateway 响应格式错误",
}

错误处理策略

// doRequest 中的错误处理
func (c *Client) doRequest(ctx context.Context, path string, businessData interface{}) ([]byte, error) {
	// 1. 序列化业务数据
	dataBytes, err := sonic.Marshal(businessData)
	if err != nil {
		return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败")
	}
	
	// 2. 加密
	encryptedData, err := aesEncrypt(dataBytes, c.appSecret)
	if err != nil {
		return nil, err // 已在 aesEncrypt 中包装
	}
	
	// 3. 生成签名
	timestamp := time.Now().Unix()
	sign := generateSign(c.appID, encryptedData, timestamp, c.appSecret)
	
	// 4. 构建请求
	reqBody := map[string]interface{}{
		"appId":     c.appID,
		"data":      encryptedData,
		"sign":      sign,
		"timestamp": timestamp,
	}
	
	reqBytes, err := sonic.Marshal(reqBody)
	if err != nil {
		return nil, errors.Wrap(errors.CodeInternalError, err, "序列化请求失败")
	}
	
	// 5. 发送 HTTP 请求
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(reqBytes))
	if err != nil {
		return nil, errors.Wrap(errors.CodeGatewayError, err, "创建 HTTP 请求失败")
	}
	
	req.Header.Set("Content-Type", "application/json;charset=utf-8")
	req.Header.Set("Accept", "application/json")
	
	resp, err := c.httpClient.Do(req)
	if err != nil {
		// 判断是否超时
		if ctx.Err() == context.DeadlineExceeded {
			return nil, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时")
		}
		return nil, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败")
	}
	defer resp.Body.Close()
	
	// 6. 读取响应
	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, errors.Wrap(errors.CodeGatewayError, err, "读取响应失败")
	}
	
	// 7. 检查 HTTP 状态码
	if resp.StatusCode != http.StatusOK {
		return nil, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d, 响应: %s", resp.StatusCode, string(respBody)))
	}
	
	// 8. 解析响应
	var gatewayResp GatewayResponse
	if err := sonic.Unmarshal(respBody, &gatewayResp); err != nil {
		return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败")
	}
	
	// 9. 检查业务状态码
	if gatewayResp.Code != 200 {
		return nil, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg))
	}
	
	// 10. 返回业务数据
	return gatewayResp.Data, nil
}

配置集成设计

配置结构

pkg/config/config.go 中添加:

type Config struct {
	Server   ServerConfig   `mapstructure:"server"`
	Database DatabaseConfig `mapstructure:"database"`
	Redis    RedisConfig    `mapstructure:"redis"`
	Gateway  GatewayConfig  `mapstructure:"gateway"` // 新增
	// ...
}

// GatewayConfig Gateway API 配置
type GatewayConfig struct {
	BaseURL   string `mapstructure:"base_url"`   // Gateway API 基础 URL
	AppID     string `mapstructure:"app_id"`     // 应用 ID
	AppSecret string `mapstructure:"app_secret"` // 应用密钥
	Timeout   int    `mapstructure:"timeout"`    // 超时时间(秒,默认 30
}

配置文件

pkg/config/defaults/config.yaml 中添加:

gateway:
  base_url: "https://lplan.whjhft.com/openapi"
  app_id: "60bgt1X8i7AvXqkd"
  app_secret: "BZeQttaZQt0i73moF"
  timeout: 30

环境变量覆盖

export JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
export JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
export JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
export JUNHONG_GATEWAY_TIMEOUT=30

依赖注入设计

Bootstrap 初始化

internal/bootstrap/bootstrap.go 中添加:

// Dependencies 系统依赖
type Dependencies struct {
	DB            *gorm.DB
	Redis         *redis.Client
	QueueClient   *asynq.Client
	Logger        *zap.Logger
	Config        *config.Config
	GatewayClient *gateway.Client // 新增
}

// Bootstrap 初始化所有组件
func Bootstrap(deps *Dependencies) (*Handlers, error) {
	// ... 现有初始化
	
	// 初始化 Gateway 客户端
	gatewayClient := gateway.NewClient(
		deps.Config.Gateway.BaseURL,
		deps.Config.Gateway.AppID,
		deps.Config.Gateway.AppSecret,
	).WithTimeout(time.Duration(deps.Config.Gateway.Timeout) * time.Second)
	
	deps.GatewayClient = gatewayClient
	
	// ... 后续初始化
}

Service 注入

// internal/service/iot_card/service.go
type Service struct {
	store         *postgres.IotCardStore
	gatewayClient *gateway.Client // 新增
	logger        *zap.Logger
}

func NewService(store *postgres.IotCardStore, gatewayClient *gateway.Client, logger *zap.Logger) *Service {
	return &Service{
		store:         store,
		gatewayClient: gatewayClient,
		logger:        logger,
	}
}

// SyncCardStatus 同步卡状态
func (s *Service) SyncCardStatus(ctx context.Context, cardNo string) error {
	// 调用 Gateway API
	resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
		CardNo: cardNo,
	})
	if err != nil {
		return errors.Wrap(errors.CodeInternalError, err, "查询卡状态失败")
	}
	
	// 更新数据库
	return s.store.UpdateStatus(ctx, cardNo, resp.CardStatus)
}

测试设计

单元测试

// TestAESEncrypt 测试 AES 加密
func TestAESEncrypt(t *testing.T) {
	tests := []struct {
		name      string
		data      []byte
		appSecret string
		wantErr   bool
	}{
		{
			name:      "正常加密",
			data:      []byte(`{"params":{"cardNo":"898608070422D0010269"}}`),
			appSecret: "BZeQttaZQt0i73moF",
			wantErr:   false,
		},
	}
	
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			encrypted, err := aesEncrypt(tt.data, tt.appSecret)
			if (err != nil) != tt.wantErr {
				t.Errorf("aesEncrypt() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if !tt.wantErr && encrypted == "" {
				t.Error("aesEncrypt() 返回空字符串")
			}
		})
	}
}

// TestGenerateSign 测试签名生成
func TestGenerateSign(t *testing.T) {
	appID := "60bgt1X8i7AvXqkd"
	encryptedData := "test_encrypted_data"
	timestamp := int64(1704067200)
	appSecret := "BZeQttaZQt0i73moF"
	
	sign := generateSign(appID, encryptedData, timestamp, appSecret)
	
	// 验证签名格式32 位大写十六进制)
	if len(sign) != 32 {
		t.Errorf("签名长度错误: got %d, want 32", len(sign))
	}
	
	if sign != strings.ToUpper(sign) {
		t.Error("签名应为大写")
	}
}

集成测试

// TestQueryCardStatus 测试卡状态查询
func TestQueryCardStatus(t *testing.T) {
	if testing.Short() {
		t.Skip("跳过集成测试")
	}
	
	cfg := config.Get()
	client := gateway.NewClient(
		cfg.Gateway.BaseURL,
		cfg.Gateway.AppID,
		cfg.Gateway.AppSecret,
	).WithTimeout(30 * time.Second)
	
	ctx := context.Background()
	resp, err := client.QueryCardStatus(ctx, &gateway.CardStatusReq{
		CardNo: "898608070422D0010269",
	})
	
	require.NoError(t, err)
	require.NotNil(t, resp)
	require.NotEmpty(t, resp.ICCID)
	require.NotEmpty(t, resp.CardStatus)
}

性能考虑

  1. HTTP 连接复用http.Client 复用 TCP 连接
  2. 超时控制:通过 context.WithTimeout 控制请求超时
  3. 并发安全Client 结构体无状态,可安全并发调用
  4. 内存优化:使用 sonic 进行高性能 JSON 序列化

安全性考虑

  1. AES-ECB 模式:虽不推荐,但由 Gateway 强制要求
  2. 密钥管理AppSecret 通过环境变量注入,不硬编码
  3. 签名验证:每个请求都进行签名,防止篡改
  4. HTTPS:生产环境使用 HTTPS 加密传输