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

571 lines
15 KiB
Markdown
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.
# 设计文档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&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 封装示例
#### 流量卡状态查询
```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 加密传输