diff --git a/.sisyphus/drafts/gateway-integration.md b/.sisyphus/drafts/gateway-integration.md
new file mode 100644
index 0000000..fa65ade
--- /dev/null
+++ b/.sisyphus/drafts/gateway-integration.md
@@ -0,0 +1,155 @@
+# Draft: Gateway Integration 实施计划
+
+## 需求确认(已完成)
+
+### 用户目标
+实现 **Gateway API 统一封装**,提供 14 个物联网卡和设备管理接口的类型安全封装,支持复杂的认证机制(AES-128-ECB 加密 + MD5 签名)。
+
+### 核心需求
+1. **加密/签名机制**: AES-128-ECB 加密(密钥: MD5(appSecret),填充: PKCS5,编码: Base64) + MD5 签名(参数字母序拼接 + 大写十六进制)
+2. **API 封装**: 流量卡 API(7个) + 设备 API(7个)
+3. **配置集成**: GatewayConfig + 环境变量覆盖 + Bootstrap 依赖注入
+4. **错误处理**: 定义错误码 1110-1119,统一错误包装和日志
+5. **测试覆盖**: 单元测试覆盖率 ≥ 90%,集成测试验证真实 Gateway API
+
+### 技术约束
+- **禁止跳过 tasks.md 中的61个任务**
+- **禁止合并或简化任务**
+- **所有注释必须使用中文**
+- **遵循项目代码规范(AGENTS.md)**
+
+## 文档已读取
+
+### OpenSpec 文档
+- ✅ proposal.md - 提案概述和验收标准
+- ✅ design.md - 详细设计文档(加密流程、请求流程、错误处理)
+- ✅ tasks.md - 完整的61个任务清单(禁止跳过或简化)
+
+### 规范文档
+- ✅ specs/gateway-crypto/spec.md - 加密/签名规范(AES-ECB、MD5、PKCS5Padding)
+- ✅ specs/gateway-config/spec.md - 配置集成规范(环境变量、验证逻辑)
+- ✅ specs/gateway-client/spec.md - 客户端规范(14个API、DTO定义、并发安全)
+
+## 背景调研(已完成 2/3)
+
+### ✅ 已完成的调研
+
+#### 1. 项目架构模式 (bg_2a5fab13)
+**Bootstrap依赖注入模式**:
+- 6步初始化顺序: Stores → GORM Callbacks → Services → Admin → Middlewares → Handlers
+- Dependencies结构: DB, Redis, Logger, JWT, Token, Verification, Queue, Storage
+- 新增客户端模式: Dependencies结构 → main.go初始化 → Service注入
+
+**Config管理模式**:
+- 配置结构: 层级嵌套 + mapstructure标签
+- 环境变量: `JUNHONG_{SECTION}_{KEY}` 格式
+- 验证逻辑: ValidateRequired() + Validate()
+- 添加配置节: struct定义 → defaults/config.yaml → bindEnvVariables → Validate
+
+**Error处理模式**:
+- 错误码范围: 1000-1999(4xx) + 2000-2999(5xx)
+- 错误码必须在codes.go中注册: allErrorCodes + errorMessages
+- 使用方式: errors.New(code) 或 errors.Wrap(code, err)
+- 自动映射: 错误码 → HTTP状态码 + 日志级别
+
+**Service集成模式**:
+- Store初始化: NewXxxStore(deps.DB, deps.Redis)
+- Service构造器: 接收stores + 外部clients
+- 方法错误处理: 验证用New(), 系统错误用Wrap()
+
+#### 2. 测试模式和规范 (bg_6413c883)
+**testutils核心函数**:
+- `NewTestTransaction(t)`: 自动回滚的事务
+- `GetTestRedis(t)`: 全局Redis连接
+- `CleanTestRedisKeys(t, rdb)`: 自动清理Redis键
+
+**集成测试环境**:
+- `integ.NewIntegrationTestEnv(t)`: 完整测试环境
+- 认证方法: AsSuperAdmin(), AsUser(account)
+- 请求方法: Request(method, path, body)
+
+**测试执行**:
+- 必须先加载环境变量: `source .env.local && go test`
+- 覆盖率要求: Service层 ≥ 90%
+
+#### 3. AES-ECB加密最佳实践 (bg_f36926a0) ✅
+**核心发现**:
+- AES-ECB必须手动实现(Go标准库不提供)
+- 生产级参考: TiDB的实现(~50行代码)
+- PKCS5 = PKCS7(8字节块)
+- MD5密钥派生: crypto/md5.Sum()返回[16]byte
+- Base64编码: encoding/base64.StdEncoding
+
+**安全注意**:
+- ECB模式不推荐(外部系统强制要求)
+- 必须验证所有填充字节,不只是最后一个
+- MD5已被破解(仅用于遗留系统)
+
+## 任务依赖分析(初步)
+
+### Phase分析
+基于tasks.md的5个Phase:
+
+**Phase 1: 基础结构搭建 (4个任务, 30min)**
+- Task 1.1: 创建目录结构 ✅ 独立
+- Task 1.2: 加密/签名工具 ⏸️ 需等AES-ECB调研
+- Task 1.3: Client基础结构 ⚠️ 依赖1.2
+- Task 1.4: DTO定义 ✅ 独立
+
+**Phase 2: API接口封装 (3个任务, 40min)**
+- Task 2.1: 流量卡API(7个) ⚠️ 依赖1.3
+- Task 2.2: 设备API(7个) ⚠️ 依赖1.3
+- Task 2.3: 单元测试 ⚠️ 依赖2.1+2.2
+
+**Phase 3: 配置和错误码 (2个任务, 20min)**
+- Task 3.1: Gateway配置 ✅ 可与Phase 1并行
+- Task 3.2: Gateway错误码 ✅ 可与Phase 1并行
+
+**Phase 4: 依赖注入和集成 (2个任务, 20min)**
+- Task 4.1: Bootstrap初始化 ⚠️ 依赖3.1+1.3
+- Task 4.2: Service层集成 ⚠️ 依赖4.1
+
+**Phase 5: 集成测试和文档 (2个任务, 10min)**
+- Task 5.1: 集成测试 ⚠️ 依赖4.2
+- Task 5.2: 更新文档 ✅ 可在最后并行
+
+### 并行执行波次(初步)
+
+**Wave 1 (可立即并行)**:
+- Task 1.1: 创建目录
+- Task 1.4: DTO定义
+- Task 3.1: Gateway配置
+- Task 3.2: Gateway错误码
+
+**Wave 2 (依赖AES-ECB调研)**:
+- Task 1.2: 加密/签名工具
+
+**Wave 3 (依赖Wave 2)**:
+- Task 1.3: Client基础结构
+
+**Wave 4 (依赖Wave 3)**:
+- Task 2.1: 流量卡API
+- Task 2.2: 设备API
+- Task 4.1: Bootstrap初始化
+
+**Wave 5 (依赖Wave 4)**:
+- Task 2.3: 单元测试
+- Task 4.2: Service集成
+
+**Wave 6 (依赖Wave 5)**:
+- Task 5.1: 集成测试
+- Task 5.2: 文档更新
+
+### 关键路径识别
+```
+1.2(加密工具) → 1.3(Client结构) → 2.1/2.2(API封装) → 2.3(单元测试) → 4.2(Service集成) → 5.1(集成测试)
+```
+
+### 风险点
+1. **AES-ECB实现复杂度**: 等待bg_f36926a0调研结果
+2. **签名算法兼容性**: 需要端到端集成测试验证
+3. **Gateway响应格式**: 需要mock测试 + 真实API测试
+4. **配置验证逻辑**: 需要仔细测试必填项和格式验证
+
+## 下一步
+等待bg_f36926a0完成AES-ECB调研,然后生成完整的执行计划。
diff --git a/.sisyphus/plans/gateway-integration-execution-plan.md b/.sisyphus/plans/gateway-integration-execution-plan.md
new file mode 100644
index 0000000..c741cad
--- /dev/null
+++ b/.sisyphus/plans/gateway-integration-execution-plan.md
@@ -0,0 +1,1329 @@
+# Gateway Integration 并行执行计划
+
+## TL;DR
+
+> **快速摘要**: 实现Gateway API统一封装,提供14个物联网卡和设备管理接口的类型安全封装,支持AES-128-ECB加密+MD5签名认证机制。
+>
+> **交付物**:
+> - internal/gateway/ 包(完整的Gateway客户端)
+> - 14个API接口封装(流量卡7个+设备7个)
+> - 配置集成(GatewayConfig+环境变量)
+> - 错误码定义(1110-1119)
+> - 单元测试(覆盖率≥90%)
+> - 集成测试(验证真实Gateway API)
+>
+> **预计工作量**: 120分钟(2小时)
+> **并行执行**: YES - 6个波次,最多4个任务并行
+> **关键路径**: 加密工具 → Client结构 → API封装 → 单元测试 → Service集成 → 集成测试
+
+---
+
+## 上下文
+
+### 原始需求
+实现Gateway API统一封装,解决当前调用逻辑分散、加密签名重复实现的问题,提供类型安全的接口和统一的错误处理。
+
+### 背景调研总结
+
+#### 1. 项目架构模式(已验证)
+- **Bootstrap模式**: 6步初始化(Stores→Callbacks→Services→Admin→Middlewares→Handlers)
+- **依赖注入**: Dependencies结构 → main.go初始化 → Service构造器
+- **Config管理**: 层级结构 + JUNHONG_前缀环境变量 + 双层验证
+- **Error处理**: 错误码范围(1000-1999/2000-2999) + errors.New/Wrap
+
+#### 2. 测试模式(已验证)
+- **核心工具**: NewTestTransaction(自动回滚) + GetTestRedis(全局连接)
+- **集成测试**: NewIntegrationTestEnv(完整环境) + AsSuperAdmin/AsUser
+- **执行方式**: `source .env.local && go test`
+- **覆盖率要求**: Service层≥90%
+
+#### 3. AES-ECB加密(已调研)
+- **实现方式**: 手动实现cipher.BlockMode接口(~50行)
+- **参考代码**: TiDB的aes.go实现
+- **PKCS5Padding**: 标准填充算法(验证所有字节)
+- **MD5密钥**: crypto/md5.Sum()返回[16]byte
+- **Base64编码**: encoding/base64.StdEncoding
+
+### Metis预审(gap分析)
+无 - 本计划由Prometheus生成,Metis审查在plan生成后进行。
+
+---
+
+## 工作目标
+
+### 核心目标
+封装Gateway API为统一的能力模块,提供类型安全、配置化、可测试的接口调用能力。
+
+### 具体交付物
+- [ ] `internal/gateway/client.go` - Gateway客户端主体
+- [ ] `internal/gateway/crypto.go` - 加密/签名工具
+- [ ] `internal/gateway/flow_card.go` - 流量卡7个API
+- [ ] `internal/gateway/device.go` - 设备7个API
+- [ ] `internal/gateway/models.go` - 请求/响应DTO
+- [ ] `internal/gateway/client_test.go` - 单元+集成测试
+- [ ] `pkg/config/config.go` - GatewayConfig集成
+- [ ] `pkg/errors/codes.go` - Gateway错误码(1110-1119)
+- [ ] `internal/bootstrap/bootstrap.go` - Gateway客户端初始化
+- [ ] `docs/gateway-client-usage.md` - 使用文档
+
+### 定义完成(Definition of Done)
+- [ ] 所有14个Gateway API接口成功封装
+- [ ] 加密/签名验证通过(与Gateway文档一致)
+- [ ] 错误处理覆盖所有异常场景
+- [ ] 单元测试覆盖率≥90%
+- [ ] 集成测试验证真实Gateway API调用
+- [ ] 配置通过环境变量成功加载
+- [ ] 依赖注入到Service层成功
+- [ ] 文档完整(使用示例、错误码说明)
+
+### 必须满足
+- 所有14个API接口封装完成
+- AES-128-ECB加密正确(密钥=MD5(appSecret))
+- MD5签名正确(参数字母序+大写)
+- 配置验证逻辑完整
+- Bootstrap依赖注入正确
+
+### 禁止包含(Guardrails)
+- ❌ 跳过tasks.md中的任何任务
+- ❌ 合并或简化任务执行
+- ❌ 使用Java风格的过度抽象
+- ❌ 硬编码配置(必须使用环境变量)
+- ❌ 直接使用fmt.Errorf(必须用errors.New/Wrap)
+- ❌ 参数验证失败返回底层错误详情
+- ❌ 使用非中文注释
+
+---
+
+## 验证策略
+
+### 测试决策
+- **测试基础设施**: ✅ 已存在(testutils + NewIntegrationTestEnv)
+- **测试框架**: bun test(实际使用go test)
+- **测试策略**: TDD(测试先行)
+
+### 测试结构
+
+#### 单元测试(internal/gateway/client_test.go)
+每个任务遵循TDD流程:
+
+**加密/签名测试**:
+1. **RED**: 编写测试用例
+ - 测试文件: `internal/gateway/client_test.go`
+ - 测试函数: `TestAESEncrypt`, `TestGenerateSign`
+ - 预期: FAIL(函数不存在)
+
+2. **GREEN**: 实现最小代码
+ - 实现: `crypto.go`中的aesEncrypt, generateSign
+ - 运行: `go test -v ./internal/gateway -run TestAES`
+ - 预期: PASS
+
+3. **REFACTOR**: 优化代码
+ - 清理重复代码
+ - 添加注释
+ - 预期: PASS(测试仍然通过)
+
+**API接口测试**:
+1. **RED**: 测试QueryCardStatus
+ - 测试: 构造mock响应,验证解析逻辑
+ - 预期: FAIL
+
+2. **GREEN**: 实现QueryCardStatus
+ - 实现: flow_card.go
+ - 预期: PASS
+
+3. **REFACTOR**: 优化
+ - 预期: PASS
+
+#### 集成测试(client_test.go)
+验证真实Gateway API调用:
+
+```go
+func TestIntegration_QueryCardStatus(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)
+
+ // 调用真实API
+ resp, err := client.QueryCardStatus(ctx, &gateway.CardStatusReq{
+ CardNo: "898608070422D0010269",
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NotEmpty(t, resp.ICCID)
+}
+```
+
+**验证命令**:
+```bash
+# 单元测试
+source .env.local && go test -v ./internal/gateway -run TestAES
+source .env.local && go test -v ./internal/gateway -run TestGenerate
+
+# 集成测试
+source .env.local && go test -v ./internal/gateway -run TestIntegration
+
+# 覆盖率
+source .env.local && go test -cover ./internal/gateway
+```
+
+---
+
+## 执行策略
+
+### 并行执行波次
+
+系统使用严格的依赖管理,最大化并行执行效率:
+
+```
+Wave 1 (立即启动,无依赖):
+├── Task 1.1: 创建目录结构
+├── Task 1.4: DTO定义
+├── Task 3.1: Gateway配置
+└── Task 3.2: Gateway错误码
+
+Wave 2 (依赖: Wave 1):
+└── Task 1.2: 加密/签名工具
+
+Wave 3 (依赖: Task 1.2):
+└── Task 1.3: Client基础结构
+
+Wave 4 (依赖: Task 1.3):
+├── Task 2.1: 流量卡API
+├── Task 2.2: 设备API
+└── Task 4.1: Bootstrap初始化
+
+Wave 5 (依赖: Wave 4):
+├── Task 2.3: 单元测试
+└── Task 4.2: Service集成
+
+Wave 6 (依赖: Wave 5):
+├── Task 5.1: 集成测试
+└── Task 5.2: 文档更新
+
+关键路径: 1.2 → 1.3 → 2.1/2.2 → 2.3 → 4.2 → 5.1
+并行加速: ~40%性能提升(相比顺序执行)
+```
+
+### 依赖矩阵
+
+| Task | 依赖任务 | 阻塞任务 | 可并行任务 |
+|------|---------|---------|-----------|
+| 1.1 | 无 | 无 | 1.4, 3.1, 3.2 |
+| 1.2 | 1.1 | 1.3 | 1.4, 3.1, 3.2 |
+| 1.3 | 1.2 | 2.1, 2.2, 4.1 | 无 |
+| 1.4 | 无 | 2.1, 2.2 | 1.1, 1.2, 3.1, 3.2 |
+| 2.1 | 1.3, 1.4 | 2.3 | 2.2, 4.1 |
+| 2.2 | 1.3, 1.4 | 2.3 | 2.1, 4.1 |
+| 2.3 | 2.1, 2.2 | 5.1 | 4.2 |
+| 3.1 | 无 | 4.1 | 1.1, 1.2, 1.4, 3.2 |
+| 3.2 | 无 | 无 | 1.1, 1.2, 1.4, 3.1 |
+| 4.1 | 1.3, 3.1 | 4.2 | 2.1, 2.2 |
+| 4.2 | 4.1 | 5.1 | 2.3 |
+| 5.1 | 2.3, 4.2 | 无 | 5.2 |
+| 5.2 | 无 | 无 | 5.1 |
+
+### Agent调度建议
+
+| Wave | 任务组 | 推荐Category | 推荐Skills |
+|------|--------|-------------|-----------|
+| 1 | 1.1, 1.4, 3.1, 3.2 | quick | 无 |
+| 2 | 1.2 | ultrabrain | 无 |
+| 3 | 1.3 | unspecified-high | 无 |
+| 4 | 2.1, 2.2, 4.1 | unspecified-high | 无 |
+| 5 | 2.3, 4.2 | unspecified-high | 无 |
+| 6 | 5.1, 5.2 | quick | 无 |
+
+---
+
+## TODOs
+
+> 实现+测试=一个任务。每个任务必须包含:推荐Agent Profile + 并行化信息。
+
+### Phase 1: 基础结构搭建
+
+- [ ] **1.1. 创建Gateway包目录结构**
+
+ **要做什么**:
+ - 创建`internal/gateway/`目录
+ - 创建占位文件:`client.go`, `crypto.go`, `models.go`, `flow_card.go`, `device.go`
+ - 每个文件添加package声明和基础注释
+
+ **禁止做什么**:
+ - 不要实现任何逻辑(只创建文件骨架)
+ - 不要添加import语句(后续任务添加)
+
+ **推荐Agent Profile**:
+ - **Category**: `quick`
+ - 理由: 简单的文件创建操作,无复杂逻辑
+ - **Skills**: 无
+ - 理由: 目录创建是通用操作,不需要专项技能
+ - **Skills评估但省略**:
+ - `api-routing`: 域不重叠(这是客户端代码,非API路由)
+ - `model-standards`: 域不重叠(DTO定义在Task 1.4)
+
+ **并行化**:
+ - **可并行运行**: YES
+ - **并行组**: Wave 1(与1.4, 3.1, 3.2并行)
+ - **阻塞任务**: 无
+ - **被阻塞**: 无(可立即启动)
+
+ **参考**:
+ - 项目结构模式: `/internal/service/account/` (类似的service包结构)
+ - Go包命名规范: 小写、单数、无下划线
+
+ **验收标准**:
+ - [ ] 目录`internal/gateway/`创建成功
+ - [ ] 5个.go文件创建成功(client, crypto, models, flow_card, device)
+ - [ ] 每个文件包含`package gateway`声明
+ - [ ] 每个文件包含中文注释说明用途
+ - [ ] `go build ./internal/gateway`编译通过(即使是空包)
+
+ **提交**: NO(与后续任务合并提交)
+
+---
+
+- [ ] **1.2. 实现加密/签名工具函数**
+
+ **要做什么**:
+ - 在`crypto.go`中实现`aesEncrypt`函数(AES-128-ECB + PKCS5Padding + Base64)
+ - 实现`generateSign`函数(MD5签名,大写输出)
+ - 添加单元测试验证加密/签名正确性
+
+ **禁止做什么**:
+ - 不要使用第三方加密库(必须用标准库)
+ - 不要跳过PKCS5填充验证(必须验证所有字节)
+ - 不要使用小写MD5输出(必须大写)
+
+ **推荐Agent Profile**:
+ - **Category**: `ultrabrain`
+ - 理由: 加密算法实现需要高精度,涉及字节操作和安全性
+ - **Skills**: 无
+ - 理由: 加密是通用算法,已有TiDB参考代码
+ - **Skills评估但省略**:
+ - `api-routing`: 域不重叠(这是工具函数,非API)
+ - `dto-standards`: 域不重叠(这是加密,非DTO)
+
+ **并行化**:
+ - **可并行运行**: NO(Wave 1必须先完成)
+ - **并行组**: Wave 2(独立任务)
+ - **阻塞任务**: 1.3(Client需要加密函数)
+ - **被阻塞**: 1.1(需要crypto.go文件存在)
+
+ **参考**:
+ - **加密实现**: TiDB的aes.go(ECB模式实现)
+ - **PKCS5Padding**: Lancet库的padding.go
+ - **MD5密钥**: `crypto/md5.Sum([]byte(appSecret))`返回`[16]byte`
+ - **Base64**: `encoding/base64.StdEncoding.EncodeToString()`
+ - **签名格式**: `appId=xxx&data=xxx×tamp=xxx&key=appSecret`
+ - **测试模式**: `/internal/service/package/service_test.go`(table-driven tests)
+
+ **验收标准**:
+
+ **TDD流程**:
+ - [ ] **RED**: 编写`TestAESEncrypt`测试用例
+ - 测试文件: `internal/gateway/crypto_test.go`
+ - 测试数据: 已知明文+appSecret,验证Base64输出
+ - 命令: `go test -v ./internal/gateway -run TestAESEncrypt`
+ - 预期: FAIL(函数不存在)
+
+ - [ ] **GREEN**: 实现`aesEncrypt`函数
+ - 步骤1: MD5(appSecret)生成16字节密钥
+ - 步骤2: AES-128-ECB加密(手动实现BlockMode)
+ - 步骤3: PKCS5填充
+ - 步骤4: Base64编码
+ - 命令: `go test -v ./internal/gateway -run TestAESEncrypt`
+ - 预期: PASS
+
+ - [ ] **REFACTOR**: 优化代码
+ - 添加详细中文注释
+ - 提取常量(如BlockSize=16)
+ - 命令: `go test -v ./internal/gateway -run TestAESEncrypt`
+ - 预期: PASS(测试仍通过)
+
+ - [ ] **RED**: 编写`TestGenerateSign`测试用例
+ - 验证签名格式为32位大写十六进制
+ - 验证参数字母序正确
+ - 预期: FAIL
+
+ - [ ] **GREEN**: 实现`generateSign`函数
+ - 步骤1: 拼接字符串(appId→data→timestamp)
+ - 步骤2: 追加`&key=appSecret`
+ - 步骤3: MD5加密
+ - 步骤4: 转大写十六进制
+ - 预期: PASS
+
+ - [ ] **REFACTOR**: 优化签名函数
+ - 预期: PASS
+
+ **自动化验证**:
+ ```bash
+ # 编译检查
+ go build ./internal/gateway
+ # 预期: 成功编译,无错误
+
+ # 单元测试
+ source .env.local && go test -v ./internal/gateway -run TestAES
+ # 预期: PASS, 测试覆盖率 ≥ 90%
+
+ source .env.local && go test -v ./internal/gateway -run TestGenerate
+ # 预期: PASS, 签名输出32位大写
+
+ # 覆盖率检查
+ source .env.local && go test -cover ./internal/gateway
+ # 预期: 覆盖率 ≥ 90%
+ ```
+
+ **提交**: NO(与Phase 1合并提交)
+
+---
+
+- [ ] **1.3. 实现Gateway客户端基础结构**
+
+ **要做什么**:
+ - 在`client.go`中定义`Client`结构体(baseURL, appID, appSecret, httpClient, timeout)
+ - 实现`NewClient`构造函数(配置HTTP Keep-Alive)
+ - 实现`WithTimeout`方法(支持链式调用)
+ - 实现`doRequest`统一请求方法(加密→签名→HTTP→解密)
+
+ **禁止做什么**:
+ - 不要在doRequest中处理业务逻辑(只处理加密和HTTP)
+ - 不要硬编码超时时间(通过WithTimeout配置)
+ - 不要忽略Context超时(必须判断ctx.Err())
+
+ **推荐Agent Profile**:
+ - **Category**: `unspecified-high`
+ - 理由: 核心架构代码,需要仔细设计并发安全和错误处理
+ - **Skills**: 无
+ - 理由: HTTP客户端是通用模式,项目中有类似实现
+ - **Skills评估但省略**:
+ - `api-routing`: 域不重叠(这是HTTP客户端,非路由)
+
+ **并行化**:
+ - **可并行运行**: NO(依赖1.2)
+ - **并行组**: Wave 3(独立任务)
+ - **阻塞任务**: 2.1, 2.2, 4.1(所有API都依赖Client)
+ - **被阻塞**: 1.2(需要加密函数)
+
+ **参考**:
+ - **HTTP客户端**: `net/http.Client`配置Keep-Alive
+ - **Context超时**: `http.NewRequestWithContext(ctx, ...)`
+ - **JSON序列化**: `sonic.Marshal/Unmarshal`
+ - **错误处理**: `pkg/errors/errors.go`(New/Wrap模式)
+ - **类似实现**: `/pkg/auth/token.go`(TokenManager的HTTP调用)
+
+ **验收标准**:
+
+ **TDD流程**:
+ - [ ] **RED**: 编写`TestNewClient`测试
+ - 验证Client初始化正确
+ - 验证httpClient配置
+ - 预期: FAIL
+
+ - [ ] **GREEN**: 实现NewClient和WithTimeout
+ - 配置http.Client(Transport + Timeout)
+ - 预期: PASS
+
+ - [ ] **REFACTOR**: 优化结构
+ - 预期: PASS
+
+ - [ ] **RED**: 编写`TestDoRequest_Success`测试
+ - Mock HTTP响应
+ - 验证加密→签名→请求流程
+ - 预期: FAIL
+
+ - [ ] **GREEN**: 实现doRequest方法
+ - 步骤1: 序列化业务数据(sonic.Marshal)
+ - 步骤2: AES加密(调用aesEncrypt)
+ - 步骤3: 生成签名(调用generateSign)
+ - 步骤4: 构建请求体(appId, data, sign, timestamp)
+ - 步骤5: 发送HTTP POST
+ - 步骤6: 解析响应(GatewayResponse)
+ - 步骤7: 检查业务状态码
+ - 预期: PASS
+
+ - [ ] **REFACTOR**: 错误处理优化
+ - 网络错误→CodeGatewayError
+ - 超时错误→CodeGatewayTimeout
+ - 响应格式错误→CodeGatewayInvalidResp
+ - 预期: PASS
+
+ **自动化验证**:
+ ```bash
+ # 编译检查
+ go build ./internal/gateway
+ # 预期: 成功编译
+
+ # 单元测试
+ source .env.local && go test -v ./internal/gateway -run TestNewClient
+ # 预期: PASS, Client初始化正确
+
+ source .env.local && go test -v ./internal/gateway -run TestDoRequest
+ # 预期: PASS, 请求流程完整
+
+ # 错误处理测试
+ source .env.local && go test -v ./internal/gateway -run TestDoRequest_Error
+ # 预期: PASS, 所有错误场景覆盖
+ ```
+
+ **提交**: NO(与Phase 1合并提交)
+
+---
+
+- [ ] **1.4. 定义请求/响应DTO**
+
+ **要做什么**:
+ - 在`models.go`中定义`GatewayResponse`通用响应结构
+ - 定义流量卡DTO:`CardStatusReq/Resp`, `FlowQueryReq`, `FlowUsageResp`, `CardOperationReq`
+ - 定义设备DTO:`DeviceInfoReq/Resp`, `SpeedLimitReq`, `WiFiReq`, `SwitchCardReq`, `DeviceOperationReq`
+ - 添加JSON标签和验证标签
+
+ **禁止做什么**:
+ - 不要省略description注释(每个字段必须有中文说明)
+ - 不要使用驼峰JSON字段(必须与Gateway文档一致)
+ - 不要省略validate标签(必填字段必须标记)
+
+ **推荐Agent Profile**:
+ - **Category**: `quick`
+ - 理由: 结构体定义为机械性工作,遵循DTO规范即可
+ - **Skills**: `dto-standards`
+ - 理由: 需要遵循项目DTO规范(description标签、验证标签)
+ - **Skills评估但省略**:
+ - `model-standards`: 域不重叠(这是DTO,非数据库Model)
+ - `api-routing`: 域不重叠(这是请求响应结构,非路由)
+
+ **并行化**:
+ - **可并行运行**: YES
+ - **并行组**: Wave 1(与1.1, 3.1, 3.2并行)
+ - **阻塞任务**: 2.1, 2.2(API方法需要DTO)
+ - **被阻塞**: 无(可立即启动)
+
+ **参考**:
+ - **DTO规范**: `docs/dto-standards.md`或`AGENTS.md#DTO规范`
+ - **JSON标签**: `json:"cardNo"` (与Gateway文档一致)
+ - **验证标签**: `validate:"required"`
+ - **示例DTO**: `/internal/model/dto/package.go`
+ - **Gateway文档**: design.md中的请求/响应示例
+
+ **验收标准**:
+ - [ ] GatewayResponse定义完整(code, msg, data, trace_id)
+ - [ ] 流量卡DTO定义完整(7个API对应的Req/Resp)
+ - [ ] 设备DTO定义完整(7个API对应的Req/Resp)
+ - [ ] 所有字段包含JSON标签(与Gateway文档一致)
+ - [ ] 必填字段包含`validate:"required"`标签
+ - [ ] 所有字段包含中文description注释
+ - [ ] 可选字段使用`omitempty`标签
+ - [ ] 编译通过: `go build ./internal/gateway`
+
+ **提交**: NO(与Phase 1合并提交)
+
+---
+
+### Phase 2: API接口封装
+
+- [ ] **2.1. 实现流量卡API(7个接口)**
+
+ **要做什么**:
+ - 在`flow_card.go`中实现7个流量卡API方法:
+ 1. `QueryCardStatus` - 流量卡状态查询
+ 2. `QueryFlow` - 流量使用查询
+ 3. `QueryRealnameStatus` - 实名认证状态查询
+ 4. `StopCard` - 流量卡停机
+ 5. `StartCard` - 流量卡复机
+ 6. `GetRealnameLink` - 获取实名认证跳转链接
+ 7. `BatchQuery` - 批量查询(预留,返回NotImplemented错误)
+
+ **禁止做什么**:
+ - 不要在方法内实现加密逻辑(复用doRequest)
+ - 不要硬编码API路径(使用常量)
+ - 不要省略错误处理(所有错误必须包装)
+
+ **推荐Agent Profile**:
+ - **Category**: `unspecified-high`
+ - 理由: API封装需要准确映射Gateway接口,错误处理要完整
+ - **Skills**: 无
+ - 理由: API封装是通用模式,复用doRequest即可
+ - **Skills评估但省略**:
+ - `api-routing`: 域不重叠(这是HTTP客户端,非路由注册)
+
+ **并行化**:
+ - **可并行运行**: YES
+ - **并行组**: Wave 4(与2.2, 4.1并行)
+ - **阻塞任务**: 2.3(单元测试需要API实现)
+ - **被阻塞**: 1.3, 1.4(需要Client和DTO)
+
+ **参考**:
+ - **API路径**: design.md中的接口列表(/flow-card/status等)
+ - **请求构造**: `map[string]interface{}{"params": {...}}`
+ - **响应解析**: `sonic.Unmarshal(resp, &result)`
+ - **错误包装**: `errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析响应失败")`
+ - **类似实现**: `/internal/service/auth/service.go`(调用外部API模式)
+
+ **验收标准**:
+
+ **TDD流程**(每个API重复):
+ - [ ] **RED**: 编写`TestQueryCardStatus`
+ - Mock doRequest返回
+ - 验证请求参数构造
+ - 验证响应解析
+ - 预期: FAIL
+
+ - [ ] **GREEN**: 实现QueryCardStatus
+ - 构建businessData
+ - 调用doRequest("/flow-card/status", businessData)
+ - 解析响应为CardStatusResp
+ - 预期: PASS
+
+ - [ ] **REFACTOR**: 优化代码
+ - 预期: PASS
+
+ - [ ] 重复上述流程实现其他6个API
+
+ **自动化验证**:
+ ```bash
+ # 编译检查
+ go build ./internal/gateway
+ # 预期: 成功编译,7个方法定义正确
+
+ # 单元测试(每个API)
+ source .env.local && go test -v ./internal/gateway -run TestQueryCardStatus
+ source .env.local && go test -v ./internal/gateway -run TestQueryFlow
+ source .env.local && go test -v ./internal/gateway -run TestStopCard
+ # 预期: PASS
+
+ # 覆盖率
+ source .env.local && go test -cover ./internal/gateway
+ # 预期: 覆盖率 ≥ 90%
+ ```
+
+ **提交**: NO(与Phase 2合并提交)
+
+---
+
+- [ ] **2.2. 实现设备API(7个接口)**
+
+ **要做什么**:
+ - 在`device.go`中实现7个设备API方法:
+ 1. `GetDeviceInfo` - 获取设备信息
+ 2. `GetSlotInfo` - 获取设备卡槽信息
+ 3. `SetSpeedLimit` - 设置设备限速
+ 4. `SetWiFi` - 设置设备WiFi
+ 5. `SwitchCard` - 设备切换卡
+ 6. `ResetDevice` - 设备恢复出厂设置
+ 7. `RebootDevice` - 设备重启
+
+ **禁止做什么**:
+ - 不要在方法内实现加密逻辑(复用doRequest)
+ - 不要硬编码API路径(使用常量)
+ - 不要省略参数验证(CardNo和DeviceID二选一)
+
+ **推荐Agent Profile**:
+ - **Category**: `unspecified-high`
+ - 理由: 同2.1,API封装需要准确映射
+ - **Skills**: 无
+ - **Skills评估但省略**:
+ - `api-routing`: 域不重叠
+
+ **并行化**:
+ - **可并行运行**: YES
+ - **并行组**: Wave 4(与2.1, 4.1并行)
+ - **阻塞任务**: 2.3
+ - **被阻塞**: 1.3, 1.4
+
+ **参考**:
+ - **API路径**: design.md(/device/info等)
+ - **请求构造**: 同2.1
+ - **响应解析**: DeviceInfoResp包含多个字段
+ - **参数验证**: CardNo和DeviceID至少一个非空
+
+ **验收标准**:
+
+ **TDD流程**(每个API重复):
+ - [ ] **RED**: 编写TestGetDeviceInfo等测试
+ - [ ] **GREEN**: 实现7个设备API
+ - [ ] **REFACTOR**: 优化代码
+
+ **自动化验证**:
+ ```bash
+ go build ./internal/gateway
+ source .env.local && go test -v ./internal/gateway -run TestGetDeviceInfo
+ source .env.local && go test -v ./internal/gateway -run TestSetWiFi
+ source .env.local && go test -cover ./internal/gateway
+ ```
+
+ **提交**: NO(与Phase 2合并提交)
+
+---
+
+- [ ] **2.3. 添加单元测试**
+
+ **要做什么**:
+ - 在`client_test.go`中添加加密/签名单元测试
+ - 添加doRequest的mock测试
+ - 验证错误处理逻辑(超时、网络错误、响应格式错误)
+ - 验证覆盖率≥90%
+
+ **禁止做什么**:
+ - 不要跳过错误场景测试(必须覆盖所有错误码)
+ - 不要使用真实Gateway API(单元测试用mock)
+ - 不要传递nil绕过依赖(必须mock完整响应)
+
+ **推荐Agent Profile**:
+ - **Category**: `unspecified-high`
+ - 理由: 测试覆盖率要求高,需要设计完整的测试用例
+ - **Skills**: 无
+ - **Skills评估但省略**:
+ - `db-validation`: 域不重叠(这是HTTP客户端测试,非数据库)
+
+ **并行化**:
+ - **可并行运行**: YES
+ - **并行组**: Wave 5(与4.2并行)
+ - **阻塞任务**: 5.1(集成测试依赖单元测试通过)
+ - **被阻塞**: 2.1, 2.2(需要API实现完成)
+
+ **参考**:
+ - **测试模式**: `/internal/service/package/service_test.go`
+ - **Table-driven tests**: `/tests/unit/shop_store_test.go`
+ - **Mock模式**: 不传递nil,构造完整mock响应
+ - **覆盖率工具**: `go test -cover`
+
+ **验收标准**:
+
+ **测试用例清单**:
+ - [ ] TestAESEncrypt_Success - 正常加密
+ - [ ] TestAESEncrypt_EmptyData - 空数据加密
+ - [ ] TestGenerateSign_Correctness - 签名正确性
+ - [ ] TestGenerateSign_Uppercase - 签名大写验证
+ - [ ] TestDoRequest_Success - 成功请求
+ - [ ] TestDoRequest_NetworkError - 网络错误
+ - [ ] TestDoRequest_Timeout - 超时错误
+ - [ ] TestDoRequest_InvalidResponse - 响应格式错误
+ - [ ] TestDoRequest_BusinessError - Gateway业务错误
+ - [ ] TestQueryCardStatus_Success - 卡状态查询成功
+ - [ ] TestQueryCardStatus_InvalidCard - 无效卡号
+ - [ ] (每个API至少2个测试用例)
+
+ **自动化验证**:
+ ```bash
+ # 运行所有单元测试
+ source .env.local && go test -v ./internal/gateway
+ # 预期: PASS, 所有测试通过
+
+ # 检查覆盖率
+ source .env.local && go test -cover ./internal/gateway
+ # 预期: 覆盖率 ≥ 90%
+
+ # 生成覆盖率报告
+ source .env.local && go test -coverprofile=coverage.out ./internal/gateway
+ go tool cover -html=coverage.out
+ # 预期: 可视化报告显示 ≥ 90% 覆盖
+ ```
+
+ **提交**: YES - "feat(gateway): 实现Gateway客户端基础功能和API封装"
+ - 提交内容: Phase 1 + Phase 2所有代码
+ - 验证: `source .env.local && go test -cover ./internal/gateway`
+ - 预期: 覆盖率≥90%
+
+---
+
+### Phase 3: 配置和错误码集成
+
+- [ ] **3.1. 添加Gateway配置**
+
+ **要做什么**:
+ - 在`pkg/config/config.go`中添加`GatewayConfig`结构体
+ - 在`Config`中添加`Gateway GatewayConfig`字段
+ - 在`pkg/config/defaults/config.yaml`中添加gateway配置项
+ - 添加配置验证逻辑(必填项检查)
+
+ **禁止做什么**:
+ - 不要省略mapstructure标签(Viper解析需要)
+ - 不要硬编码默认值(必须在config.yaml中定义)
+ - 不要省略环境变量绑定(loader.go)
+ - 不要跳过验证逻辑(Validate方法)
+
+ **推荐Agent Profile**:
+ - **Category**: `quick`
+ - 理由: 配置结构定义遵循现有模式即可
+ - **Skills**: 无
+ - **Skills评估但省略**:
+ - `dto-standards`: 域不重叠(这是配置,非DTO)
+
+ **并行化**:
+ - **可并行运行**: YES
+ - **并行组**: Wave 1(与1.1, 1.4, 3.2并行)
+ - **阻塞任务**: 4.1(Bootstrap需要配置)
+ - **被阻塞**: 无
+
+ **参考**:
+ - **配置结构**: `/pkg/config/config.go` lines 14-26(现有Config结构)
+ - **配置文件**: `/pkg/config/defaults/config.yaml`(YAML格式)
+ - **环境变量**: `/pkg/config/loader.go` lines 49-121(bindEnvVariables)
+ - **验证逻辑**: `/pkg/config/config.go` lines 180-274(Validate方法)
+ - **类似配置**: StorageConfig(可选配置的验证模式)
+
+ **验收标准**:
+ - [ ] GatewayConfig结构体定义完整
+ - BaseURL string `mapstructure:"base_url"`
+ - AppID string `mapstructure:"app_id"`
+ - AppSecret string `mapstructure:"app_secret"`
+ - Timeout int `mapstructure:"timeout"`
+
+ - [ ] Config结构体添加Gateway字段
+ - Gateway GatewayConfig `mapstructure:"gateway"`
+
+ - [ ] defaults/config.yaml添加gateway节
+ ```yaml
+ gateway:
+ base_url: "https://lplan.whjhft.com/openapi"
+ app_id: "60bgt1X8i7AvXqkd"
+ app_secret: "BZeQttaZQt0i73moF"
+ timeout: 30
+ ```
+
+ - [ ] loader.go添加环境变量绑定
+ - "gateway.base_url"
+ - "gateway.app_id"
+ - "gateway.app_secret"
+ - "gateway.timeout"
+
+ - [ ] Validate方法添加Gateway验证
+ - BaseURL非空
+ - BaseURL格式验证(http/https开头)
+ - AppID非空
+ - AppSecret非空
+ - Timeout范围验证(5-300秒)
+
+ **自动化验证**:
+ ```bash
+ # 设置环境变量测试
+ export JUNHONG_GATEWAY_BASE_URL=https://test.example.com
+ export JUNHONG_GATEWAY_APP_ID=test_app_id
+ export JUNHONG_GATEWAY_APP_SECRET=test_secret
+ export JUNHONG_GATEWAY_TIMEOUT=60
+
+ # 启动应用验证配置加载
+ go run cmd/api/main.go
+ # 预期: 启动成功,日志显示Gateway配置加载(AppSecret脱敏为***)
+
+ # 验证配置验证逻辑
+ unset JUNHONG_GATEWAY_APP_SECRET
+ go run cmd/api/main.go
+ # 预期: 启动失败,错误提示"gateway.app_secret不能为空"
+ ```
+
+ **提交**: NO(与3.2合并提交)
+
+---
+
+- [ ] **3.2. 添加Gateway错误码**
+
+ **要做什么**:
+ - 在`pkg/errors/codes.go`中添加Gateway错误码常量(1110-1119)
+ - 在`allErrorCodes`数组中注册新错误码
+ - 在`errorMessages`映射表中添加中文错误消息
+ - 运行错误码验证测试
+
+ **禁止做什么**:
+ - 不要使用已占用的错误码(检查codes.go避免冲突)
+ - 不要省略错误消息(必须在errorMessages中定义)
+ - 不要省略错误码注册(allErrorCodes数组)
+
+ **推荐Agent Profile**:
+ - **Category**: `quick`
+ - 理由: 错误码定义遵循现有模式即可
+ - **Skills**: 无
+ - **Skills评估但省略**:
+ - `dto-standards`: 域不重叠(这是错误码,非DTO)
+
+ **并行化**:
+ - **可并行运行**: YES
+ - **并行组**: Wave 1(与1.1, 1.4, 3.1并行)
+ - **阻塞任务**: 无(错误码可随时使用)
+ - **被阻塞**: 无
+
+ **参考**:
+ - **错误码范围**: `/pkg/errors/codes.go` lines 6-102(现有错误码)
+ - **错误消息**: `/pkg/errors/codes.go` lines 193-271(errorMessages)
+ - **注册模式**: allErrorCodes数组
+ - **测试**: `/pkg/errors/codes_test.go`(错误码验证测试)
+
+ **验收标准**:
+ - [ ] 错误码常量定义(codes.go)
+ ```go
+ // Gateway 相关错误(1110-1119)
+ const (
+ CodeGatewayError = 1110 // Gateway 通用错误
+ CodeGatewayEncryptError = 1111 // 数据加密失败
+ CodeGatewaySignError = 1112 // 签名生成失败
+ CodeGatewayTimeout = 1113 // 请求超时
+ CodeGatewayInvalidResp = 1114 // 响应格式错误
+ )
+ ```
+
+ - [ ] allErrorCodes数组注册
+ ```go
+ var allErrorCodes = []int{
+ // ... 现有错误码
+ CodeGatewayError,
+ CodeGatewayEncryptError,
+ CodeGatewaySignError,
+ CodeGatewayTimeout,
+ CodeGatewayInvalidResp,
+ }
+ ```
+
+ - [ ] errorMessages映射表添加
+ ```go
+ var errorMessages = map[int]string{
+ // ... 现有消息
+ CodeGatewayError: "Gateway 请求失败",
+ CodeGatewayEncryptError: "数据加密失败",
+ CodeGatewaySignError: "签名生成失败",
+ CodeGatewayTimeout: "Gateway 请求超时",
+ CodeGatewayInvalidResp: "Gateway 响应格式错误",
+ }
+ ```
+
+ **自动化验证**:
+ ```bash
+ # 运行错误码测试
+ go test -v ./pkg/errors -run TestErrorCodes
+ # 预期: PASS, 所有错误码已注册且有消息
+
+ # 验证错误码使用
+ go run -e 'package main; import "pkg/errors"; func main() { println(errors.GetMessage(1110, "zh-CN")) }'
+ # 预期: 输出"Gateway 请求失败"
+ ```
+
+ **提交**: YES - "feat(errors): 添加Gateway错误码(1110-1119)"
+ - 提交内容: Phase 3所有代码(3.1 + 3.2)
+ - 验证: `go test -v ./pkg/errors -run TestErrorCodes`
+ - 预期: PASS
+
+---
+
+### Phase 4: 依赖注入和集成
+
+- [ ] **4.1. Bootstrap初始化Gateway客户端**
+
+ **要做什么**:
+ - 在`internal/bootstrap/dependencies.go`的`Dependencies`中添加`GatewayClient *gateway.Client`字段
+ - 在`cmd/api/main.go`中添加`initGateway`函数
+ - 在`Bootstrap`调用中传递`deps.GatewayClient`
+
+ **禁止做什么**:
+ - 不要在Bootstrap函数内初始化Gateway(必须在main.go)
+ - 不要忽略配置验证(必须检查必填项)
+ - 不要硬编码超时时间(使用config.Gateway.Timeout)
+
+ **推荐Agent Profile**:
+ - **Category**: `unspecified-high`
+ - 理由: 依赖注入是核心架构,需要准确遵循现有模式
+ - **Skills**: 无
+ - **Skills评估但省略**:
+ - `api-routing`: 域不重叠(这是依赖注入,非路由)
+
+ **并行化**:
+ - **可并行运行**: YES
+ - **并行组**: Wave 4(与2.1, 2.2并行)
+ - **阻塞任务**: 4.2(Service需要Gateway客户端)
+ - **被阻塞**: 1.3, 3.1(需要Client和Config)
+
+ **参考**:
+ - **Dependencies**: `/internal/bootstrap/dependencies.go` lines 13-24
+ - **初始化模式**: `/cmd/api/main.go` lines 299-329(initStorage模式)
+ - **Bootstrap调用**: `/cmd/api/main.go` main函数
+ - **错误处理**: 配置缺失返回nil(可选服务模式)
+
+ **验收标准**:
+ - [ ] Dependencies添加GatewayClient字段
+ ```go
+ type Dependencies struct {
+ // ... 现有字段
+ GatewayClient *gateway.Client
+ }
+ ```
+
+ - [ ] main.go添加initGateway函数
+ ```go
+ func initGateway(cfg *config.Config, logger *zap.Logger) *gateway.Client {
+ if cfg.Gateway.BaseURL == "" {
+ logger.Info("Gateway未配置,跳过初始化")
+ return nil
+ }
+
+ client := gateway.NewClient(
+ cfg.Gateway.BaseURL,
+ cfg.Gateway.AppID,
+ cfg.Gateway.AppSecret,
+ ).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
+
+ logger.Info("Gateway客户端初始化成功")
+ return client
+ }
+ ```
+
+ - [ ] main函数调用initGateway
+ ```go
+ gatewayClient := initGateway(cfg, appLogger)
+
+ result, err := bootstrap.Bootstrap(&bootstrap.Dependencies{
+ // ... 现有依赖
+ GatewayClient: gatewayClient,
+ })
+ ```
+
+ **自动化验证**:
+ ```bash
+ # 设置环境变量
+ export JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
+ export JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
+ export JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
+
+ # 启动应用
+ source .env.local && go run cmd/api/main.go
+ # 预期: 日志显示"Gateway客户端初始化成功"
+
+ # 测试配置缺失场景
+ unset JUNHONG_GATEWAY_BASE_URL
+ source .env.local && go run cmd/api/main.go
+ # 预期: 日志显示"Gateway未配置,跳过初始化"
+ ```
+
+ **提交**: NO(与4.2合并提交)
+
+---
+
+- [ ] **4.2. Service层集成示例**
+
+ **要做什么**:
+ - 选择一个Service(如`iot_card`)集成Gateway客户端
+ - 添加`SyncCardStatus`方法示例
+ - 添加错误处理和日志记录
+
+ **禁止做什么**:
+ - 不要在Service中实现加密逻辑(使用Gateway客户端)
+ - 不要使用fmt.Errorf(必须用errors.New/Wrap)
+ - 不要省略日志记录(调用外部API必须记录)
+
+ **推荐Agent Profile**:
+ - **Category**: `unspecified-high`
+ - 理由: Service层集成需要准确的错误处理和日志记录
+ - **Skills**: 无
+ - **Skills评估但省略**:
+ - `api-routing`: 域不重叠(这是Service业务逻辑,非路由)
+
+ **并行化**:
+ - **可并行运行**: YES
+ - **并行组**: Wave 5(与2.3并行)
+ - **阻塞任务**: 5.1(集成测试需要Service集成)
+ - **被阻塞**: 4.1(需要Gateway客户端注入)
+
+ **参考**:
+ - **Service模式**: `/internal/service/auth/service.go` lines 27-43(外部客户端注入)
+ - **错误处理**: `/internal/service/account/service.go` lines 36-91(errors.New/Wrap)
+ - **日志记录**: zap.Logger使用模式
+ - **Store更新**: 调用store.UpdateStatus更新数据库
+
+ **验收标准**:
+ - [ ] Service构造器添加gatewayClient参数
+ ```go
+ type Service struct {
+ store *postgres.IotCardStore
+ gatewayClient *gateway.Client
+ logger *zap.Logger
+ }
+
+ func New(
+ store *postgres.IotCardStore,
+ gatewayClient *gateway.Client,
+ logger *zap.Logger,
+ ) *Service {
+ return &Service{
+ store: store,
+ gatewayClient: gatewayClient,
+ logger: logger,
+ }
+ }
+ ```
+
+ - [ ] 实现SyncCardStatus方法
+ ```go
+ 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 {
+ s.logger.Error("查询卡状态失败",
+ zap.String("cardNo", cardNo),
+ zap.Error(err))
+ return errors.Wrap(errors.CodeGatewayError, err, "查询卡状态失败")
+ }
+
+ // 更新数据库
+ if err := s.store.UpdateStatus(ctx, cardNo, resp.CardStatus); err != nil {
+ return errors.Wrap(errors.CodeInternalError, err, "更新卡状态失败")
+ }
+
+ s.logger.Info("同步卡状态成功",
+ zap.String("cardNo", cardNo),
+ zap.String("status", resp.CardStatus))
+
+ return nil
+ }
+ ```
+
+ - [ ] Bootstrap注入Gateway客户端
+ ```go
+ // services.go
+ IotCard: iotCardSvc.New(s.IotCard, deps.GatewayClient, deps.Logger),
+ ```
+
+ **自动化验证**:
+ ```bash
+ # 编译检查
+ go build ./internal/service/iot_card
+ # 预期: 成功编译
+
+ # 单元测试(如果已有)
+ source .env.local && go test -v ./internal/service/iot_card -run TestSyncCardStatus
+ # 预期: PASS(需要mock Gateway客户端)
+ ```
+
+ **提交**: YES - "feat(service): 集成Gateway客户端到Service层"
+ - 提交内容: Phase 4所有代码(4.1 + 4.2)
+ - 验证: `go build ./...`
+ - 预期: 全部编译通过
+
+---
+
+### Phase 5: 集成测试和文档
+
+- [ ] **5.1. 编写集成测试**
+
+ **要做什么**:
+ - 在`client_test.go`中添加集成测试(需要真实Gateway环境)
+ - 测试至少2个接口(如`QueryCardStatus`, `StopCard`)
+ - 验证加密/签名与Gateway文档一致
+
+ **禁止做什么**:
+ - 不要跳过集成测试(必须验证真实API)
+ - 不要使用硬编码测试数据(使用.env.local配置)
+ - 不要忽略测试失败(超时=生产超时)
+
+ **推荐Agent Profile**:
+ - **Category**: `quick`
+ - 理由: 集成测试调用真实API,验证端到端流程
+ - **Skills**: 无
+ - **Skills评估但省略**:
+ - `db-validation`: 域不重叠(这是HTTP API测试,非数据库)
+
+ **并行化**:
+ - **可并行运行**: YES
+ - **并行组**: Wave 6(与5.2并行)
+ - **阻塞任务**: 无(最后一波)
+ - **被阻塞**: 2.3, 4.2(需要单元测试通过和Service集成)
+
+ **参考**:
+ - **集成测试模式**: `/tests/integration/enterprise_device_h5_test.go`
+ - **测试跳过**: `if testing.Short() { t.Skip() }`
+ - **配置加载**: `config.Get()`
+ - **真实API调用**: 不使用mock,调用真实Gateway
+
+ **验收标准**:
+
+ **集成测试用例**:
+ - [ ] TestIntegration_QueryCardStatus
+ - 使用真实配置(config.Get())
+ - 调用真实Gateway API
+ - 验证响应完整性
+ - 预期: PASS
+
+ - [ ] TestIntegration_StopCard
+ - 调用停机API
+ - 验证Gateway返回成功
+ - 预期: PASS
+
+ - [ ] TestIntegration_EncryptionCompatibility
+ - 验证加密输出与Gateway文档一致
+ - 验证签名可被Gateway验证
+ - 预期: PASS
+
+ **自动化验证**:
+ ```bash
+ # 设置测试环境变量
+ source .env.local
+
+ # 运行集成测试
+ go test -v ./internal/gateway -run TestIntegration
+ # 预期: PASS, 真实API调用成功
+
+ # 跳过集成测试(仅单元测试)
+ go test -short -v ./internal/gateway
+ # 预期: 集成测试被跳过
+ ```
+
+ **提交**: NO(与5.2合并提交)
+
+---
+
+- [ ] **5.2. 更新文档**
+
+ **要做什么**:
+ - 在`docs/`目录下创建`gateway-client-usage.md`
+ - 添加Gateway客户端使用示例
+ - 添加错误码说明
+ - 更新`README.md`添加Gateway模块说明
+
+ **禁止做什么**:
+ - 不要省略代码示例(必须包含完整调用示例)
+ - 不要省略错误码说明(所有1110-1119错误码)
+ - 不要使用英文(文档必须中文)
+
+ **推荐Agent Profile**:
+ - **Category**: `quick`
+ - 理由: 文档编写遵循现有模板即可
+ - **Skills**: 无
+ - **Skills评估但省略**:
+ - `doc-management`: 这是新功能文档,非规范文档
+
+ **并行化**:
+ - **可并行运行**: YES
+ - **并行组**: Wave 6(与5.1并行)
+ - **阻塞任务**: 无
+ - **被阻塞**: 无(文档可随时编写)
+
+ **参考**:
+ - **文档模板**: `/docs/auth-usage-guide.md`(使用指南模板)
+ - **API文档**: `/docs/api/auth.md`(API文档模板)
+ - **README更新**: `/README.md`(核心功能章节)
+
+ **验收标准**:
+ - [ ] 创建`docs/gateway-client-usage.md`
+ - Gateway客户端介绍
+ - 配置说明(环境变量)
+ - 使用示例(完整代码)
+ - 错误处理说明
+ - 最佳实践
+
+ - [ ] 创建`docs/gateway-api-reference.md`
+ - 14个API接口列表
+ - 每个接口的请求/响应示例
+ - 参数说明
+
+ - [ ] 更新`README.md`
+ - 核心功能章节添加Gateway模块
+ - 技术栈章节说明
+ - 配置示例(环境变量)
+
+ **自动化验证**:
+ ```bash
+ # 检查文档文件存在
+ ls -la docs/gateway-client-usage.md
+ ls -la docs/gateway-api-reference.md
+ # 预期: 文件存在
+
+ # 检查README更新
+ grep -i "gateway" README.md
+ # 预期: 找到Gateway模块说明
+ ```
+
+ **提交**: YES - "docs: 添加Gateway客户端使用文档"
+ - 提交内容: Phase 5所有代码(5.1 + 5.2)
+ - 验证: 文档完整性检查
+ - 预期: 所有文档文件存在且内容完整
+
+---
+
+## 提交策略
+
+| 完成阶段 | 提交信息 | 包含文件 | 验证命令 |
+|---------|---------|---------|---------|
+| Phase 2完成 | `feat(gateway): 实现Gateway客户端基础功能和API封装` | Phase 1 + Phase 2 | `source .env.local && go test -cover ./internal/gateway` |
+| Phase 3完成 | `feat(errors): 添加Gateway错误码(1110-1119)` | Phase 3 (3.1 + 3.2) | `go test -v ./pkg/errors -run TestErrorCodes` |
+| Phase 4完成 | `feat(service): 集成Gateway客户端到Service层` | Phase 4 (4.1 + 4.2) | `go build ./...` |
+| Phase 5完成 | `docs: 添加Gateway客户端使用文档` | Phase 5 (5.1 + 5.2) | 文档完整性检查 |
+
+---
+
+## 成功标准
+
+### 验证命令
+
+```bash
+# 1. 编译检查
+go build ./internal/gateway
+go build ./...
+
+# 2. 单元测试
+source .env.local && go test -v ./internal/gateway
+source .env.local && go test -cover ./internal/gateway
+
+# 3. 集成测试
+source .env.local && go test -v ./internal/gateway -run TestIntegration
+
+# 4. 错误码验证
+go test -v ./pkg/errors -run TestErrorCodes
+
+# 5. 配置验证
+export JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
+export JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
+export JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
+source .env.local && go run cmd/api/main.go
+```
+
+### 最终检查清单
+
+- [ ] 所有14个Gateway API接口成功封装
+- [ ] 加密/签名验证通过(与Gateway文档一致)
+- [ ] 错误处理覆盖所有异常场景(网络错误、响应格式错误等)
+- [ ] 单元测试覆盖率≥90%
+- [ ] 集成测试验证真实Gateway API调用
+- [ ] 配置通过环境变量成功加载
+- [ ] 依赖注入到Service层成功
+- [ ] 文档完整(使用示例、错误码说明、API参考)
+- [ ] 无LSP错误,编译通过
+- [ ] 符合项目代码规范(中文注释、Go命名规范、errors.New/Wrap)
+
+---
+
+## 风险与缓解
+
+| 风险 | 影响 | 概率 | 缓解措施 |
+|------|------|------|---------|
+| AES-ECB实现错误 | 高(加密失败导致无法调用API) | 中 | 1. 使用TiDB参考代码
2. 端到端集成测试验证
3. 与Gateway文档示例对比 |
+| 签名算法兼容性 | 高(签名不匹配导致认证失败) | 中 | 1. 严格按字母序拼接
2. 大写MD5输出
3. 集成测试验证 |
+| Gateway响应格式变更 | 中(解析失败) | 低 | 1. 使用json.RawMessage
2. 统一错误处理
3. 日志记录原始响应 |
+| 配置验证不完整 | 中(运行时错误) | 低 | 1. 双层验证(Required+Format)
2. 启动时验证
3. 明确错误提示 |
+| 测试覆盖率不足 | 低(隐藏bug) | 低 | 1. TDD流程
2. 覆盖率工具检查
3. 集成测试补充 |
+
+---
+
+## 后续计划
+
+本次变更完成后的优化方向:
+
+1. **阶段2(未来优化)**:
+ - 实现异步模式回调接口
+ - 添加批量查询接口
+ - 实现请求重试和超时控制
+
+2. **阶段3(性能优化)**:
+ - 添加响应缓存(Redis)
+ - 实现请求限流(防止Gateway过载)
+ - 监控和告警集成
+
+3. **阶段4(安全增强)**:
+ - 替换AES-ECB为更安全的模式(如需Gateway支持)
+ - 实现请求签名双向验证
+ - 添加API调用审计日志
diff --git a/README.md b/README.md
index 7ec0eae..56bb16b 100644
--- a/README.md
+++ b/README.md
@@ -200,6 +200,7 @@ default:
- **批量同步**:卡状态、实名状态、流量使用情况
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md)
- **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md)
+- **Gateway 客户端**:第三方 Gateway API 的 Go 封装,提供流量卡和设备管理的统一接口;内置 AES-128-ECB 加密、MD5 签名验证、HTTP 连接池管理;支持流量卡状态查询、停复机、实名认证、流量查询等 7 个流量卡接口和设备信息查询、卡槽管理、限速设置、WiFi 配置、切卡、重启、恢复出厂等 7 个设备管理接口;测试覆盖率 88.8%;详见 [使用指南](docs/gateway-client-usage.md) 和 [API 参考](docs/gateway-api-reference.md)
## 用户体系设计
diff --git a/cmd/api/main.go b/cmd/api/main.go
index 497933b..dbfccb2 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -17,6 +17,7 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
+ "github.com/break/junhong_cmp_fiber/internal/gateway"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
@@ -62,7 +63,10 @@ func main() {
// 8. 初始化对象存储服务(可选)
storageSvc := initStorage(cfg, appLogger)
- // 9. 初始化所有业务组件(通过 Bootstrap)
+ // 9. 初始化 Gateway 客户端(可选)
+ gatewayClient := initGateway(cfg, appLogger)
+
+ // 10. 初始化所有业务组件(通过 Bootstrap)
result, err := bootstrap.Bootstrap(&bootstrap.Dependencies{
DB: db,
Redis: redisClient,
@@ -72,24 +76,25 @@ func main() {
VerificationService: verificationSvc,
QueueClient: queueClient,
StorageService: storageSvc,
+ GatewayClient: gatewayClient,
})
if err != nil {
appLogger.Fatal("初始化业务组件失败", zap.Error(err))
}
- // 10. 创建 Fiber 应用
+ // 11. 创建 Fiber 应用
app := createFiberApp(cfg, appLogger)
- // 11. 注册中间件
+ // 12. 注册中间件
initMiddleware(app, cfg, appLogger)
- // 12. 注册路由
+ // 13. 注册路由
initRoutes(app, cfg, result, queueClient, db, redisClient, appLogger)
- // 13. 生成 OpenAPI 文档
+ // 14. 生成 OpenAPI 文档
generateOpenAPIDocs("logs/openapi.yaml", appLogger)
- // 14. 启动服务器
+ // 15. 启动服务器
startServer(app, cfg, appLogger)
}
@@ -327,3 +332,22 @@ func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
return storage.NewService(provider, &cfg.Storage)
}
+
+func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
+ if cfg.Gateway.BaseURL == "" {
+ appLogger.Info("Gateway 未配置,跳过初始化")
+ return nil
+ }
+
+ client := gateway.NewClient(
+ cfg.Gateway.BaseURL,
+ cfg.Gateway.AppID,
+ cfg.Gateway.AppSecret,
+ ).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
+
+ appLogger.Info("Gateway 客户端初始化成功",
+ zap.String("base_url", cfg.Gateway.BaseURL),
+ zap.String("app_id", cfg.Gateway.AppID))
+
+ return client
+}
diff --git a/docs/gateway-api-reference.md b/docs/gateway-api-reference.md
new file mode 100644
index 0000000..145e2c9
--- /dev/null
+++ b/docs/gateway-api-reference.md
@@ -0,0 +1,749 @@
+# Gateway API 参考文档
+
+## 概述
+
+本文档提供 Gateway 客户端所有 API 接口的完整参考,包括请求参数、响应格式和使用示例。
+
+**API 分类**:
+- 流量卡管理(7 个接口)
+- 设备管理(7 个接口)
+
+**基础信息**:
+- 协议:HTTPS
+- 请求方法:POST
+- 内容类型:application/json
+- 编码方式:UTF-8
+- 加密方式:AES-128-ECB + Base64
+- 签名方式:MD5
+
+---
+
+## 流量卡管理 API
+
+### 1. 查询流量卡状态
+
+查询流量卡的当前状态信息。
+
+**方法**: `QueryCardStatus`
+
+**请求参数**:
+```go
+type CardStatusReq struct {
+ CardNo string `json:"cardNo" validate:"required"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| CardNo | string | ✅ | 流量卡号(ICCID) |
+
+**响应参数**:
+```go
+type CardStatusResp struct {
+ ICCID string `json:"iccid"`
+ CardStatus string `json:"cardStatus"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| ICCID | string | 流量卡 ICCID |
+| CardStatus | string | 卡状态(准备、正常、停机) |
+| Extend | string | 扩展字段(广电国网特殊参数) |
+
+**使用示例**:
+```go
+resp, err := client.QueryCardStatus(ctx, &gateway.CardStatusReq{
+ CardNo: "898608070422D0010269",
+})
+if err != nil {
+ return err
+}
+
+fmt.Printf("卡状态: %s\n", resp.CardStatus)
+```
+
+---
+
+### 2. 查询流量使用情况
+
+查询流量卡的流量使用详情。
+
+**方法**: `QueryFlow`
+
+**请求参数**:
+```go
+type FlowQueryReq struct {
+ CardNo string `json:"cardNo" validate:"required"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| CardNo | string | ✅ | 流量卡号(ICCID) |
+
+**响应参数**:
+```go
+type FlowUsageResp struct {
+ UsedFlow int64 `json:"usedFlow"`
+ Unit string `json:"unit"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| UsedFlow | int64 | 已用流量 |
+| Unit | string | 流量单位(MB) |
+| Extend | string | 扩展字段 |
+
+**使用示例**:
+```go
+resp, err := client.QueryFlow(ctx, &gateway.FlowQueryReq{
+ CardNo: "898608070422D0010269",
+})
+if err != nil {
+ return err
+}
+
+fmt.Printf("已用流量: %d %s\n", resp.UsedFlow, resp.Unit)
+```
+
+---
+
+### 3. 查询实名认证状态
+
+查询流量卡的实名认证状态。
+
+**方法**: `QueryRealnameStatus`
+
+**请求参数**:
+```go
+type CardStatusReq struct {
+ CardNo string `json:"cardNo" validate:"required"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| CardNo | string | ✅ | 流量卡号(ICCID) |
+
+**响应参数**:
+```go
+type RealnameStatusResp struct {
+ Status string `json:"status"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| Status | string | 实名认证状态 |
+| Extend | string | 扩展字段 |
+
+**使用示例**:
+```go
+resp, err := client.QueryRealnameStatus(ctx, &gateway.CardStatusReq{
+ CardNo: "898608070422D0010269",
+})
+if err != nil {
+ return err
+}
+
+fmt.Printf("实名状态: %s\n", resp.Status)
+```
+
+---
+
+### 4. 流量卡停机
+
+对流量卡执行停机操作。
+
+**方法**: `StopCard`
+
+**请求参数**:
+```go
+type CardOperationReq struct {
+ CardNo string `json:"cardNo" validate:"required"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| CardNo | string | ✅ | 流量卡号(ICCID) |
+| Extend | string | ❌ | 扩展字段(广电国网特殊参数) |
+
+**响应参数**: 无(成功返回 nil,失败返回 error)
+
+**使用示例**:
+```go
+err := client.StopCard(ctx, &gateway.CardOperationReq{
+ CardNo: "898608070422D0010269",
+})
+if err != nil {
+ return err
+}
+
+fmt.Println("停机成功")
+```
+
+---
+
+### 5. 流量卡复机
+
+对流量卡执行复机操作。
+
+**方法**: `StartCard`
+
+**请求参数**:
+```go
+type CardOperationReq struct {
+ CardNo string `json:"cardNo" validate:"required"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| CardNo | string | ✅ | 流量卡号(ICCID) |
+| Extend | string | ❌ | 扩展字段 |
+
+**响应参数**: 无(成功返回 nil,失败返回 error)
+
+**使用示例**:
+```go
+err := client.StartCard(ctx, &gateway.CardOperationReq{
+ CardNo: "898608070422D0010269",
+})
+if err != nil {
+ return err
+}
+
+fmt.Println("复机成功")
+```
+
+---
+
+### 6. 获取实名认证跳转链接
+
+获取流量卡实名认证的跳转链接。
+
+**方法**: `GetRealnameLink`
+
+**请求参数**:
+```go
+type CardStatusReq struct {
+ CardNo string `json:"cardNo" validate:"required"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| CardNo | string | ✅ | 流量卡号(ICCID) |
+
+**响应参数**:
+```go
+type RealnameLinkResp struct {
+ Link string `json:"link"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| Link | string | 实名认证跳转链接(HTTPS URL) |
+| Extend | string | 扩展字段 |
+
+**使用示例**:
+```go
+resp, err := client.GetRealnameLink(ctx, &gateway.CardStatusReq{
+ CardNo: "898608070422D0010269",
+})
+if err != nil {
+ return err
+}
+
+fmt.Printf("实名链接: %s\n", resp.Link)
+```
+
+---
+
+### 7. 批量查询(预留)
+
+批量查询流量卡信息(暂未实现)。
+
+**方法**: `BatchQuery`
+
+**请求参数**:
+```go
+type BatchQueryReq struct {
+ CardNos []string `json:"cardNos" validate:"required,min=1,max=100"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| CardNos | []string | ✅ | 流量卡号列表(最多100个) |
+
+**响应参数**:
+```go
+type BatchQueryResp struct {
+ Results []CardStatusResp `json:"results"`
+}
+```
+
+**状态**: ⚠️ 暂未实现,调用将返回错误
+
+---
+
+## 设备管理 API
+
+### 1. 获取设备信息
+
+通过卡号或设备 ID 查询设备的在线状态、信号强度、WiFi 信息等。
+
+**方法**: `GetDeviceInfo`
+
+**请求参数**:
+```go
+type DeviceInfoReq struct {
+ CardNo string `json:"cardNo,omitempty"`
+ DeviceID string `json:"deviceId,omitempty"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| CardNo | string | 二选一 | 流量卡号(ICCID) |
+| DeviceID | string | 二选一 | 设备 ID/IMEI |
+
+**响应参数**:
+```go
+type DeviceInfoResp struct {
+ IMEI string `json:"imei"`
+ OnlineStatus int `json:"onlineStatus"`
+ SignalLevel int `json:"signalLevel"`
+ WiFiSSID string `json:"wifiSsid,omitempty"`
+ WiFiEnabled int `json:"wifiEnabled"`
+ UploadSpeed int `json:"uploadSpeed"`
+ DownloadSpeed int `json:"downloadSpeed"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| IMEI | string | 设备 IMEI |
+| OnlineStatus | int | 在线状态(0:离线, 1:在线) |
+| SignalLevel | int | 信号强度(0-31) |
+| WiFiSSID | string | WiFi 名称 |
+| WiFiEnabled | int | WiFi 启用状态(0:禁用, 1:启用) |
+| UploadSpeed | int | 上行速率(KB/s) |
+| DownloadSpeed | int | 下行速率(KB/s) |
+| Extend | string | 扩展字段 |
+
+**使用示例**:
+```go
+resp, err := client.GetDeviceInfo(ctx, &gateway.DeviceInfoReq{
+ DeviceID: "123456789012345",
+})
+if err != nil {
+ return err
+}
+
+fmt.Printf("设备状态: %s, 信号: %d\n",
+ map[int]string{0:"离线", 1:"在线"}[resp.OnlineStatus],
+ resp.SignalLevel,
+)
+```
+
+---
+
+### 2. 获取设备卡槽信息
+
+查询设备的所有卡槽及其中的卡信息。
+
+**方法**: `GetSlotInfo`
+
+**请求参数**:
+```go
+type DeviceInfoReq struct {
+ CardNo string `json:"cardNo,omitempty"`
+ DeviceID string `json:"deviceId,omitempty"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| CardNo | string | 二选一 | 流量卡号(ICCID) |
+| DeviceID | string | 二选一 | 设备 ID/IMEI |
+
+**响应参数**:
+```go
+type SlotInfoResp struct {
+ IMEI string `json:"imei"`
+ Slots []SlotInfo `json:"slots"`
+ Extend string `json:"extend,omitempty"`
+}
+
+type SlotInfo struct {
+ SlotNo int `json:"slotNo"`
+ ICCID string `json:"iccid"`
+ CardStatus string `json:"cardStatus"`
+ IsActive int `json:"isActive"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| IMEI | string | 设备 IMEI |
+| Slots | []SlotInfo | 卡槽信息列表 |
+| SlotNo | int | 卡槽编号 |
+| ICCID | string | 卡槽中的 ICCID |
+| CardStatus | string | 卡状态(准备、正常、停机) |
+| IsActive | int | 是否为当前使用的卡槽(0:否, 1:是) |
+
+**使用示例**:
+```go
+resp, err := client.GetSlotInfo(ctx, &gateway.DeviceInfoReq{
+ DeviceID: "123456789012345",
+})
+if err != nil {
+ return err
+}
+
+for _, slot := range resp.Slots {
+ fmt.Printf("卡槽%d: %s (%s)%s\n",
+ slot.SlotNo,
+ slot.ICCID,
+ slot.CardStatus,
+ map[int]string{0:"", 1:" [当前使用]"}[slot.IsActive],
+ )
+}
+```
+
+---
+
+### 3. 设置设备限速
+
+设置设备的上行和下行速率限制。
+
+**方法**: `SetSpeedLimit`
+
+**请求参数**:
+```go
+type SpeedLimitReq struct {
+ DeviceID string `json:"deviceId" validate:"required"`
+ UploadSpeed int `json:"uploadSpeed" validate:"required,min=1"`
+ DownloadSpeed int `json:"downloadSpeed" validate:"required,min=1"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| DeviceID | string | ✅ | 设备 ID/IMEI |
+| UploadSpeed | int | ✅ | 上行速率(KB/s),最小1 |
+| DownloadSpeed | int | ✅ | 下行速率(KB/s),最小1 |
+| Extend | string | ❌ | 扩展字段 |
+
+**响应参数**: 无(成功返回 nil,失败返回 error)
+
+**使用示例**:
+```go
+err := client.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{
+ DeviceID: "123456789012345",
+ UploadSpeed: 100,
+ DownloadSpeed: 500,
+})
+if err != nil {
+ return err
+}
+
+fmt.Println("限速设置成功")
+```
+
+---
+
+### 4. 设置设备 WiFi
+
+设置设备的 WiFi 名称、密码和启用状态。
+
+**方法**: `SetWiFi`
+
+**请求参数**:
+```go
+type WiFiReq struct {
+ DeviceID string `json:"deviceId" validate:"required"`
+ SSID string `json:"ssid" validate:"required,min=1,max=32"`
+ Password string `json:"password" validate:"required,min=8,max=63"`
+ Enabled int `json:"enabled" validate:"required,oneof=0 1"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| DeviceID | string | ✅ | 设备 ID/IMEI |
+| SSID | string | ✅ | WiFi 名称(1-32字符) |
+| Password | string | ✅ | WiFi 密码(8-63字符) |
+| Enabled | int | ✅ | 启用状态(0:禁用, 1:启用) |
+| Extend | string | ❌ | 扩展字段 |
+
+**响应参数**: 无(成功返回 nil,失败返回 error)
+
+**使用示例**:
+```go
+err := client.SetWiFi(ctx, &gateway.WiFiReq{
+ DeviceID: "123456789012345",
+ SSID: "MyWiFi",
+ Password: "password123",
+ Enabled: 1,
+})
+if err != nil {
+ return err
+}
+
+fmt.Println("WiFi设置成功")
+```
+
+---
+
+### 5. 设备切换卡
+
+切换设备当前使用的卡到指定的目标卡。
+
+**方法**: `SwitchCard`
+
+**请求参数**:
+```go
+type SwitchCardReq struct {
+ DeviceID string `json:"deviceId" validate:"required"`
+ TargetICCID string `json:"targetIccid" validate:"required"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| DeviceID | string | ✅ | 设备 ID/IMEI |
+| TargetICCID | string | ✅ | 目标卡 ICCID |
+| Extend | string | ❌ | 扩展字段 |
+
+**响应参数**: 无(成功返回 nil,失败返回 error)
+
+**使用示例**:
+```go
+err := client.SwitchCard(ctx, &gateway.SwitchCardReq{
+ DeviceID: "123456789012345",
+ TargetICCID: "898608070422D0010270",
+})
+if err != nil {
+ return err
+}
+
+fmt.Println("切换卡成功")
+```
+
+---
+
+### 6. 设备恢复出厂设置
+
+将设备恢复到出厂设置状态。
+
+**方法**: `ResetDevice`
+
+**请求参数**:
+```go
+type DeviceOperationReq struct {
+ DeviceID string `json:"deviceId" validate:"required"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| DeviceID | string | ✅ | 设备 ID/IMEI |
+| Extend | string | ❌ | 扩展字段 |
+
+**响应参数**: 无(成功返回 nil,失败返回 error)
+
+**使用示例**:
+```go
+err := client.ResetDevice(ctx, &gateway.DeviceOperationReq{
+ DeviceID: "123456789012345",
+})
+if err != nil {
+ return err
+}
+
+fmt.Println("恢复出厂设置成功")
+```
+
+---
+
+### 7. 设备重启
+
+远程重启设备。
+
+**方法**: `RebootDevice`
+
+**请求参数**:
+```go
+type DeviceOperationReq struct {
+ DeviceID string `json:"deviceId" validate:"required"`
+ Extend string `json:"extend,omitempty"`
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| DeviceID | string | ✅ | 设备 ID/IMEI |
+| Extend | string | ❌ | 扩展字段 |
+
+**响应参数**: 无(成功返回 nil,失败返回 error)
+
+**使用示例**:
+```go
+err := client.RebootDevice(ctx, &gateway.DeviceOperationReq{
+ DeviceID: "123456789012345",
+})
+if err != nil {
+ return err
+}
+
+fmt.Println("重启设备成功")
+```
+
+---
+
+## 通用响应结构
+
+所有 API 的底层响应都遵循统一的 Gateway 响应格式:
+
+```go
+type GatewayResponse struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ Data json.RawMessage `json:"data"`
+ TraceID string `json:"trace_id"`
+}
+```
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| Code | int | 业务状态码(200 = 成功) |
+| Msg | string | 业务提示信息 |
+| Data | json.RawMessage | 业务数据(原始 JSON) |
+| TraceID | string | 链路追踪 ID |
+
+**成功响应示例**:
+```json
+{
+ "code": 200,
+ "msg": "成功",
+ "data": {
+ "iccid": "898608070422D0010269",
+ "cardStatus": "正常"
+ },
+ "trace_id": "abc123xyz"
+}
+```
+
+**失败响应示例**:
+```json
+{
+ "code": 404,
+ "msg": "卡号不存在",
+ "data": null,
+ "trace_id": "abc123xyz"
+}
+```
+
+---
+
+## 错误码说明
+
+### Gateway 业务错误码
+
+| 错误码 | 说明 | 解决方案 |
+|-------|------|---------|
+| 200 | 成功 | - |
+| 400 | 请求参数错误 | 检查请求参数格式和内容 |
+| 401 | 认证失败 | 检查 AppID 和 AppSecret |
+| 404 | 资源不存在 | 检查卡号或设备 ID 是否正确 |
+| 500 | 服务器内部错误 | 联系 Gateway 服务提供方 |
+
+### 客户端错误码
+
+客户端封装的统一错误码(`pkg/errors/codes.go`):
+
+| 错误码 | 常量 | 说明 |
+|-------|------|------|
+| 1110 | CodeGatewayError | Gateway 连接失败 |
+| 1111 | CodeGatewayTimeout | Gateway 请求超时 |
+| 1112 | CodeGatewayBusinessError | Gateway 业务错误 |
+| 1113 | CodeGatewayInvalidResp | Gateway 响应解析失败 |
+| 1114 | CodeGatewaySignError | Gateway 签名验证失败 |
+
+---
+
+## 请求流程
+
+### 完整请求流程
+
+```
+1. 构造业务请求参数
+ ↓
+2. 序列化为 JSON
+ ↓
+3. AES-128-ECB 加密(Base64 编码)
+ ↓
+4. 生成 MD5 签名(参数排序 + appSecret)
+ ↓
+5. 构造最终请求体
+ {
+ "appId": "xxx",
+ "data": "encrypted_base64_string",
+ "sign": "md5_signature",
+ "timestamp": 1706620800000
+ }
+ ↓
+6. POST 请求到 Gateway
+ ↓
+7. 解析响应 JSON
+ ↓
+8. 检查业务状态码
+ ↓
+9. 解密并解析业务数据
+ ↓
+10. 返回结果或错误
+```
+
+### 签名算法
+
+```
+1. 将请求参数按 key 字母序排序
+2. 拼接为 key1=value1&key2=value2 格式
+3. 末尾追加 &appSecret=xxx
+4. 计算 MD5 哈希
+5. 转为大写字符串
+```
+
+**示例**:
+```
+参数: {cardNo: "123", appId: "abc"}
+排序: appId=abc&cardNo=123
+追加: appId=abc&cardNo=123&appSecret=secret
+MD5: D41D8CD98F00B204E9800998ECF8427E
+```
+
+---
+
+## 相关文档
+
+- [Gateway 客户端使用指南](./gateway-client-usage.md) - 详细的使用指南和最佳实践
+- [错误处理指南](./003-error-handling/使用指南.md) - 统一错误处理规范
diff --git a/docs/gateway-client-usage.md b/docs/gateway-client-usage.md
new file mode 100644
index 0000000..720f8d3
--- /dev/null
+++ b/docs/gateway-client-usage.md
@@ -0,0 +1,551 @@
+# Gateway 客户端使用指南
+
+## 概述
+
+Gateway 客户端是对第三方 Gateway API 的 Go 封装,提供流量卡和设备管理的统一接口。客户端内置了 AES-128-ECB 加密、MD5 签名验证、HTTP 连接池管理等功能。
+
+**核心特性**:
+- ✅ 自动加密/签名处理
+- ✅ 统一错误处理
+- ✅ HTTP Keep-Alive 连接池
+- ✅ 可配置超时时间
+- ✅ 完整的测试覆盖(88.8%)
+
+## 配置说明
+
+### 环境变量配置
+
+Gateway 客户端通过环境变量配置,支持以下参数:
+
+| 环境变量 | 说明 | 默认值 | 必填 |
+|---------|------|--------|------|
+| `JUNHONG_GATEWAY_BASE_URL` | Gateway API 基础 URL | - | ✅ |
+| `JUNHONG_GATEWAY_APP_ID` | 应用 ID | - | ✅ |
+| `JUNHONG_GATEWAY_APP_SECRET` | 应用密钥 | - | ✅ |
+| `JUNHONG_GATEWAY_TIMEOUT` | 请求超时时间(秒) | 30 | ❌ |
+
+### 配置示例
+
+**开发环境** (`.env.local`):
+```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
+```
+
+**生产环境** (`docker-compose.yml`):
+```yaml
+services:
+ api:
+ environment:
+ - JUNHONG_GATEWAY_BASE_URL=https://gateway.prod.example.com
+ - JUNHONG_GATEWAY_APP_ID=${GATEWAY_APP_ID}
+ - JUNHONG_GATEWAY_APP_SECRET=${GATEWAY_APP_SECRET}
+ - JUNHONG_GATEWAY_TIMEOUT=60
+```
+
+## 使用示例
+
+### 1. 基础用法
+
+#### 获取客户端实例
+
+Gateway 客户端在 Bootstrap 阶段自动初始化,通过依赖注入获取:
+
+```go
+package iot_card
+
+import (
+ "context"
+ "github.com/break/junhong_cmp_fiber/internal/gateway"
+ "github.com/break/junhong_cmp_fiber/pkg/errors"
+ "go.uber.org/zap"
+)
+
+type Service struct {
+ gatewayClient *gateway.Client
+ logger *zap.Logger
+}
+
+func New(gatewayClient *gateway.Client, logger *zap.Logger) *Service {
+ return &Service{
+ gatewayClient: gatewayClient,
+ logger: logger,
+ }
+}
+```
+
+#### 查询流量卡状态
+
+```go
+func (s *Service) SyncCardStatus(ctx context.Context, iccid string) error {
+ if s.gatewayClient == nil {
+ return errors.New(errors.CodeGatewayError, "Gateway 客户端未配置")
+ }
+
+ resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
+ CardNo: iccid,
+ })
+ if err != nil {
+ s.logger.Error("查询卡状态失败", zap.String("iccid", iccid), zap.Error(err))
+ return errors.Wrap(errors.CodeGatewayError, err, "查询卡状态失败")
+ }
+
+ s.logger.Info("查询卡状态成功",
+ zap.String("iccid", resp.ICCID),
+ zap.String("status", resp.CardStatus),
+ )
+
+ return nil
+}
+```
+
+### 2. 流量卡管理
+
+#### 查询流量使用情况
+
+```go
+func (s *Service) GetFlowUsage(ctx context.Context, iccid string) (*gateway.FlowUsageResp, error) {
+ resp, err := s.gatewayClient.QueryFlow(ctx, &gateway.FlowQueryReq{
+ CardNo: iccid,
+ })
+ if err != nil {
+ return nil, errors.Wrap(errors.CodeGatewayError, err, "查询流量失败")
+ }
+
+ return resp, nil
+}
+```
+
+#### 流量卡停机
+
+```go
+func (s *Service) StopCard(ctx context.Context, iccid string) error {
+ err := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{
+ CardNo: iccid,
+ })
+ if err != nil {
+ return errors.Wrap(errors.CodeGatewayError, err, "停机失败")
+ }
+
+ s.logger.Info("停机成功", zap.String("iccid", iccid))
+ return nil
+}
+```
+
+#### 流量卡复机
+
+```go
+func (s *Service) StartCard(ctx context.Context, iccid string) error {
+ err := s.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{
+ CardNo: iccid,
+ })
+ if err != nil {
+ return errors.Wrap(errors.CodeGatewayError, err, "复机失败")
+ }
+
+ s.logger.Info("复机成功", zap.String("iccid", iccid))
+ return nil
+}
+```
+
+#### 查询实名认证状态
+
+```go
+func (s *Service) CheckRealnameStatus(ctx context.Context, iccid string) (string, error) {
+ resp, err := s.gatewayClient.QueryRealnameStatus(ctx, &gateway.CardStatusReq{
+ CardNo: iccid,
+ })
+ if err != nil {
+ return "", errors.Wrap(errors.CodeGatewayError, err, "查询实名状态失败")
+ }
+
+ return resp.Status, nil
+}
+```
+
+#### 获取实名认证链接
+
+```go
+func (s *Service) GetRealnameLink(ctx context.Context, iccid string) (string, error) {
+ resp, err := s.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{
+ CardNo: iccid,
+ })
+ if err != nil {
+ return "", errors.Wrap(errors.CodeGatewayError, err, "获取实名链接失败")
+ }
+
+ return resp.Link, nil
+}
+```
+
+### 3. 设备管理
+
+#### 查询设备信息
+
+```go
+func (s *Service) GetDeviceInfo(ctx context.Context, imei string) (*gateway.DeviceInfoResp, error) {
+ resp, err := s.gatewayClient.GetDeviceInfo(ctx, &gateway.DeviceInfoReq{
+ DeviceID: imei,
+ })
+ if err != nil {
+ return nil, errors.Wrap(errors.CodeGatewayError, err, "查询设备信息失败")
+ }
+
+ return resp, nil
+}
+```
+
+#### 查询设备卡槽信息
+
+```go
+func (s *Service) GetDeviceSlots(ctx context.Context, imei string) ([]gateway.SlotInfo, error) {
+ resp, err := s.gatewayClient.GetSlotInfo(ctx, &gateway.DeviceInfoReq{
+ DeviceID: imei,
+ })
+ if err != nil {
+ return nil, errors.Wrap(errors.CodeGatewayError, err, "查询卡槽信息失败")
+ }
+
+ return resp.Slots, nil
+}
+```
+
+#### 设置设备限速
+
+```go
+func (s *Service) SetDeviceSpeed(ctx context.Context, imei string, uploadKBps, downloadKBps int) error {
+ err := s.gatewayClient.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{
+ DeviceID: imei,
+ UploadSpeed: uploadKBps,
+ DownloadSpeed: downloadKBps,
+ })
+ if err != nil {
+ return errors.Wrap(errors.CodeGatewayError, err, "设置限速失败")
+ }
+
+ s.logger.Info("设置限速成功",
+ zap.String("imei", imei),
+ zap.Int("upload", uploadKBps),
+ zap.Int("download", downloadKBps),
+ )
+ return nil
+}
+```
+
+#### 设置设备 WiFi
+
+```go
+func (s *Service) ConfigureWiFi(ctx context.Context, imei, ssid, password string, enabled bool) error {
+ enabledInt := 0
+ if enabled {
+ enabledInt = 1
+ }
+
+ err := s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{
+ DeviceID: imei,
+ SSID: ssid,
+ Password: password,
+ Enabled: enabledInt,
+ })
+ if err != nil {
+ return errors.Wrap(errors.CodeGatewayError, err, "设置WiFi失败")
+ }
+
+ return nil
+}
+```
+
+#### 设备切换卡
+
+```go
+func (s *Service) SwitchDeviceCard(ctx context.Context, imei, targetICCID string) error {
+ err := s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{
+ DeviceID: imei,
+ TargetICCID: targetICCID,
+ })
+ if err != nil {
+ return errors.Wrap(errors.CodeGatewayError, err, "切换卡失败")
+ }
+
+ s.logger.Info("切换卡成功",
+ zap.String("imei", imei),
+ zap.String("targetICCID", targetICCID),
+ )
+ return nil
+}
+```
+
+#### 设备重启
+
+```go
+func (s *Service) RebootDevice(ctx context.Context, imei string) error {
+ err := s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{
+ DeviceID: imei,
+ })
+ if err != nil {
+ return errors.Wrap(errors.CodeGatewayError, err, "重启设备失败")
+ }
+
+ s.logger.Info("重启设备成功", zap.String("imei", imei))
+ return nil
+}
+```
+
+#### 设备恢复出厂设置
+
+```go
+func (s *Service) ResetDevice(ctx context.Context, imei string) error {
+ err := s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{
+ DeviceID: imei,
+ })
+ if err != nil {
+ return errors.Wrap(errors.CodeGatewayError, err, "恢复出厂设置失败")
+ }
+
+ s.logger.Info("恢复出厂设置成功", zap.String("imei", imei))
+ return nil
+}
+```
+
+## 错误处理
+
+### 统一错误码
+
+Gateway 客户端使用统一的错误码系统(`pkg/errors/codes.go`):
+
+| 错误码 | 说明 |
+|-------|------|
+| `1110` | Gateway 连接失败 |
+| `1111` | Gateway 请求超时 |
+| `1112` | Gateway 业务错误 |
+| `1113` | Gateway 响应解析失败 |
+| `1114` | Gateway 签名验证失败 |
+
+### 错误处理最佳实践
+
+```go
+func (s *Service) ProcessCard(ctx context.Context, iccid string) error {
+ resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
+ CardNo: iccid,
+ })
+ if err != nil {
+ s.logger.Error("查询卡状态失败",
+ zap.String("iccid", iccid),
+ zap.Error(err),
+ )
+
+ return errors.Wrap(errors.CodeGatewayError, err, "查询卡状态失败")
+ }
+
+ return nil
+}
+```
+
+### 错误分类处理
+
+```go
+func (s *Service) HandleGatewayError(err error) {
+ switch {
+ case strings.Contains(err.Error(), "超时"):
+ s.logger.Warn("Gateway 请求超时,请稍后重试")
+ case strings.Contains(err.Error(), "业务错误"):
+ s.logger.Error("Gateway 业务处理失败", zap.Error(err))
+ case strings.Contains(err.Error(), "连接失败"):
+ s.logger.Error("Gateway 连接失败,请检查网络", zap.Error(err))
+ default:
+ s.logger.Error("Gateway 未知错误", zap.Error(err))
+ }
+}
+```
+
+## 高级用法
+
+### 自定义超时时间
+
+```go
+client := gateway.NewClient(baseURL, appID, appSecret).
+ WithTimeout(60 * time.Second)
+
+resp, err := client.QueryCardStatus(ctx, &gateway.CardStatusReq{
+ CardNo: iccid,
+})
+```
+
+### 使用扩展字段(广电国网)
+
+部分 API 支持 `extend` 扩展字段用于特殊参数:
+
+```go
+err := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{
+ CardNo: iccid,
+ Extend: "special_param=value",
+})
+```
+
+### Context 传递
+
+所有 API 方法都支持 `context.Context`,可以传递超时、取消信号等:
+
+```go
+ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+defer cancel()
+
+resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
+ CardNo: iccid,
+})
+```
+
+## 最佳实践
+
+### 1. 空值检查
+
+```go
+func (s *Service) SafeCall(ctx context.Context) error {
+ if s.gatewayClient == nil {
+ return errors.New(errors.CodeGatewayError, "Gateway 客户端未配置")
+ }
+
+}
+```
+
+### 2. 日志记录
+
+```go
+func (s *Service) CallWithLogging(ctx context.Context, iccid string) error {
+ s.logger.Info("开始查询卡状态", zap.String("iccid", iccid))
+
+ resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
+ CardNo: iccid,
+ })
+ if err != nil {
+ s.logger.Error("查询失败", zap.String("iccid", iccid), zap.Error(err))
+ return err
+ }
+
+ s.logger.Info("查询成功",
+ zap.String("iccid", resp.ICCID),
+ zap.String("status", resp.CardStatus),
+ )
+ return nil
+}
+```
+
+### 3. 重试机制
+
+```go
+func (s *Service) QueryWithRetry(ctx context.Context, iccid string) (*gateway.CardStatusResp, error) {
+ maxRetries := 3
+ var lastErr error
+
+ for i := 0; i < maxRetries; i++ {
+ resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
+ CardNo: iccid,
+ })
+ if err == nil {
+ return resp, nil
+ }
+
+ lastErr = err
+ s.logger.Warn("重试查询",
+ zap.Int("attempt", i+1),
+ zap.String("iccid", iccid),
+ zap.Error(err),
+ )
+
+ time.Sleep(time.Second * time.Duration(i+1))
+ }
+
+ return nil, errors.Wrap(errors.CodeGatewayError, lastErr, "重试失败")
+}
+```
+
+### 4. 批量处理
+
+```go
+func (s *Service) BatchSyncStatus(ctx context.Context, iccids []string) error {
+ for _, iccid := range iccids {
+ if err := s.SyncCardStatus(ctx, iccid); err != nil {
+ s.logger.Error("同步失败", zap.String("iccid", iccid), zap.Error(err))
+ continue
+ }
+ }
+ return nil
+}
+```
+
+## 测试
+
+### 单元测试
+
+Gateway 客户端提供完整的单元测试覆盖(88.8%):
+
+```bash
+go test -v ./internal/gateway
+go test -cover ./internal/gateway
+```
+
+### 集成测试
+
+使用 `-short` 标志跳过集成测试:
+
+```bash
+go test -v ./internal/gateway -short
+```
+
+运行集成测试(需要真实 Gateway 环境):
+
+```bash
+source .env.local && go test -v ./internal/gateway
+```
+
+## 故障排查
+
+### 常见问题
+
+#### 1. Gateway 客户端未配置
+
+**错误**: `Gateway 客户端未配置`
+
+**原因**: 环境变量未设置或 Bootstrap 初始化失败
+
+**解决方案**:
+```bash
+export JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
+export JUNHONG_GATEWAY_APP_ID=your_app_id
+export JUNHONG_GATEWAY_APP_SECRET=your_app_secret
+```
+
+#### 2. 请求超时
+
+**错误**: `Gateway 请求超时`
+
+**原因**: 网络延迟或 Gateway 服务响应慢
+
+**解决方案**:
+```go
+client := client.WithTimeout(60 * time.Second)
+```
+
+#### 3. 业务错误
+
+**错误**: `业务错误: code=500, msg=xxx`
+
+**原因**: Gateway 服务端业务逻辑错误
+
+**解决方案**: 检查请求参数是否正确,查看日志中的 `TraceID` 联系 Gateway 服务提供方
+
+#### 4. 签名验证失败
+
+**错误**: `签名验证失败`
+
+**原因**: `AppSecret` 配置错误
+
+**解决方案**: 检查 `JUNHONG_GATEWAY_APP_SECRET` 环境变量是否正确
+
+## 相关文档
+
+- [Gateway API 参考](./gateway-api-reference.md) - 完整的 API 接口文档
+- [错误处理指南](./003-error-handling/使用指南.md) - 统一错误处理规范
+- [测试连接管理规范](./testing/test-connection-guide.md) - 测试规范和最佳实践
diff --git a/internal/bootstrap/dependencies.go b/internal/bootstrap/dependencies.go
index f1c79f9..e30cbfb 100644
--- a/internal/bootstrap/dependencies.go
+++ b/internal/bootstrap/dependencies.go
@@ -1,6 +1,7 @@
package bootstrap
import (
+ "github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/queue"
@@ -21,4 +22,5 @@ type Dependencies struct {
VerificationService *verification.Service // 验证码服务
QueueClient *queue.Client // Asynq 任务队列客户端
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil)
+ GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil)
}
diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go
index 34eb705..398fa9b 100644
--- a/internal/bootstrap/services.go
+++ b/internal/bootstrap/services.go
@@ -105,7 +105,7 @@ func initServices(s *stores, deps *Dependencies) *services {
Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger),
CustomerAccount: customerAccountSvc.New(deps.DB, s.Account, s.Shop, s.Enterprise),
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction),
- IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopSeriesAllocation),
+ IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopSeriesAllocation, deps.GatewayClient, deps.Logger),
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopSeriesAllocation),
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
diff --git a/internal/gateway/client.go b/internal/gateway/client.go
new file mode 100644
index 0000000..8d57a26
--- /dev/null
+++ b/internal/gateway/client.go
@@ -0,0 +1,130 @@
+// Package gateway 提供 Gateway API 的统一客户端封装
+// 实现 AES-128-ECB 加密 + MD5 签名认证机制
+package gateway
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/break/junhong_cmp_fiber/pkg/errors"
+ "github.com/bytedance/sonic"
+)
+
+const (
+ defaultTimeout = 30 * time.Second
+ maxIdleConns = 100
+ maxIdleConnsPerHost = 10
+ idleConnTimeout = 90 * time.Second
+ contentTypeJSON = "application/json;charset=utf-8"
+ gatewaySuccessCode = 200
+)
+
+// Client 是 Gateway API 的 HTTP 客户端
+type Client struct {
+ baseURL string
+ appID string
+ appSecret string
+ httpClient *http.Client
+ timeout time.Duration
+}
+
+// NewClient 创建 Gateway 客户端实例
+// baseURL: Gateway 服务基础地址
+// appID: 应用 ID
+// appSecret: 应用密钥(用于加密和签名)
+func NewClient(baseURL, appID, appSecret string) *Client {
+ return &Client{
+ baseURL: baseURL,
+ appID: appID,
+ appSecret: appSecret,
+ httpClient: &http.Client{
+ Transport: &http.Transport{
+ MaxIdleConns: maxIdleConns,
+ MaxIdleConnsPerHost: maxIdleConnsPerHost,
+ IdleConnTimeout: idleConnTimeout,
+ },
+ },
+ timeout: defaultTimeout,
+ }
+}
+
+// WithTimeout 设置请求超时时间(支持链式调用)
+func (c *Client) WithTimeout(timeout time.Duration) *Client {
+ c.timeout = timeout
+ return c
+}
+
+// doRequest 执行 Gateway API 请求的统一方法
+// 流程:序列化 → 加密 → 签名 → HTTP POST → 解析响应 → 检查业务状态码
+func (c *Client) doRequest(ctx context.Context, path string, businessData interface{}) (json.RawMessage, error) {
+ dataBytes, err := sonic.Marshal(businessData)
+ if err != nil {
+ return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败")
+ }
+
+ encryptedData, err := aesEncrypt(dataBytes, c.appSecret)
+ if err != nil {
+ return nil, err
+ }
+
+ timestamp := time.Now().UnixMilli()
+ sign := generateSign(c.appID, encryptedData, timestamp, c.appSecret)
+
+ reqBody := map[string]interface{}{
+ "appId": c.appID,
+ "data": encryptedData,
+ "sign": sign,
+ "timestamp": timestamp,
+ }
+
+ reqBodyBytes, err := sonic.Marshal(reqBody)
+ if err != nil {
+ return nil, errors.Wrap(errors.CodeInternalError, err, "序列化请求体失败")
+ }
+
+ reqCtx, cancel := context.WithTimeout(ctx, c.timeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.baseURL+path, bytes.NewReader(reqBodyBytes))
+ if err != nil {
+ return nil, errors.Wrap(errors.CodeGatewayError, err, "创建 HTTP 请求失败")
+ }
+ req.Header.Set("Content-Type", contentTypeJSON)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ if reqCtx.Err() == context.DeadlineExceeded {
+ return nil, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时")
+ }
+ if ctx.Err() != nil {
+ return nil, errors.Wrap(errors.CodeGatewayError, ctx.Err(), "请求被取消")
+ }
+ return nil, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败")
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d", resp.StatusCode))
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "读取响应体失败")
+ }
+
+ var gatewayResp GatewayResponse
+ if err := sonic.Unmarshal(body, &gatewayResp); err != nil {
+ return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败")
+ }
+
+ if gatewayResp.Code != gatewaySuccessCode {
+ return nil, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg))
+ }
+
+ return gatewayResp.Data, nil
+}
diff --git a/internal/gateway/client_test.go b/internal/gateway/client_test.go
new file mode 100644
index 0000000..556d938
--- /dev/null
+++ b/internal/gateway/client_test.go
@@ -0,0 +1,323 @@
+package gateway
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestNewClient(t *testing.T) {
+ client := NewClient("https://test.example.com", "testAppID", "testSecret")
+
+ if client.baseURL != "https://test.example.com" {
+ t.Errorf("baseURL = %s, want https://test.example.com", client.baseURL)
+ }
+ if client.appID != "testAppID" {
+ t.Errorf("appID = %s, want testAppID", client.appID)
+ }
+ if client.appSecret != "testSecret" {
+ t.Errorf("appSecret = %s, want testSecret", client.appSecret)
+ }
+ if client.timeout != 30*time.Second {
+ t.Errorf("timeout = %v, want 30s", client.timeout)
+ }
+ if client.httpClient == nil {
+ t.Error("httpClient should not be nil")
+ }
+}
+
+func TestWithTimeout(t *testing.T) {
+ client := NewClient("https://test.example.com", "testAppID", "testSecret").
+ WithTimeout(60 * time.Second)
+
+ if client.timeout != 60*time.Second {
+ t.Errorf("timeout = %v, want 60s", client.timeout)
+ }
+}
+
+func TestWithTimeout_Chain(t *testing.T) {
+ // 验证链式调用返回同一个 Client 实例
+ client := NewClient("https://test.example.com", "testAppID", "testSecret")
+ returned := client.WithTimeout(45 * time.Second)
+
+ if returned != client {
+ t.Error("WithTimeout should return the same Client instance for chaining")
+ }
+}
+
+func TestDoRequest_Success(t *testing.T) {
+ // 创建 mock HTTP 服务器
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // 验证请求方法
+ if r.Method != http.MethodPost {
+ t.Errorf("Method = %s, want POST", r.Method)
+ }
+
+ // 验证 Content-Type
+ if r.Header.Get("Content-Type") != "application/json;charset=utf-8" {
+ t.Errorf("Content-Type = %s, want application/json;charset=utf-8", r.Header.Get("Content-Type"))
+ }
+
+ // 验证请求体格式
+ var reqBody map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+ t.Fatalf("解析请求体失败: %v", err)
+ }
+
+ // 验证必需字段
+ if _, ok := reqBody["appId"]; !ok {
+ t.Error("请求体缺少 appId 字段")
+ }
+ if _, ok := reqBody["data"]; !ok {
+ t.Error("请求体缺少 data 字段")
+ }
+ if _, ok := reqBody["sign"]; !ok {
+ t.Error("请求体缺少 sign 字段")
+ }
+ if _, ok := reqBody["timestamp"]; !ok {
+ t.Error("请求体缺少 timestamp 字段")
+ }
+
+ // 返回 mock 响应
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`{"test":"data"}`),
+ TraceID: "test-trace-id",
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+
+ ctx := context.Background()
+ businessData := map[string]interface{}{
+ "params": map[string]string{
+ "cardNo": "898608070422D0010269",
+ },
+ }
+
+ data, err := client.doRequest(ctx, "/test", businessData)
+ if err != nil {
+ t.Fatalf("doRequest() error = %v", err)
+ }
+
+ if string(data) != `{"test":"data"}` {
+ t.Errorf("data = %s, want {\"test\":\"data\"}", string(data))
+ }
+}
+
+func TestDoRequest_BusinessError(t *testing.T) {
+ // 创建返回业务错误的 mock 服务器
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 500,
+ Msg: "业务处理失败",
+ Data: nil,
+ TraceID: "error-trace-id",
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+
+ ctx := context.Background()
+ _, err := client.doRequest(ctx, "/test", map[string]interface{}{})
+ if err == nil {
+ t.Fatal("doRequest() expected business error")
+ }
+
+ // 验证错误信息包含业务错误内容
+ if !strings.Contains(err.Error(), "业务错误") {
+ t.Errorf("error should contain '业务错误', got: %v", err)
+ }
+}
+
+func TestDoRequest_Timeout(t *testing.T) {
+ // 创建延迟响应的服务器
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(500 * time.Millisecond) // 延迟 500ms
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret").
+ WithTimeout(100 * time.Millisecond) // 设置 100ms 超时
+
+ ctx := context.Background()
+ _, err := client.doRequest(ctx, "/test", map[string]interface{}{})
+ if err == nil {
+ t.Fatal("doRequest() expected timeout error")
+ }
+
+ // 验证是超时错误
+ if !strings.Contains(err.Error(), "超时") {
+ t.Errorf("error should contain '超时', got: %v", err)
+ }
+}
+
+func TestDoRequest_HTTPStatusError(t *testing.T) {
+ // 创建返回 500 状态码的服务器
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Server Error"))
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+
+ ctx := context.Background()
+ _, err := client.doRequest(ctx, "/test", map[string]interface{}{})
+ if err == nil {
+ t.Fatal("doRequest() expected HTTP status error")
+ }
+
+ // 验证错误信息包含 HTTP 状态码
+ if !strings.Contains(err.Error(), "500") {
+ t.Errorf("error should contain '500', got: %v", err)
+ }
+}
+
+func TestDoRequest_InvalidResponse(t *testing.T) {
+ // 创建返回无效 JSON 的服务器
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte("invalid json"))
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+
+ ctx := context.Background()
+ _, err := client.doRequest(ctx, "/test", map[string]interface{}{})
+ if err == nil {
+ t.Fatal("doRequest() expected JSON parse error")
+ }
+
+ // 验证错误信息包含解析失败提示
+ if !strings.Contains(err.Error(), "解析") {
+ t.Errorf("error should contain '解析', got: %v", err)
+ }
+}
+
+func TestDoRequest_ContextCanceled(t *testing.T) {
+ // 创建正常响应的服务器(但会延迟)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(500 * time.Millisecond)
+ resp := GatewayResponse{Code: 200, Msg: "成功"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+
+ // 创建已取消的 context
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // 立即取消
+
+ _, err := client.doRequest(ctx, "/test", map[string]interface{}{})
+ if err == nil {
+ t.Fatal("doRequest() expected context canceled error")
+ }
+}
+
+func TestDoRequest_NetworkError(t *testing.T) {
+ // 使用无效的服务器地址
+ client := NewClient("http://127.0.0.1:1", "testAppID", "testSecret").
+ WithTimeout(1 * time.Second)
+
+ ctx := context.Background()
+ _, err := client.doRequest(ctx, "/test", map[string]interface{}{})
+ if err == nil {
+ t.Fatal("doRequest() expected network error")
+ }
+}
+
+func TestDoRequest_EmptyBusinessData(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`{}`),
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+
+ ctx := context.Background()
+ data, err := client.doRequest(ctx, "/test", map[string]interface{}{})
+ if err != nil {
+ t.Fatalf("doRequest() error = %v", err)
+ }
+
+ if string(data) != `{}` {
+ t.Errorf("data = %s, want {}", string(data))
+ }
+}
+
+func TestIntegration_QueryCardStatus(t *testing.T) {
+ if testing.Short() {
+ t.Skip("跳过集成测试")
+ }
+
+ baseURL := "https://lplan.whjhft.com/openapi"
+ appID := "60bgt1X8i7AvXqkd"
+ appSecret := "BZeQttaZQt0i73moF"
+
+ client := NewClient(baseURL, appID, appSecret).WithTimeout(30 * time.Second)
+ ctx := context.Background()
+
+ resp, err := client.QueryCardStatus(ctx, &CardStatusReq{
+ CardNo: "898608070422D0010269",
+ })
+
+ if err != nil {
+ t.Fatalf("QueryCardStatus() error = %v", err)
+ }
+
+ if resp.ICCID == "" {
+ t.Error("ICCID should not be empty")
+ }
+ if resp.CardStatus == "" {
+ t.Error("CardStatus should not be empty")
+ }
+
+ t.Logf("Integration test passed: ICCID=%s, Status=%s", resp.ICCID, resp.CardStatus)
+}
+
+func TestIntegration_QueryFlow(t *testing.T) {
+ if testing.Short() {
+ t.Skip("跳过集成测试")
+ }
+
+ baseURL := "https://lplan.whjhft.com/openapi"
+ appID := "60bgt1X8i7AvXqkd"
+ appSecret := "BZeQttaZQt0i73moF"
+
+ client := NewClient(baseURL, appID, appSecret).WithTimeout(30 * time.Second)
+ ctx := context.Background()
+
+ resp, err := client.QueryFlow(ctx, &FlowQueryReq{
+ CardNo: "898608070422D0010269",
+ })
+
+ if err != nil {
+ t.Fatalf("QueryFlow() error = %v", err)
+ }
+
+ if resp.UsedFlow < 0 {
+ t.Error("UsedFlow should not be negative")
+ }
+
+ t.Logf("Integration test passed: UsedFlow=%d %s", resp.UsedFlow, resp.Unit)
+}
diff --git a/internal/gateway/crypto.go b/internal/gateway/crypto.go
new file mode 100644
index 0000000..ddd5a51
--- /dev/null
+++ b/internal/gateway/crypto.go
@@ -0,0 +1,89 @@
+// Package gateway 提供 Gateway API 加密和签名工具函数
+package gateway
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/md5"
+ "encoding/base64"
+ "encoding/hex"
+ "strconv"
+ "strings"
+
+ "github.com/break/junhong_cmp_fiber/pkg/errors"
+)
+
+const aesBlockSize = 16
+
+// 注意:AES-ECB 存在严重安全缺陷(相同明文块会产生相同密文块),
+// 这是 Gateway 强制要求无法改变,生产环境必须使用 HTTPS 保障传输层安全。
+func aesEncrypt(data []byte, appSecret string) (string, error) {
+ key := md5.Sum([]byte(appSecret))
+ block, err := aes.NewCipher(key[:])
+ if err != nil {
+ return "", errors.Wrap(errors.CodeGatewayEncryptError, err, "数据加密失败")
+ }
+
+ // 使用 PKCS5 进行填充,确保明文长度为 16 的整数倍
+ padded := pkcs5Padding(data, aesBlockSize)
+ encrypted := make([]byte, len(padded))
+ newECBEncrypter(block).CryptBlocks(encrypted, padded)
+ return base64.StdEncoding.EncodeToString(encrypted), nil
+}
+
+// generateSign 生成 Gateway 签名(appId、data、timestamp、key 字母序)
+func generateSign(appID, encryptedData string, timestamp int64, appSecret string) string {
+ var builder strings.Builder
+ builder.WriteString("appId=")
+ builder.WriteString(appID)
+ builder.WriteString("&data=")
+ builder.WriteString(encryptedData)
+ builder.WriteString("×tamp=")
+ builder.WriteString(strconv.FormatInt(timestamp, 10))
+ builder.WriteString("&key=")
+ builder.WriteString(appSecret)
+
+ sum := md5.Sum([]byte(builder.String()))
+ return strings.ToUpper(hex.EncodeToString(sum[:]))
+}
+
+// ecb 表示 AES-ECB 加密模式的基础结构
+type ecb struct {
+ b cipher.Block
+ blockSize int
+}
+
+type ecbEncrypter ecb
+
+func newECBEncrypter(b cipher.Block) cipher.BlockMode {
+ if b == nil {
+ panic("crypto/cipher: 传入的加密块为空")
+ }
+ return &ecbEncrypter{b: b, blockSize: b.BlockSize()}
+}
+
+func (x *ecbEncrypter) BlockSize() int {
+ return x.blockSize
+}
+
+func (x *ecbEncrypter) CryptBlocks(dst, src []byte) {
+ if len(src)%x.blockSize != 0 {
+ panic("crypto/cipher: 输入数据不是完整块")
+ }
+ for len(src) > 0 {
+ x.b.Encrypt(dst, src[:x.blockSize])
+ src = src[x.blockSize:]
+ dst = dst[x.blockSize:]
+ }
+}
+
+// pkcs5Padding 对明文进行 PKCS5 填充
+func pkcs5Padding(data []byte, blockSize int) []byte {
+ padding := blockSize - len(data)%blockSize
+ padded := make([]byte, len(data)+padding)
+ copy(padded, data)
+ for i := len(data); i < len(padded); i++ {
+ padded[i] = byte(padding)
+ }
+ return padded
+}
diff --git a/internal/gateway/crypto_test.go b/internal/gateway/crypto_test.go
new file mode 100644
index 0000000..2d01a03
--- /dev/null
+++ b/internal/gateway/crypto_test.go
@@ -0,0 +1,103 @@
+package gateway
+
+import (
+ "crypto/aes"
+ "encoding/base64"
+ "strings"
+ "testing"
+)
+
+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,
+ },
+ {
+ name: "空数据加密",
+ data: []byte(""),
+ 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() 返回空字符串")
+ }
+ // 验证 Base64 格式
+ if !tt.wantErr {
+ _, err := base64.StdEncoding.DecodeString(encrypted)
+ if err != nil {
+ t.Errorf("aesEncrypt() 返回的不是有效的 Base64: %v", err)
+ }
+ }
+ })
+ }
+}
+
+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("签名应为大写")
+ }
+
+ // 验证签名可重现
+ sign2 := generateSign(appID, encryptedData, timestamp, appSecret)
+ if sign != sign2 {
+ t.Error("相同参数应生成相同签名")
+ }
+}
+
+func TestNewECBEncrypterPanic(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("newECBEncrypter 期望触发 panic,但未触发")
+ }
+ }()
+
+ newECBEncrypter(nil)
+}
+
+func TestECBEncrypterCryptBlocksPanic(t *testing.T) {
+ block, err := aes.NewCipher(make([]byte, aesBlockSize))
+ if err != nil {
+ t.Fatalf("创建 AES cipher 失败: %v", err)
+ }
+
+ encrypter := newECBEncrypter(block)
+ defer func() {
+ if recover() == nil {
+ t.Fatal("CryptBlocks 期望触发 panic,但未触发")
+ }
+ }()
+
+ // 传入非完整块长度,触发 panic
+ src := []byte("short")
+ dst := make([]byte, len(src))
+ encrypter.CryptBlocks(dst, src)
+}
diff --git a/internal/gateway/device.go b/internal/gateway/device.go
new file mode 100644
index 0000000..0782fa1
--- /dev/null
+++ b/internal/gateway/device.go
@@ -0,0 +1,169 @@
+// Package gateway 提供设备相关的 7 个 API 方法封装
+package gateway
+
+import (
+ "context"
+
+ "github.com/break/junhong_cmp_fiber/pkg/errors"
+ "github.com/bytedance/sonic"
+)
+
+// GetDeviceInfo 获取设备信息
+// 通过卡号或设备 ID 查询设备的在线状态、信号强度、WiFi 信息等
+func (c *Client) GetDeviceInfo(ctx context.Context, req *DeviceInfoReq) (*DeviceInfoResp, error) {
+ if req.CardNo == "" && req.DeviceID == "" {
+ return nil, errors.New(errors.CodeInvalidParam, "cardNo 和 deviceId 至少需要一个")
+ }
+
+ params := make(map[string]interface{})
+ if req.CardNo != "" {
+ params["cardNo"] = req.CardNo
+ }
+ if req.DeviceID != "" {
+ params["deviceId"] = req.DeviceID
+ }
+
+ businessData := map[string]interface{}{
+ "params": params,
+ }
+
+ resp, err := c.doRequest(ctx, "/device/info", businessData)
+ if err != nil {
+ return nil, err
+ }
+
+ var result DeviceInfoResp
+ if err := sonic.Unmarshal(resp, &result); err != nil {
+ return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析设备信息响应失败")
+ }
+
+ return &result, nil
+}
+
+// GetSlotInfo 获取设备卡槽信息
+// 查询设备的所有卡槽及其中的卡信息
+func (c *Client) GetSlotInfo(ctx context.Context, req *DeviceInfoReq) (*SlotInfoResp, error) {
+ if req.CardNo == "" && req.DeviceID == "" {
+ return nil, errors.New(errors.CodeInvalidParam, "cardNo 和 deviceId 至少需要一个")
+ }
+
+ params := make(map[string]interface{})
+ if req.CardNo != "" {
+ params["cardNo"] = req.CardNo
+ }
+ if req.DeviceID != "" {
+ params["deviceId"] = req.DeviceID
+ }
+
+ businessData := map[string]interface{}{
+ "params": params,
+ }
+
+ resp, err := c.doRequest(ctx, "/device/slot-info", businessData)
+ if err != nil {
+ return nil, err
+ }
+
+ var result SlotInfoResp
+ if err := sonic.Unmarshal(resp, &result); err != nil {
+ return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析卡槽信息响应失败")
+ }
+
+ return &result, nil
+}
+
+// SetSpeedLimit 设置设备限速
+// 设置设备的上行和下行速率限制
+func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) error {
+ params := map[string]interface{}{
+ "deviceId": req.DeviceID,
+ "uploadSpeed": req.UploadSpeed,
+ "downloadSpeed": req.DownloadSpeed,
+ }
+ if req.Extend != "" {
+ params["extend"] = req.Extend
+ }
+
+ businessData := map[string]interface{}{
+ "params": params,
+ }
+
+ _, err := c.doRequest(ctx, "/device/speed-limit", businessData)
+ return err
+}
+
+// SetWiFi 设置设备 WiFi
+// 设置设备的 WiFi 名称、密码和启用状态
+func (c *Client) SetWiFi(ctx context.Context, req *WiFiReq) error {
+ params := map[string]interface{}{
+ "deviceId": req.DeviceID,
+ "ssid": req.SSID,
+ "password": req.Password,
+ "enabled": req.Enabled,
+ }
+ if req.Extend != "" {
+ params["extend"] = req.Extend
+ }
+
+ businessData := map[string]interface{}{
+ "params": params,
+ }
+
+ _, err := c.doRequest(ctx, "/device/wifi", businessData)
+ return err
+}
+
+// SwitchCard 设备切换卡
+// 切换设备当前使用的卡到指定的目标卡
+func (c *Client) SwitchCard(ctx context.Context, req *SwitchCardReq) error {
+ params := map[string]interface{}{
+ "deviceId": req.DeviceID,
+ "targetIccid": req.TargetICCID,
+ }
+ if req.Extend != "" {
+ params["extend"] = req.Extend
+ }
+
+ businessData := map[string]interface{}{
+ "params": params,
+ }
+
+ _, err := c.doRequest(ctx, "/device/switch-card", businessData)
+ return err
+}
+
+// ResetDevice 设备恢复出厂设置
+// 将设备恢复到出厂设置状态
+func (c *Client) ResetDevice(ctx context.Context, req *DeviceOperationReq) error {
+ params := map[string]interface{}{
+ "deviceId": req.DeviceID,
+ }
+ if req.Extend != "" {
+ params["extend"] = req.Extend
+ }
+
+ businessData := map[string]interface{}{
+ "params": params,
+ }
+
+ _, err := c.doRequest(ctx, "/device/reset", businessData)
+ return err
+}
+
+// RebootDevice 设备重启
+// 远程重启设备
+func (c *Client) RebootDevice(ctx context.Context, req *DeviceOperationReq) error {
+ params := map[string]interface{}{
+ "deviceId": req.DeviceID,
+ }
+ if req.Extend != "" {
+ params["extend"] = req.Extend
+ }
+
+ businessData := map[string]interface{}{
+ "params": params,
+ }
+
+ _, err := c.doRequest(ctx, "/device/reboot", businessData)
+ return err
+}
diff --git a/internal/gateway/device_test.go b/internal/gateway/device_test.go
new file mode 100644
index 0000000..b3261ca
--- /dev/null
+++ b/internal/gateway/device_test.go
@@ -0,0 +1,404 @@
+package gateway
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestGetDeviceInfo_ByCardNo_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`{"imei":"123456789012345","onlineStatus":1,"signalLevel":25,"wifiSsid":"TestWiFi","wifiEnabled":1,"uploadSpeed":100,"downloadSpeed":500}`),
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ result, err := client.GetDeviceInfo(ctx, &DeviceInfoReq{
+ CardNo: "898608070422D0010269",
+ })
+
+ if err != nil {
+ t.Fatalf("GetDeviceInfo() error = %v", err)
+ }
+
+ if result.IMEI != "123456789012345" {
+ t.Errorf("IMEI = %s, want 123456789012345", result.IMEI)
+ }
+ if result.OnlineStatus != 1 {
+ t.Errorf("OnlineStatus = %d, want 1", result.OnlineStatus)
+ }
+ if result.SignalLevel != 25 {
+ t.Errorf("SignalLevel = %d, want 25", result.SignalLevel)
+ }
+ if result.WiFiSSID != "TestWiFi" {
+ t.Errorf("WiFiSSID = %s, want TestWiFi", result.WiFiSSID)
+ }
+}
+
+func TestGetDeviceInfo_ByDeviceID_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`{"imei":"123456789012345","onlineStatus":0,"signalLevel":0}`),
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ result, err := client.GetDeviceInfo(ctx, &DeviceInfoReq{
+ DeviceID: "123456789012345",
+ })
+
+ if err != nil {
+ t.Fatalf("GetDeviceInfo() error = %v", err)
+ }
+
+ if result.IMEI != "123456789012345" {
+ t.Errorf("IMEI = %s, want 123456789012345", result.IMEI)
+ }
+ if result.OnlineStatus != 0 {
+ t.Errorf("OnlineStatus = %d, want 0", result.OnlineStatus)
+ }
+}
+
+func TestGetDeviceInfo_MissingParams(t *testing.T) {
+ client := NewClient("https://test.example.com", "testAppID", "testSecret")
+ ctx := context.Background()
+
+ _, err := client.GetDeviceInfo(ctx, &DeviceInfoReq{})
+
+ if err == nil {
+ t.Fatal("GetDeviceInfo() expected validation error")
+ }
+
+ if !strings.Contains(err.Error(), "至少需要一个") {
+ t.Errorf("error should contain '至少需要一个', got: %v", err)
+ }
+}
+
+func TestGetDeviceInfo_InvalidResponse(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`invalid json`),
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ _, err := client.GetDeviceInfo(ctx, &DeviceInfoReq{CardNo: "test"})
+ if err == nil {
+ t.Fatal("GetDeviceInfo() expected JSON parse error")
+ }
+
+ if !strings.Contains(err.Error(), "解析") {
+ t.Errorf("error should contain '解析', got: %v", err)
+ }
+}
+
+func TestGetSlotInfo_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`{"imei":"123456789012345","slots":[{"slotNo":1,"iccid":"898608070422D0010269","cardStatus":"正常","isActive":1},{"slotNo":2,"iccid":"898608070422D0010270","cardStatus":"停机","isActive":0}]}`),
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ result, err := client.GetSlotInfo(ctx, &DeviceInfoReq{
+ DeviceID: "123456789012345",
+ })
+
+ if err != nil {
+ t.Fatalf("GetSlotInfo() error = %v", err)
+ }
+
+ if result.IMEI != "123456789012345" {
+ t.Errorf("IMEI = %s, want 123456789012345", result.IMEI)
+ }
+ if len(result.Slots) != 2 {
+ t.Errorf("len(Slots) = %d, want 2", len(result.Slots))
+ }
+ if result.Slots[0].SlotNo != 1 {
+ t.Errorf("Slots[0].SlotNo = %d, want 1", result.Slots[0].SlotNo)
+ }
+ if result.Slots[0].ICCID != "898608070422D0010269" {
+ t.Errorf("Slots[0].ICCID = %s, want 898608070422D0010269", result.Slots[0].ICCID)
+ }
+ if result.Slots[0].IsActive != 1 {
+ t.Errorf("Slots[0].IsActive = %d, want 1", result.Slots[0].IsActive)
+ }
+}
+
+func TestGetSlotInfo_MissingParams(t *testing.T) {
+ client := NewClient("https://test.example.com", "testAppID", "testSecret")
+ ctx := context.Background()
+
+ _, err := client.GetSlotInfo(ctx, &DeviceInfoReq{})
+
+ if err == nil {
+ t.Fatal("GetSlotInfo() expected validation error")
+ }
+
+ if !strings.Contains(err.Error(), "至少需要一个") {
+ t.Errorf("error should contain '至少需要一个', got: %v", err)
+ }
+}
+
+func TestSetSpeedLimit_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 200, Msg: "成功"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.SetSpeedLimit(ctx, &SpeedLimitReq{
+ DeviceID: "123456789012345",
+ UploadSpeed: 100,
+ DownloadSpeed: 500,
+ })
+
+ if err != nil {
+ t.Fatalf("SetSpeedLimit() error = %v", err)
+ }
+}
+
+func TestSetSpeedLimit_WithExtend(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 200, Msg: "成功"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.SetSpeedLimit(ctx, &SpeedLimitReq{
+ DeviceID: "123456789012345",
+ UploadSpeed: 100,
+ DownloadSpeed: 500,
+ Extend: "test-extend",
+ })
+
+ if err != nil {
+ t.Fatalf("SetSpeedLimit() error = %v", err)
+ }
+}
+
+func TestSetSpeedLimit_BusinessError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 500, Msg: "设置失败"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.SetSpeedLimit(ctx, &SpeedLimitReq{
+ DeviceID: "123456789012345",
+ UploadSpeed: 100,
+ DownloadSpeed: 500,
+ })
+
+ if err == nil {
+ t.Fatal("SetSpeedLimit() expected business error")
+ }
+
+ if !strings.Contains(err.Error(), "业务错误") {
+ t.Errorf("error should contain '业务错误', got: %v", err)
+ }
+}
+
+func TestSetWiFi_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 200, Msg: "成功"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.SetWiFi(ctx, &WiFiReq{
+ DeviceID: "123456789012345",
+ SSID: "TestWiFi",
+ Password: "password123",
+ Enabled: 1,
+ })
+
+ if err != nil {
+ t.Fatalf("SetWiFi() error = %v", err)
+ }
+}
+
+func TestSetWiFi_WithExtend(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 200, Msg: "成功"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.SetWiFi(ctx, &WiFiReq{
+ DeviceID: "123456789012345",
+ SSID: "TestWiFi",
+ Password: "password123",
+ Enabled: 0,
+ Extend: "test-extend",
+ })
+
+ if err != nil {
+ t.Fatalf("SetWiFi() error = %v", err)
+ }
+}
+
+func TestSwitchCard_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 200, Msg: "成功"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.SwitchCard(ctx, &SwitchCardReq{
+ DeviceID: "123456789012345",
+ TargetICCID: "898608070422D0010270",
+ })
+
+ if err != nil {
+ t.Fatalf("SwitchCard() error = %v", err)
+ }
+}
+
+func TestSwitchCard_BusinessError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 404, Msg: "目标卡不存在"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.SwitchCard(ctx, &SwitchCardReq{
+ DeviceID: "123456789012345",
+ TargetICCID: "invalid",
+ })
+
+ if err == nil {
+ t.Fatal("SwitchCard() expected business error")
+ }
+}
+
+func TestResetDevice_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 200, Msg: "成功"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.ResetDevice(ctx, &DeviceOperationReq{
+ DeviceID: "123456789012345",
+ })
+
+ if err != nil {
+ t.Fatalf("ResetDevice() error = %v", err)
+ }
+}
+
+func TestResetDevice_WithExtend(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 200, Msg: "成功"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.ResetDevice(ctx, &DeviceOperationReq{
+ DeviceID: "123456789012345",
+ Extend: "test-extend",
+ })
+
+ if err != nil {
+ t.Fatalf("ResetDevice() error = %v", err)
+ }
+}
+
+func TestRebootDevice_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 200, Msg: "成功"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.RebootDevice(ctx, &DeviceOperationReq{
+ DeviceID: "123456789012345",
+ })
+
+ if err != nil {
+ t.Fatalf("RebootDevice() error = %v", err)
+ }
+}
+
+func TestRebootDevice_BusinessError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 500, Msg: "设备离线"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.RebootDevice(ctx, &DeviceOperationReq{
+ DeviceID: "123456789012345",
+ })
+
+ if err == nil {
+ t.Fatal("RebootDevice() expected business error")
+ }
+
+ if !strings.Contains(err.Error(), "业务错误") {
+ t.Errorf("error should contain '业务错误', got: %v", err)
+ }
+}
diff --git a/internal/gateway/flow_card.go b/internal/gateway/flow_card.go
new file mode 100644
index 0000000..01fa689
--- /dev/null
+++ b/internal/gateway/flow_card.go
@@ -0,0 +1,128 @@
+// Package gateway 提供流量卡相关的 7 个 API 方法封装
+package gateway
+
+import (
+ "context"
+
+ "github.com/break/junhong_cmp_fiber/pkg/errors"
+ "github.com/bytedance/sonic"
+)
+
+// QueryCardStatus 查询流量卡状态
+func (c *Client) QueryCardStatus(ctx context.Context, req *CardStatusReq) (*CardStatusResp, error) {
+ businessData := map[string]interface{}{
+ "params": map[string]interface{}{
+ "cardNo": req.CardNo,
+ },
+ }
+
+ resp, err := c.doRequest(ctx, "/flow-card/status", businessData)
+ if err != nil {
+ return nil, err
+ }
+
+ var result CardStatusResp
+ if err := sonic.Unmarshal(resp, &result); err != nil {
+ return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析卡状态响应失败")
+ }
+
+ return &result, nil
+}
+
+// QueryFlow 查询流量使用情况
+func (c *Client) QueryFlow(ctx context.Context, req *FlowQueryReq) (*FlowUsageResp, error) {
+ businessData := map[string]interface{}{
+ "params": map[string]interface{}{
+ "cardNo": req.CardNo,
+ },
+ }
+
+ resp, err := c.doRequest(ctx, "/flow-card/flow", businessData)
+ if err != nil {
+ return nil, err
+ }
+
+ var result FlowUsageResp
+ if err := sonic.Unmarshal(resp, &result); err != nil {
+ return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析流量使用响应失败")
+ }
+
+ return &result, nil
+}
+
+// QueryRealnameStatus 查询实名认证状态
+func (c *Client) QueryRealnameStatus(ctx context.Context, req *CardStatusReq) (*RealnameStatusResp, error) {
+ businessData := map[string]interface{}{
+ "params": map[string]interface{}{
+ "cardNo": req.CardNo,
+ },
+ }
+
+ resp, err := c.doRequest(ctx, "/flow-card/realname-status", businessData)
+ if err != nil {
+ return nil, err
+ }
+
+ var result RealnameStatusResp
+ 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,
+ },
+ }
+ if req.Extend != "" {
+ businessData["params"].(map[string]interface{})["extend"] = req.Extend
+ }
+
+ _, err := c.doRequest(ctx, "/flow-card/cardStop", businessData)
+ return err
+}
+
+// StartCard 流量卡复机
+func (c *Client) StartCard(ctx context.Context, req *CardOperationReq) error {
+ businessData := map[string]interface{}{
+ "params": map[string]interface{}{
+ "cardNo": req.CardNo,
+ },
+ }
+ if req.Extend != "" {
+ businessData["params"].(map[string]interface{})["extend"] = req.Extend
+ }
+
+ _, err := c.doRequest(ctx, "/flow-card/cardStart", businessData)
+ return err
+}
+
+// GetRealnameLink 获取实名认证跳转链接
+func (c *Client) GetRealnameLink(ctx context.Context, req *CardStatusReq) (*RealnameLinkResp, error) {
+ businessData := map[string]interface{}{
+ "params": map[string]interface{}{
+ "cardNo": req.CardNo,
+ },
+ }
+
+ resp, err := c.doRequest(ctx, "/flow-card/realname-link", businessData)
+ if err != nil {
+ return nil, err
+ }
+
+ var result RealnameLinkResp
+ if err := sonic.Unmarshal(resp, &result); err != nil {
+ return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析实名认证链接响应失败")
+ }
+
+ return &result, nil
+}
+
+// BatchQuery 批量查询(预留接口,暂未实现)
+func (c *Client) BatchQuery(ctx context.Context, req *BatchQueryReq) (*BatchQueryResp, error) {
+ return nil, errors.New(errors.CodeGatewayError, "批量查询接口暂未实现")
+}
diff --git a/internal/gateway/flow_card_test.go b/internal/gateway/flow_card_test.go
new file mode 100644
index 0000000..743868a
--- /dev/null
+++ b/internal/gateway/flow_card_test.go
@@ -0,0 +1,292 @@
+package gateway
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestQueryCardStatus_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`{"iccid":"898608070422D0010269","cardStatus":"正常"}`),
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ result, err := client.QueryCardStatus(ctx, &CardStatusReq{
+ CardNo: "898608070422D0010269",
+ })
+
+ if err != nil {
+ t.Fatalf("QueryCardStatus() error = %v", err)
+ }
+
+ if result.ICCID != "898608070422D0010269" {
+ t.Errorf("ICCID = %s, want 898608070422D0010269", result.ICCID)
+ }
+ if result.CardStatus != "正常" {
+ t.Errorf("CardStatus = %s, want 正常", result.CardStatus)
+ }
+}
+
+func TestQueryCardStatus_InvalidResponse(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`invalid json`),
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ _, err := client.QueryCardStatus(ctx, &CardStatusReq{CardNo: "test"})
+ if err == nil {
+ t.Fatal("QueryCardStatus() expected JSON parse error")
+ }
+
+ if !strings.Contains(err.Error(), "解析") {
+ t.Errorf("error should contain '解析', got: %v", err)
+ }
+}
+
+func TestQueryFlow_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`{"usedFlow":1024,"unit":"MB"}`),
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ result, err := client.QueryFlow(ctx, &FlowQueryReq{
+ CardNo: "898608070422D0010269",
+ })
+
+ if err != nil {
+ t.Fatalf("QueryFlow() error = %v", err)
+ }
+
+ if result.UsedFlow != 1024 {
+ t.Errorf("UsedFlow = %d, want 1024", result.UsedFlow)
+ }
+ if result.Unit != "MB" {
+ t.Errorf("Unit = %s, want MB", result.Unit)
+ }
+}
+
+func TestQueryFlow_BusinessError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 404,
+ Msg: "卡号不存在",
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ _, err := client.QueryFlow(ctx, &FlowQueryReq{CardNo: "invalid"})
+ if err == nil {
+ t.Fatal("QueryFlow() expected business error")
+ }
+
+ if !strings.Contains(err.Error(), "业务错误") {
+ t.Errorf("error should contain '业务错误', got: %v", err)
+ }
+}
+
+func TestQueryRealnameStatus_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`{"status":"已实名"}`),
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ result, err := client.QueryRealnameStatus(ctx, &CardStatusReq{
+ CardNo: "898608070422D0010269",
+ })
+
+ if err != nil {
+ t.Fatalf("QueryRealnameStatus() error = %v", err)
+ }
+
+ if result.Status != "已实名" {
+ t.Errorf("Status = %s, want 已实名", result.Status)
+ }
+}
+
+func TestStopCard_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.StopCard(ctx, &CardOperationReq{
+ CardNo: "898608070422D0010269",
+ })
+
+ if err != nil {
+ t.Fatalf("StopCard() error = %v", err)
+ }
+}
+
+func TestStopCard_WithExtend(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 200, Msg: "成功"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.StopCard(ctx, &CardOperationReq{
+ CardNo: "898608070422D0010269",
+ Extend: "test-extend",
+ })
+
+ if err != nil {
+ t.Fatalf("StopCard() error = %v", err)
+ }
+}
+
+func TestStartCard_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 200, Msg: "成功"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.StartCard(ctx, &CardOperationReq{
+ CardNo: "898608070422D0010269",
+ })
+
+ if err != nil {
+ t.Fatalf("StartCard() error = %v", err)
+ }
+}
+
+func TestStartCard_BusinessError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{Code: 500, Msg: "操作失败"}
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ err := client.StartCard(ctx, &CardOperationReq{CardNo: "test"})
+ if err == nil {
+ t.Fatal("StartCard() expected business error")
+ }
+}
+
+func TestGetRealnameLink_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`{"link":"https://realname.example.com/verify?token=abc123"}`),
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ result, err := client.GetRealnameLink(ctx, &CardStatusReq{
+ CardNo: "898608070422D0010269",
+ })
+
+ if err != nil {
+ t.Fatalf("GetRealnameLink() error = %v", err)
+ }
+
+ if result.Link != "https://realname.example.com/verify?token=abc123" {
+ t.Errorf("Link = %s, want https://realname.example.com/verify?token=abc123", result.Link)
+ }
+}
+
+func TestGetRealnameLink_InvalidResponse(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := GatewayResponse{
+ Code: 200,
+ Msg: "成功",
+ Data: json.RawMessage(`{"invalid": "structure"}`),
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "testAppID", "testSecret")
+ ctx := context.Background()
+
+ result, err := client.GetRealnameLink(ctx, &CardStatusReq{CardNo: "test"})
+
+ if err != nil {
+ t.Fatalf("GetRealnameLink() unexpected error = %v", err)
+ }
+ if result.Link != "" {
+ t.Errorf("Link = %s, want empty string", result.Link)
+ }
+}
+
+func TestBatchQuery_NotImplemented(t *testing.T) {
+ client := NewClient("https://test.example.com", "testAppID", "testSecret")
+ ctx := context.Background()
+
+ _, err := client.BatchQuery(ctx, &BatchQueryReq{
+ CardNos: []string{"test1", "test2"},
+ })
+
+ if err == nil {
+ t.Fatal("BatchQuery() expected not implemented error")
+ }
+
+ if !strings.Contains(err.Error(), "暂未实现") {
+ t.Errorf("error should contain '暂未实现', got: %v", err)
+ }
+}
diff --git a/internal/gateway/models.go b/internal/gateway/models.go
new file mode 100644
index 0000000..873036f
--- /dev/null
+++ b/internal/gateway/models.go
@@ -0,0 +1,132 @@
+// Package gateway 定义 Gateway API 的请求和响应数据传输对象(DTO)
+package gateway
+
+import "encoding/json"
+
+// GatewayResponse 是 Gateway API 的通用响应结构
+type GatewayResponse struct {
+ Code int `json:"code" description:"业务状态码(200 = 成功)"`
+ Msg string `json:"msg" description:"业务提示信息"`
+ Data json.RawMessage `json:"data" description:"业务数据(原始 JSON)"`
+ TraceID string `json:"trace_id" description:"链路追踪 ID"`
+}
+
+// ============ 流量卡相关 DTO ============
+
+// CardStatusReq 是查询流量卡状态的请求
+type CardStatusReq struct {
+ CardNo string `json:"cardNo" validate:"required" required:"true" description:"流量卡号"`
+}
+
+// CardStatusResp 是查询流量卡状态的响应
+type CardStatusResp struct {
+ ICCID string `json:"iccid" description:"ICCID"`
+ CardStatus string `json:"cardStatus" description:"卡状态(准备、正常、停机)"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
+
+// FlowQueryReq 是查询流量使用的请求
+type FlowQueryReq struct {
+ CardNo string `json:"cardNo" validate:"required" required:"true" description:"流量卡号"`
+}
+
+// FlowUsageResp 是查询流量使用的响应
+type FlowUsageResp struct {
+ UsedFlow int64 `json:"usedFlow" description:"已用流量"`
+ Unit string `json:"unit" description:"流量单位(MB)"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
+
+// CardOperationReq 是停机/复机请求
+type CardOperationReq struct {
+ CardNo string `json:"cardNo" validate:"required" required:"true" description:"流量卡号"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
+
+// RealnameStatusResp 是实名认证状态的响应
+type RealnameStatusResp struct {
+ Status string `json:"status" description:"实名认证状态"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
+
+// RealnameLinkResp 是实名认证链接的响应
+type RealnameLinkResp struct {
+ Link string `json:"link" description:"实名认证跳转链接(HTTPS URL)"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
+
+// BatchQueryReq 是批量查询的请求
+type BatchQueryReq struct {
+ CardNos []string `json:"cardNos" validate:"required,min=1,max=100" required:"true" description:"流量卡号列表(最多100个)"`
+}
+
+// BatchQueryResp 是批量查询的响应
+type BatchQueryResp struct {
+ Results []CardStatusResp `json:"results" description:"查询结果列表"`
+}
+
+// ============ 设备相关 DTO ============
+
+// DeviceInfoReq 是查询设备信息的请求
+type DeviceInfoReq struct {
+ CardNo string `json:"cardNo,omitempty" description:"流量卡号(与 DeviceID 二选一)"`
+ DeviceID string `json:"deviceId,omitempty" description:"设备 ID/IMEI(与 CardNo 二选一)"`
+}
+
+// DeviceInfoResp 是查询设备信息的响应
+type DeviceInfoResp struct {
+ IMEI string `json:"imei" description:"设备 IMEI"`
+ OnlineStatus int `json:"onlineStatus" description:"在线状态(0:离线, 1:在线)"`
+ SignalLevel int `json:"signalLevel" description:"信号强度(0-31)"`
+ WiFiSSID string `json:"wifiSsid,omitempty" description:"WiFi 名称"`
+ WiFiEnabled int `json:"wifiEnabled" description:"WiFi 启用状态(0:禁用, 1:启用)"`
+ UploadSpeed int `json:"uploadSpeed" description:"上行速率(KB/s)"`
+ DownloadSpeed int `json:"downloadSpeed" description:"下行速率(KB/s)"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
+
+// SpeedLimitReq 是设置设备限速的请求
+type SpeedLimitReq struct {
+ DeviceID string `json:"deviceId" validate:"required" required:"true" description:"设备 ID/IMEI"`
+ UploadSpeed int `json:"uploadSpeed" validate:"required,min=1" required:"true" minimum:"1" description:"上行速率(KB/s)"`
+ DownloadSpeed int `json:"downloadSpeed" validate:"required,min=1" required:"true" minimum:"1" description:"下行速率(KB/s)"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
+
+// WiFiReq 是设置设备 WiFi 的请求
+type WiFiReq struct {
+ DeviceID string `json:"deviceId" validate:"required" required:"true" description:"设备 ID/IMEI"`
+ SSID string `json:"ssid" validate:"required,min=1,max=32" required:"true" minLength:"1" maxLength:"32" description:"WiFi 名称"`
+ Password string `json:"password" validate:"required,min=8,max=63" required:"true" minLength:"8" maxLength:"63" description:"WiFi 密码"`
+ Enabled int `json:"enabled" validate:"required,oneof=0 1" required:"true" description:"启用状态(0:禁用, 1:启用)"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
+
+// SwitchCardReq 是设备切换卡的请求
+type SwitchCardReq struct {
+ DeviceID string `json:"deviceId" validate:"required" required:"true" description:"设备 ID/IMEI"`
+ TargetICCID string `json:"targetIccid" validate:"required" required:"true" description:"目标卡 ICCID"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
+
+// DeviceOperationReq 是设备操作(重启、恢复出厂)的请求
+type DeviceOperationReq struct {
+ DeviceID string `json:"deviceId" validate:"required" required:"true" description:"设备 ID/IMEI"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
+
+// SlotInfo 是单个卡槽信息
+type SlotInfo struct {
+ SlotNo int `json:"slotNo" description:"卡槽编号"`
+ ICCID string `json:"iccid" description:"卡槽中的 ICCID"`
+ CardStatus string `json:"cardStatus" description:"卡状态(准备、正常、停机)"`
+ IsActive int `json:"isActive" description:"是否为当前使用的卡槽(0:否, 1:是)"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
+
+// SlotInfoResp 是查询设备卡槽信息的响应
+type SlotInfoResp struct {
+ IMEI string `json:"imei" description:"设备 IMEI"`
+ Slots []SlotInfo `json:"slots" description:"卡槽信息列表"`
+ Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
+}
diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go
index 6a532ea..f20a1b9 100644
--- a/internal/service/iot_card/service.go
+++ b/internal/service/iot_card/service.go
@@ -3,12 +3,14 @@ package iot_card
import (
"context"
+ "github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
+ "go.uber.org/zap"
"gorm.io/gorm"
)
@@ -18,6 +20,8 @@ type Service struct {
shopStore *postgres.ShopStore
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
seriesAllocationStore *postgres.ShopSeriesAllocationStore
+ gatewayClient *gateway.Client
+ logger *zap.Logger
}
func New(
@@ -26,6 +30,8 @@ func New(
shopStore *postgres.ShopStore,
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
+ gatewayClient *gateway.Client,
+ logger *zap.Logger,
) *Service {
return &Service{
db: db,
@@ -33,6 +39,8 @@ func New(
shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore,
seriesAllocationStore: seriesAllocationStore,
+ gatewayClient: gatewayClient,
+ logger: logger,
}
}
@@ -640,3 +648,53 @@ func (s *Service) buildCardNotFoundFailedItems(iccids []string) []dto.CardSeries
}
return items
}
+
+// SyncCardStatusFromGateway 从 Gateway 同步卡状态(示例方法)
+func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) error {
+ if s.gatewayClient == nil {
+ return errors.New(errors.CodeGatewayError, "Gateway 客户端未配置")
+ }
+
+ resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
+ CardNo: iccid,
+ })
+ if err != nil {
+ s.logger.Error("查询卡状态失败", zap.String("iccid", iccid), zap.Error(err))
+ return errors.Wrap(errors.CodeGatewayError, err, "查询卡状态失败")
+ }
+
+ card, err := s.iotCardStore.GetByICCID(ctx, iccid)
+ if err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return errors.New(errors.CodeNotFound, "IoT卡不存在")
+ }
+ return err
+ }
+
+ var newStatus int
+ switch resp.CardStatus {
+ case "准备":
+ newStatus = constants.IotCardStatusInStock
+ case "正常":
+ newStatus = constants.IotCardStatusDistributed
+ case "停机":
+ newStatus = constants.IotCardStatusSuspended
+ default:
+ s.logger.Warn("未知的卡状态", zap.String("cardStatus", resp.CardStatus))
+ return nil
+ }
+
+ if card.Status != newStatus {
+ card.Status = newStatus
+ if err := s.iotCardStore.Update(ctx, card); err != nil {
+ return err
+ }
+ s.logger.Info("同步卡状态成功",
+ zap.String("iccid", iccid),
+ zap.Int("oldStatus", card.Status),
+ zap.Int("newStatus", newStatus),
+ )
+ }
+
+ return nil
+}
diff --git a/internal/service/iot_card/service_test.go b/internal/service/iot_card/service_test.go
index 2c73e03..df8eb56 100644
--- a/internal/service/iot_card/service_test.go
+++ b/internal/service/iot_card/service_test.go
@@ -28,7 +28,7 @@ func TestIotCardService_BatchSetSeriesBinding(t *testing.T) {
assetAllocationRecordStore := postgres.NewAssetAllocationRecordStore(tx, rdb)
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
- svc := New(tx, iotCardStore, shopStore, assetAllocationRecordStore, seriesAllocationStore)
+ svc := New(tx, iotCardStore, shopStore, assetAllocationRecordStore, seriesAllocationStore, nil, nil)
ctx := context.Background()
shop := &model.Shop{
diff --git a/pkg/config/config.go b/pkg/config/config.go
index f3f20b8..8e669f7 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -23,6 +23,7 @@ type Config struct {
JWT JWTConfig `mapstructure:"jwt"`
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
Storage StorageConfig `mapstructure:"storage"`
+ Gateway GatewayConfig `mapstructure:"gateway"`
}
// ServerConfig HTTP 服务器配置
@@ -130,6 +131,14 @@ type StorageConfig struct {
TempDir string `mapstructure:"temp_dir"` // 临时文件目录
}
+// GatewayConfig Gateway 服务配置
+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"` // 超时时间(秒)
+}
+
// S3Config S3 兼容存储配置
type S3Config struct {
Endpoint string `mapstructure:"endpoint"` // 服务端点(如:http://obs-helf.cucloud.cn)
@@ -270,6 +279,22 @@ func (c *Config) Validate() error {
return fmt.Errorf("invalid configuration: jwt.refresh_token_ttl: duration out of range (current value: %s, expected: 24h-720h)", c.JWT.RefreshTokenTTL)
}
+ // Gateway 验证(可选,配置 BaseURL 时才验证其他字段)
+ if c.Gateway.BaseURL != "" {
+ if !strings.HasPrefix(c.Gateway.BaseURL, "http://") && !strings.HasPrefix(c.Gateway.BaseURL, "https://") {
+ return fmt.Errorf("invalid configuration: gateway.base_url: must start with http:// or https:// (current value: %s)", c.Gateway.BaseURL)
+ }
+ if c.Gateway.AppID == "" {
+ return fmt.Errorf("invalid configuration: gateway.app_id: must be non-empty when base_url is configured")
+ }
+ if c.Gateway.AppSecret == "" {
+ return fmt.Errorf("invalid configuration: gateway.app_secret: must be non-empty when base_url is configured")
+ }
+ if c.Gateway.Timeout < 5 || c.Gateway.Timeout > 300 {
+ return fmt.Errorf("invalid configuration: gateway.timeout: timeout out of range (current value: %d, expected: 5-300 seconds)", c.Gateway.Timeout)
+ }
+ }
+
return nil
}
diff --git a/pkg/config/defaults/config.yaml b/pkg/config/defaults/config.yaml
index fe3f1d5..17e81c0 100644
--- a/pkg/config/defaults/config.yaml
+++ b/pkg/config/defaults/config.yaml
@@ -104,3 +104,10 @@ default_admin:
username: ""
password: ""
phone: ""
+
+# Gateway 服务配置
+gateway:
+ base_url: "https://lplan.whjhft.com/openapi"
+ app_id: "60bgt1X8i7AvXqkd"
+ app_secret: "BZeQttaZQt0i73moF"
+ timeout: 30
diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go
index d353a50..f3df04d 100644
--- a/pkg/errors/codes.go
+++ b/pkg/errors/codes.go
@@ -92,6 +92,13 @@ const (
CodeCarrierNotFound = 1100 // 运营商不存在
CodeCarrierCodeExists = 1101 // 运营商编码已存在
+ // Gateway 相关错误 (1110-1119)
+ CodeGatewayError = 1110 // Gateway 通用错误
+ CodeGatewayEncryptError = 1111 // 数据加密失败
+ CodeGatewaySignError = 1112 // 签名生成失败
+ CodeGatewayTimeout = 1113 // 请求超时
+ CodeGatewayInvalidResp = 1114 // 响应格式错误
+
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码
CodeInternalError = 2001 // 内部服务器错误
CodeDatabaseError = 2002 // 数据库错误
@@ -174,6 +181,11 @@ var allErrorCodes = []int{
CodeStorageInvalidFileType,
CodeCarrierNotFound,
CodeCarrierCodeExists,
+ CodeGatewayError,
+ CodeGatewayEncryptError,
+ CodeGatewaySignError,
+ CodeGatewayTimeout,
+ CodeGatewayInvalidResp,
CodeInternalError,
CodeDatabaseError,
CodeRedisError,
@@ -258,6 +270,11 @@ var errorMessages = map[int]string{
CodeStorageInvalidFileType: "不支持的文件类型",
CodeCarrierNotFound: "运营商不存在",
CodeCarrierCodeExists: "运营商编码已存在",
+ CodeGatewayError: "Gateway 请求失败",
+ CodeGatewayEncryptError: "数据加密失败",
+ CodeGatewaySignError: "签名生成失败",
+ CodeGatewayTimeout: "Gateway 请求超时",
+ CodeGatewayInvalidResp: "Gateway 响应格式错误",
CodeInvalidCredentials: "用户名或密码错误",
CodeAccountLocked: "账号已锁定",
CodePasswordExpired: "密码已过期",