sync: 同步 v1.0.4 开源代码并脱敏
This commit is contained in:
+256
-3
@@ -1,16 +1,268 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"gopkg.in/yaml.v3"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamapi"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
)
|
||||
|
||||
// TestAPNHTTPHarness 仅供跨模块测试子进程使用,不启动真实运营商或默认业务连接。
|
||||
func TestAPNHTTPHarness(t *testing.T) {
|
||||
if os.Getenv("APN_HTTP_CHAIN_HELPER") != "1" {
|
||||
t.Skip("未启用本机HTTP模拟链")
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join("..", "..", "..", "iotlcm-sms", "deploy", "config.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal("读取本机认证配置失败")
|
||||
}
|
||||
var source struct {
|
||||
DSN string `yaml:"sceneDbDsn"`
|
||||
}
|
||||
if yaml.Unmarshal(body, &source) != nil {
|
||||
t.Fatal("解析本机认证配置失败")
|
||||
}
|
||||
dsn, err := mysql.ParseDSN(source.DSN)
|
||||
if err != nil || dsn.Net != "tcp" || (dsn.Addr != "127.0.0.1:3306" && dsn.Addr != "localhost:3306") {
|
||||
t.Fatal("不是已授权本机连接")
|
||||
}
|
||||
node := gdb.ConfigNode{Type: "mysql", Host: "127.0.0.1", Port: "3306", User: dsn.User, Pass: dsn.Passwd, Name: "iotdb_apn_chan_test_20261015"}
|
||||
db, err := gdb.New(node)
|
||||
if err != nil {
|
||||
t.Fatal("创建专用测试连接失败")
|
||||
}
|
||||
identity, err := db.GetOne(context.Background(), "SELECT @@port AS port,@@server_uuid AS uuid,@@datadir AS datadir,DATABASE() AS dbname")
|
||||
if err != nil || identity["port"].String() != "3306" || identity["uuid"].String() != "4fa545af-ab36-11f1-be02-040e3c9de361" || identity["dbname"].String() != node.Name || strings.TrimRight(strings.ReplaceAll(identity["datadir"].String(), "\\", "/"), "/") != "D:/MySQL/MySQL Server 8.0/Data" {
|
||||
t.Fatal("专用测试实例身份核验失败")
|
||||
}
|
||||
if _, err := db.GetAll(context.Background(), "SELECT revision FROM t_apn_operation LIMIT 0"); err != nil {
|
||||
t.Fatal("先执行指定测试库迁移回归")
|
||||
}
|
||||
adapter, _ := gcfg.NewAdapterContent(`{}`)
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{node}}); err != nil {
|
||||
t.Fatal("设置专用测试连接失败")
|
||||
}
|
||||
var mu sync.Mutex
|
||||
writes, queries := map[string]int{}, map[string]int{}
|
||||
submits, unexpected := 0, 0
|
||||
jobs := map[string][]string{}
|
||||
operator := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if strings.HasSuffix(r.URL.Path, "/sim-communication-function/batch") {
|
||||
numbers := strings.Split(r.URL.Query().Get("msisdns"), "_")
|
||||
first := numbers[0]
|
||||
submits++
|
||||
for _, number := range numbers {
|
||||
writes[number]++
|
||||
}
|
||||
if strings.HasPrefix(first, "lost-") {
|
||||
conn, _, e := w.(http.Hijacker).Hijack()
|
||||
if e != nil {
|
||||
t.Error(e)
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
job := "fake-job-" + first
|
||||
jobs[job] = numbers
|
||||
_ = json.NewEncoder(w).Encode(g.Map{"status": "0", "result": []g.Map{{"jobId": job}}})
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/sim-batch-result") {
|
||||
job := r.URL.Query().Get("jobId")
|
||||
queries[job]++
|
||||
if queries[job] == 1 {
|
||||
_, _ = io.WriteString(w, `{"status":"14008","message":"尚未可查"}`)
|
||||
return
|
||||
}
|
||||
if queries[job] == 2 {
|
||||
_, _ = io.WriteString(w, `{"status":"0","result":[{"jobStatus":"1"}]}`)
|
||||
return
|
||||
}
|
||||
if strings.Contains(job, "failed-") {
|
||||
_, _ = io.WriteString(w, `{"status":"0","message":"明确失败","result":[{"jobStatus":"4"}]}`)
|
||||
return
|
||||
}
|
||||
cards := []g.Map{}
|
||||
for _, number := range jobs[job] {
|
||||
cards = append(cards, g.Map{"resultId": number, "status": "0", "message": "明确成功"})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(g.Map{"status": "0", "result": []g.Map{{"jobStatus": "2", "resultList": cards}}})
|
||||
return
|
||||
}
|
||||
unexpected++
|
||||
t.Errorf("模拟运营商收到非预期请求:%s", r.URL.Path)
|
||||
http.Error(w, "非预期请求", 500)
|
||||
}))
|
||||
defer operator.Close()
|
||||
channel := upstreamimpl.NewSimYD().NewTemp(context.Background(), &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"baseUrl":"` + operator.URL + `/v5"},"http":{"timeout":"2s"}}`},
|
||||
Channel: &entity.TSimChannels{ChanKey: "fake-channel", AppKey: "fake", AccessToken: "fake-token", AccessTokenExpire: gtime.New(time.Now().Add(time.Hour))},
|
||||
})
|
||||
register := func() {
|
||||
api := upstreamapi.NewUpstreamAPI()
|
||||
api.RegisterChan("fake-channel", "1", &model.SimCardChannelCache{Instance: channel})
|
||||
service.RegisterUpstreamAPI(api)
|
||||
}
|
||||
register()
|
||||
server := ghttp.GetServer(t.Name())
|
||||
server.SetAddr("127.0.0.1:0")
|
||||
bindHTTPRoutes(server)
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Shutdown()
|
||||
stop := make(chan struct{})
|
||||
var stopOnce sync.Once
|
||||
dropped := map[string]bool{}
|
||||
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/__test/stats":
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(g.Map{"writes": writes, "queries": queries, "submits": submits, "unexpected": unexpected})
|
||||
return
|
||||
case "/__test/stop":
|
||||
stopOnce.Do(func() { close(stop) })
|
||||
return
|
||||
case "/__test/restart":
|
||||
register()
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var input model.SimCardOperateChangeApnInput
|
||||
if json.Unmarshal(raw, &input) != nil || len(input.SimKeys) == 0 || input.SimKeys[0].ChanKey != "fake-channel" {
|
||||
http.Error(w, "只接受假卡", 400)
|
||||
return
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(r.Context(), r.Method, fmt.Sprintf("http://127.0.0.1:%d%s", server.GetListenedPort(), r.URL.Path), strings.NewReader(string(raw)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, e := http.DefaultClient.Do(req)
|
||||
if e != nil {
|
||||
http.Error(w, e.Error(), 500)
|
||||
return
|
||||
}
|
||||
out, _ := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
mu.Lock()
|
||||
lose := input.ReqData.SubmitOnly && !dropped[input.ReqData.OperationID] && (strings.Contains(input.ReqData.OperationID, "drop-response") || strings.Contains(input.ReqData.OperationID, "top-error"))
|
||||
if lose {
|
||||
dropped[input.ReqData.OperationID] = true
|
||||
}
|
||||
mu.Unlock()
|
||||
if lose {
|
||||
if strings.Contains(input.ReqData.OperationID, "top-error") {
|
||||
_, _ = io.WriteString(w, `{"code":9999,"message":"模拟受理后网关结果丢失"}`)
|
||||
return
|
||||
}
|
||||
conn, _, e := w.(http.Hijacker).Hijack()
|
||||
if e != nil {
|
||||
t.Error(e)
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(res.StatusCode)
|
||||
_, _ = w.Write(out)
|
||||
}))
|
||||
defer front.Close()
|
||||
fmt.Println("APN_CHAIN_READY=" + front.URL)
|
||||
select {
|
||||
case <-stop:
|
||||
case <-time.After(3 * time.Minute):
|
||||
t.Error("HTTP模拟链未正常结束")
|
||||
}
|
||||
}
|
||||
|
||||
type apnBindingTestAPI struct {
|
||||
service.IUpstreamAPI
|
||||
captured chan model.SimCardOperateChangeApnInput
|
||||
}
|
||||
|
||||
func (api *apnBindingTestAPI) ChangeSimCardOperateApn(_ context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
api.captured <- *input
|
||||
return model.SimCardOperateChangeApnOutput{}, nil
|
||||
}
|
||||
|
||||
func (api *apnBindingTestAPI) QuerySimApnCapability(_ context.Context, input *model.SimCardOperateChangeApnInput) (model.SimApnCapabilityOutput, error) {
|
||||
api.captured <- *input
|
||||
return model.SimApnCapabilityOutput{}, nil
|
||||
}
|
||||
|
||||
func TestAPNProductionHTTPBindingPreservesAsyncFields(t *testing.T) {
|
||||
old := service.UpstreamAPI()
|
||||
api := &apnBindingTestAPI{IUpstreamAPI: old, captured: make(chan model.SimCardOperateChangeApnInput, 1)}
|
||||
service.RegisterUpstreamAPI(api)
|
||||
t.Cleanup(func() { service.RegisterUpstreamAPI(old) })
|
||||
server := ghttp.GetServer(t.Name())
|
||||
server.SetAddr("127.0.0.1:0")
|
||||
bindHTTPRoutes(server)
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = server.Shutdown() })
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
data model.SimCardOperateChangeApnInputItem
|
||||
}{
|
||||
{"/sim/change/sim-apn", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT", SubmitOnly: true}},
|
||||
{"/sim/change/sim-apn", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT", QueryJobID: "saved-job"}},
|
||||
{"/sim/change/sim-apn", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT", RecoverOnly: true}},
|
||||
{"/sim/query/sim-apn-capability", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT"}},
|
||||
} {
|
||||
input := model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{ChanKey: "fake", ICCID: "fake-card", MSISDN: "frozen-number", IMSI: "frozen-imsi"}}, ReqData: tc.data}
|
||||
client := g.Client().ContentJson()
|
||||
res, err := client.Post(context.Background(), fmt.Sprintf("http://127.0.0.1:%d%s", server.GetListenedPort(), tc.path), input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := res.ReadAll()
|
||||
_ = res.Close()
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if json.Unmarshal(body, &envelope) != nil || envelope.Code != 0 {
|
||||
t.Fatalf("生产绑定失败:%s", body)
|
||||
}
|
||||
select {
|
||||
case got := <-api.captured:
|
||||
if got.ReqData != tc.data || len(got.SimKeys) != 1 || *got.SimKeys[0] != *input.SimKeys[0] {
|
||||
t.Fatalf("异步字段或快照丢失:%+v", got)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("未调用生产controller:%s", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalChannelTestWithoutTokenReachesRequestValidation(t *testing.T) {
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
adapter, err := gcfg.NewAdapterContent(`{"internal":{}}`)
|
||||
@@ -51,9 +303,10 @@ func TestBindHTTPRoutesRegistersChannelControlRoutes(t *testing.T) {
|
||||
})
|
||||
|
||||
want := map[string]bool{
|
||||
"/internal/channel/test": false,
|
||||
"/internal/channel/refresh": false,
|
||||
"/internal/channel/remove": false,
|
||||
"/internal/channel/test": false,
|
||||
"/sim/query/sim-apn-capability": false,
|
||||
"/internal/channel/refresh": false,
|
||||
"/internal/channel/remove": false,
|
||||
}
|
||||
for _, route := range server.GetRoutes() {
|
||||
if _, ok := want[route.Route]; ok && strings.EqualFold(route.Method, "POST") {
|
||||
|
||||
@@ -151,6 +151,14 @@ func (c *ControllerV1) SimStatusChange(ctx context.Context, req *v1.SimStatusCha
|
||||
return &output, nil
|
||||
}
|
||||
|
||||
func (c *ControllerV1) SimApnCapability(ctx context.Context, req *v1.SimApnCapabilityReq) (res *v1.SimApnCapabilityRes, err error) {
|
||||
output, err := service.UpstreamAPI().QuerySimApnCapability(ctx, &req.SimCardOperateChangeApnInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &output, nil
|
||||
}
|
||||
|
||||
func (c *ControllerV1) SimApnChange(ctx context.Context, req *v1.SimApnChangeReq) (res *v1.SimApnChangeRes, err error) {
|
||||
output, err := service.UpstreamAPI().ChangeSimCardOperateApn(ctx, &req.SimCardOperateChangeApnInput)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,10 +3,10 @@ package cardquery
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
v1 "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/api/cardquery/v1"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/mingqian"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
func (controller *ControllerV1) MingqianPackageFlow(ctx context.Context, req *v1.MingqianPackageFlowReq) (*v1.MingqianPackageFlowRes, error) {
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao"
|
||||
"sync"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao"
|
||||
|
||||
"github.com/gogf/gf/v2/crypto/gmd5"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
|
||||
@@ -2,8 +2,8 @@ package channelconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"testing"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestChannelVersionDetectsCredentialChanges(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package upstreamapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
type apnOperationStore interface {
|
||||
Claim(context.Context, string, string, string) (bool, error)
|
||||
Load(context.Context, string, string) (string, string, error)
|
||||
Save(context.Context, string, string, string, string) error
|
||||
}
|
||||
|
||||
type sqlApnOperationStore struct{ db gdb.DB }
|
||||
|
||||
func (s sqlApnOperationStore) database() gdb.DB {
|
||||
if s.db != nil {
|
||||
return s.db
|
||||
}
|
||||
return g.DB()
|
||||
}
|
||||
|
||||
func validateApnOperationIdentity(id, channel, hash string) error {
|
||||
for _, value := range []string{id, channel} {
|
||||
if len(value) == 0 || len(value) > 128 || !utf8.ValidString(value) || strings.TrimSpace(value) != value || strings.ContainsRune(value, 0) {
|
||||
return fmt.Errorf("operationId和chanKey必须为1至128字节且不含首尾空白或空字符")
|
||||
}
|
||||
}
|
||||
decoded, err := hex.DecodeString(hash)
|
||||
if err != nil || len(decoded) != sha256.Size || hash != strings.ToLower(hash) {
|
||||
return fmt.Errorf("APN请求摘要必须为小写SHA-256")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s sqlApnOperationStore) Claim(ctx context.Context, id, channel, hash string) (bool, error) {
|
||||
if err := validateApnOperationIdentity(id, channel, hash); err != nil {
|
||||
return false, err
|
||||
}
|
||||
result, err := s.database().Exec(ctx, "INSERT INTO t_apn_operation (operation_id,chan_key,request_hash,response_json,created_at,updated_at) VALUES (?,?,?,'',NOW(),NOW())", id, channel, hash)
|
||||
if err != nil {
|
||||
var duplicate *mysql.MySQLError
|
||||
if errors.As(err, &duplicate) && duplicate.Number == 1062 {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count != 1 {
|
||||
return false, fmt.Errorf("APN提交意图插入行数异常:%d", count)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s sqlApnOperationStore) Load(ctx context.Context, id, channel string) (string, string, error) {
|
||||
row, err := s.database().GetOne(ctx, "SELECT request_hash,response_json FROM t_apn_operation WHERE operation_id=? AND chan_key=? AND BINARY operation_id=BINARY ? AND BINARY chan_key=BINARY ?", id, channel, id, channel)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(row) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
return row["request_hash"].String(), row["response_json"].String(), nil
|
||||
}
|
||||
|
||||
func (s sqlApnOperationStore) Save(ctx context.Context, id, channel, hash, response string) error {
|
||||
if err := validateApnOperationIdentity(id, channel, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
row, err := tx.GetOne("SELECT request_hash,response_json FROM t_apn_operation WHERE operation_id=? AND chan_key=? AND BINARY operation_id=BINARY ? AND BINARY chan_key=BINARY ? FOR UPDATE", id, channel, id, channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(row) == 0 {
|
||||
return fmt.Errorf("APN受理映射不存在,禁止无意图保存")
|
||||
}
|
||||
if row["request_hash"].String() != hash {
|
||||
return fmt.Errorf("operationId已绑定不同的办理请求")
|
||||
}
|
||||
merged, err := mergeApnOperationJSON(row["response_json"].String(), response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.Exec("UPDATE t_apn_operation SET response_json=?,revision=revision+1,updated_at=NOW(3) WHERE operation_id=? AND chan_key=? AND request_hash=?", merged, id, channel, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("APN受理映射保存行数异常:%d", count)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 已确认逐卡结果不回退未知,已持久化jobId不能被后续空响应或错误抹掉。
|
||||
func mergeApnOperationJSON(saved, incoming string) (string, error) {
|
||||
var output, updates model.SimCardOperateChangeApnOutput
|
||||
if saved != "" {
|
||||
if err := json.Unmarshal([]byte(saved), &output); err != nil {
|
||||
return "", fmt.Errorf("解析已存APN受理映射失败:%w", err)
|
||||
}
|
||||
}
|
||||
if incoming != "" {
|
||||
if err := json.Unmarshal([]byte(incoming), &updates); err != nil {
|
||||
return "", fmt.Errorf("解析待存APN受理映射失败:%w", err)
|
||||
}
|
||||
}
|
||||
positions := make(map[model.SimKey]int)
|
||||
for i, item := range output {
|
||||
if item == nil || item.SimKey == nil {
|
||||
return "", fmt.Errorf("已存APN受理映射缺少卡关联")
|
||||
}
|
||||
positions[*item.SimKey] = i
|
||||
}
|
||||
for _, item := range updates {
|
||||
if item == nil || item.SimKey == nil {
|
||||
return "", fmt.Errorf("待存APN受理映射缺少卡关联")
|
||||
}
|
||||
i, exists := positions[*item.SimKey]
|
||||
if !exists {
|
||||
positions[*item.SimKey] = len(output)
|
||||
output = append(output, item)
|
||||
continue
|
||||
}
|
||||
old := output[i]
|
||||
if old.ResData.OrderNum != "" && item.ResData.OrderNum != "" && old.ResData.OrderNum != item.ResData.OrderNum {
|
||||
return "", fmt.Errorf("同卡APN受理映射出现不同jobId,禁止覆盖")
|
||||
}
|
||||
oldTerminal := !old.ResData.Pending && !old.ResData.Uncertain
|
||||
newTerminal := !item.ResData.Pending && !item.ResData.Uncertain
|
||||
if oldTerminal || (!newTerminal && old.ResData.OrderNum != "") {
|
||||
continue
|
||||
}
|
||||
if item.ResData.OrderNum == "" {
|
||||
item.ResData.OrderNum = old.ResData.OrderNum
|
||||
}
|
||||
output[i] = item
|
||||
}
|
||||
body, err := json.Marshal(output)
|
||||
return string(body), err
|
||||
}
|
||||
|
||||
func unknownApnOperation(input *model.SimCardOperateChangeApnInput, reason string) model.SimCardOperateChangeApnOutput {
|
||||
output := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: key, Code: consts.ApiStatusError, Message: reason, ResData: model.SimCardOperateChangeApnItem{Uncertain: true}})
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
// durableApnOperation 先落提交意图,重复投递与重启恢复均不重新办理。
|
||||
func durableApnOperation(ctx context.Context, store apnOperationStore, channel string, input *model.SimCardOperateChangeApnInput, submit func(context.Context) (model.SimCardOperateChangeApnOutput, error)) (model.SimCardOperateChangeApnOutput, error) {
|
||||
id := input.ReqData.OperationID
|
||||
if len(input.SimKeys) == 0 || len(input.SimKeys) > 100 {
|
||||
return nil, fmt.Errorf("异步APN子任务必须包含1至100张卡")
|
||||
}
|
||||
normalized := *input
|
||||
normalized.SimKeys = append([]*model.SimKey(nil), input.SimKeys...)
|
||||
for _, key := range normalized.SimKeys {
|
||||
if key == nil || key.ChanKey != channel || key.ICCID == "" {
|
||||
return nil, fmt.Errorf("异步APN子任务卡标识或通道归属无效")
|
||||
}
|
||||
}
|
||||
sort.Slice(normalized.SimKeys, func(i, j int) bool {
|
||||
a, b := normalized.SimKeys[i], normalized.SimKeys[j]
|
||||
return a.ICCID+"|"+a.MSISDN+"|"+a.IMSI < b.ICCID+"|"+b.MSISDN+"|"+b.IMSI
|
||||
})
|
||||
normalized.ReqData.QueryJobID = ""
|
||||
normalized.ReqData.RecoverOnly = false
|
||||
normalized.ReqData.SubmitOnly = true
|
||||
payload, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
hash := hex.EncodeToString(digest[:])
|
||||
if err := validateApnOperationIdentity(id, channel, hash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claimed := false
|
||||
if !input.ReqData.RecoverOnly {
|
||||
claimed, err = store.Claim(ctx, id, channel, hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("保存APN提交意图失败,未调用运营商:%w", err)
|
||||
}
|
||||
}
|
||||
if !claimed {
|
||||
savedHash, saved, err := store.Load(ctx, id, channel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取APN受理映射失败:%w", err)
|
||||
}
|
||||
if savedHash == "" {
|
||||
return unknownApnOperation(input, "未找到提交意图,禁止自动重新办理,请核对"), nil
|
||||
}
|
||||
if savedHash != hash {
|
||||
return nil, fmt.Errorf("operationId已绑定不同的办理请求")
|
||||
}
|
||||
if saved == "" {
|
||||
return unknownApnOperation(input, "已记录提交意图但受理结果未确认,禁止重复办理,请核对"), nil
|
||||
}
|
||||
return completeApnOperation(input, saved)
|
||||
}
|
||||
var checkpointMu sync.Mutex
|
||||
latest := ""
|
||||
checkpoint := func(partial model.SimCardOperateChangeApnOutput) error {
|
||||
checkpointMu.Lock()
|
||||
defer checkpointMu.Unlock()
|
||||
snapshot, err := json.Marshal(partial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged, err := mergeApnOperationJSON(latest, string(snapshot))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest = merged
|
||||
saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
return store.Save(saveCtx, id, channel, hash, latest)
|
||||
}
|
||||
submitCtx := context.WithValue(ctx, model.ApnOperationCheckpointKey{}, checkpoint)
|
||||
output, submitErr := submit(submitCtx)
|
||||
if submitErr != nil {
|
||||
output = unknownApnOperation(input, "提交结果未确认:"+submitErr.Error())
|
||||
} else if len(output) == 0 {
|
||||
output = unknownApnOperation(input, "提交未返回逐卡受理结果,请核对")
|
||||
}
|
||||
if err := checkpoint(output); err != nil {
|
||||
g.Log().Errorf(context.WithoutCancel(ctx), "APN受理结果持久化失败 operationId=%s chanKey=%s error=%v", id, channel, err)
|
||||
return nil, fmt.Errorf("上游受理结果持久化失败,禁止重新办理:%w", err)
|
||||
}
|
||||
return completeApnOperation(input, latest)
|
||||
}
|
||||
|
||||
func (u *sUpstreamAPI) QuerySimApnCapability(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimApnCapabilityOutput, error) {
|
||||
if len(input.SimKeys) == 0 || len(input.SimKeys) > 100 {
|
||||
return nil, fmt.Errorf("APN能力探测必须包含1至100张卡")
|
||||
}
|
||||
output := make(model.SimApnCapabilityOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
if key == nil || key.ChanKey == "" || key.ICCID == "" {
|
||||
return nil, fmt.Errorf("APN能力探测缺少冻结卡标识")
|
||||
}
|
||||
output = append(output, &model.SimBaseRes[model.SimApnCapabilityItem]{SimKey: key, ResData: model.SimApnCapabilityItem{ProtocolVersion: 1}})
|
||||
}
|
||||
// 已有 jobId 只读查询运营商,不依赖本地提交意图表;提交和 operationId 恢复仍需该表。
|
||||
if strings.TrimSpace(input.ReqData.QueryJobID) == "" || input.ReqData.SubmitOnly || input.ReqData.RecoverOnly {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := g.DB().GetAll(probeCtx, "SELECT revision FROM t_apn_operation LIMIT 0"); err != nil {
|
||||
return nil, fmt.Errorf("APN持久化迁移未就绪:%w", err)
|
||||
}
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func completeApnOperation(input *model.SimCardOperateChangeApnInput, saved string) (model.SimCardOperateChangeApnOutput, error) {
|
||||
var output model.SimCardOperateChangeApnOutput
|
||||
if err := json.Unmarshal([]byte(saved), &output); err != nil {
|
||||
return nil, fmt.Errorf("解析已持久化受理映射失败:%w", err)
|
||||
}
|
||||
for _, key := range input.SimKeys {
|
||||
found := false
|
||||
for _, item := range output {
|
||||
if item != nil && item.SimKey != nil && *item.SimKey == *key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
missing := *input
|
||||
missing.SimKeys = []*model.SimKey{key}
|
||||
output = append(output, unknownApnOperation(&missing, "该卡未持久化受理结果,禁止重新办理,请核对")...)
|
||||
}
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package upstreamapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"gopkg.in/yaml.v3"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
// 仅显式授权的loopback专用实例可写;身份核验始终先于DDL和DML。
|
||||
func TestApnOperationMySQLIntegration(t *testing.T) {
|
||||
if os.Getenv("APN_MYSQL_INTEGRATION") != "1" {
|
||||
t.Skip("未启用本机专用MySQL集成;不会连接默认业务数据库")
|
||||
}
|
||||
const port = "3306"
|
||||
const name = "iotdb_apn_chan_test_20261015"
|
||||
const expectedUUID = "4fa545af-ab36-11f1-be02-040e3c9de361"
|
||||
const expectedDir = "D:/MySQL/MySQL Server 8.0/Data/"
|
||||
if value := os.Getenv("APN_MYSQL_PORT"); value != "" && value != port {
|
||||
t.Fatal("只允许已核实的127.0.0.1:3306")
|
||||
}
|
||||
if value := os.Getenv("APN_MYSQL_DATABASE"); value != "" && value != name {
|
||||
t.Fatal("只允许指定chan测试库")
|
||||
}
|
||||
configPath := filepath.Join("..", "..", "..", "..", "iotlcm-sms", "deploy", "config.yaml")
|
||||
configBody, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal("无法读取本机已有配置")
|
||||
}
|
||||
var config struct {
|
||||
SceneDbDsn string `yaml:"sceneDbDsn"`
|
||||
}
|
||||
if err := yaml.Unmarshal(configBody, &config); err != nil {
|
||||
t.Fatal("本机已有配置解析失败")
|
||||
}
|
||||
dsn, err := mysql.ParseDSN(config.SceneDbDsn)
|
||||
if err != nil || dsn.Net != "tcp" || (dsn.Addr != "127.0.0.1:3306" && dsn.Addr != "localhost:3306") {
|
||||
t.Fatal("已有配置不是已授权本机连接,禁止连接")
|
||||
}
|
||||
db, err := gdb.New(gdb.ConfigNode{Type: "mysql", Host: "127.0.0.1", Port: port, User: dsn.User, Pass: dsn.Passwd, Name: name})
|
||||
if err != nil {
|
||||
t.Fatal("建立本机测试连接失败")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
identity, err := db.GetOne(ctx, "SELECT @@hostname AS hostname,@@port AS port,@@datadir AS datadir,@@server_uuid AS server_uuid,DATABASE() AS dbname")
|
||||
if err != nil {
|
||||
t.Fatal("读取本机实例身份失败")
|
||||
}
|
||||
normalize := func(s string) string {
|
||||
return strings.ToLower(strings.TrimRight(filepath.ToSlash(strings.ReplaceAll(s, "\\", "/")), "/"))
|
||||
}
|
||||
if identity["port"].String() != port || identity["server_uuid"].String() != expectedUUID || identity["dbname"].String() != name || normalize(identity["datadir"].String()) != normalize(expectedDir) {
|
||||
t.Fatalf("隔离实例身份不符,禁止写入:%v", identity)
|
||||
}
|
||||
t.Logf("本机专用库核验通过 hostname=%s port=%s uuid=%s datadir=%s database=%s", identity["hostname"], identity["port"], identity["server_uuid"], identity["datadir"], identity["dbname"])
|
||||
exists, err := db.GetValue(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='t_apn_operation'")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists.Int() == 0 {
|
||||
_, err = db.Exec(ctx, "CREATE TABLE t_apn_operation (operation_id varchar(128) NOT NULL,chan_key varchar(128) NOT NULL,request_hash char(64) NOT NULL,response_json longtext NOT NULL,created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(operation_id,chan_key)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
migration, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "iotlcm-database", "SQL", "20261015_apn_operation_chan.sql"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
master, err := db.Master()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connection, err := master.Conn(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
for repeat := 0; repeat < 2; repeat++ {
|
||||
for _, statement := range strings.Split(string(migration), ";") {
|
||||
if strings.TrimSpace(statement) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := connection.ExecContext(ctx, statement); err != nil {
|
||||
t.Fatalf("迁移执行失败:%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
store := sqlApnOperationStore{db: db}
|
||||
id := fmt.Sprintf("integration-%d", time.Now().UnixNano())
|
||||
channel := "isolated-test-channel"
|
||||
hash := strings.Repeat("a", 64)
|
||||
claimed, err := store.Claim(ctx, id, channel, hash)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("Claim失败:%v %v", claimed, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Exec(context.Background(), "DELETE FROM t_apn_operation WHERE operation_id=? AND chan_key=?", id, channel)
|
||||
})
|
||||
if claimed, err = store.Claim(ctx, id, channel, hash); err != nil || claimed {
|
||||
t.Fatalf("重复Claim未幂等:%v %v", claimed, err)
|
||||
}
|
||||
if err := store.Save(ctx, id+"-missing", channel, hash, "[]"); err == nil {
|
||||
t.Fatal("不存在的映射保存必须失败")
|
||||
}
|
||||
if err := store.Save(ctx, id, channel, strings.Repeat("b", 64), "[]"); err == nil {
|
||||
t.Fatal("不同摘要不得覆盖")
|
||||
}
|
||||
if claimed, err = store.Claim(ctx, strings.Repeat("x", 129), channel, hash); err == nil || claimed {
|
||||
t.Fatal("非法ID不得被INSERT忽略为成功")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
key := &model.SimKey{ChanKey: channel, ICCID: fmt.Sprintf("fake-%d", i)}
|
||||
body, _ := json.Marshal(model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: key, ResData: model.SimCardOperateChangeApnItem{Pending: true, OrderNum: fmt.Sprintf("fake-job-%d", i)}}})
|
||||
errors <- store.Save(ctx, id, channel, hash, string(body))
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
for err := range errors {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, saved, err := store.Load(ctx, id, channel)
|
||||
var output model.SimCardOperateChangeApnOutput
|
||||
if err != nil || json.Unmarshal([]byte(saved), &output) != nil || len(output) != 10 {
|
||||
t.Fatalf("并发保存丢失卡映射:%s %v", saved, err)
|
||||
}
|
||||
unknown := &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{output[0].SimKey}}
|
||||
body, _ := json.Marshal(unknownApnOperation(unknown, "模拟checkpoint后错误"))
|
||||
if err := store.Save(ctx, id, channel, hash, string(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, merged, err := store.Load(ctx, id, channel)
|
||||
if err != nil || merged != saved {
|
||||
t.Fatalf("已存jobId被unknown覆盖:%s %v", merged, err)
|
||||
}
|
||||
if err := store.Save(ctx, id, channel, hash, "[]"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row, err := db.GetOne(ctx, "SELECT revision FROM t_apn_operation WHERE operation_id=? AND chan_key=?", id, channel)
|
||||
if err != nil || row["revision"].Int() != 12 {
|
||||
t.Fatalf("每次保存必须影响一行并递增revision:%v %v", row, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package upstreamapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
type memoryApnOperationStore struct {
|
||||
mu sync.Mutex
|
||||
rows map[string][2]string
|
||||
failSave bool
|
||||
}
|
||||
|
||||
func (s *memoryApnOperationStore) Claim(_ context.Context, id, ch, hash string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
k := id + ch
|
||||
if _, ok := s.rows[k]; ok {
|
||||
return false, nil
|
||||
}
|
||||
s.rows[k] = [2]string{hash, ""}
|
||||
return true, nil
|
||||
}
|
||||
func (s *memoryApnOperationStore) Load(_ context.Context, id, ch string) (string, string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
r := s.rows[id+ch]
|
||||
return r[0], r[1], nil
|
||||
}
|
||||
func (s *memoryApnOperationStore) Save(ctx context.Context, id, ch, hash, body string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.failSave {
|
||||
return errors.New("模拟磁盘失败")
|
||||
}
|
||||
r, exists := s.rows[id+ch]
|
||||
if !exists || r[0] != hash {
|
||||
return errors.New("意图不存在或摘要不符")
|
||||
}
|
||||
merged, err := mergeApnOperationJSON(r[1], body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r[1] = merged
|
||||
s.rows[id+ch] = r
|
||||
return nil
|
||||
}
|
||||
func operationTestInput() *model.SimCardOperateChangeApnInput {
|
||||
return &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{ICCID: "test", ChanKey: "channel"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperationID: "stable-operation", SubmitOnly: true, OperType: "1"}}
|
||||
}
|
||||
func acceptedOperation(input *model.SimCardOperateChangeApnInput) model.SimCardOperateChangeApnOutput {
|
||||
return model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: input.SimKeys[0], ResData: model.SimCardOperateChangeApnItem{OrderNum: "saved-job", Pending: true}}}
|
||||
}
|
||||
|
||||
func TestApnCapabilityExistingJobNeedsNoIntentDatabase(t *testing.T) {
|
||||
input := operationTestInput()
|
||||
input.ReqData.SubmitOnly = false
|
||||
input.ReqData.QueryJobID = "existing-job"
|
||||
// 此包未配置数据库:已有 jobId 的只读协议探测应仍然返回能力。
|
||||
output, err := (&sUpstreamAPI{}).QuerySimApnCapability(context.Background(), input)
|
||||
if err != nil || len(output) != 1 || output[0].ResData.ProtocolVersion != 1 || *output[0].SimKey != *input.SimKeys[0] {
|
||||
t.Fatalf("已有任务的只读协议探测依赖了提交意图表: output=%+v err=%v", output, err)
|
||||
}
|
||||
input.SimKeys = []*model.SimKey{nil}
|
||||
if _, err := (&sUpstreamAPI{}).QuerySimApnCapability(context.Background(), input); err == nil {
|
||||
t.Fatal("已有jobId也必须校验冻结卡标识")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApnOperationResponseLossAndRestartRecovery(t *testing.T) {
|
||||
store := &memoryApnOperationStore{rows: map[string][2]string{}}
|
||||
input := operationTestInput()
|
||||
calls := 0
|
||||
submit := func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
calls++
|
||||
return acceptedOperation(input), nil
|
||||
}
|
||||
if _, err := durableApnOperation(context.Background(), store, "channel", input, submit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 丢弃首次响应后,另一次调用只读受理映射,不访问运营商。
|
||||
input.ReqData.RecoverOnly = true
|
||||
recovered, err := durableApnOperation(context.Background(), store, "channel", input, submit)
|
||||
if err != nil || calls != 1 || recovered[0].ResData.OrderNum != "saved-job" {
|
||||
t.Fatalf("恢复失败 calls=%d result=%+v err=%v", calls, recovered, err)
|
||||
}
|
||||
input.ReqData.RecoverOnly = false
|
||||
if _, err = durableApnOperation(context.Background(), store, "channel", input, submit); err != nil || calls != 1 {
|
||||
t.Fatalf("重复投递重新办理 calls=%d err=%v", calls, err)
|
||||
}
|
||||
input.ReqData.OperType = "0"
|
||||
if _, err = durableApnOperation(context.Background(), store, "channel", input, submit); err == nil {
|
||||
t.Fatal("相同ID不得绑定不同请求")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApnOperationConcurrentSubmitOnlyCallsCarrierOnce(t *testing.T) {
|
||||
store := &memoryApnOperationStore{rows: map[string][2]string{}}
|
||||
input := operationTestInput()
|
||||
started, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
|
||||
go func() {
|
||||
_, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return acceptedOperation(input), nil
|
||||
})
|
||||
done <- err
|
||||
}()
|
||||
<-started
|
||||
duplicate, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
t.Error("并发重复operationId不得重新办理")
|
||||
return nil, nil
|
||||
})
|
||||
close(release)
|
||||
if err != nil || len(duplicate) != 1 || !duplicate[0].ResData.Uncertain {
|
||||
t.Fatalf("并发处理中应标待核对: %v %+v", err, duplicate)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApnOperationRecoveryRejectsDifferentPayload(t *testing.T) {
|
||||
store := &memoryApnOperationStore{rows: map[string][2]string{}}
|
||||
input := operationTestInput()
|
||||
_, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
return acceptedOperation(input), nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input.ReqData.RecoverOnly = true
|
||||
input.ReqData.OperType = "0"
|
||||
if _, err = durableApnOperation(context.Background(), store, "channel", input, nil); err == nil {
|
||||
t.Fatal("恢复请求不得读取其他操作的受理映射")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApnOperationIntentWithoutResponseNeverResubmits(t *testing.T) {
|
||||
store := &memoryApnOperationStore{rows: map[string][2]string{}}
|
||||
input := operationTestInput()
|
||||
payload, _ := json.Marshal(input)
|
||||
digest := sha256.Sum256(payload)
|
||||
_, _ = store.Claim(context.Background(), input.ReqData.OperationID, "channel", hex.EncodeToString(digest[:]))
|
||||
input.ReqData.RecoverOnly = true
|
||||
calls := 0
|
||||
output, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) { calls++; return nil, nil })
|
||||
if err != nil || calls != 0 || !output[0].ResData.Uncertain {
|
||||
t.Fatalf("未确认意图被重新提交:calls=%d err=%v", calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApnOperationPersistenceFailureNeverResubmits(t *testing.T) {
|
||||
store := &memoryApnOperationStore{rows: map[string][2]string{}, failSave: true}
|
||||
input := operationTestInput()
|
||||
calls := 0
|
||||
submit := func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
calls++
|
||||
return acceptedOperation(input), nil
|
||||
}
|
||||
if _, err := durableApnOperation(context.Background(), store, "channel", input, submit); err == nil {
|
||||
t.Fatal("持久化失败不得返回已可靠保存")
|
||||
}
|
||||
store.failSave = false
|
||||
input.ReqData.RecoverOnly = true
|
||||
output, err := durableApnOperation(context.Background(), store, "channel", input, submit)
|
||||
if err != nil || calls != 1 || !output[0].ResData.Uncertain {
|
||||
t.Fatalf("受理映射失败后不得重复办理:calls=%d err=%v", calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApnOperationClientCancellationDoesNotLoseJob(t *testing.T) {
|
||||
store := &memoryApnOperationStore{rows: map[string][2]string{}}
|
||||
input := operationTestInput()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
output, err := durableApnOperation(ctx, store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
cancel()
|
||||
return acceptedOperation(input), nil
|
||||
})
|
||||
if err != nil || output[0].ResData.OrderNum != "saved-job" {
|
||||
t.Fatalf("客户端取消导致jobId丢失:%v", err)
|
||||
}
|
||||
_, saved, _ := store.Load(context.Background(), input.ReqData.OperationID, "channel")
|
||||
if saved == "" {
|
||||
t.Fatal("jobId未落库")
|
||||
}
|
||||
}
|
||||
|
||||
type affectedApnTestDB struct {
|
||||
gdb.DB
|
||||
tx gdb.TX
|
||||
}
|
||||
|
||||
func (d affectedApnTestDB) Transaction(ctx context.Context, f func(context.Context, gdb.TX) error) error {
|
||||
return f(ctx, d.tx)
|
||||
}
|
||||
|
||||
type affectedApnTestTX struct {
|
||||
gdb.TX
|
||||
hash string
|
||||
rows int64
|
||||
}
|
||||
|
||||
func (tx affectedApnTestTX) GetOne(string, ...any) (gdb.Record, error) {
|
||||
return gdb.Record{"request_hash": gvar.New(tx.hash), "response_json": gvar.New("")}, nil
|
||||
}
|
||||
func (tx affectedApnTestTX) Exec(string, ...any) (sql.Result, error) {
|
||||
return affectedApnTestResult(tx.rows), nil
|
||||
}
|
||||
|
||||
type affectedApnTestResult int64
|
||||
|
||||
func (r affectedApnTestResult) LastInsertId() (int64, error) { return 0, nil }
|
||||
func (r affectedApnTestResult) RowsAffected() (int64, error) { return int64(r), nil }
|
||||
func TestApnOperationSQLSaveRejectsAffectedZeroAndHashMismatch(t *testing.T) {
|
||||
hash := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
for _, tc := range []struct {
|
||||
rows int64
|
||||
hash string
|
||||
wantError bool
|
||||
}{{0, hash, true}, {1, "wrong-hash", true}, {1, hash, false}} {
|
||||
store := sqlApnOperationStore{db: affectedApnTestDB{tx: affectedApnTestTX{hash: tc.hash, rows: tc.rows}}}
|
||||
err := store.Save(context.Background(), "test-operation", "test-channel", hash, "[]")
|
||||
if (err != nil) != tc.wantError {
|
||||
t.Fatalf("rows=%d hash=%s err=%v", tc.rows, tc.hash, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type retryCheckpointApnStore struct {
|
||||
memoryApnOperationStore
|
||||
failed bool
|
||||
}
|
||||
|
||||
func (s *retryCheckpointApnStore) Save(ctx context.Context, id, channel, hash, body string) error {
|
||||
if !s.failed {
|
||||
s.failed = true
|
||||
return errors.New("模拟首次checkpoint磁盘故障")
|
||||
}
|
||||
return s.memoryApnOperationStore.Save(ctx, id, channel, hash, body)
|
||||
}
|
||||
func TestApnOperationFailedCheckpointRetriesKnownJobNotUnknown(t *testing.T) {
|
||||
store := &retryCheckpointApnStore{memoryApnOperationStore: memoryApnOperationStore{rows: map[string][2]string{}}}
|
||||
input := operationTestInput()
|
||||
output, err := durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
|
||||
if err := checkpoint(acceptedOperation(input)); err == nil {
|
||||
t.Fatal("应模拟checkpoint失败")
|
||||
}
|
||||
return nil, errors.New("受理后响应断开")
|
||||
})
|
||||
if err != nil || output[0].ResData.OrderNum != "saved-job" || !output[0].ResData.Pending {
|
||||
t.Fatalf("保存重试丢掉已取得jobId:%+v %v", output, err)
|
||||
}
|
||||
_, saved, _ := store.Load(context.Background(), input.ReqData.OperationID, "channel")
|
||||
if !strings.Contains(saved, "saved-job") {
|
||||
t.Fatalf("重试未可靠保存jobId:%s", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApnOperationCheckpointCannotBeErased(t *testing.T) {
|
||||
for _, failed := range []bool{false, true} {
|
||||
store := &memoryApnOperationStore{rows: map[string][2]string{}}
|
||||
input := operationTestInput()
|
||||
output, err := durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
|
||||
if err := checkpoint(acceptedOperation(input)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failed {
|
||||
return nil, errors.New("模拟受理后请求失败")
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil || len(output) != 1 || output[0].ResData.OrderNum != "saved-job" || !output[0].ResData.Pending {
|
||||
t.Fatalf("后续空响应或错误抹掉jobId:%+v %v", output, err)
|
||||
}
|
||||
input.ReqData.RecoverOnly = true
|
||||
output, err = durableApnOperation(context.Background(), store, "channel", input, nil)
|
||||
if err != nil || output[0].ResData.OrderNum != "saved-job" {
|
||||
t.Fatalf("持久化映射已被回退:%+v %v", output, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApnOperationMergePreservesTerminalAndCardAssociation(t *testing.T) {
|
||||
input := operationTestInput()
|
||||
terminal := acceptedOperation(input)
|
||||
terminal[0].ResData.Pending = false
|
||||
terminal[0].Message = "明确成功"
|
||||
body, _ := json.Marshal(terminal)
|
||||
unknown, _ := json.Marshal(unknownApnOperation(input, "后来的未知结果"))
|
||||
merged, err := mergeApnOperationJSON(string(body), string(unknown))
|
||||
if err != nil || merged != string(body) {
|
||||
t.Fatalf("逐卡终态被未知覆盖:%s %v", merged, err)
|
||||
}
|
||||
second := *input.SimKeys[0]
|
||||
second.ICCID = "second"
|
||||
other, _ := json.Marshal(model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: &second, ResData: model.SimCardOperateChangeApnItem{Pending: true, OrderNum: "second-job"}}})
|
||||
merged, err = mergeApnOperationJSON(merged, string(other))
|
||||
var results model.SimCardOperateChangeApnOutput
|
||||
_ = json.Unmarshal([]byte(merged), &results)
|
||||
if err != nil || len(results) != 2 || results[0].SimKey.ICCID != "test" || results[1].SimKey.ICCID != "second" {
|
||||
t.Fatalf("逐卡合并丢失卡关联:%s %v", merged, err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingClaimApnStore struct{ memoryApnOperationStore }
|
||||
|
||||
func (s *failingClaimApnStore) Claim(context.Context, string, string, string) (bool, error) {
|
||||
return false, errors.New("模拟INSERT失败")
|
||||
}
|
||||
func TestApnOperationClaimFailureNeverCallsCarrier(t *testing.T) {
|
||||
called := false
|
||||
_, err := durableApnOperation(context.Background(), &failingClaimApnStore{}, "channel", operationTestInput(), func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
})
|
||||
if err == nil || called {
|
||||
t.Fatalf("意图INSERT失败仍调用运营商:called=%v err=%v", called, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApnOperationExplicitFailureWinsPendingAndUnknown(t *testing.T) {
|
||||
input := operationTestInput()
|
||||
pending, _ := json.Marshal(acceptedOperation(input))
|
||||
failure := model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: input.SimKeys[0], Code: 9999, Message: "运营商明确拒绝"}}
|
||||
failed, _ := json.Marshal(failure)
|
||||
merged, err := mergeApnOperationJSON(string(pending), string(failed))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unknown, _ := json.Marshal(unknownApnOperation(input, "后到的通信错误"))
|
||||
merged, err = mergeApnOperationJSON(merged, string(unknown))
|
||||
var output model.SimCardOperateChangeApnOutput
|
||||
if err != nil || json.Unmarshal([]byte(merged), &output) != nil || len(output) != 1 || output[0].Code != 9999 || output[0].Message != "运营商明确拒绝" || output[0].ResData.Pending || output[0].ResData.Uncertain || output[0].ResData.OrderNum != "saved-job" {
|
||||
t.Fatalf("明确失败被pending或未知覆盖:%s %v", merged, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApnOperationCheckpointSurvivesCrashBeforeReturn(t *testing.T) {
|
||||
store := &memoryApnOperationStore{rows: map[string][2]string{}}
|
||||
input := operationTestInput()
|
||||
func() {
|
||||
defer func() { _ = recover() }()
|
||||
_, _ = durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
|
||||
if err := checkpoint(acceptedOperation(input)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
panic("模拟拿jobId后进程中断")
|
||||
})
|
||||
}()
|
||||
input.ReqData.RecoverOnly = true
|
||||
output, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
t.Fatal("恢复不得重新办理")
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil || output[0].ResData.OrderNum != "saved-job" {
|
||||
t.Fatalf("checkpoint未恢复:%+v %v", output, err)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import "testing"
|
||||
|
||||
func TestICCIDFromChannelCacheKey(t *testing.T) {
|
||||
tests := []struct{ key, want string }{
|
||||
{"sim:chan:iccid:898604F4152391115474", "898604F4152391115474"},
|
||||
{"sim:chan:iccid:8986112223600594371X", "8986112223600594371X"},
|
||||
{"sim:chan:iccid:89860000000000000004", "89860000000000000004"},
|
||||
{"sim:chan:iccid:8986000000000000007X", "8986000000000000007X"},
|
||||
{"sim:chan:iccid:", ""},
|
||||
{"unrelated:898604F4152391115474", ""},
|
||||
{"unrelated:89860000000000000004", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := iccidFromChannelCacheKey(tt.key); got != tt.want {
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package upstreamapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/database/gredis"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
type packChanKeyRedis struct {
|
||||
gredis.Adapter
|
||||
gredis.IGroupString
|
||||
values map[string]string
|
||||
mgetCalls int
|
||||
setKeys []string
|
||||
}
|
||||
|
||||
func (r *packChanKeyRedis) GroupGeneric() gredis.IGroupGeneric { return nil }
|
||||
func (r *packChanKeyRedis) GroupHash() gredis.IGroupHash { return nil }
|
||||
func (r *packChanKeyRedis) GroupList() gredis.IGroupList { return nil }
|
||||
func (r *packChanKeyRedis) GroupPubSub() gredis.IGroupPubSub { return nil }
|
||||
func (r *packChanKeyRedis) GroupScript() gredis.IGroupScript { return nil }
|
||||
func (r *packChanKeyRedis) GroupSet() gredis.IGroupSet { return nil }
|
||||
func (r *packChanKeyRedis) GroupSortedSet() gredis.IGroupSortedSet { return nil }
|
||||
func (r *packChanKeyRedis) GroupString() gredis.IGroupString { return r }
|
||||
|
||||
func (r *packChanKeyRedis) MGet(_ context.Context, keys ...string) (map[string]*gvar.Var, error) {
|
||||
r.mgetCalls++
|
||||
values := make(map[string]*gvar.Var, len(keys))
|
||||
for _, key := range keys {
|
||||
values[key] = gvar.New(r.values[key])
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (r *packChanKeyRedis) Set(_ context.Context, key string, value any, _ ...gredis.SetOption) (*gvar.Var, error) {
|
||||
r.values[key] = gvar.New(value).String()
|
||||
r.setKeys = append(r.setKeys, key)
|
||||
return gvar.New("OK"), nil
|
||||
}
|
||||
|
||||
func TestPackChanKeyPreservesIdentifiersAcrossLookupBranches(t *testing.T) {
|
||||
// 子进程隔离 GoFrame 缓存的默认 DB/Redis 实例,所有查询只使用临时 SQLite 和内存适配器。
|
||||
const childEnv = "PACK_CHAN_KEY_TEST_CHILD"
|
||||
if os.Getenv(childEnv) != "1" {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := exec.CommandContext(t.Context(), executable, "-test.run=^TestPackChanKeyPreservesIdentifiersAcrossLookupBranches$", "-test.v")
|
||||
command.Env = append(os.Environ(), childEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
t.Logf("%s", output)
|
||||
if err != nil {
|
||||
t.Fatalf("隔离回归失败:%v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cache := &packChanKeyRedis{}
|
||||
gredis.RegisterAdapterFunc(func(*gredis.Config) gredis.Adapter { return cache })
|
||||
gredis.SetConfig(&gredis.Config{Address: "in-memory-test"})
|
||||
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{
|
||||
Type: "sqlite", Name: filepath.Join(t.TempDir(), "pack-chan-key.db"),
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := g.DB()
|
||||
t.Cleanup(func() { _ = db.Close(ctx) })
|
||||
if _, err := db.Exec(ctx, "CREATE TABLE t_sim_card_info (iccid TEXT PRIMARY KEY, chan_key TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
api := NewUpstreamAPI()
|
||||
for _, branch := range []string{"redis", "database", "unmatched"} {
|
||||
t.Run(branch, func(t *testing.T) {
|
||||
cache.values = make(map[string]string)
|
||||
cache.mgetCalls = 0
|
||||
cache.setKeys = nil
|
||||
if _, err := db.Exec(ctx, "DELETE FROM t_sim_card_info"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := &model.SimKey{ICCID: "iccid-duplicate", MSISDN: "msisdn-first", IMSI: "imsi-first"}
|
||||
second := &model.SimKey{ICCID: first.ICCID, MSISDN: "msisdn-second", IMSI: "imsi-second"}
|
||||
other := &model.SimKey{ICCID: "iccid-other", MSISDN: "msisdn-other", IMSI: "imsi-other"}
|
||||
inputs := []*model.SimKey{first, second, first, other}
|
||||
wantChannel := ""
|
||||
if branch != "unmatched" {
|
||||
wantChannel = branch + "-channel"
|
||||
for _, key := range []*model.SimKey{first, other} {
|
||||
if branch == "redis" {
|
||||
cache.values["sim:chan:iccid:"+key.ICCID] = wantChannel
|
||||
} else if _, err := db.Exec(ctx, "INSERT INTO t_sim_card_info (iccid, chan_key) VALUES (?, ?)", key.ICCID, wantChannel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
before := make([]model.SimKey, len(inputs))
|
||||
for i, key := range inputs {
|
||||
before[i] = *key
|
||||
}
|
||||
got, err := api.packChanKey(ctx, inputs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || len(got[wantChannel]) != len(inputs) {
|
||||
t.Fatalf("分组数量错误:%+v", got)
|
||||
}
|
||||
assertPackedSimKeys(t, got[wantChannel], inputs, before, wantChannel)
|
||||
if cache.mgetCalls != 1 {
|
||||
t.Fatalf("MGet 调用次数=%d,期望1", cache.mgetCalls)
|
||||
}
|
||||
wantSets := 0
|
||||
if branch == "database" {
|
||||
wantSets = 2
|
||||
}
|
||||
if len(cache.setKeys) != wantSets {
|
||||
t.Fatalf("缓存回填数量=%d,期望%d", len(cache.setKeys), wantSets)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("mixed_with_explicit_channel", func(t *testing.T) {
|
||||
cache.values = map[string]string{"sim:chan:iccid:redis-card": "redis-channel"}
|
||||
if _, err := db.Exec(ctx, "DELETE FROM t_sim_card_info"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(ctx, "INSERT INTO t_sim_card_info (iccid, chan_key) VALUES ('database-card', 'database-channel')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicit := &model.SimKey{ChanKey: "explicit-channel", ICCID: "redis-card", MSISDN: "msisdn-explicit", IMSI: "imsi-explicit"}
|
||||
inputs := []*model.SimKey{explicit}
|
||||
wanted := map[string][]*model.SimKey{}
|
||||
for _, tc := range []struct{ iccid, channel string }{
|
||||
{"redis-card", "redis-channel"}, {"database-card", "database-channel"}, {"missing-card", ""},
|
||||
} {
|
||||
first := &model.SimKey{ICCID: tc.iccid, MSISDN: tc.iccid + "-msisdn-first", IMSI: tc.iccid + "-imsi-first"}
|
||||
second := &model.SimKey{ICCID: tc.iccid, MSISDN: tc.iccid + "-msisdn-second", IMSI: tc.iccid + "-imsi-second"}
|
||||
inputs = append(inputs, first, second)
|
||||
wanted[tc.channel] = []*model.SimKey{first, second}
|
||||
}
|
||||
before := make(map[*model.SimKey]model.SimKey, len(inputs))
|
||||
for _, key := range inputs {
|
||||
before[key] = *key
|
||||
}
|
||||
got, err := api.packChanKey(ctx, inputs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 4 || len(got[explicit.ChanKey]) != 1 || got[explicit.ChanKey][0] != explicit {
|
||||
t.Fatalf("显式通道分组被改变:%+v", got)
|
||||
}
|
||||
for channel, originals := range wanted {
|
||||
assertPackedSimKeys(t, got[channel], originals, []model.SimKey{before[originals[0]], before[originals[1]]}, channel)
|
||||
}
|
||||
if *explicit != before[explicit] {
|
||||
t.Fatal("显式通道原始输入被修改")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertPackedSimKeys(t *testing.T, got, originals []*model.SimKey, before []model.SimKey, channel string) {
|
||||
t.Helper()
|
||||
if len(got) != len(originals) {
|
||||
t.Fatalf("输出数量=%d,期望%d", len(got), len(originals))
|
||||
}
|
||||
want := make(map[model.SimKey]int, len(originals))
|
||||
for i, original := range originals {
|
||||
if *original != before[i] {
|
||||
t.Fatalf("原始输入被修改:%+v", original)
|
||||
}
|
||||
key := before[i]
|
||||
key.ChanKey = channel
|
||||
want[key]++
|
||||
}
|
||||
seen := make(map[*model.SimKey]bool, len(got))
|
||||
for _, key := range got {
|
||||
if key == nil || seen[key] {
|
||||
t.Fatal("输出为空或复用了同一指针")
|
||||
}
|
||||
seen[key] = true
|
||||
for _, original := range originals {
|
||||
if key == original {
|
||||
t.Fatal("输出复用了原始输入指针")
|
||||
}
|
||||
}
|
||||
if want[*key] == 0 {
|
||||
t.Fatalf("卡标识丢失或被另一条重复ICCID输入覆盖:%+v", key)
|
||||
}
|
||||
want[*key]--
|
||||
}
|
||||
for _, remaining := range want {
|
||||
if remaining != 0 {
|
||||
t.Fatal("输出未保留每条原始输入")
|
||||
}
|
||||
}
|
||||
if len(got) > 1 {
|
||||
other := *got[1]
|
||||
got[0].MSISDN = "changed-output"
|
||||
got[0].IMSI = "changed-output"
|
||||
if *got[1] != other {
|
||||
t.Fatal("修改一条输出影响了另一条输出")
|
||||
}
|
||||
}
|
||||
for i, original := range originals {
|
||||
if *original != before[i] {
|
||||
t.Fatalf("修改输出影响了原始输入:%+v", original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackChanKeyEmptyInput(t *testing.T) {
|
||||
for _, inputs := range [][]*model.SimKey{nil, {}} {
|
||||
got, err := NewUpstreamAPI().packChanKey(context.Background(), inputs)
|
||||
if err != nil || got == nil || len(got) != 0 {
|
||||
t.Fatalf("空输入结果=%+v,错误=%v", got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,14 @@ package upstreamapi
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
@@ -285,9 +285,11 @@ func (u *sUpstreamAPI) packChanKey(ctx context.Context, simKeys []*model.SimKey)
|
||||
queryRedisIccids := make([]string, 0)
|
||||
queryDbIccids := make([]string, 0)
|
||||
chanKeyMap := make(map[string][]*model.SimKey)
|
||||
simKeyMap := make(map[string][]*model.SimKey, len(simKeys))
|
||||
// 检查通道id是都携带
|
||||
for _, simKey := range simKeys {
|
||||
if simKey.ChanKey == "" {
|
||||
simKeyMap[simKey.ICCID] = append(simKeyMap[simKey.ICCID], simKey)
|
||||
unknownChanKeyIccids = append(unknownChanKeyIccids, simKey.ICCID)
|
||||
queryRedisIccids = append(queryRedisIccids, redisKey+simKey.ICCID)
|
||||
} else {
|
||||
@@ -319,10 +321,11 @@ func (u *sUpstreamAPI) packChanKey(ctx context.Context, simKeys []*model.SimKey)
|
||||
continue
|
||||
}
|
||||
allIccid[iccid] = true
|
||||
chanKeyMap[chanKeyStr] = append(chanKeyMap[chanKeyStr], &model.SimKey{
|
||||
ChanKey: chanKeyStr,
|
||||
ICCID: iccid,
|
||||
})
|
||||
for _, original := range simKeyMap[iccid] {
|
||||
key := *original
|
||||
key.ChanKey = chanKeyStr
|
||||
chanKeyMap[chanKeyStr] = append(chanKeyMap[chanKeyStr], &key)
|
||||
}
|
||||
}
|
||||
for iccid, found := range allIccid {
|
||||
if !found {
|
||||
@@ -347,18 +350,20 @@ func (u *sUpstreamAPI) packChanKey(ctx context.Context, simKeys []*model.SimKey)
|
||||
|
||||
for _, simCardInfo := range simCardInfos {
|
||||
allIccid[simCardInfo.Iccid] = true
|
||||
chanKeyMap[simCardInfo.ChanKey] = append(chanKeyMap[simCardInfo.ChanKey], &model.SimKey{
|
||||
ChanKey: simCardInfo.ChanKey,
|
||||
ICCID: simCardInfo.Iccid,
|
||||
})
|
||||
for _, original := range simKeyMap[simCardInfo.Iccid] {
|
||||
key := *original
|
||||
key.ChanKey = simCardInfo.ChanKey
|
||||
chanKeyMap[simCardInfo.ChanKey] = append(chanKeyMap[simCardInfo.ChanKey], &key)
|
||||
}
|
||||
g.Redis().Set(ctx, redisKey+simCardInfo.Iccid, simCardInfo.ChanKey)
|
||||
}
|
||||
for iccid, found := range allIccid {
|
||||
if !found {
|
||||
chanKeyMap[""] = append(chanKeyMap[""], &model.SimKey{
|
||||
ChanKey: "",
|
||||
ICCID: iccid,
|
||||
})
|
||||
for _, original := range simKeyMap[iccid] {
|
||||
key := *original
|
||||
key.ChanKey = ""
|
||||
chanKeyMap[""] = append(chanKeyMap[""], &key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return chanKeyMap, nil
|
||||
@@ -1273,7 +1278,19 @@ func (u *sUpstreamAPI) ChangeSimCardOperateApn(ctx context.Context, input *model
|
||||
}
|
||||
}
|
||||
for chanKey, inputNew := range inputMap {
|
||||
if inputNew.ReqData.RecoverOnly && inputNew.ReqData.QueryJobID == "" {
|
||||
recovered, recoverErr := durableApnOperation(ctx, sqlApnOperationStore{}, chanKey, inputNew, nil)
|
||||
if recoverErr != nil {
|
||||
recovered = unknownApnOperation(inputNew, recoverErr.Error())
|
||||
}
|
||||
output = append(output, recovered...)
|
||||
continue
|
||||
}
|
||||
if chanKey == "" {
|
||||
if inputNew.ReqData.SubmitOnly || inputNew.ReqData.QueryJobID != "" {
|
||||
output = append(output, unknownApnOperation(inputNew, "通道未配置,异步结果未确认")...)
|
||||
continue
|
||||
}
|
||||
for _, simKey := range inputNew.SimKeys {
|
||||
output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
|
||||
SimKey: simKey,
|
||||
@@ -1285,6 +1302,10 @@ func (u *sUpstreamAPI) ChangeSimCardOperateApn(ctx context.Context, input *model
|
||||
}
|
||||
channel, ok := u.GetChan(chanKey)
|
||||
if !ok || channel.Instance == nil {
|
||||
if inputNew.ReqData.SubmitOnly || inputNew.ReqData.QueryJobID != "" {
|
||||
output = append(output, unknownApnOperation(inputNew, "通道未实例化,异步结果未确认")...)
|
||||
continue
|
||||
}
|
||||
for _, simKey := range inputNew.SimKeys {
|
||||
output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
|
||||
SimKey: simKey,
|
||||
@@ -1294,13 +1315,33 @@ func (u *sUpstreamAPI) ChangeSimCardOperateApn(ctx context.Context, input *model
|
||||
}
|
||||
continue
|
||||
}
|
||||
out, err := channel.Instance.ChangeSimApnFunction(ctx, inputNew)
|
||||
var out model.SimCardOperateChangeApnOutput
|
||||
var err error
|
||||
if inputNew.ReqData.QueryJobID == "" && (inputNew.ReqData.RecoverOnly || inputNew.ReqData.SubmitOnly) {
|
||||
out, err = durableApnOperation(ctx, sqlApnOperationStore{}, chanKey, inputNew, func(submitCtx context.Context) (model.SimCardOperateChangeApnOutput, error) {
|
||||
return channel.Instance.ChangeSimApnFunction(submitCtx, inputNew)
|
||||
})
|
||||
} else if inputNew.ReqData.QueryJobID != "" {
|
||||
querier, supported := channel.Instance.(interface {
|
||||
QuerySimApnFunctionResult(context.Context, *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error)
|
||||
})
|
||||
if !supported {
|
||||
for _, key := range inputNew.SimKeys {
|
||||
output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: key, Code: consts.ApiStatusError, Message: "该通道不支持异步APN任务查询", ResData: model.SimCardOperateChangeApnItem{OrderNum: inputNew.ReqData.QueryJobID, Uncertain: true}})
|
||||
}
|
||||
continue
|
||||
}
|
||||
out, err = querier.QuerySimApnFunctionResult(ctx, inputNew)
|
||||
} else {
|
||||
out, err = channel.Instance.ChangeSimApnFunction(ctx, inputNew)
|
||||
}
|
||||
if err != nil {
|
||||
for _, simKey := range inputNew.SimKeys {
|
||||
output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: err.Error(),
|
||||
ResData: model.SimCardOperateChangeApnItem{OrderNum: inputNew.ReqData.QueryJobID, Uncertain: inputNew.ReqData.SubmitOnly || inputNew.ReqData.RecoverOnly || inputNew.ReqData.QueryJobID != ""},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -2,9 +2,9 @@ package upstreamapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (u *sUpstreamAPI) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (output model.SimCardStatusOutput, err error) {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
)
|
||||
|
||||
// 真实上游时延测量(默认跳过)。
|
||||
//
|
||||
// 目的:实测移动 OneLink「批量提交返回 jobId」与「按 jobId 查到终态」各自的耗时,
|
||||
// 用于校准平台超时与补查节奏。
|
||||
//
|
||||
// 仅在 APN_REAL_CARRIER_TEST=1 时执行。
|
||||
//
|
||||
// APN_REAL_CREDS_FILE=<path> 两行:appid / appsecret
|
||||
// APN_REAL_BASE_URL=<url> 默认 https://api.iot.10086.cn/v5
|
||||
// APN_REAL_APN=CMIOT
|
||||
// APN_REAL_BATCHES=<b1>|<b2>|... 每批:operType,iccid:msisdn,iccid:msisdn,...
|
||||
// APN_REAL_POLL_INTERVAL=15s 查询间隔
|
||||
// APN_REAL_MAX_WAIT=15m 最长等待终态
|
||||
func TestRealCarrierApnLatencyMeasure(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("APN_REAL_CARRIER_TEST")) != "1" {
|
||||
t.Skip("真实上游测量未启用:设置 APN_REAL_CARRIER_TEST=1 才执行")
|
||||
}
|
||||
appID, appSecret, err := loadRealCarrierCreds()
|
||||
if err != nil {
|
||||
t.Fatalf("读取凭证失败:%v", err)
|
||||
}
|
||||
baseURL := strings.TrimSpace(os.Getenv("APN_REAL_BASE_URL"))
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.iot.10086.cn/v5"
|
||||
}
|
||||
apn := strings.TrimSpace(os.Getenv("APN_REAL_APN"))
|
||||
if apn == "" {
|
||||
apn = "CMIOT"
|
||||
}
|
||||
batches, operTypes, err := parseRealBatches(os.Getenv("APN_REAL_BATCHES"))
|
||||
if err != nil {
|
||||
t.Fatalf("解析批次失败:%v", err)
|
||||
}
|
||||
pollInterval := 15 * time.Second
|
||||
if raw := strings.TrimSpace(os.Getenv("APN_REAL_POLL_INTERVAL")); raw != "" {
|
||||
if d, derr := time.ParseDuration(raw); derr == nil && d > 0 {
|
||||
pollInterval = d
|
||||
}
|
||||
}
|
||||
maxWait := 15 * time.Minute
|
||||
if raw := strings.TrimSpace(os.Getenv("APN_REAL_MAX_WAIT")); raw != "" {
|
||||
if d, derr := time.ParseDuration(raw); derr == nil && d > 0 {
|
||||
maxWait = d
|
||||
}
|
||||
}
|
||||
|
||||
channel := &sSimYD{
|
||||
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "real-carrier-measure", AppKey: appID}},
|
||||
tokenProvider: &realCarrierToken{appID: appID, appSecret: appSecret, baseURL: baseURL},
|
||||
apiConfig: &oneLinkAPIConfig{BaseURL: baseURL, Timeout: 60 * time.Second},
|
||||
}
|
||||
|
||||
type batchOutcome struct {
|
||||
index int
|
||||
operType string
|
||||
cards int
|
||||
elapsed time.Duration
|
||||
jobID string
|
||||
failed string
|
||||
}
|
||||
start := time.Now()
|
||||
ctx := context.Background()
|
||||
results := make([]batchOutcome, len(batches))
|
||||
var wg sync.WaitGroup
|
||||
for i := range batches {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
outcome := batchOutcome{index: i, operType: operTypes[i], cards: len(batches[i])}
|
||||
begin := time.Now()
|
||||
items, callErr := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: batches[i],
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{
|
||||
OperType: operTypes[i],
|
||||
ApnName: apn,
|
||||
SubmitOnly: true,
|
||||
OperationID: fmt.Sprintf("measure-%d-%d", i, begin.UnixNano()),
|
||||
},
|
||||
})
|
||||
outcome.elapsed = time.Since(begin)
|
||||
switch {
|
||||
case callErr != nil:
|
||||
outcome.failed = callErr.Error()
|
||||
case len(items) == 0:
|
||||
outcome.failed = "空返回"
|
||||
default:
|
||||
outcome.jobID = items[0].ResData.OrderNum
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if item.ResData.OrderNum != "" && item.ResData.OrderNum != outcome.jobID {
|
||||
outcome.failed = "同批出现多个jobId"
|
||||
}
|
||||
if !item.ResData.Pending && !item.ResData.Uncertain {
|
||||
outcome.failed = fmt.Sprintf("提交即终态 code=%d msg=%s", item.Code, item.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
results[i] = outcome
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
submitWall := time.Since(start)
|
||||
|
||||
fmt.Printf("MEASURE_SUBMIT batches=%d cards=%d wall=%s\n", len(batches), totalRealCards(batches), submitWall)
|
||||
for _, r := range results {
|
||||
fmt.Printf("MEASURE_SUBMIT_BATCH idx=%d operType=%s cards=%d elapsed=%s jobId=%s failed=%s\n",
|
||||
r.index, r.operType, r.cards, r.elapsed, r.jobID, r.failed)
|
||||
}
|
||||
|
||||
// 汇总待查任务
|
||||
pendingJobs := map[string][]*model.SimKey{}
|
||||
for _, r := range results {
|
||||
if r.jobID != "" {
|
||||
pendingJobs[r.jobID] = batches[r.index]
|
||||
}
|
||||
}
|
||||
terminalAt := map[string]time.Duration{}
|
||||
fmt.Printf("MEASURE_JOBS distinct=%d %v\n", len(pendingJobs), sortedJobIDs(pendingJobs))
|
||||
|
||||
deadline := start.Add(maxWait)
|
||||
for len(terminalAt) < len(pendingJobs) && time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
elapsed := time.Since(start)
|
||||
for jobID, keys := range pendingJobs {
|
||||
if _, done := terminalAt[jobID]; done {
|
||||
continue
|
||||
}
|
||||
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: keys,
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: jobID},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("MEASURE_QUERY_ERR jobId=%s at=%s err=%v\n", jobID, elapsed.Round(time.Second), err)
|
||||
continue
|
||||
}
|
||||
allTerminal := len(items) > 0
|
||||
for _, item := range items {
|
||||
if item == nil || item.ResData.Pending {
|
||||
allTerminal = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allTerminal {
|
||||
terminalAt[jobID] = elapsed
|
||||
fmt.Printf("MEASURE_TERMINAL jobId=%s at=%s\n", jobID, elapsed.Round(time.Second))
|
||||
for _, item := range items {
|
||||
fmt.Printf("MEASURE_CARD jobId=%s iccid=%s msisdn=%s code=%d uncertain=%v message=%s\n",
|
||||
jobID, item.SimKey.ICCID, item.SimKey.MSISDN, item.Code, item.ResData.Uncertain, item.Message)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("MEASURE_PENDING jobId=%s at=%s note=%s\n", jobID, elapsed.Round(time.Second), firstMessage(items))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("MEASURE_SUMMARY\n")
|
||||
for _, jobID := range sortedJobIDs(pendingJobs) {
|
||||
if at, ok := terminalAt[jobID]; ok {
|
||||
fmt.Printf("MEASURE_RESULT jobId=%s terminalAt=%s\n", jobID, at.Round(time.Second))
|
||||
} else {
|
||||
fmt.Printf("MEASURE_RESULT jobId=%s terminalAt=NONE within %s\n", jobID, maxWait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func totalRealCards(batches [][]*model.SimKey) int {
|
||||
total := 0
|
||||
for _, b := range batches {
|
||||
total += len(b)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func sortedJobIDs(jobs map[string][]*model.SimKey) []string {
|
||||
ids := make([]string, 0, len(jobs))
|
||||
for id := range jobs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func firstMessage(items model.SimCardOperateChangeApnOutput) string {
|
||||
for _, item := range items {
|
||||
if item != nil {
|
||||
return item.Message
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseRealBatches 解析 APN_REAL_BATCHES:批之间用 | 分隔,每批首项为 operType,其余为 iccid:msisdn。
|
||||
func parseRealBatches(raw string) ([][]*model.SimKey, []string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil, fmt.Errorf("未设置 APN_REAL_BATCHES")
|
||||
}
|
||||
batches := make([][]*model.SimKey, 0)
|
||||
operTypes := make([]string, 0)
|
||||
for _, batchRaw := range strings.Split(raw, "|") {
|
||||
batchRaw = strings.TrimSpace(batchRaw)
|
||||
if batchRaw == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(batchRaw, ",")
|
||||
operType := strings.TrimSpace(fields[0])
|
||||
if _, err := strconv.Atoi(operType); err != nil {
|
||||
return nil, nil, fmt.Errorf("批次首项应为 operType:%q", batchRaw)
|
||||
}
|
||||
keys := make([]*model.SimKey, 0, len(fields)-1)
|
||||
for _, card := range fields[1:] {
|
||||
card = strings.TrimSpace(card)
|
||||
if card == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(card, ":")
|
||||
if len(parts) < 2 {
|
||||
return nil, nil, fmt.Errorf("卡项格式应为 iccid:msisdn:%q", card)
|
||||
}
|
||||
keys = append(keys, &model.SimKey{
|
||||
ChanKey: "real-carrier-measure",
|
||||
ICCID: strings.TrimSpace(parts[0]),
|
||||
MSISDN: strings.TrimSpace(parts[1]),
|
||||
})
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
batches = append(batches, keys)
|
||||
operTypes = append(operTypes, operType)
|
||||
}
|
||||
if len(batches) == 0 {
|
||||
return nil, nil, fmt.Errorf("批次为空")
|
||||
}
|
||||
return batches, operTypes, nil
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// 真实移动 OneLink 上游功能验证(默认跳过)。
|
||||
//
|
||||
// 仅在显式设置 APN_REAL_CARRIER_TEST=1 时执行,并且只做本文件声明的动作,
|
||||
// 用于小范围真实卡的功能验证。凭证从文件读取(APN_REAL_CREDS_FILE),
|
||||
// 不出现在命令行或代码里。
|
||||
//
|
||||
// 环境变量:
|
||||
//
|
||||
// APN_REAL_CARRIER_TEST=1 必需,否则整包跳过
|
||||
// APN_REAL_CREDS_FILE=<path> 两行:appid / appsecret
|
||||
// APN_REAL_BASE_URL=<url> 默认 https://api.iot.10086.cn/v5
|
||||
// APN_REAL_CARDS=<iccid:msisdn:imsi;...> 卡清单
|
||||
// APN_REAL_ACTION=apninfo|submit|query
|
||||
// APN_REAL_OPERTYPE=0|1 submit 时使用:0=开 1=停
|
||||
// APN_REAL_APN=CMIOT submit 时使用
|
||||
// APN_REAL_JOBID=<jobId> query 时使用
|
||||
func TestRealCarrierApnFunctional(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("APN_REAL_CARRIER_TEST")) != "1" {
|
||||
t.Skip("真实上游测试未启用:设置 APN_REAL_CARRIER_TEST=1 才执行")
|
||||
}
|
||||
appID, appSecret, err := loadRealCarrierCreds()
|
||||
if err != nil {
|
||||
t.Fatalf("读取凭证失败:%v", err)
|
||||
}
|
||||
baseURL := strings.TrimSpace(os.Getenv("APN_REAL_BASE_URL"))
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.iot.10086.cn/v5"
|
||||
}
|
||||
keys, err := loadRealCarrierKeys()
|
||||
if err != nil {
|
||||
t.Fatalf("解析卡清单失败:%v", err)
|
||||
}
|
||||
channel := &sSimYD{
|
||||
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "real-carrier-verify", AppKey: appID}},
|
||||
tokenProvider: &realCarrierToken{appID: appID, appSecret: appSecret, baseURL: baseURL},
|
||||
apiConfig: &oneLinkAPIConfig{BaseURL: baseURL, Timeout: 30 * time.Second},
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
switch strings.TrimSpace(os.Getenv("APN_REAL_ACTION")) {
|
||||
case "apninfo":
|
||||
apnInfoProbe(t, ctx, channel, keys)
|
||||
case "submit":
|
||||
submitProbe(t, ctx, channel, keys)
|
||||
case "query":
|
||||
queryProbe(t, ctx, channel, keys)
|
||||
default:
|
||||
t.Fatalf("APN_REAL_ACTION 必须为 apninfo|submit|query")
|
||||
}
|
||||
}
|
||||
|
||||
// apninfoProbe 只读:查询每张卡当前已开通 APN 列表及状态。
|
||||
func apnInfoProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
|
||||
for _, key := range keys {
|
||||
params := g.Map{"transid": channel.getTransId(ctx), "iccid": key.ICCID}
|
||||
resp, err := channel.requestYd(ctx, "QuerySimApnInfo", channel.getUrl(ctx, "ec/query/apn-info"), params)
|
||||
if err != nil {
|
||||
t.Errorf("apn-info %s 请求失败:%v", key.ICCID, err)
|
||||
continue
|
||||
}
|
||||
body := resp.ReadAllString()
|
||||
fmt.Printf("APNINFO iccid=%s msisdn=%s body=%s\n", key.ICCID, key.MSISDN, oneLine(body))
|
||||
}
|
||||
}
|
||||
|
||||
// submitProbe 真实提交一次批量开/停,只取受理结果(SubmitOnly),不轮询。
|
||||
func submitProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
|
||||
operType := strings.TrimSpace(os.Getenv("APN_REAL_OPERTYPE"))
|
||||
if operType == "" {
|
||||
t.Fatalf("submit 必须指定 APN_REAL_OPERTYPE(0开/1停)")
|
||||
}
|
||||
apn := strings.TrimSpace(os.Getenv("APN_REAL_APN"))
|
||||
if apn == "" {
|
||||
apn = "CMIOT"
|
||||
}
|
||||
checkpointed := make(model.SimCardOperateChangeApnOutput, 0)
|
||||
ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(partial model.SimCardOperateChangeApnOutput) error {
|
||||
checkpointed = append(checkpointed, partial...)
|
||||
return nil
|
||||
})
|
||||
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: keys,
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: operType, ApnName: apn, SubmitOnly: true, OperationID: "real-verify-" + fmt.Sprint(time.Now().Unix())},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("提交失败:%v", err)
|
||||
}
|
||||
fmt.Printf("SUBMIT operType=%s apn=%s cards=%d checkpointed=%d\n", operType, apn, len(items), len(checkpointed))
|
||||
jobIDs := map[string]int{}
|
||||
for _, item := range items {
|
||||
if item == nil || item.SimKey == nil {
|
||||
t.Errorf("返回项缺少卡关联")
|
||||
continue
|
||||
}
|
||||
jobIDs[item.ResData.OrderNum]++
|
||||
fmt.Printf("SUBMIT_ITEM iccid=%s code=%d pending=%v uncertain=%v jobId=%s message=%s\n",
|
||||
item.SimKey.ICCID, item.Code, item.ResData.Pending, item.ResData.Uncertain, item.ResData.OrderNum, item.Message)
|
||||
}
|
||||
fmt.Printf("SUBMIT_JOBS %v\n", jobIDs)
|
||||
}
|
||||
|
||||
// queryProbe 真实查询一次批量任务结果(单次,不轮询)。
|
||||
func queryProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
|
||||
jobID := strings.TrimSpace(os.Getenv("APN_REAL_JOBID"))
|
||||
if jobID == "" {
|
||||
t.Fatalf("query 必须指定 APN_REAL_JOBID")
|
||||
}
|
||||
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: keys,
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: jobID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败:%v", err)
|
||||
}
|
||||
fmt.Printf("QUERY jobId=%s cards=%d\n", jobID, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil || item.SimKey == nil {
|
||||
t.Errorf("返回项缺少卡关联")
|
||||
continue
|
||||
}
|
||||
fmt.Printf("QUERY_ITEM iccid=%s msisdn=%s code=%d pending=%v uncertain=%v jobId=%s message=%s\n",
|
||||
item.SimKey.ICCID, item.SimKey.MSISDN, item.Code, item.ResData.Pending, item.ResData.Uncertain, item.ResData.OrderNum, item.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// realCarrierToken 直接从 OneLink 换 token,不落库、不依赖数据库。
|
||||
type realCarrierToken struct {
|
||||
appID string
|
||||
appSecret string
|
||||
baseURL string
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
expireAt time.Time
|
||||
seq int64
|
||||
}
|
||||
|
||||
func (p *realCarrierToken) GetToken(ctx context.Context) (string, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.token != "" && time.Now().Before(p.expireAt.Add(-2*time.Minute)) {
|
||||
return p.token, nil
|
||||
}
|
||||
return p.fetch(ctx)
|
||||
}
|
||||
|
||||
func (p *realCarrierToken) RecoverToken(ctx context.Context, rejected string) (string, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.token != "" && p.token != rejected {
|
||||
return p.token, nil
|
||||
}
|
||||
p.token = ""
|
||||
return p.fetch(ctx)
|
||||
}
|
||||
|
||||
func (p *realCarrierToken) fetch(ctx context.Context) (string, error) {
|
||||
p.seq++
|
||||
transid := p.appID + time.Now().Format("20060102150405") + fmt.Sprintf("%08d", p.seq)
|
||||
endpoint := strings.TrimRight(p.baseURL, "/") + "/ec/get/token?" + url.Values{
|
||||
"appid": {p.appID},
|
||||
"password": {p.appSecret},
|
||||
"transid": {transid},
|
||||
"refresh": {"0"},
|
||||
}.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var parsed struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Result []struct {
|
||||
Token string `json:"token"`
|
||||
Ttl int `json:"ttl"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.Status != "0" || len(parsed.Result) == 0 || parsed.Result[0].Token == "" {
|
||||
return "", fmt.Errorf("换 token 失败 status=%s message=%s", parsed.Status, parsed.Message)
|
||||
}
|
||||
p.token = parsed.Result[0].Token
|
||||
ttl := parsed.Result[0].Ttl
|
||||
if ttl <= 0 {
|
||||
ttl = 3600
|
||||
}
|
||||
p.expireAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||
return p.token, nil
|
||||
}
|
||||
|
||||
func loadRealCarrierCreds() (string, string, error) {
|
||||
path := strings.TrimSpace(os.Getenv("APN_REAL_CREDS_FILE"))
|
||||
if path == "" {
|
||||
return "", "", fmt.Errorf("未设置 APN_REAL_CREDS_FILE")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
lines := make([]string, 0, 2)
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) < 2 {
|
||||
return "", "", fmt.Errorf("凭证文件需要两行:appid / appsecret")
|
||||
}
|
||||
return lines[0], lines[1], nil
|
||||
}
|
||||
|
||||
func loadRealCarrierKeys() ([]*model.SimKey, error) {
|
||||
raw := strings.TrimSpace(os.Getenv("APN_REAL_CARDS"))
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("未设置 APN_REAL_CARDS")
|
||||
}
|
||||
keys := make([]*model.SimKey, 0)
|
||||
for _, entry := range strings.Split(raw, ";") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(entry, ":")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("卡项格式应为 iccid:msisdn[:imsi]:%q", entry)
|
||||
}
|
||||
key := &model.SimKey{ChanKey: "real-carrier-verify", ICCID: strings.TrimSpace(parts[0]), MSISDN: strings.TrimSpace(parts[1])}
|
||||
if len(parts) > 2 {
|
||||
key.IMSI = strings.TrimSpace(parts[2])
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, fmt.Errorf("卡清单为空")
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func oneLine(body string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(body, "\n", " "), "\r", " ")
|
||||
}
|
||||
@@ -175,13 +175,13 @@ func TestBeixinTemplateRegistration(t *testing.T) {
|
||||
|
||||
config := &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkBeixinTemplateKey,
|
||||
TempModel: templateModelSharedToken,
|
||||
TempKey: sharedOneLinkBeixinTemplateKey,
|
||||
TempModel: templateModelSharedToken,
|
||||
TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` +
|
||||
`"path":"/prod-api/open/api/oneLinkToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "252",
|
||||
ChanKey: "252",
|
||||
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -76,7 +76,46 @@ func (c *Client) DoGetRequest(ctx context.Context, endpoint string, params url.V
|
||||
return c.doRequest(req)
|
||||
}
|
||||
|
||||
// DoGetWriteRequest 用独立无复用连接发送非幂等GET,禁止运输层重放与跳转。
|
||||
func (c *Client) DoGetWriteRequest(ctx context.Context, endpoint string, params url.Values) ([]byte, error) {
|
||||
req, err := c.doGetRequest(ctx, endpoint, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.doWriteRequest(req)
|
||||
}
|
||||
|
||||
func (c *Client) doWriteRequest(req *http.Request) ([]byte, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if original, ok := c.http.Transport.(*http.Transport); ok {
|
||||
transport = original.Clone()
|
||||
}
|
||||
transport.DisableKeepAlives = true
|
||||
defer transport.CloseIdleConnections()
|
||||
client := *c.http
|
||||
client.Transport = transport
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return c.doRequestWithClient(&client, req)
|
||||
}
|
||||
|
||||
func (c *Client) DoPostJSONRequest(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
|
||||
req, err := c.doPostJSONRequest(ctx, endpoint, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.doRequest(req)
|
||||
}
|
||||
|
||||
// DoPostJSONWriteRequest 仅用于办理写请求,禁止连接复用及自动跳转重放。
|
||||
func (c *Client) DoPostJSONWriteRequest(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
|
||||
req, err := c.doPostJSONRequest(ctx, endpoint, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.doWriteRequest(req)
|
||||
}
|
||||
|
||||
func (c *Client) doPostJSONRequest(ctx context.Context, endpoint string, body []byte) (*http.Request, error) {
|
||||
timestamp := GetTimestampGMT()
|
||||
signStr, err := BuildSign("", string(body), c.appSecret, timestamp)
|
||||
if c.uppercaseSign {
|
||||
@@ -93,7 +132,7 @@ func (c *Client) DoPostJSONRequest(ctx context.Context, endpoint string, body []
|
||||
req.Header.Set("Sign", c.formatSign(signStr))
|
||||
req.Header.Set("Timestamp", timestamp)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.doRequest(req)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// doGetRequest 封装通用的 GET 请求构建逻辑
|
||||
@@ -127,7 +166,11 @@ func (c *Client) formatSign(sign string) string {
|
||||
|
||||
// doRequest 执行HTTP请求
|
||||
func (c *Client) doRequest(req *http.Request) ([]byte, error) {
|
||||
resp, err := c.http.Do(req)
|
||||
return c.doRequestWithClient(c.http, req)
|
||||
}
|
||||
|
||||
func (c *Client) doRequestWithClient(client *http.Client, req *http.Request) ([]byte, error) {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -2,11 +2,11 @@ package upstreamimpl_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMingqianRegistration(t *testing.T) {
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/daihe"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const daiheTemplateKey = "sim_daihe_v1"
|
||||
|
||||
@@ -20,7 +20,7 @@ func (diagnosisTestToken) RecoverToken(context.Context, string) (string, error)
|
||||
|
||||
func TestYdImeiUsesTerminalDeviceInfo(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v5/ec/query/sim-terminal-device-info" || r.URL.Query().Get("iccid") != "898604F4152391115474" {
|
||||
if r.URL.Path != "/v5/ec/query/sim-terminal-device-info" || r.URL.Query().Get("iccid") != "89860000000000000004" {
|
||||
t.Errorf("unexpected request %s", r.URL.String())
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"terminalDeviceImei":"123456789012345"}]}`))
|
||||
@@ -30,7 +30,7 @@ func TestYdImeiUsesTerminalDeviceInfo(t *testing.T) {
|
||||
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "test", AppKey: "test"}},
|
||||
tokenProvider: diagnosisTestToken{}, apiConfig: &oneLinkAPIConfig{BaseURL: server.URL + "/v5"},
|
||||
}
|
||||
result, err := sim.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "898604F4152391115474"}}})
|
||||
result, err := sim.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "89860000000000000004"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -63,14 +63,14 @@ func TestDxRegionLimitStatusByOrder(t *testing.T) {
|
||||
|
||||
func TestDxCardBindingUsesDedicatedEndpoint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/corp/connect/chkDeviceBindingInfo" || r.URL.Query().Get("iccid") != "8986112223600594371" {
|
||||
if r.URL.Path != "/api/v1/corp/connect/chkDeviceBindingInfo" || r.URL.Query().Get("iccid") != "8986000000000000007" {
|
||||
t.Errorf("unexpected request %s", r.URL.String())
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":"0","data":{"orderStaus":"0","bindType":"1","bindImei":"123456789012345","bindStaus":"1"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
result, err := sim.QuerySimCardBindStatus(context.Background(), &model.SimCardBindStatusInput{SimKeys: []*model.SimKey{{ICCID: "8986112223600594371"}}})
|
||||
result, err := sim.QuerySimCardBindStatus(context.Background(), &model.SimCardBindStatusInput{SimKeys: []*model.SimKey{{ICCID: "8986000000000000007"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -89,13 +89,13 @@ func TestDxImeiReadsNQ003Response(t *testing.T) {
|
||||
{`{"RESULT":"S00004","MESSAGE":"没有查询权限"}`, 9999, "", "没有查询权限"},
|
||||
} {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/openapi/v1/prodinst/gete2einfo" || r.URL.Query().Get("iccid") != "8986112223600594371" {
|
||||
if r.URL.Path != "/openapi/v1/prodinst/gete2einfo" || r.URL.Query().Get("iccid") != "8986000000000000007" {
|
||||
t.Errorf("unexpected request: %s", r.URL.String())
|
||||
}
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
results, err := sim.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "8986112223600594371"}}})
|
||||
results, err := sim.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "8986000000000000007"}}})
|
||||
server.Close()
|
||||
if err != nil || len(results) != 1 || results[0].Code != tc.wantCode || results[0].ResData.Imei != tc.wantImei || results[0].Message != tc.wantMessage {
|
||||
t.Errorf("response = %+v, error = %v", results, err)
|
||||
|
||||
@@ -6,10 +6,6 @@ import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"math"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -17,6 +13,10 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -132,6 +133,27 @@ const dxCutNetSuccessCode = "0000"
|
||||
// 电信侧没有「按 APN 定向」这一维度,因此忽略调用方携带的 ApnName
|
||||
// (预警自动断网、批量数据开停都会固定带 apnName="CMIOT"),不能因此拒绝请求。
|
||||
func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
checkpoint, durable := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
|
||||
if !durable {
|
||||
return s.changeSimApnFunctionCards(ctx, input)
|
||||
}
|
||||
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
one := *input
|
||||
one.SimKeys = []*model.SimKey{key}
|
||||
result, err := s.changeSimApnFunctionCards(ctx, &one)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, result...)
|
||||
if err := checkpoint(items); err != nil {
|
||||
return nil, fmt.Errorf("电信 v1 第%d张卡办理结果持久化失败,停止后续写入:%w", len(items), err)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimDX) changeSimApnFunctionCards(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
// 响应结构 (XML)
|
||||
type dxCutNetResponse struct {
|
||||
XMLName xml.Name `xml:"SvcCont"`
|
||||
@@ -144,6 +166,7 @@ func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardO
|
||||
}
|
||||
|
||||
action, ok := convertDxApnOperType(ctx, input.ReqData.OperType)
|
||||
_, durable := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
|
||||
|
||||
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
|
||||
if !ok {
|
||||
@@ -173,15 +196,16 @@ func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardO
|
||||
"access_number": []string{accessNumber},
|
||||
"action": []string{action},
|
||||
}
|
||||
respBody, err := s.client.DoGetRequest(ctx, "/5gcmp/openapi/v1/common/singleCutNet", params)
|
||||
// singleCutNet 虽使用 GET,但为非幂等写请求,禁止运输层重放和重定向。
|
||||
respBody, err := s.client.DoGetWriteRequest(ctx, "/5gcmp/openapi/v1/common/singleCutNet", params)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "ChangeSimApnFunction error %+v, simKey %s, params %+v", err, simKey.ChanKey, params)
|
||||
g.Log().Errorf(ctx, "电信单独断网请求异常,办理结果未确认,chanKey=%s,错误类型=%T", simKey.ChanKey, err)
|
||||
item.Code, item.Message = consts.ApiStatusError, err.Error()
|
||||
item.ResData.Uncertain = true
|
||||
continue
|
||||
}
|
||||
|
||||
g.Log().Debugf(ctx, "ChangeSimApnFunction response: %s", string(respBody))
|
||||
|
||||
// 去除XML命名空间前缀
|
||||
xmlBody := xmlNamespaceRegex.ReplaceAllString(string(respBody), "")
|
||||
|
||||
@@ -189,18 +213,21 @@ func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardO
|
||||
if err = xml.Unmarshal([]byte(xmlBody), &dxResp); err != nil {
|
||||
g.Log().Errorf(ctx, "ChangeSimApnFunction unmarshal error %+v, body %s", err, xmlBody)
|
||||
item.Code, item.Message = consts.ApiStatusError, err.Error()
|
||||
item.ResData.Uncertain = true
|
||||
continue
|
||||
}
|
||||
|
||||
item.Message = strings.TrimSpace(dxResp.Response.RspDesc)
|
||||
rawCode := strings.TrimSpace(dxResp.Response.RspCode)
|
||||
item.ErrCode = rawCode
|
||||
// 缺返回码、非 0000 均不视为成功,不自动重试以免重复操作。
|
||||
// 缺返回码属于未确认;明确拒绝无需按未知结果重试。
|
||||
if rawCode != dxCutNetSuccessCode {
|
||||
g.Log().Warningf(ctx, "ChangeSimApnFunction upstream error %s, %s, simKey %s", rawCode, item.Message, simKey.ChanKey)
|
||||
item.Code = consts.ApiStatusError
|
||||
if item.Message == "" {
|
||||
if rawCode == "" {
|
||||
item.ResData.Uncertain = true
|
||||
item.Message = "电信单独断网未返回结果码,办理结果未确认,请核对上游"
|
||||
} else if item.Message == "" {
|
||||
item.Message = "电信单独断网请求被上游拒绝"
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -209,6 +236,10 @@ func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardO
|
||||
item.ResData.OrderNum = strings.TrimSpace(dxResp.GroupTransactionID)
|
||||
if item.ResData.OrderNum == "" {
|
||||
item.Code, item.Message = consts.ApiStatusError, "电信单独断网未返回订单号,办理结果未确认,请核对上游"
|
||||
item.ResData.Uncertain = true
|
||||
} else if input.ReqData.SubmitOnly || durable {
|
||||
item.ResData.Uncertain = true
|
||||
item.Message = "电信单独断网请求已受理,最终办理结果未确认,请核对上游"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
@@ -40,7 +41,7 @@ func TestDXQueryAPNV3(t *testing.T) {
|
||||
if request.URL.Path != "/api/v3/getApnInfo" || request.Method != http.MethodGet {
|
||||
t.Errorf("unexpected request: %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
if request.URL.Query().Get("msisdn") != "138****8000" || request.URL.Query().Get("access_number") != "" {
|
||||
if request.URL.Query().Get("msisdn") != "13300000000" || request.URL.Query().Get("access_number") != "" {
|
||||
t.Errorf("query = %s", request.URL.RawQuery)
|
||||
}
|
||||
if request.Header.Get("AppKey") != "test" || request.Header.Get("Sign") == "" || request.Header.Get("Timestamp") == "" {
|
||||
@@ -50,7 +51,7 @@ func TestDXQueryAPNV3(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
output, err := sim.QuerySimApnInfo(context.Background(), &model.SimApnInfoInput{SimKeys: []*model.SimKey{{MSISDN: "138****8000"}}})
|
||||
output, err := sim.QuerySimApnInfo(context.Background(), &model.SimApnInfoInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}})
|
||||
if err != nil || len(output) != 1 {
|
||||
t.Fatalf("output = %+v, error = %v", output, err)
|
||||
}
|
||||
@@ -75,8 +76,8 @@ func TestDXAPNIdentifierFallback(t *testing.T) {
|
||||
param string
|
||||
value string
|
||||
}{
|
||||
{name: "iccid", key: &model.SimKey{ICCID: " 8986110000000000000 "}, param: "iccid", value: "8986110000000000000"},
|
||||
{name: "imsi", key: &model.SimKey{IMSI: "460030000000000"}, param: "imsi", value: "460030000000000"},
|
||||
{name: "iccid", key: &model.SimKey{ICCID: " 8986000000000000006 "}, param: "iccid", value: "8986000000000000006"},
|
||||
{name: "imsi", key: &model.SimKey{IMSI: "460000000000001"}, param: "imsi", value: "460000000000001"},
|
||||
{name: "nil", key: nil},
|
||||
{name: "empty", key: &model.SimKey{}},
|
||||
}
|
||||
@@ -129,7 +130,7 @@ func TestDXChangeAPNSingleCutNet(t *testing.T) {
|
||||
if query.Get("action") != test.wantAction {
|
||||
t.Errorf("action = %q, want %q", query.Get("action"), test.wantAction)
|
||||
}
|
||||
if query.Get("access_number") != "138****8000" {
|
||||
if query.Get("access_number") != "13300000000" {
|
||||
t.Errorf("access_number = %q", query.Get("access_number"))
|
||||
}
|
||||
// 不能携带 APN 定向参数,singleCutNet 是整卡语义。
|
||||
@@ -145,7 +146,7 @@ func TestDXChangeAPNSingleCutNet(t *testing.T) {
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
// 即使调用方传了移动默认的 apnName="CMIOT",也必须正常执行。
|
||||
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: []*model.SimKey{{MSISDN: "138****8000", ChanKey: "71"}},
|
||||
SimKeys: []*model.SimKey{{MSISDN: "13300000000", ChanKey: "71"}},
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{ApnName: "CMIOT", OperType: test.operType},
|
||||
})
|
||||
if err != nil || len(output) != 1 || output[0].Code != 0 || output[0].ResData.OrderNum != "order-1" || requests != 1 {
|
||||
@@ -155,6 +156,138 @@ func TestDXChangeAPNSingleCutNet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXSingleCutNetAcceptanceModes(t *testing.T) {
|
||||
for _, mode := range []string{"同步兼容", "仅提交", "持久化回调"} {
|
||||
for _, accepted := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("%s/受理=%t", mode, accepted), func(t *testing.T) {
|
||||
writes, checkpoints := 0, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writes++
|
||||
code := "1000"
|
||||
if accepted {
|
||||
code = "0000"
|
||||
}
|
||||
fmt.Fprintf(w, `<SvcCont><Response><RspCode>%s</RspCode><RspDesc>响应</RspDesc></Response><GROUP_TRANSACTIONID>order-%d</GROUP_TRANSACTIONID></SvcCont>`, code, writes)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
|
||||
wantUncertain := accepted && mode != "同步兼容"
|
||||
check := func(items model.SimCardOperateChangeApnOutput) {
|
||||
t.Helper()
|
||||
for i, item := range items {
|
||||
if (item.Code == 0) != accepted || item.ResData.Uncertain != wantUncertain || item.ResData.Pending {
|
||||
t.Fatalf("受理与完成分类错误:%+v", item)
|
||||
}
|
||||
if accepted && (item.ErrCode != "0000" || item.ResData.OrderNum != fmt.Sprintf("order-%d", i+1)) {
|
||||
t.Fatalf("未保留逐卡受理流水与原始码:%+v", item)
|
||||
}
|
||||
if wantUncertain && !strings.Contains(item.Message, "最终办理结果未确认") {
|
||||
t.Fatalf("受理消息未明确最终结果未知:%s", item.Message)
|
||||
}
|
||||
if !accepted && (item.ErrCode != "1000" || item.ResData.OrderNum != "") {
|
||||
t.Fatalf("明确拒绝不应携带受理流水:%+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
if mode == "持久化回调" {
|
||||
ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error {
|
||||
checkpoints++
|
||||
check(items)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
items, err := sim.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "card-1", MSISDN: "test-msisdn-1"}, {ChanKey: "test", ICCID: "card-2", MSISDN: "test-msisdn-2"}},
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: mode == "仅提交"},
|
||||
})
|
||||
if err != nil || len(items) != 2 || writes != 2 || (mode == "持久化回调" && checkpoints != 2) {
|
||||
t.Fatalf("逐卡处理异常:items=%+v writes=%d checkpoints=%d err=%v", items, writes, checkpoints, err)
|
||||
}
|
||||
check(items)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXSingleCutNetRedirectDoesNotReplay(t *testing.T) {
|
||||
for _, status := range []int{301, 302, 307, 308} {
|
||||
t.Run(fmt.Sprint(status), func(t *testing.T) {
|
||||
writes, redirected := 0, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/redirected" {
|
||||
redirected++
|
||||
return
|
||||
}
|
||||
writes++
|
||||
w.Header().Set("Location", "/redirected")
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
|
||||
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: []*model.SimKey{{MSISDN: "13300000000"}},
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"},
|
||||
})
|
||||
if err != nil || len(output) != 1 {
|
||||
t.Fatalf("output=%+v err=%v", output, err)
|
||||
}
|
||||
if writes != 1 || redirected != 0 || output[0].Code == 0 || !output[0].ResData.Uncertain {
|
||||
t.Fatalf("writes=%d redirected=%d output=%+v", writes, redirected, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXSingleCutNetResponseLossIsUncertain(t *testing.T) {
|
||||
writes := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writes++
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Errorf("hijack: %v", err)
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
|
||||
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: []*model.SimKey{{MSISDN: "13300000000"}},
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"},
|
||||
})
|
||||
if err != nil || len(output) != 1 {
|
||||
t.Fatalf("output=%+v err=%v", output, err)
|
||||
}
|
||||
if writes != 1 || output[0].Code == 0 || !output[0].ResData.Uncertain {
|
||||
t.Fatalf("writes=%d output=%+v", writes, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXSingleCutNetDurableCheckpoint(t *testing.T) {
|
||||
writes, checkpoints := 0, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writes++
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><SvcCont><Response><RspCode>0000</RspCode></Response><GROUP_TRANSACTIONID>job-1</GROUP_TRANSACTIONID></SvcCont>`)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
|
||||
ctx := context.WithValue(context.Background(), model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error {
|
||||
checkpoints++
|
||||
if len(items) != 1 || items[0].ResData.OrderNum != "job-1" {
|
||||
t.Errorf("checkpoint must preserve the first card job ID: %+v", items)
|
||||
}
|
||||
return fmt.Errorf("stop after durable checkpoint")
|
||||
})
|
||||
_, err := sim.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: []*model.SimKey{{MSISDN: "13300000000"}, {MSISDN: "13300000001"}},
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"},
|
||||
})
|
||||
if err == nil || writes != 1 || checkpoints != 1 {
|
||||
t.Fatalf("must stop before second write: writes=%d checkpoints=%d err=%v", writes, checkpoints, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDXChangeAPNSingleCutNetFailClosed 验证失败保护:不成功不重试、不盲目视为成功。
|
||||
func TestDXChangeAPNSingleCutNetFailClosed(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -166,12 +299,12 @@ func TestDXChangeAPNSingleCutNetFailClosed(t *testing.T) {
|
||||
wantRequests int
|
||||
}{
|
||||
{name: "missing access number", operType: "0"},
|
||||
{name: "invalid operation", operType: "2", msisdn: "138****8000"},
|
||||
{name: "carrier rejected", operType: "1", msisdn: "138****8000", body: `<?xml version="1.0"?><SvcCont><Response><RspCode>1000</RspCode><RspDesc>rejected</RspDesc></Response></SvcCont>`, wantRequests: 1},
|
||||
{name: "missing result code", operType: "0", msisdn: "138****8000", body: `<?xml version="1.0"?><SvcCont><Response><RspDesc>ok</RspDesc></Response><GROUP_TRANSACTIONID>order</GROUP_TRANSACTIONID></SvcCont>`, wantRequests: 1},
|
||||
{name: "missing order number", operType: "0", msisdn: "138****8000", body: `<?xml version="1.0"?><SvcCont><Response><RspCode>0000</RspCode><RspDesc>ok</RspDesc></Response></SvcCont>`, wantRequests: 1},
|
||||
{name: "malformed response", operType: "0", msisdn: "138****8000", body: `{"code":"0"}`, wantRequests: 1},
|
||||
{name: "HTTP failed no retry", operType: "0", msisdn: "138****8000", body: `gateway down`, httpStatus: 503, wantRequests: 1},
|
||||
{name: "invalid operation", operType: "2", msisdn: "13300000000"},
|
||||
{name: "carrier rejected", operType: "1", msisdn: "13300000000", body: `<?xml version="1.0"?><SvcCont><Response><RspCode>1000</RspCode><RspDesc>rejected</RspDesc></Response></SvcCont>`, wantRequests: 1},
|
||||
{name: "missing result code", operType: "0", msisdn: "13300000000", body: `<?xml version="1.0"?><SvcCont><Response><RspDesc>ok</RspDesc></Response><GROUP_TRANSACTIONID>order</GROUP_TRANSACTIONID></SvcCont>`, wantRequests: 1},
|
||||
{name: "missing order number", operType: "0", msisdn: "13300000000", body: `<?xml version="1.0"?><SvcCont><Response><RspCode>0000</RspCode><RspDesc>ok</RspDesc></Response></SvcCont>`, wantRequests: 1},
|
||||
{name: "malformed response", operType: "0", msisdn: "13300000000", body: `{"code":"0"}`, wantRequests: 1},
|
||||
{name: "HTTP failed no retry", operType: "0", msisdn: "13300000000", body: `gateway down`, httpStatus: 503, wantRequests: 1},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
@@ -4,13 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"strings"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
func dxSessionAccessMode(netModel string, accessType string) string {
|
||||
|
||||
@@ -3,11 +3,11 @@ package upstreamimpl
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
func TestDXNetworkBlockStatus(t *testing.T) {
|
||||
@@ -38,14 +38,14 @@ func TestDXNetworkBlockStatus(t *testing.T) {
|
||||
|
||||
func TestDXCardStatusIncludesNetworkBlock(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/openapi/v1/prodinst/queryCardMainStatus" || request.URL.Query().Get("access_number") != "1410964350275" {
|
||||
if request.URL.Path != "/openapi/v1/prodinst/queryCardMainStatus" || request.URL.Query().Get("access_number") != "1440000000001" {
|
||||
t.Errorf("request = %s", request.URL)
|
||||
}
|
||||
fmt.Fprint(writer, `{"result":"0","resultMsg":"处理成功","productInfo":[{"productMainStatusCd":"4"}],"netBlockStatusName":"已断网","netBlockTypeList":[{"blockType":"1","blockTypeName":"达量断网"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
|
||||
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "1410964350275"}}})
|
||||
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "1440000000001"}}})
|
||||
if err != nil || len(output) != 1 {
|
||||
t.Fatalf("output = %v, err = %v", output, err)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestQuerySimRegionLimitStatusUsesDocumentedParameter(t *testing.T) {
|
||||
if r.URL.Path != "/openapi/v1/areaLimit/qryAreaLimitInfo" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("accNum"); got != "138****8000" {
|
||||
if got := r.URL.Query().Get("accNum"); got != "13300000000" {
|
||||
t.Fatalf("accNum = %q", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("access_number"); got != "" {
|
||||
@@ -76,7 +76,7 @@ func TestQuerySimRegionLimitStatusUsesDocumentedParameter(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
output, err := sim.QuerySimRegionLimitStatus(context.Background(), &model.SimRegionLimitStatusInput{SimKeys: []*model.SimKey{{MSISDN: "138****8000"}}})
|
||||
output, err := sim.QuerySimRegionLimitStatus(context.Background(), &model.SimRegionLimitStatusInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func TestQuerySimCardStatusPreservesTelecomHTTPRespCode(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "138****8000"}}})
|
||||
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func TestQuerySimCardStatusPreservesTelecomBusinessCode(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "138****8000"}}})
|
||||
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -53,14 +53,14 @@ func TestQuerySimDataUsageMonthlyUsesBatchForCurrentMonth(t *testing.T) {
|
||||
if len(request.AccessNumbers) != 2 || request.CustNumber != "cust-1" {
|
||||
t.Fatalf("request = %+v", request)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"查询成功","data":[{"accessNumber":"138****8001","result":"1","unit":"MB","flowAmount":"12.5"},{"accessNumber":"138****8002","result":"1","unit":"MB","flowAmount":"7.5"}]}`))
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"查询成功","data":[{"accessNumber":"13300000001","result":"1","unit":"MB","flowAmount":"12.5"},{"accessNumber":"13300000002","result":"1","unit":"MB","flowAmount":"7.5"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"}), custNumber: "cust-1"}
|
||||
queryMonth := time.Now().In(dxBatchLocation).Format("200601")
|
||||
output, err := sim.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{
|
||||
SimKeys: []*model.SimKey{{MSISDN: "138****8001"}, {MSISDN: "138****8002"}},
|
||||
SimKeys: []*model.SimKey{{MSISDN: "13300000001"}, {MSISDN: "13300000002"}},
|
||||
ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: queryMonth},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -92,7 +92,7 @@ func TestQuerySimDataUsageDailyLegacyFallbackIsBounded(t *testing.T) {
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret", Timeout: time.Second})}
|
||||
keys := make([]*model.SimKey, 12)
|
||||
for index := range keys {
|
||||
keys[index] = &model.SimKey{MSISDN: "138****8003"}
|
||||
keys[index] = &model.SimKey{MSISDN: "13300000000"}
|
||||
}
|
||||
output, err := sim.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{
|
||||
SimKeys: keys,
|
||||
@@ -130,7 +130,7 @@ func TestQuerySimDataUsageDailyRejectsMalformedXML(t *testing.T) {
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "test"})}
|
||||
output, err := sim.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{
|
||||
SimKeys: []*model.SimKey{{ICCID: "audit-card", MSISDN: "138****8001"}},
|
||||
SimKeys: []*model.SimKey{{ICCID: "audit-card", MSISDN: "13300000001"}},
|
||||
ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20260101"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -162,7 +162,7 @@ func TestQuerySimDataUsageDailyBatchFailureFallsBackToLegacy(t *testing.T) {
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "test"}), custNumber: "audit-customer"}
|
||||
queryDate := time.Now().In(dxBatchLocation).AddDate(0, 0, -1).Format("20060102")
|
||||
output, err := sim.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{
|
||||
SimKeys: []*model.SimKey{{MSISDN: "138****8001"}},
|
||||
SimKeys: []*model.SimKey{{MSISDN: "13300000001"}},
|
||||
ReqData: model.SimDataUsageDailyInputItem{QueryDate: queryDate},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -6,16 +6,16 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
@@ -84,7 +84,11 @@ func (s *sSimDXV3) request(ctx context.Context, path string, params url.Values,
|
||||
} else {
|
||||
data, err = json.Marshal(body)
|
||||
if err == nil {
|
||||
data, err = s.client.DoPostJSONRequest(ctx, "/api/v3/"+path, data)
|
||||
if path == "singleCutNet" {
|
||||
data, err = s.client.DoPostJSONWriteRequest(ctx, "/api/v3/"+path, data)
|
||||
} else {
|
||||
data, err = s.client.DoPostJSONRequest(ctx, "/api/v3/"+path, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -180,6 +184,7 @@ func dxV3QueryOne[T any](ctx context.Context, s *sSimDXV3, key *model.SimKey, pa
|
||||
err = prepare(params)
|
||||
}
|
||||
var result map[string]any
|
||||
apnWriteStarted := err == nil && post && path == "singleCutNet"
|
||||
if err == nil {
|
||||
var body any
|
||||
if post {
|
||||
@@ -204,7 +209,14 @@ func dxV3QueryOne[T any](ctx context.Context, s *sSimDXV3, key *model.SimKey, pa
|
||||
output, err = convert(result)
|
||||
}
|
||||
if err != nil {
|
||||
return dxV3Failure[T](key, err)
|
||||
item := dxV3Failure[T](key, err)
|
||||
var business *dxV3Error
|
||||
if apnWriteStarted && !errors.As(err, &business) {
|
||||
if apn, ok := any(&item.ResData).(*model.SimCardOperateChangeApnItem); ok {
|
||||
apn.Uncertain = true
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
return &model.SimBaseRes[T]{SimKey: key, ResData: output}
|
||||
}
|
||||
@@ -540,7 +552,7 @@ func (s *sSimDXV3) ChangeSimStatus(ctx context.Context, input *model.SimCardStat
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "singleCutNet", func(params url.Values) error {
|
||||
prepare := func(params url.Values) error {
|
||||
if !s.enableOperations {
|
||||
return fmt.Errorf("telecom v3 operations are disabled")
|
||||
}
|
||||
@@ -559,10 +571,27 @@ func (s *sSimDXV3) ChangeSimApnFunction(ctx context.Context, input *model.SimCar
|
||||
return fmt.Errorf("unsupported data operType")
|
||||
}
|
||||
return nil
|
||||
}, func(item map[string]any) (model.SimCardOperateChangeApnItem, error) {
|
||||
}
|
||||
convert := func(item map[string]any) (model.SimCardOperateChangeApnItem, error) {
|
||||
order, err := dxV3Order(item)
|
||||
return model.SimCardOperateChangeApnItem{OrderNum: order.OrderNum}, err
|
||||
}, true), nil
|
||||
}
|
||||
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
|
||||
checkpoint, durable := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
|
||||
for _, key := range input.SimKeys {
|
||||
item := dxV3QueryOne(ctx, s, key, "singleCutNet", prepare, convert, true)
|
||||
if item.Code == consts.ApiStatusSuccess && (input.ReqData.SubmitOnly || durable) {
|
||||
item.ResData.Uncertain = true
|
||||
item.Message = "电信单独断网请求已受理,最终办理结果未确认,请核对上游"
|
||||
}
|
||||
items = append(items, item)
|
||||
if durable {
|
||||
if err := checkpoint(items); err != nil {
|
||||
return nil, fmt.Errorf("电信 v3 第%d张卡办理结果持久化失败,停止后续写入:%w", len(items), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
var _ model.SimCardChannelI = (*sSimDXV3)(nil)
|
||||
|
||||
@@ -3,9 +3,9 @@ package upstreamimpl
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"strings"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
const dxV3BatchLimit = 100
|
||||
|
||||
@@ -4,17 +4,18 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newDXV3TestClient(t *testing.T, handler http.HandlerFunc) *sSimDXV3 {
|
||||
@@ -24,6 +25,167 @@ func newDXV3TestClient(t *testing.T, handler http.HandlerFunc) *sSimDXV3 {
|
||||
return &sSimDXV3{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test-key", AppSecret: "test-secret", UppercaseSign: true}), custNbr: "test-customer"}
|
||||
}
|
||||
|
||||
func TestDXV3APNWriteUncertainAndNoReplay(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
drop, uncertain bool
|
||||
redirect int
|
||||
}{
|
||||
{name: "断响应", drop: true, uncertain: true},
|
||||
{name: "坏JSON", body: "{", uncertain: true},
|
||||
{name: "缺result", body: `{"code":0}`, uncertain: true},
|
||||
{name: "卡号不符", body: `{"code":0,"result":{"msisdn":"other","transactionId":"job"}}`, uncertain: true},
|
||||
{name: "缺transactionId", body: `{"code":0,"result":{}}`, uncertain: true},
|
||||
{name: "明确拒绝", body: `{"code":9999,"message":"拒绝"}`},
|
||||
{name: "明确成功", body: `{"code":0,"result":{"transactionId":"job"}}`},
|
||||
{name: "跳转307", redirect: 307, uncertain: true},
|
||||
{name: "跳转308", redirect: 308, uncertain: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var writes, targetWrites atomic.Int32
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("办理方法被改变:%s", r.Method)
|
||||
}
|
||||
writes.Add(1)
|
||||
if r.URL.Path == "/redirect-target" {
|
||||
targetWrites.Add(1)
|
||||
fmt.Fprint(w, `{"code":0,"result":{"transactionId":"unexpected"}}`)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/api/v3/singleCutNet" {
|
||||
t.Errorf("错误办理路径:%s", r.URL.Path)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["msisdn"] != "fake-msisdn" || body["operType"] != "1" {
|
||||
t.Errorf("办理请求不符:%+v %v", body, err)
|
||||
}
|
||||
if tc.drop {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
if tc.redirect != 0 {
|
||||
w.Header().Set("Location", "/redirect-target")
|
||||
w.WriteHeader(tc.redirect)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, tc.body)
|
||||
})
|
||||
client.enableOperations = true
|
||||
out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{MSISDN: "fake-msisdn"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0"}})
|
||||
if err != nil || len(out) != 1 || writes.Load() != 1 || targetWrites.Load() != 0 || out[0].ResData.Uncertain != tc.uncertain || out[0].ResData.Pending {
|
||||
t.Fatalf("写后分类/重放错误:out=%+v writes=%d target=%d err=%v", out, writes.Load(), targetWrites.Load(), err)
|
||||
}
|
||||
if (tc.name == "明确成功") != (out[0].Code == 0) {
|
||||
t.Fatalf("成功失败分类错误:%+v", out[0])
|
||||
}
|
||||
t.Logf("writes=%d targetWrites=%d uncertain=%v code=%d", writes.Load(), targetWrites.Load(), out[0].ResData.Uncertain, out[0].Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3SingleCutNetAcceptanceModes(t *testing.T) {
|
||||
for _, mode := range []string{"同步兼容", "仅提交", "持久化回调"} {
|
||||
for _, accepted := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("%s/受理=%t", mode, accepted), func(t *testing.T) {
|
||||
writes, checkpoints := 0, 0
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writes++
|
||||
if !accepted {
|
||||
fmt.Fprint(w, `{"code":9999,"message":"明确拒绝"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `{"code":0,"result":{"transactionId":"order-%d"}}`, writes)
|
||||
})
|
||||
client.enableOperations = true
|
||||
wantUncertain := accepted && mode != "同步兼容"
|
||||
check := func(items model.SimCardOperateChangeApnOutput) {
|
||||
t.Helper()
|
||||
for i, item := range items {
|
||||
if (item.Code == 0) != accepted || item.ResData.Uncertain != wantUncertain || item.ResData.Pending {
|
||||
t.Fatalf("受理与完成分类错误:%+v", item)
|
||||
}
|
||||
if accepted && item.ResData.OrderNum != fmt.Sprintf("order-%d", i+1) {
|
||||
t.Fatalf("未保留逐卡受理流水:%+v", item)
|
||||
}
|
||||
if wantUncertain && !strings.Contains(item.Message, "最终办理结果未确认") {
|
||||
t.Fatalf("受理消息未明确最终结果未知:%s", item.Message)
|
||||
}
|
||||
if !accepted && (item.ErrCode != "9999" || item.ResData.OrderNum != "") {
|
||||
t.Fatalf("明确拒绝不应携带受理流水:%+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
if mode == "持久化回调" {
|
||||
ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error {
|
||||
checkpoints++
|
||||
check(items)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
items, err := client.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "card-1", MSISDN: "test-msisdn-1"}, {ChanKey: "test", ICCID: "card-2", MSISDN: "test-msisdn-2"}},
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: mode == "仅提交"},
|
||||
})
|
||||
if err != nil || len(items) != 2 || writes != 2 || (mode == "持久化回调" && checkpoints != 2) {
|
||||
t.Fatalf("逐卡处理异常:items=%+v writes=%d checkpoints=%d err=%v", items, writes, checkpoints, err)
|
||||
}
|
||||
check(items)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3APNPrewriteValidationSendsNothing(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
enabled bool
|
||||
key *model.SimKey
|
||||
oper string
|
||||
}{
|
||||
{"禁用办理", false, &model.SimKey{MSISDN: "fake"}, "0"},
|
||||
{"缺卡号", true, nil, "0"},
|
||||
{"缺msisdn", true, &model.SimKey{ICCID: "fake"}, "0"},
|
||||
{"错误操作", true, &model.SimKey{MSISDN: "fake"}, "invalid"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var writes atomic.Int32
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { writes.Add(1) })
|
||||
client.enableOperations = tc.enabled
|
||||
out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{tc.key}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: tc.oper}})
|
||||
if err != nil || len(out) != 1 || writes.Load() != 0 || out[0].Code == 0 || out[0].ResData.Uncertain {
|
||||
t.Fatalf("预写拒绝错误:out=%+v writes=%d err=%v", out, writes.Load(), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3ReadRedirectUnchanged(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("查询方法改变:%s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/read-target" {
|
||||
w.Header().Set("Location", "/read-target")
|
||||
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0,"result":{"dataAmount":"2"}}`)
|
||||
})
|
||||
out, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{MSISDN: "fake"}}})
|
||||
if err != nil || len(out) != 1 || out[0].Code != 0 || calls.Load() != 2 {
|
||||
t.Fatalf("查询跳转被改变:%+v %v calls=%d", out, err, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3TemplateCoexistsWithV1(t *testing.T) {
|
||||
for _, key := range []string{"sim_dx_v1", "sim_dx_v3"} {
|
||||
if _, ok := service.UpstreamAPI().GetTemp(key); !ok {
|
||||
@@ -57,7 +219,7 @@ func TestDXV3SessionAndRealTimeUsageContracts(t *testing.T) {
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/getOnlineStatus":
|
||||
fmt.Fprint(w, `{"code":"0","result":{"onlineStatus":"1","apnName":"iot.apn","ipv4Address":"192.0.2.1"}}`)
|
||||
fmt.Fprint(w, `{"code":"0","result":{"onlineStatus":"1","apnName":"iot.apn","ipv4Address":"127.0.0.1"}}`)
|
||||
case "/api/v3/getDataUsage":
|
||||
fmt.Fprint(w, `{"code":0,"result":{"dataAmount":"12.5"}}`)
|
||||
default:
|
||||
|
||||
@@ -2,9 +2,9 @@ package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
@@ -2,10 +2,10 @@ package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/mingqian"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type simMingqianTemplate struct{}
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -15,11 +15,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -3,13 +3,13 @@ package upstreamimpl
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/recharge"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestRechargeFindCardCacheIsSharedAcrossDiagnosisQueries(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"msg":"ok","data":{"w_real_name":1,"c_no":"8986000000000000000","status_show_id":4,"c_total":1024,"c_used":128,"c_left":896,"prodinstexp_time":"2026-12-31","activate_date":"2026-01-01"}}`))
|
||||
_, _ = w.Write([]byte(`{"code":200,"msg":"ok","data":{"w_real_name":1,"c_no":"8986000000000000001","status_show_id":4,"c_total":1024,"c_used":128,"c_left":896,"prodinstexp_time":"2026-12-31","activate_date":"2026-01-01"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestRechargeFindCardCacheIsSharedAcrossDiagnosisQueries(t *testing.T) {
|
||||
Pwdiv: "secret",
|
||||
Timeout: time.Second,
|
||||
})}
|
||||
simKey := &model.SimKey{ICCID: "8986000000000000000"}
|
||||
simKey := &model.SimKey{ICCID: "8986000000000000001"}
|
||||
|
||||
basic, err := sim.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: []*model.SimKey{simKey}})
|
||||
if err != nil || len(basic) != 1 || basic[0].Code != 0 {
|
||||
|
||||
@@ -15,12 +15,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"golang.org/x/sync/singleflight"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -206,6 +206,18 @@ func (s *sSimTibiot) post(ctx context.Context, endpoint string, params map[strin
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: defaultUpstreamHTTPTimeout}
|
||||
}
|
||||
if endpoint == "/api/v1/card/cardNetWork" {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if original, ok := client.Transport.(*http.Transport); ok {
|
||||
transport = original.Clone()
|
||||
}
|
||||
transport.DisableKeepAlives = true
|
||||
defer transport.CloseIdleConnections()
|
||||
writeClient := *client
|
||||
writeClient.Transport = transport
|
||||
writeClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
client = &writeClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return tibiotResponse{}, fmt.Errorf("Tibiot request %s: %w", endpoint, err)
|
||||
@@ -930,10 +942,22 @@ func (s *sSimTibiot) ChangeSimApnFunction(ctx context.Context, input *model.SimC
|
||||
params["operateType"] = operateType
|
||||
resp, err := s.post(ctx, "/api/v1/card/cardNetWork", params)
|
||||
if err != nil {
|
||||
items = append(items, tibiotError[model.SimCardOperateChangeApnItem](simKey, err))
|
||||
item := tibiotError[model.SimCardOperateChangeApnItem](simKey, err)
|
||||
item.ResData.Uncertain = true
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
if !tibiotSuccess(resp) {
|
||||
statusText := strings.TrimSpace(fmt.Sprint(resp.Status))
|
||||
status, statusErr := strconv.Atoi(statusText)
|
||||
if statusErr != nil || (status == 0 && statusText != "0") {
|
||||
items = append(items, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
|
||||
SimKey: simKey, Code: consts.ApiStatusError,
|
||||
Message: "天比高 APN 开停响应状态无效,办理结果未确认:" + statusText,
|
||||
ResData: model.SimCardOperateChangeApnItem{Uncertain: true},
|
||||
})
|
||||
continue
|
||||
}
|
||||
if status != 0 {
|
||||
items = append(items, tibiotBusinessError[model.SimCardOperateChangeApnItem](simKey, resp))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -13,6 +13,86 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestTibiotAPNWriteResponseLossIsUncertain(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
drop bool
|
||||
uncertain bool
|
||||
wantCode int
|
||||
}{
|
||||
{"已办理后断响应", "", true, true, 9999},
|
||||
{"响应损坏", "{", false, true, 9999},
|
||||
{"缺status", `{}`, false, true, 9999},
|
||||
{"畸形status", `{"status":true}`, false, true, 9999},
|
||||
{"小数status", `{"status":0.5}`, false, true, 9999},
|
||||
{"明确拒绝", `{"status":9999,"message":"拒绝"}`, false, false, 9999},
|
||||
{"明确成功", `{"status":0,"message":"成功"}`, false, false, 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var writes atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/card/cardNetWork" {
|
||||
t.Errorf("错误路由:%s", r.URL.Path)
|
||||
}
|
||||
writes.Add(1)
|
||||
if tc.drop {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &sSimTibiot{baseURL: server.URL, userName: "tester", password: "fake", http: server.Client()}
|
||||
out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: []*model.SimKey{{ICCID: "fake-card"}},
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", SubmitOnly: true},
|
||||
})
|
||||
if err != nil || len(out) != 1 || writes.Load() != 1 || out[0].Code != tc.wantCode || out[0].ResData.Uncertain != tc.uncertain || out[0].ResData.Pending {
|
||||
t.Fatalf("写后语义错误:out=%+v writes=%d err=%v", out, writes.Load(), err)
|
||||
}
|
||||
t.Logf("writes=%d uncertain=%v code=%d", writes.Load(), out[0].ResData.Uncertain, out[0].Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTibiotAPNRedirectDoesNotReplayAndReadUnchanged(t *testing.T) {
|
||||
for _, status := range []int{307, 308} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
var writes, targets atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("方法改变:%s", r.Method)
|
||||
}
|
||||
writes.Add(1)
|
||||
if r.URL.Path == "/target" {
|
||||
targets.Add(1)
|
||||
_, _ = w.Write([]byte(`{"status":0}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/target")
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &sSimTibiot{baseURL: server.URL, userName: "tester", password: "fake", http: server.Client()}
|
||||
out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{ICCID: "fake"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0"}})
|
||||
if err != nil || len(out) != 1 || writes.Load() != 1 || targets.Load() != 0 || out[0].Code == 0 || !out[0].ResData.Uncertain {
|
||||
t.Fatalf("跳转重放/分类错误:out=%+v writes=%d targets=%d err=%v", out, writes.Load(), targets.Load(), err)
|
||||
}
|
||||
t.Logf("APN writes=%d targetWrites=%d uncertain=%v", writes.Load(), targets.Load(), out[0].ResData.Uncertain)
|
||||
resp, err := client.post(context.Background(), "/read-endpoint", map[string]any{})
|
||||
if err != nil || !tibiotSuccess(resp) || writes.Load() != 3 || targets.Load() != 1 {
|
||||
t.Fatalf("只读跳转改变:%+v %v calls=%d targets=%d", resp, err, writes.Load(), targets.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTibiotRateLimiterSpacesRequestsAndHonorsCancellation(t *testing.T) {
|
||||
limiter := newTibiotRateLimiter(25 * time.Millisecond)
|
||||
if err := limiter.wait(context.Background()); err != nil {
|
||||
@@ -67,7 +147,7 @@ func TestTibiotQuerySimDataUsageRetriesOnceAfterUpstreamFailure(t *testing.T) {
|
||||
Channel: &entity.TSimChannels{ChanConfig: `{"userName":"tester","password":"secret"}`},
|
||||
}).(*sSimTibiot)
|
||||
items, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{
|
||||
SimKeys: []*model.SimKey{{ICCID: "8986000000000000000"}},
|
||||
SimKeys: []*model.SimKey{{ICCID: "8986000000000000001"}},
|
||||
})
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("unexpected query result: items=%#v err=%v", items, err)
|
||||
@@ -80,7 +160,7 @@ func TestTibiotQuerySimDataUsageRetriesOnceAfterUpstreamFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
cachedItems, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{
|
||||
SimKeys: []*model.SimKey{{ICCID: "8986000000000000000"}},
|
||||
SimKeys: []*model.SimKey{{ICCID: "8986000000000000001"}},
|
||||
})
|
||||
if err != nil || len(cachedItems) != 1 {
|
||||
t.Fatalf("unexpected cached query result: items=%#v err=%v", cachedItems, err)
|
||||
|
||||
@@ -4,13 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
@@ -266,16 +266,16 @@ func (s *sSimYD) httpTimeout() time.Duration {
|
||||
}
|
||||
|
||||
// 单卡基本信息查询
|
||||
// http://api.iot.10086.cn/v5/ec/query/sim-basic-info?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474
|
||||
// http://api.iot.10086.cn/v5/ec/query/sim-basic-info?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000004
|
||||
func (s *sSimYD) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
|
||||
// {
|
||||
// "status": "0",
|
||||
// "message": "正确",
|
||||
// "result": [
|
||||
// {
|
||||
// "msisdn": "1441543605474",
|
||||
// "imsi": "460087436015474",
|
||||
// "iccid": "898604F4152391115474",
|
||||
// "msisdn": "1440000000002",
|
||||
// "imsi": "460000000000002",
|
||||
// "iccid": "89860000000000000004",
|
||||
// "activeDate": "2023-05-11 15:07:06", // 激活日期
|
||||
// "openDate": "2023-05-11 14:53:24", // 开卡日期
|
||||
// "remark": ""
|
||||
@@ -559,7 +559,7 @@ func (s *sSimYD) QuerySimSession(ctx context.Context, input *model.SimSessionInp
|
||||
}
|
||||
|
||||
// 5.1.6 单卡状态查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-status?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000004
|
||||
func (s *sSimYD) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/sim-status")
|
||||
|
||||
@@ -664,7 +664,7 @@ func (s *sSimYD) QuerySimCardStatus(ctx context.Context, input *model.SimCardSta
|
||||
}
|
||||
|
||||
// 物联网卡IMEI查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-imei?transid=371AIOT20210605101712810732026031520280010000000&token=YOUR_TOKEN&iccid=898604F81623D2249725
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-imei?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000005
|
||||
func (s *sSimYD) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/sim-stop-reason")
|
||||
|
||||
@@ -929,7 +929,7 @@ func (s *sSimYD) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (m
|
||||
}
|
||||
|
||||
// 单卡本月流量累计使用量查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-data-usage-inpool?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-data-usage-inpool?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003
|
||||
func (s *sSimYD) QuerySimDataUsageInPool(ctx context.Context, input *model.SimDataUsageInPoolInput) (model.SimDataUsageInPoolOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/sim-data-usage-inpool")
|
||||
// {"status":"0","message":"正确","result":[{"apnList":[{"apnName":"CMIOT","apnUseAmount":"5994479616"}]}]}
|
||||
@@ -1023,7 +1023,7 @@ func (s *sSimYD) QuerySimDataUsageInPool(ctx context.Context, input *model.SimDa
|
||||
}
|
||||
|
||||
// 单卡本月流量累计使用量查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-data-usage?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-data-usage?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003
|
||||
func (s *sSimYD) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/sim-data-usage")
|
||||
// {"status":"0","message":"正确","result":[{"dataAmount":"710569.00","apnUseAmountList":[{"apnName":"CMIOT","apnUseAmount":"710569.00","pccCodeUseAmountList":[]}]}]}
|
||||
@@ -1367,7 +1367,7 @@ func (s *sSimYD) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDat
|
||||
}
|
||||
|
||||
// 单卡已开通APN信息查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/apn-info?transid=C5010531Af52022004082026060114500001000000&token=YOUR_TOKEN&iccid=898604F4152391115473
|
||||
// https://api.iot.10086.cn/v5/ec/query/apn-info?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000003
|
||||
func (s *sSimYD) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/apn-info")
|
||||
// {"status":"0","message":"正确","result":[{"apnList":[{"apnName":"CMIOT","status":"1"}]}]}
|
||||
@@ -1487,7 +1487,7 @@ func (s *sSimYD) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInp
|
||||
|
||||
// 单卡状态变更
|
||||
// 平台 operType: 0=申请停机(已激活转已停机), 1=申请复机(已停机转已激活)
|
||||
// https://api.iot.10086.cn/v5/ec/change/sim-status?transid=371AIOT20210605101712810732026031315120010000000&token=YOUR_TOKEN&msisdn=1441589159774&operType=0
|
||||
// https://api.iot.10086.cn/v5/ec/change/sim-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003&operType=0
|
||||
func (s *sSimYD) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) {
|
||||
operType, ok := normalizeCardStatusChangeOperType(ctx, input.ReqData.OperType)
|
||||
if !ok {
|
||||
@@ -1495,7 +1495,7 @@ func (s *sSimYD) ChangeSimStatus(ctx context.Context, input *model.SimCardStatus
|
||||
}
|
||||
|
||||
url := s.getUrl(ctx, "ec/change/sim-status")
|
||||
// {"status":"0","message":"正确","result":[{"iccid":"898604F4152391115474","orderNum":"995000011515720993"}]}
|
||||
// {"status":"0","message":"正确","result":[{"iccid":"89860000000000000004","orderNum":"995000011515720993"}]}
|
||||
type YdResp struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
@@ -1701,7 +1701,7 @@ func (s *sSimYD) QuerySimCardBindStatus(ctx context.Context, input *model.SimCar
|
||||
}
|
||||
|
||||
// 单卡设备状态查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-operating-status?transid=xxx&token=xxx&iccid=xxx
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-operating-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=xxx
|
||||
// 上游返回 result 中的 operatingStatus/onOffStatus/status 作为设备状态
|
||||
func (s *sSimYD) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/sim-operating-status")
|
||||
|
||||
@@ -3,6 +3,8 @@ package upstreamimpl
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -10,7 +12,6 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// 移动 OneLink「通信功能开停」接口(2026 接口改版后,替代原 ec/operate/sim-apn-function)。
|
||||
@@ -75,6 +76,9 @@ type ydApnRequester interface {
|
||||
// changeSimApnFunctionYD 平台「开通数据(dataStart)/停用数据(dataStop)/达量断网(dataCut)」在移动侧的统一入口。
|
||||
func changeSimApnFunctionYD(ctx context.Context, c ydApnRequester, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
|
||||
if jobID := strings.TrimSpace(input.ReqData.QueryJobID); jobID != "" {
|
||||
return mapYdQueryResult(input.SimKeys, jobID, queryBatchResultOnceYD(ctx, c, jobID)), nil
|
||||
}
|
||||
|
||||
// 缺 msisdn 的卡不能进批量接口,先分流出来单独走单卡。
|
||||
withMsisdn := make([]*model.SimKey, 0, len(input.SimKeys))
|
||||
@@ -101,9 +105,29 @@ func changeSimApnFunctionYD(ctx context.Context, c ydApnRequester, input *model.
|
||||
continue
|
||||
}
|
||||
items = append(items, changeApnBatchYD(ctx, c, chunk, input.ReqData)...)
|
||||
if input.ReqData.SubmitOnly {
|
||||
if checkpoint, ok := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error); ok {
|
||||
if err := checkpoint(items); err != nil {
|
||||
g.Log().Errorf(ctx, "APN批量结果持久化失败 operationId=%s error=%v", input.ReqData.OperationID, err)
|
||||
return items, fmt.Errorf("APN批量结果持久化失败,停止后续办理:%w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, changeApnSingleYD(ctx, c, singleKeys, input.ReqData)...)
|
||||
if input.ReqData.SubmitOnly {
|
||||
for _, key := range singleKeys {
|
||||
items = append(items, changeApnSingleYD(ctx, c, []*model.SimKey{key}, input.ReqData)...)
|
||||
if checkpoint, ok := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error); ok {
|
||||
if err := checkpoint(items); err != nil {
|
||||
g.Log().Errorf(ctx, "APN单卡结果持久化失败 operationId=%s error=%v", input.ReqData.OperationID, err)
|
||||
return items, fmt.Errorf("APN单卡结果持久化失败,停止后续办理:%w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items = append(items, changeApnSingleYD(ctx, c, singleKeys, input.ReqData)...)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -123,7 +147,7 @@ func changeApnSingleYD(ctx context.Context, c ydApnRequester, simKeys []*model.S
|
||||
}
|
||||
|
||||
url := c.getUrl(ctx, ydCommunicationServicePath)
|
||||
// {"status":"0","message":"正确","result":[{"iccid":"898602B2221430000005","orderNum":"xxxxxxx"}]}
|
||||
// {"status":"0","message":"正确","result":[{"iccid":"89860000000000000002","orderNum":"xxxxxxx"}]}
|
||||
type ydSingleResp struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
@@ -156,19 +180,23 @@ func changeApnSingleYD(ctx context.Context, c ydApnRequester, simKeys []*model.S
|
||||
resp, err := c.requestYd(ctx, "ChangeSimApnFunction", url, params)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "changeApnSingleYD request error %+v, url %s, params %+v", err, url, params)
|
||||
items = append(items, ydApnErrorItem(simKey, err.Error()))
|
||||
item := ydApnErrorItem(simKey, err.Error())
|
||||
item.ResData.Uncertain = req.SubmitOnly
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
body := resp.ReadAllString()
|
||||
var ydResp ydSingleResp
|
||||
if err = json.Unmarshal([]byte(body), &ydResp); err != nil {
|
||||
g.Log().Errorf(ctx, "changeApnSingleYD unmarshal error %+v, url %s, params %+v, body %s", err, url, params, body)
|
||||
items = append(items, ydApnErrorItem(simKey, err.Error()))
|
||||
item := ydApnErrorItem(simKey, err.Error())
|
||||
item.ResData.Uncertain = req.SubmitOnly
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
if ydResp.Status != "0" {
|
||||
g.Log().Warningf(ctx, "changeApnSingleYD upstream error %s, url %s, params %+v, body %s", ydResp.Message, url, params, body)
|
||||
items = append(items, ydApnResultItem(simKey, gconv.Int(ydResp.Status), ydResp.Message, ""))
|
||||
items = append(items, ydApnRejectedItem(simKey, ydResp.Status, ydResp.Message))
|
||||
continue
|
||||
}
|
||||
orderNum := ""
|
||||
@@ -219,7 +247,9 @@ func changeApnBatchYD(ctx context.Context, c ydApnRequester, simKeys []*model.Si
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "changeApnBatchYD request error %+v, url %s, params %+v", err, url, params)
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnErrorItem(simKey, err.Error()))
|
||||
item := ydApnErrorItem(simKey, err.Error())
|
||||
item.ResData.Uncertain = req.SubmitOnly
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -228,14 +258,16 @@ func changeApnBatchYD(ctx context.Context, c ydApnRequester, simKeys []*model.Si
|
||||
if err = json.Unmarshal([]byte(body), &ydResp); err != nil {
|
||||
g.Log().Errorf(ctx, "changeApnBatchYD unmarshal error %+v, url %s, params %+v, body %s", err, url, params, body)
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnErrorItem(simKey, err.Error()))
|
||||
item := ydApnErrorItem(simKey, err.Error())
|
||||
item.ResData.Uncertain = req.SubmitOnly
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
if ydResp.Status != "0" {
|
||||
g.Log().Warningf(ctx, "changeApnBatchYD upstream error %s, url %s, params %+v, body %s", ydResp.Message, url, params, body)
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnResultItem(simKey, gconv.Int(ydResp.Status), ydResp.Message, ""))
|
||||
items = append(items, ydApnRejectedItem(simKey, ydResp.Status, ydResp.Message))
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -246,7 +278,18 @@ func changeApnBatchYD(ctx context.Context, c ydApnRequester, simKeys []*model.Si
|
||||
if jobId == "" {
|
||||
g.Log().Warningf(ctx, "changeApnBatchYD accepted without jobId, url %s, params %+v, body %s", url, params, body)
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, "上游受理成功但未返回 jobId", ""))
|
||||
item := ydApnResultItem(simKey, consts.ApiStatusError, "上游受理成功但未返回 jobId,需核对受理结果", "")
|
||||
item.ResData.Uncertain = true
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
if req.SubmitOnly {
|
||||
for _, simKey := range simKeys {
|
||||
item := ydApnResultItem(simKey, 0, "上游已受理,等待查询最终结果", jobId)
|
||||
item.ResData.Pending = true
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -263,26 +306,41 @@ func changeApnBatchYD(ctx context.Context, c ydApnRequester, simKeys []*model.Si
|
||||
for _, simKey := range simKeys {
|
||||
card, exist := result.cards[strings.TrimSpace(simKey.MSISDN)]
|
||||
if !exist {
|
||||
items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, "上游批量结果未返回该卡", jobId))
|
||||
item := ydApnResultItem(simKey, consts.ApiStatusError, "上游批量结果未返回该卡", jobId)
|
||||
item.ResData.Uncertain = true
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
items = append(items, ydApnResultItem(simKey, card.code, card.message, jobId))
|
||||
item := ydApnResultItem(simKey, card.code, card.message, jobId)
|
||||
item.ResData.Uncertain = card.uncertain
|
||||
items = append(items, item)
|
||||
}
|
||||
default:
|
||||
// 轮询超时或查询失败:该子批下每张卡都按"查询上游通信结果失败"落库,避免误报成功。
|
||||
// 该文案会逐卡写进执行日志明细;日后若增加"重新查询上游结果"的补偿能力,从这一层补做即可。
|
||||
g.Log().Warningf(ctx, "changeApnBatchYD result poll not finished, jobId %s, params %+v", jobId, params)
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, ydBatchResultQueryFailedMessage, jobId))
|
||||
item := ydApnResultItem(simKey, consts.ApiStatusError, ydBatchResultQueryFailedMessage+":"+result.reason, jobId)
|
||||
item.ResData.Uncertain = true
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *sSimYD) QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
return mapYdQueryResult(input.SimKeys, input.ReqData.QueryJobID, queryBatchResultOnceYD(ctx, s, input.ReqData.QueryJobID)), nil
|
||||
}
|
||||
|
||||
func (s *sSimYDMock) QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
return mapYdQueryResult(input.SimKeys, input.ReqData.QueryJobID, queryBatchResultOnceYD(ctx, s, input.ReqData.QueryJobID)), nil
|
||||
}
|
||||
|
||||
// ydBatchCardResult 批量任务中单张卡的最终办理结果。
|
||||
type ydBatchCardResult struct {
|
||||
code int
|
||||
message string
|
||||
code int
|
||||
message string
|
||||
uncertain bool
|
||||
}
|
||||
|
||||
// ydBatchQueryResult 批量办理结果查询的结论。
|
||||
@@ -290,6 +348,7 @@ type ydBatchQueryResult struct {
|
||||
finished bool // 是否已取得终态(jobStatus 2/3/4)
|
||||
failed bool // jobStatus=4,整批处理失败
|
||||
cards map[string]ydBatchCardResult // resultId(msisdn)-> 单卡结果
|
||||
reason string
|
||||
}
|
||||
|
||||
// ydBatchResultItem 5.1.2 sim-batch-result 的逐卡结果项。
|
||||
@@ -313,43 +372,83 @@ type ydBatchResultResp struct {
|
||||
// queryBatchResultYD 轮询 7.2.2 批量任务结果,直到取得终态或超时。
|
||||
// 超时 / 请求失败均返回 finished=false,由调用方按"未确认"处理。
|
||||
func queryBatchResultYD(ctx context.Context, c ydApnRequester, jobId string) ydBatchQueryResult {
|
||||
url := c.getUrl(ctx, ydCommunicationBatchResultPath)
|
||||
deadline := time.Now().Add(ydBatchResultPollTimeout)
|
||||
pollCtx, cancel := context.WithTimeout(ctx, ydBatchResultPollTimeout)
|
||||
defer cancel()
|
||||
var last ydBatchQueryResult
|
||||
for {
|
||||
params := g.Map{
|
||||
"transid": c.getTransId(ctx),
|
||||
"jobId": jobId,
|
||||
if err := pollCtx.Err(); err != nil {
|
||||
last.reason = "查询上游通信结果截止:" + err.Error() + ";" + last.reason
|
||||
return last
|
||||
}
|
||||
resp, err := c.requestYd(ctx, "QuerySimBatchResult", url, params)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "queryBatchResultYD request error %+v, jobId %s", err, jobId)
|
||||
} else {
|
||||
body := resp.ReadAllString()
|
||||
var parsed ydBatchResultResp
|
||||
if jsonErr := json.Unmarshal([]byte(body), &parsed); jsonErr != nil {
|
||||
g.Log().Warningf(ctx, "queryBatchResultYD unmarshal error %+v, jobId %s, body %s", jsonErr, jobId, body)
|
||||
} else if parsed.Status != "0" {
|
||||
g.Log().Warningf(ctx, "queryBatchResultYD upstream error %s, jobId %s, body %s", parsed.Message, jobId, body)
|
||||
} else if len(parsed.Result) > 0 {
|
||||
switch parsed.Result[0].JobStatus {
|
||||
case ydJobStatusDone, ydJobStatusDonePartial:
|
||||
return ydBatchQueryResult{finished: true, cards: toYdBatchCardMap(parsed.Result[0].ResultList)}
|
||||
case ydJobStatusFailed:
|
||||
return ydBatchQueryResult{finished: true, failed: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return ydBatchQueryResult{}
|
||||
last = queryBatchResultOnceYD(pollCtx, c, jobId)
|
||||
if last.finished {
|
||||
return last
|
||||
}
|
||||
timer := time.NewTimer(ydBatchResultPollInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ydBatchQueryResult{}
|
||||
case <-time.After(ydBatchResultPollInterval):
|
||||
case <-pollCtx.Done():
|
||||
timer.Stop()
|
||||
last.reason = "查询上游通信结果截止:" + pollCtx.Err().Error() + ";" + last.reason
|
||||
return last
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// queryBatchResultOnceYD 只查询一次;持久化调度器负责后续补查。
|
||||
func queryBatchResultOnceYD(ctx context.Context, c ydApnRequester, jobID string) ydBatchQueryResult {
|
||||
params := g.Map{"transid": c.getTransId(ctx), "jobId": jobID}
|
||||
resp, err := c.requestYd(ctx, "QuerySimBatchResult", c.getUrl(ctx, ydCommunicationBatchResultPath), params)
|
||||
if err != nil {
|
||||
return ydBatchQueryResult{reason: "查询上游通信结果失败:" + err.Error()}
|
||||
}
|
||||
body := resp.ReadAllString()
|
||||
var parsed ydBatchResultResp
|
||||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
||||
return ydBatchQueryResult{reason: "查询上游通信结果解析失败:" + err.Error()}
|
||||
}
|
||||
if parsed.Status != "0" {
|
||||
return ydBatchQueryResult{reason: "查询上游通信结果失败,status=" + parsed.Status + ",message=" + parsed.Message}
|
||||
}
|
||||
if len(parsed.Result) == 0 {
|
||||
return ydBatchQueryResult{reason: "上游查询未返回任务结果"}
|
||||
}
|
||||
switch parsed.Result[0].JobStatus {
|
||||
case ydJobStatusDone, ydJobStatusDonePartial:
|
||||
return ydBatchQueryResult{finished: true, cards: toYdBatchCardMap(parsed.Result[0].ResultList)}
|
||||
case ydJobStatusFailed:
|
||||
return ydBatchQueryResult{finished: true, failed: true, reason: parsed.Message}
|
||||
default:
|
||||
return ydBatchQueryResult{reason: "上游任务尚未完成,jobStatus=" + parsed.Result[0].JobStatus}
|
||||
}
|
||||
}
|
||||
|
||||
func mapYdQueryResult(keys []*model.SimKey, jobID string, result ydBatchQueryResult) model.SimCardOperateChangeApnOutput {
|
||||
items := make(model.SimCardOperateChangeApnOutput, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if key == nil {
|
||||
continue
|
||||
}
|
||||
item := ydApnResultItem(key, consts.ApiStatusError, result.reason, jobID)
|
||||
switch {
|
||||
case !result.finished:
|
||||
item.ResData.Pending = true
|
||||
case result.failed:
|
||||
item.Message = "上游批量任务处理失败:" + result.reason
|
||||
default:
|
||||
if card, ok := result.cards[strings.TrimSpace(key.MSISDN)]; ok {
|
||||
item.Code, item.Message = card.code, card.message
|
||||
item.ResData.Uncertain = card.uncertain
|
||||
} else {
|
||||
item.Message = "上游批量结果未返回该卡,需继续核对"
|
||||
item.ResData.Uncertain = true
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// toYdBatchCardMap 把逐卡结果按 resultId(msisdn)建索引。
|
||||
func toYdBatchCardMap(list []ydBatchResultItem) map[string]ydBatchCardResult {
|
||||
cards := make(map[string]ydBatchCardResult, len(list))
|
||||
@@ -358,11 +457,26 @@ func toYdBatchCardMap(list []ydBatchResultItem) map[string]ydBatchCardResult {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
cards[id] = ydBatchCardResult{code: gconv.Int(item.Status), message: item.Message}
|
||||
code, err := strconv.Atoi(strings.TrimSpace(item.Status))
|
||||
if err != nil {
|
||||
cards[id] = ydBatchCardResult{code: consts.ApiStatusError, message: "上游逐卡结果状态无效,status=" + item.Status + ",message=" + item.Message, uncertain: true}
|
||||
continue
|
||||
}
|
||||
cards[id] = ydBatchCardResult{code: code, message: item.Message}
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
func ydApnRejectedItem(key *model.SimKey, status, message string) *model.SimBaseRes[model.SimCardOperateChangeApnItem] {
|
||||
code, err := strconv.Atoi(strings.TrimSpace(status))
|
||||
if err != nil || code == 0 {
|
||||
item := ydApnErrorItem(key, "上游受理状态无效,status="+status+",message="+message)
|
||||
item.ResData.Uncertain = true
|
||||
return item
|
||||
}
|
||||
return ydApnResultItem(key, code, message, "")
|
||||
}
|
||||
|
||||
func ydApnErrorItem(simKey *model.SimKey, message string) *model.SimBaseRes[model.SimCardOperateChangeApnItem] {
|
||||
return &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
|
||||
SimKey: simKey,
|
||||
|
||||
@@ -2,6 +2,7 @@ package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -14,6 +15,196 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestYdLegacyPollingHardDeadlineCancelsInflightRequest(t *testing.T) {
|
||||
defer shortenYdBatchPoll(50 * time.Millisecond)()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(time.Second):
|
||||
_, _ = w.Write([]byte(`{"status":"0","result":[{"jobStatus":"1"}]}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
started := time.Now()
|
||||
result := queryBatchResultYD(context.Background(), newYdApnChannelWithServer(server), "deadline-job")
|
||||
if result.finished || time.Since(started) > 500*time.Millisecond || !strings.Contains(result.reason, "deadline") {
|
||||
t.Fatalf("轮询没有硬截止:elapsed=%s result=%+v", time.Since(started), result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYdAsyncCheckpointFailureStopsFurtherCarrierWrites(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
keys []*model.SimKey
|
||||
firstPath string
|
||||
firstLen int
|
||||
}{
|
||||
{name: "single_then_single", keys: ydSimKeysWithMsisdn(2), firstPath: ydCommunicationServicePath, firstLen: 1},
|
||||
{name: "batch_then_single", keys: func() []*model.SimKey {
|
||||
keys := ydSimKeysWithMsisdn(11)
|
||||
keys[10].MSISDN = ""
|
||||
return keys
|
||||
}(), firstPath: ydCommunicationFunctionBatchPath, firstLen: 10},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
capture := &ydApnCapture{}
|
||||
channel, closeServer := newYdApnTestChannel(capture)
|
||||
defer closeServer()
|
||||
sentinel := errors.New("checkpoint unavailable")
|
||||
checkpoints := 0
|
||||
ctx := context.WithValue(context.Background(), model.ApnOperationCheckpointKey{}, func(output model.SimCardOperateChangeApnOutput) error {
|
||||
checkpoints++
|
||||
if len(output) != tc.firstLen {
|
||||
t.Errorf("checkpoint contains %d results, want %d", len(output), tc.firstLen)
|
||||
}
|
||||
return sentinel
|
||||
})
|
||||
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: tc.keys, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", SubmitOnly: true, OperationID: "test-operation"},
|
||||
})
|
||||
if !errors.Is(err, sentinel) || checkpoints != 1 || len(items) != tc.firstLen {
|
||||
t.Fatalf("checkpoint failure not propagated: err=%v checkpoints=%d items=%d", err, checkpoints, len(items))
|
||||
}
|
||||
if len(capture.paths) != 1 || !strings.HasSuffix(capture.paths[0], tc.firstPath) {
|
||||
t.Fatalf("wrote another carrier request after checkpoint failure: %v", capture.paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestYdAsyncSubmitReturnsJobWithoutQuery(t *testing.T) {
|
||||
capture := &ydApnCapture{}
|
||||
channel, closeServer := newYdApnTestChannel(capture)
|
||||
defer closeServer()
|
||||
checkpoints := 0
|
||||
ctx := context.WithValue(context.Background(), model.ApnOperationCheckpointKey{}, func(output model.SimCardOperateChangeApnOutput) error {
|
||||
checkpoints++
|
||||
if output[0].ResData.OrderNum != "JOB1" {
|
||||
t.Fatal("checkpoint缺少jobId")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: ydSimKeysWithMsisdn(100),
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: true},
|
||||
})
|
||||
if checkpoints != 1 {
|
||||
t.Fatalf("拿jobId后未立即持久化:%d", checkpoints)
|
||||
}
|
||||
if err != nil || len(items) != 100 || len(capture.paths) != 1 {
|
||||
t.Fatalf("提交应仅调用一次,items=%d paths=%v err=%v", len(items), capture.paths, err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if !item.ResData.Pending || item.ResData.OrderNum != "JOB1" || item.ResData.Uncertain {
|
||||
t.Fatalf("受理状态错误:%+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestYdAsyncQueryOnlyOnceAndPreservesReason(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"status":"13012","message":"查询限流"}`,
|
||||
`{"status":"0","result":[{"jobStatus":"1"}]}`,
|
||||
`invalid-json`,
|
||||
`{"status":"0","result":[]}`,
|
||||
} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if !strings.Contains(r.URL.Path, "sim-batch-result") || r.URL.Query().Get("jobId") != "persisted-job" {
|
||||
t.Errorf("不应重新提交:%s", r.URL.String())
|
||||
}
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer server.Close()
|
||||
items, err := changeSimApnFunctionYD(context.Background(), newYdApnChannelWithServer(server), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: ydSimKeysWithMsisdn(10), ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: "persisted-job"},
|
||||
})
|
||||
if err != nil || calls != 1 || len(items) != 10 {
|
||||
t.Fatalf("查询次数错误:calls=%d err=%v", calls, err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if !item.ResData.Pending || item.Message == "" || item.ResData.OrderNum != "persisted-job" {
|
||||
t.Fatalf("应保留待查状态和具体原因:%+v", item)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestYdAsyncSubmitUnknownIsNotConfirmedFailure(t *testing.T) {
|
||||
for _, body := range []string{`{"status":"0","result":[]}`, `not-json`, `{}`, `{"status":"bad"}`} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer server.Close()
|
||||
items, err := changeSimApnFunctionYD(context.Background(), newYdApnChannelWithServer(server), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: ydSimKeysWithMsisdn(10), ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: true},
|
||||
})
|
||||
if err != nil || len(items) != 10 {
|
||||
t.Fatalf("未知提交结果应逐卡返回:%v", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if !item.ResData.Uncertain || item.Message == "" {
|
||||
t.Fatalf("提交结果未知不得确认办理失败:%+v", item)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestYdAsyncQueryTerminalResults(t *testing.T) {
|
||||
keys := ydSimKeysWithMsisdn(10)
|
||||
for _, tc := range []struct{ status, want string }{{"2", "0"}, {"3", "4001"}, {"4", "failed"}} {
|
||||
t.Run(tc.status, func(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if !strings.Contains(r.URL.Path, "sim-batch-result") {
|
||||
t.Error("查询不得提交")
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"status":"0","message":"任务完成","result":[{"jobStatus":%q,"resultList":[{"resultId":%q,"status":%q,"message":"逐卡结果"}]}]}`, tc.status, keys[0].MSISDN, tc.want)
|
||||
}))
|
||||
defer server.Close()
|
||||
items, err := newYdApnChannelWithServer(server).QuerySimApnFunctionResult(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: keys, ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: "job"}})
|
||||
if err != nil || calls != 1 || len(items) != 10 {
|
||||
t.Fatalf("终态查询错误:%v", err)
|
||||
}
|
||||
if items[0].ResData.Pending || items[0].ResData.Uncertain {
|
||||
t.Fatalf("已确认结果仍标未知:%+v", items[0])
|
||||
}
|
||||
if tc.status == "2" && items[0].Code != 0 {
|
||||
t.Fatal("成功结果未映射")
|
||||
}
|
||||
if tc.status == "3" && items[0].Code != 4001 {
|
||||
t.Fatal("逐卡失败原因未映射")
|
||||
}
|
||||
if tc.status == "4" && items[0].Code == 0 {
|
||||
t.Fatal("任务失败误报成功")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestYdAsyncMalformedCardStatusIsUnconfirmed(t *testing.T) {
|
||||
keys := ydSimKeysWithMsisdn(1)
|
||||
cards := toYdBatchCardMap([]ydBatchResultItem{{ResultId: keys[0].MSISDN, Status: "invalid", Message: "bad status"}})
|
||||
items := mapYdQueryResult(keys, "job", ydBatchQueryResult{finished: true, cards: cards})
|
||||
if !items[0].ResData.Uncertain || items[0].Code == 0 || !strings.Contains(items[0].Message, "invalid") {
|
||||
t.Fatalf("无效状态不能按成功处理:%+v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestYdAsyncMissingCardRemainsUnconfirmed(t *testing.T) {
|
||||
keys := ydSimKeysWithMsisdn(2)
|
||||
items := mapYdQueryResult(keys, "job", ydBatchQueryResult{finished: true, cards: map[string]ydBatchCardResult{keys[0].MSISDN: {code: 0}}})
|
||||
if items[0].Code != 0 || !items[1].ResData.Uncertain || items[1].ResData.OrderNum != "job" {
|
||||
t.Fatalf("缺失结果应待核对:%+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYdCommunicationBusinessType(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
|
||||
@@ -4,12 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
@@ -195,16 +195,16 @@ func (s *sSimYDMock) getTransId(ctx context.Context) string {
|
||||
}
|
||||
|
||||
// 单卡基本信息查询
|
||||
// http://api.iot.10086.cn/v5/ec/query/sim-basic-info?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474
|
||||
// http://api.iot.10086.cn/v5/ec/query/sim-basic-info?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000004
|
||||
func (s *sSimYDMock) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
|
||||
// {
|
||||
// "status": "0",
|
||||
// "message": "正确",
|
||||
// "result": [
|
||||
// {
|
||||
// "msisdn": "1441543605474",
|
||||
// "imsi": "460087436015474",
|
||||
// "iccid": "898604F4152391115474",
|
||||
// "msisdn": "1440000000002",
|
||||
// "imsi": "460000000000002",
|
||||
// "iccid": "89860000000000000004",
|
||||
// "activeDate": "2023-05-11 15:07:06", // 激活日期
|
||||
// "openDate": "2023-05-11 14:53:24", // 开卡日期
|
||||
// "remark": ""
|
||||
@@ -487,7 +487,7 @@ func (s *sSimYDMock) QuerySimSession(ctx context.Context, input *model.SimSessio
|
||||
}
|
||||
|
||||
// 5.1.6 单卡状态查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-status?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000004
|
||||
func (s *sSimYDMock) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/sim-status")
|
||||
|
||||
@@ -592,7 +592,7 @@ func (s *sSimYDMock) QuerySimCardStatus(ctx context.Context, input *model.SimCar
|
||||
}
|
||||
|
||||
// 物联网卡IMEI查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-imei?transid=371AIOT20210605101712810732026031520280010000000&token=YOUR_TOKEN&iccid=898604F81623D2249725
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-imei?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000005
|
||||
func (s *sSimYDMock) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/sim-stop-reason")
|
||||
|
||||
@@ -855,7 +855,7 @@ func (s *sSimYDMock) QuerySimImei(ctx context.Context, input *model.SimImeiInput
|
||||
}
|
||||
|
||||
// 单卡本月流量累计使用量查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-data-usage-inpool?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-data-usage-inpool?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003
|
||||
func (s *sSimYDMock) QuerySimDataUsageInPool(ctx context.Context, input *model.SimDataUsageInPoolInput) (model.SimDataUsageInPoolOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/sim-data-usage-inpool")
|
||||
// {"status":"0","message":"正确","result":[{"apnList":[{"apnName":"CMIOT","apnUseAmount":"5994479616"}]}]}
|
||||
@@ -949,7 +949,7 @@ func (s *sSimYDMock) QuerySimDataUsageInPool(ctx context.Context, input *model.S
|
||||
}
|
||||
|
||||
// 单卡本月流量累计使用量查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-data-usage?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-data-usage?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003
|
||||
func (s *sSimYDMock) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/sim-data-usage")
|
||||
// {"status":"0","message":"正确","result":[{"dataAmount":"710569.00","apnUseAmountList":[{"apnName":"CMIOT","apnUseAmount":"710569.00","pccCodeUseAmountList":[]}]}]}
|
||||
@@ -1293,7 +1293,7 @@ func (s *sSimYDMock) QuerySimDataUsageDaily(ctx context.Context, input *model.Si
|
||||
}
|
||||
|
||||
// 单卡已开通APN信息查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/apn-info?transid=C5010531Af52022004082026060114500001000000&token=YOUR_TOKEN&iccid=898604F4152391115473
|
||||
// https://api.iot.10086.cn/v5/ec/query/apn-info?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000003
|
||||
func (s *sSimYDMock) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/apn-info")
|
||||
// {"status":"0","message":"正确","result":[{"apnList":[{"apnName":"CMIOT","status":"1"}]}]}
|
||||
@@ -1413,7 +1413,7 @@ func (s *sSimYDMock) QuerySimApnInfo(ctx context.Context, input *model.SimApnInf
|
||||
|
||||
// 单卡状态变更
|
||||
// 平台 operType: 0=申请停机(已激活转已停机), 1=申请复机(已停机转已激活)
|
||||
// https://api.iot.10086.cn/v5/ec/change/sim-status?transid=371AIOT20210605101712810732026031315120010000000&token=YOUR_TOKEN&msisdn=1441589159774&operType=0
|
||||
// https://api.iot.10086.cn/v5/ec/change/sim-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003&operType=0
|
||||
func (s *sSimYDMock) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) {
|
||||
operType, ok := normalizeCardStatusChangeOperType(ctx, input.ReqData.OperType)
|
||||
if !ok {
|
||||
@@ -1421,7 +1421,7 @@ func (s *sSimYDMock) ChangeSimStatus(ctx context.Context, input *model.SimCardSt
|
||||
}
|
||||
|
||||
url := s.getUrl(ctx, "ec/change/sim-status")
|
||||
// {"status":"0","message":"正确","result":[{"iccid":"898604F4152391115474","orderNum":"995000011515720993"}]}
|
||||
// {"status":"0","message":"正确","result":[{"iccid":"89860000000000000004","orderNum":"995000011515720993"}]}
|
||||
type YdResp struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
@@ -1621,7 +1621,7 @@ func (s *sSimYDMock) QuerySimCardBindStatus(ctx context.Context, input *model.Si
|
||||
}
|
||||
|
||||
// 单卡设备状态查询
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-operating-status?transid=xxx&token=xxx&iccid=xxx
|
||||
// https://api.iot.10086.cn/v5/ec/query/sim-operating-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=xxx
|
||||
// 上游返回 result 中的 operatingStatus/onOffStatus/status 作为设备状态
|
||||
func (s *sSimYDMock) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
|
||||
url := s.getUrl(ctx, "ec/query/sim-operating-status")
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
)
|
||||
|
||||
const ydTokenInvalidCode = "12021"
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestParseSharedOneLinkConfigBusinessURL(t *testing.T) {
|
||||
|
||||
t.Run("defaults to shared OneLink URL", func(t *testing.T) {
|
||||
config, api, err := parseSharedOneLinkConfig(context.Background(), newConfig(
|
||||
`{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/route/open/sharedToken"}}`,
|
||||
`{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/openapi/v3/query/operator-token"}}`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
@@ -156,7 +156,7 @@ func TestParseSharedOneLinkConfigBusinessURL(t *testing.T) {
|
||||
t.Run("honours template api.baseUrl", func(t *testing.T) {
|
||||
const custom = "https://custom.example/v5/"
|
||||
config, api, err := parseSharedOneLinkConfig(context.Background(), newConfig(
|
||||
`{"api":{"baseUrl":"`+custom+`"},"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/route/open/sharedToken"}}`,
|
||||
`{"api":{"baseUrl":"`+custom+`"},"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/openapi/v3/query/operator-token"}}`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
@@ -182,11 +182,11 @@ func TestYunzeTemplateRegistration(t *testing.T) {
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkYunzeTemplateKey,
|
||||
TempModel: templateModelSharedToken,
|
||||
TempConfig: `{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/route/open/sharedToken"}}`,
|
||||
TempConfig: `{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/openapi/v3/query/operator-token"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "249",
|
||||
AppKey: "TEST_APP_KEY_0001",
|
||||
AppKey: "YZTKB3FT1SJR8652C4WP9VM",
|
||||
ChanConfig: "{}",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -254,17 +254,32 @@ type SimCardStatusChangeItem struct {
|
||||
type SimCardOperateChangeApnInput = SimBaseReq[SimCardOperateChangeApnInputItem]
|
||||
|
||||
// 单卡 APN功能开停 请求参数 操作类型
|
||||
// ApnOperationCheckpointKey 传递受理即落库回调,避免等待同请求内其他单卡办理。
|
||||
type ApnOperationCheckpointKey struct{}
|
||||
|
||||
type SimCardOperateChangeApnInputItem struct {
|
||||
OperType string `json:"operType" dc:"APN功能开停操作类型(0:开 1:停)"`
|
||||
ApnName string `json:"apnName" dc:"APN名称"`
|
||||
OperType string `json:"operType" dc:"APN功能开停操作类型(0:开 1:停)"`
|
||||
ApnName string `json:"apnName" dc:"APN名称"`
|
||||
SubmitOnly bool `json:"submitOnly,omitempty"`
|
||||
QueryJobID string `json:"queryJobId,omitempty"`
|
||||
OperationID string `json:"operationId,omitempty"`
|
||||
RecoverOnly bool `json:"recoverOnly,omitempty"`
|
||||
}
|
||||
|
||||
// 单卡 APN功能开停 响应参数
|
||||
type SimCardOperateChangeApnOutput = []*SimBaseRes[SimCardOperateChangeApnItem]
|
||||
|
||||
// 单卡 APN功能开停 响应参数 数据项
|
||||
type SimApnCapabilityItem struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
}
|
||||
|
||||
type SimApnCapabilityOutput = []*SimBaseRes[SimApnCapabilityItem]
|
||||
|
||||
type SimCardOperateChangeApnItem struct {
|
||||
OrderNum string `json:"orderNum" dc:"订单号"`
|
||||
OrderNum string `json:"orderNum" dc:"订单号"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
Uncertain bool `json:"uncertain,omitempty"`
|
||||
}
|
||||
|
||||
// 物联网卡绑定状态查询 请求参数
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// package version
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package version
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package xjpubconsts
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package xjpubconsts
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// package middleware
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package middleware
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// package response
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package response
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// package response
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package response
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
const TemplateKey = "sim_mingqian_v3"
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
@@ -13,6 +11,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
)
|
||||
|
||||
func testAdapter(t *testing.T, handler http.HandlerFunc) *Adapter {
|
||||
@@ -165,13 +165,13 @@ func TestOperationsAreSafe(t *testing.T) {
|
||||
})
|
||||
input := &model.SimCardStatusChangeInput{SimKeys: []*model.SimKey{{ICCID: "A"}, {ICCID: "B"}}, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}}
|
||||
result, _ := adapter.ChangeSimStatus(context.Background(), input)
|
||||
if calls != 1 || result[0].Code != 0 || result[1].Code == 0 {
|
||||
t.Fatal("stop operation must reach the confirmed endpoint and inspect per-card success flags")
|
||||
if calls != 0 || result[0].Code == 0 {
|
||||
t.Fatal("operations must be disabled by default")
|
||||
}
|
||||
before := calls
|
||||
adapter.operations = true
|
||||
input.ReqData.OperType = "1"
|
||||
result, _ = adapter.ChangeSimStatus(context.Background(), input)
|
||||
if calls != before || result[0].Code == 0 {
|
||||
if calls != 0 || result[0].Code == 0 {
|
||||
t.Fatal("unconfirmed resume path must not send any request")
|
||||
}
|
||||
adapter.resumePath = "/simlist/test-resume"
|
||||
@@ -182,7 +182,7 @@ func TestOperationsAreSafe(t *testing.T) {
|
||||
t.Fatal("must inspect per-card success flags")
|
||||
}
|
||||
}
|
||||
before = calls
|
||||
before := calls
|
||||
input.ReqData.OperType = "invalid"
|
||||
adapter.ChangeSimStatus(context.Background(), input)
|
||||
if calls != before {
|
||||
@@ -206,7 +206,7 @@ func TestResponseFailuresAndNoRetry(t *testing.T) {
|
||||
func TestConfiguration(t *testing.T) {
|
||||
cfg := &model.SimChannelConfigItemCache{Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"timeout":"1s"}}`}, Channel: &entity.TSimChannels{AppKey: "app", AppSecret: "secret"}}
|
||||
adapter, err := New(cfg)
|
||||
if err != nil || adapter.resumePath != "" {
|
||||
if err != nil || adapter.operations {
|
||||
t.Fatalf("invalid default configuration: %v", err)
|
||||
}
|
||||
cfg.Channel.AppSecret = ""
|
||||
@@ -243,6 +243,7 @@ func TestOperationBatchLimitAndHTTPFailure(t *testing.T) {
|
||||
}
|
||||
writer.WriteHeader(http.StatusTooManyRequests)
|
||||
})
|
||||
adapter.operations = true
|
||||
keys := make([]*model.SimKey, 21)
|
||||
for index := range keys {
|
||||
keys[index] = &model.SimKey{ICCID: strconv.Itoa(index)}
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
type PackageFlowOutput = []*model.SimBaseRes[model.SimPackageFlowItem]
|
||||
|
||||
@@ -54,6 +54,7 @@ type (
|
||||
QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (output model.SimApnInfoOutput, err error)
|
||||
// 物联网卡状态变更
|
||||
ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (output model.SimCardStatusChangeOutput, err error)
|
||||
QuerySimApnCapability(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimApnCapabilityOutput, error)
|
||||
ChangeSimCardOperateApn(ctx context.Context, input *model.SimCardOperateChangeApnInput) (output model.SimCardOperateChangeApnOutput, err error)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -11,12 +11,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"golang.org/x/sync/singleflight"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
Reference in New Issue
Block a user