From 3f63fffbb1c640dc0fd2818c41c623ec988e928b Mon Sep 17 00:00:00 2001 From: huang Date: Fri, 30 Jan 2026 17:05:44 +0800 Subject: [PATCH] chore: apply task changes --- .sisyphus/drafts/gateway-integration.md | 155 ++ .../gateway-integration-execution-plan.md | 1329 +++++++++++++++++ README.md | 1 + cmd/api/main.go | 36 +- docs/gateway-api-reference.md | 749 ++++++++++ docs/gateway-client-usage.md | 551 +++++++ internal/bootstrap/dependencies.go | 2 + internal/bootstrap/services.go | 2 +- internal/gateway/client.go | 130 ++ internal/gateway/client_test.go | 323 ++++ internal/gateway/crypto.go | 89 ++ internal/gateway/crypto_test.go | 103 ++ internal/gateway/device.go | 169 +++ internal/gateway/device_test.go | 404 +++++ internal/gateway/flow_card.go | 128 ++ internal/gateway/flow_card_test.go | 292 ++++ internal/gateway/models.go | 132 ++ internal/service/iot_card/service.go | 58 + internal/service/iot_card/service_test.go | 2 +- pkg/config/config.go | 25 + pkg/config/defaults/config.yaml | 7 + pkg/errors/codes.go | 17 + 22 files changed, 4696 insertions(+), 8 deletions(-) create mode 100644 .sisyphus/drafts/gateway-integration.md create mode 100644 .sisyphus/plans/gateway-integration-execution-plan.md create mode 100644 docs/gateway-api-reference.md create mode 100644 docs/gateway-client-usage.md create mode 100644 internal/gateway/client.go create mode 100644 internal/gateway/client_test.go create mode 100644 internal/gateway/crypto.go create mode 100644 internal/gateway/crypto_test.go create mode 100644 internal/gateway/device.go create mode 100644 internal/gateway/device_test.go create mode 100644 internal/gateway/flow_card.go create mode 100644 internal/gateway/flow_card_test.go create mode 100644 internal/gateway/models.go 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: "密码已过期",