All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m2s
15 KiB
15 KiB
设计文档: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×tamp=xxx&key=appSecret
// 输出:大写十六进制字符串
func generateSign(appID, encryptedData string, timestamp int64, appSecret string) string {
// 1. 构建签名字符串(参数按字母序)
signStr := fmt.Sprintf("appId=%s&data=%s×tamp=%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)
}
性能考虑
- HTTP 连接复用:
http.Client复用 TCP 连接 - 超时控制:通过
context.WithTimeout控制请求超时 - 并发安全:
Client结构体无状态,可安全并发调用 - 内存优化:使用
sonic进行高性能 JSON 序列化
安全性考虑
- AES-ECB 模式:虽不推荐,但由 Gateway 强制要求
- 密钥管理:AppSecret 通过环境变量注入,不硬编码
- 签名验证:每个请求都进行签名,防止篡改
- HTTPS:生产环境使用 HTTPS 加密传输