docs: 新增 Gateway 集成和微信公众号支付集成的 OpenSpec 规划文档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 43s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 43s
This commit is contained in:
570
openspec/changes/gateway-integration/design.md
Normal file
570
openspec/changes/gateway-integration/design.md
Normal file
@@ -0,0 +1,570 @@
|
||||
# 设计文档: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 加密传输
|
||||
Reference in New Issue
Block a user