All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 43s
571 lines
15 KiB
Markdown
571 lines
15 KiB
Markdown
# 设计文档: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 惯用法的扁平化包结构
|
||
|
||
### 客户端设计
|
||
|
||
```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 加密
|
||
|
||
```go
|
||
// 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 签名
|
||
|
||
```go
|
||
// 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 封装示例
|
||
|
||
#### 流量卡状态查询
|
||
|
||
```go
|
||
// 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
|
||
}
|
||
```
|
||
|
||
#### 流量卡停机
|
||
|
||
```go
|
||
// 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
|
||
|
||
```go
|
||
// 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
|
||
|
||
```go
|
||
// 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` 中添加:
|
||
|
||
```go
|
||
// Gateway 相关错误(1110-1119)
|
||
const (
|
||
CodeGatewayError = 1110 // Gateway 通用错误
|
||
CodeGatewayEncryptError = 1111 // 数据加密失败
|
||
CodeGatewaySignError = 1112 // 签名生成失败
|
||
CodeGatewayTimeout = 1113 // 请求超时
|
||
CodeGatewayInvalidResp = 1114 // 响应格式错误
|
||
)
|
||
```
|
||
|
||
在 `errorMessages` 中添加:
|
||
|
||
```go
|
||
errorMessages = map[int]string{
|
||
// ...
|
||
CodeGatewayError: "Gateway 请求失败",
|
||
CodeGatewayEncryptError: "数据加密失败",
|
||
CodeGatewaySignError: "签名生成失败",
|
||
CodeGatewayTimeout: "Gateway 请求超时",
|
||
CodeGatewayInvalidResp: "Gateway 响应格式错误",
|
||
}
|
||
```
|
||
|
||
### 错误处理策略
|
||
|
||
```go
|
||
// 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` 中添加:
|
||
|
||
```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` 中添加:
|
||
|
||
```yaml
|
||
gateway:
|
||
base_url: "https://lplan.whjhft.com/openapi"
|
||
app_id: "60bgt1X8i7AvXqkd"
|
||
app_secret: "BZeQttaZQt0i73moF"
|
||
timeout: 30
|
||
```
|
||
|
||
### 环境变量覆盖
|
||
|
||
```bash
|
||
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` 中添加:
|
||
|
||
```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 注入
|
||
|
||
```go
|
||
// 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)
|
||
}
|
||
```
|
||
|
||
## 测试设计
|
||
|
||
### 单元测试
|
||
|
||
```go
|
||
// 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("签名应为大写")
|
||
}
|
||
}
|
||
```
|
||
|
||
### 集成测试
|
||
|
||
```go
|
||
// 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 加密传输
|