chore: apply task changes

This commit is contained in:
2026-01-30 17:05:44 +08:00
parent 4856a88d41
commit 3f63fffbb1
22 changed files with 4696 additions and 8 deletions

View File

@@ -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
}

View File

@@ -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),

130
internal/gateway/client.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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("&timestamp=")
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
}

View File

@@ -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)
}

169
internal/gateway/device.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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, "批量查询接口暂未实现")
}

View File

@@ -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)
}
}

132
internal/gateway/models.go Normal file
View File

@@ -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:"扩展字段(广电国网特殊参数)"`
}

View File

@@ -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
}

View File

@@ -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{