chore: 并入 iotlcm-upstream-chan 历史到 upstream-chan/(原提交 1e74497)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:02 +08:00
192 changed files with 33119 additions and 0 deletions
@@ -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)
}
}
@@ -0,0 +1,17 @@
package upstreamapi
import "testing"
func TestICCIDFromChannelCacheKey(t *testing.T) {
tests := []struct{ key, want string }{
{"sim:chan:iccid:89860000000000000004", "89860000000000000004"},
{"sim:chan:iccid:8986000000000000007X", "8986000000000000007X"},
{"sim:chan:iccid:", ""},
{"unrelated:89860000000000000004", ""},
}
for _, tt := range tests {
if got := iccidFromChannelCacheKey(tt.key); got != tt.want {
t.Errorf("iccidFromChannelCacheKey(%q) = %q, want %q", tt.key, got, tt.want)
}
}
}
@@ -0,0 +1,251 @@
package upstreamapi
import (
"context"
"errors"
"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"
)
const (
defaultChannelTestTimeout = 2 * time.Minute
defaultChannelTestItemTimeout = 10 * time.Second
)
type channelTestOperation struct {
key string
name string
execute func(context.Context, model.SimCardChannelI, *model.SimKey, time.Time) (any, int, string, bool, error)
}
func (u *sUpstreamAPI) TestChannel(ctx context.Context, input *model.ChannelTestInput) (*model.ChannelTestOutput, error) {
if input == nil {
return nil, gerror.New("channel test input is required")
}
if input.ChannelID <= 0 || strings.TrimSpace(input.TenantID) == "" {
return nil, gerror.New("channel is not accessible")
}
operations, err := selectChannelTestOperations(input.Items)
if err != nil {
return nil, err
}
totalTimeout := g.Cfg().MustGet(ctx, "upstreamapi.channel_test.timeout", defaultChannelTestTimeout).Duration()
if totalTimeout <= 0 {
totalTimeout = defaultChannelTestTimeout
}
itemTimeout := g.Cfg().MustGet(ctx, "upstreamapi.channel_test.item_timeout", defaultChannelTestItemTimeout).Duration()
if itemTimeout <= 0 {
itemTimeout = defaultChannelTestItemTimeout
}
testCtx, cancel := context.WithTimeout(ctx, totalTimeout)
defer cancel()
config, err := service.ChannelConfig().LoadChannelForTest(testCtx, input.ChannelID, input.TenantID)
if err != nil {
if errors.Is(err, model.ErrChannelNotAccessible) {
return nil, model.ErrChannelNotAccessible
}
return nil, gerror.Wrap(err, "load channel test configuration")
}
instance, err := u.newTestChannelInstance(testCtx, config)
if err != nil {
return nil, gerror.Wrap(err, "channel test configuration is invalid")
}
completionCtx, completionCancel := context.WithTimeout(testCtx, itemTimeout)
simKey, err := resolveChannelTestSimKey(completionCtx, instance, config.Channel.ChanKey, input)
completionCancel()
if err != nil {
return nil, err
}
results := make([]*model.ChannelTestItem, 0, len(operations))
now := time.Now()
for _, operation := range operations {
if testCtx.Err() != nil {
results = append(results, channelTestTimeoutResult(operation))
continue
}
results = append(results, executeChannelTestOperation(testCtx, itemTimeout, operation, instance, simKey, now))
}
return &model.ChannelTestOutput{Items: results}, nil
}
func resolveChannelTestSimKey(ctx context.Context, instance model.SimCardChannelI, chanKey string, input *model.ChannelTestInput) (*model.SimKey, error) {
mode := strings.ToLower(strings.TrimSpace(input.InputMode))
if mode == "" {
mode = model.ChannelTestInputModeComplete
}
cardType := strings.ToLower(strings.TrimSpace(input.CardValueType))
cardValue := strings.TrimSpace(input.CardValue)
if mode == model.ChannelTestInputModeComplete {
if strings.TrimSpace(input.ICCID) == "" || strings.TrimSpace(input.IMSI) == "" || strings.TrimSpace(input.MSISDN) == "" {
return nil, gerror.New("complete mode requires iccid, imsi and msisdn")
}
return &model.SimKey{ChanKey: chanKey, ICCID: input.ICCID, IMSI: input.IMSI, MSISDN: input.MSISDN}, nil
}
if mode != model.ChannelTestInputModeSingle && mode != model.ChannelTestInputModeDirectSingle {
return nil, gerror.New("unsupported channel test input mode")
}
if cardValue == "" || !validChannelTestCardType(cardType) {
return nil, gerror.New("card value type and value are required")
}
key := &model.SimKey{ChanKey: chanKey}
setChannelTestCardValue(key, cardType, cardValue)
if mode == model.ChannelTestInputModeDirectSingle {
return key, nil
}
// Card completion uses the same request-scoped instance as the selected
// checks; it never falls back to chanMap or creates a second instance.
basic, err := instance.QuerySimBasicInfo(ctx, &model.SimBasicInfoInput{SimKeys: []*model.SimKey{key}})
if err != nil {
return nil, gerror.Wrap(err, "complete card identifiers")
}
if len(basic) == 0 || basic[0] == nil || basic[0].Code != consts.ApiStatusSuccess {
if len(basic) > 0 && basic[0] != nil && basic[0].Message != "" {
return nil, gerror.New(basic[0].Message)
}
return nil, gerror.New("carrier did not return card identifiers")
}
completed := basic[0].ResData
if strings.TrimSpace(completed.ICCID) == "" || strings.TrimSpace(completed.IMSI) == "" || strings.TrimSpace(completed.MSISDN) == "" {
return nil, gerror.New("carrier did not return complete card identifiers")
}
return &model.SimKey{ChanKey: chanKey, ICCID: completed.ICCID, IMSI: completed.IMSI, MSISDN: completed.MSISDN}, nil
}
func validChannelTestCardType(valueType string) bool {
return valueType == "iccid" || valueType == "imsi" || valueType == "msisdn"
}
func setChannelTestCardValue(key *model.SimKey, valueType, value string) {
switch valueType {
case "iccid":
key.ICCID = value
case "imsi":
key.IMSI = value
case "msisdn":
key.MSISDN = value
}
}
func selectChannelTestOperations(requested []string) ([]channelTestOperation, error) {
all := newChannelTestOperations()
if len(requested) == 0 {
return all, nil
}
wanted := make(map[string]struct{}, len(requested))
for _, key := range requested {
key = strings.TrimSpace(key)
if _, ok := model.ChannelTestOperationNames[key]; !ok {
return nil, gerror.Newf("unsupported channel test item: %s", key)
}
wanted[key] = struct{}{}
}
selected := make([]channelTestOperation, 0, len(wanted))
for _, operation := range all {
if _, ok := wanted[operation.key]; ok {
selected = append(selected, operation)
}
}
return selected, nil
}
func executeChannelTestOperation(parent context.Context, itemTimeout time.Duration, operation channelTestOperation, instance model.SimCardChannelI, simKey *model.SimKey, now time.Time) *model.ChannelTestItem {
started := time.Now()
callCtx, cancel := context.WithTimeout(parent, itemTimeout)
defer cancel()
data, errorCode, message, success, err := operation.execute(callCtx, instance, simKey, now)
result := &model.ChannelTestItem{Key: operation.key, Name: operation.name, Status: "failed", Data: data, ErrorCode: errorCode, DurationMs: time.Since(started).Milliseconds()}
if callCtx.Err() != nil {
result.Status = "timeout"
result.Message = "channel test item timed out"
return result
}
if err != nil {
result.Message = err.Error()
return result
}
if !success {
result.Message = message
return result
}
result.Status = "success"
result.Message = message
return result
}
func channelTestTimeoutResult(operation channelTestOperation) *model.ChannelTestItem {
return &model.ChannelTestItem{Key: operation.key, Name: operation.name, Status: "timeout", Message: "channel test request timed out"}
}
func newChannelTestOperations() []channelTestOperation {
testType := 0
return []channelTestOperation{
newChannelTestOperation("simBasicInfo", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimBasicInfoItem], error) {
return instance.QuerySimBasicInfo(ctx, &model.SimBasicInfoInput{SimKeys: []*model.SimKey{key}})
}),
newChannelTestOperation("simSession", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimSessionItem], error) {
return instance.QuerySimSession(ctx, &model.SimSessionInput{SimKeys: []*model.SimKey{key}})
}),
newChannelTestOperation("simStatus", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimCardStatusItem], error) {
return instance.QuerySimCardStatus(ctx, &model.SimCardStatusInput{SimKeys: []*model.SimKey{key}})
}),
newChannelTestOperation("simStopReason", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimStopReasonItem], error) {
return instance.QuerySimStopReason(ctx, &model.SimStopReasonInput{SimKeys: []*model.SimKey{key}})
}),
newChannelTestOperation("simFrozenState", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimFrozenStateItem], error) {
return instance.QuerySimFrozenState(ctx, &model.SimFrozenStateInput{SimKeys: []*model.SimKey{key}})
}),
newChannelTestOperation("simCardBindStatus", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimCardBindStatusItem], error) {
return instance.QuerySimCardBindStatus(ctx, &model.SimCardBindStatusInput{SimKeys: []*model.SimKey{key}, ReqData: model.SimCardBindStatusInputItem{TestType: &testType}})
}),
newChannelTestOperation("simDeviceStatus", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimDeviceStatusItem], error) {
return instance.QuerySimDeviceStatus(ctx, &model.SimDeviceStatusInput{SimKeys: []*model.SimKey{key}})
}),
newChannelTestOperation("simRegionLimitStatus", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimRegionLimitStatusItem], error) {
return instance.QuerySimRegionLimitStatus(ctx, &model.SimRegionLimitStatusInput{SimKeys: []*model.SimKey{key}})
}),
newChannelTestOperation("simImei", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimImeiItem], error) {
return instance.QuerySimImei(ctx, &model.SimImeiInput{SimKeys: []*model.SimKey{key}})
}),
newChannelTestOperation("simDataUsage", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimDataUsageItem], error) {
return instance.QuerySimDataUsage(ctx, &model.SimDataUsageInput{SimKeys: []*model.SimKey{key}})
}),
newChannelTestOperation("simDataUsageMonthly", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, now time.Time) ([]*model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error) {
last := now.AddDate(0, -1, 0)
return instance.QuerySimDataUsageMonthly(ctx, &model.SimDataUsageMonthlyInput{SimKeys: []*model.SimKey{key}, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: last.Format("200601")}})
}),
newChannelTestOperation("simDataUsageDaily", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, now time.Time) ([]*model.SimBaseRes[model.SimDataUsageDailyOutputItem], error) {
yesterday := now.AddDate(0, 0, -1)
return instance.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{SimKeys: []*model.SimKey{key}, ReqData: model.SimDataUsageDailyInputItem{QueryDate: yesterday.Format("20060102")}})
}),
newChannelTestOperation("simApnInfo", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimApnInfoItemList], error) {
return instance.QuerySimApnInfo(ctx, &model.SimApnInfoInput{SimKeys: []*model.SimKey{key}})
}),
}
}
func newChannelTestOperation[T any](key string, query func(context.Context, model.SimCardChannelI, *model.SimKey, time.Time) ([]*model.SimBaseRes[T], error)) channelTestOperation {
return channelTestOperation{key: key, name: model.ChannelTestOperationNames[key], execute: func(ctx context.Context, instance model.SimCardChannelI, simKey *model.SimKey, now time.Time) (any, int, string, bool, error) {
output, err := query(ctx, instance, simKey, now)
if err != nil {
return nil, 0, "", false, err
}
if len(output) == 0 || output[0] == nil {
return nil, 0, "carrier returned no result", false, nil
}
first := output[0]
if first.Code != consts.ApiStatusSuccess {
return first.ResData, first.Code, first.Message, false, nil
}
return first.ResData, 0, first.Message, true, nil
}}
}
@@ -0,0 +1,163 @@
package upstreamapi
import (
"context"
"testing"
"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/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
type channelTestConfigSource struct {
service.IChannelConfig
config *model.SimChannelConfigItemCache
loadCall int
}
func (s *channelTestConfigSource) LoadChannelForTest(_ context.Context, channelID int64, tenantID string) (*model.SimChannelConfigItemCache, error) {
s.loadCall++
if channelID != 42 || tenantID != "tenant-a" {
return nil, model.ErrChannelNotAccessible
}
return s.config, nil
}
type requestScopedTestChannel struct {
model.SimCardChannelI
basicCalls int
sessionCalls int
}
func (c *requestScopedTestChannel) QuerySimBasicInfo(_ context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
c.basicCalls++
return model.SimBasicInfoOutput{&model.SimBaseRes[model.SimBasicInfoItem]{
Code: consts.ApiStatusSuccess,
SimKey: input.SimKeys[0],
ResData: model.SimBasicInfoItem{
ICCID: "iccid-complete", IMSI: "imsi-complete", MSISDN: "msisdn-complete",
},
}}, nil
}
func (c *requestScopedTestChannel) QuerySimSession(_ context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) {
c.sessionCalls++
return model.SimSessionOutput{&model.SimBaseRes[model.SimSessionItem]{
Code: consts.ApiStatusSuccess, SimKey: input.SimKeys[0], ResData: model.SimSessionItem{Status: "0"},
}}, nil
}
type requestScopedTestTemplate struct {
calls int
channel *requestScopedTestChannel
}
func (t *requestScopedTestTemplate) NewTemp(context.Context, *model.SimChannelConfigItemCache) model.SimCardChannelI {
t.calls++
t.channel = &requestScopedTestChannel{}
return t.channel
}
func TestChannelUsesOneTemporaryInstanceWithoutRuntimeRegistration(t *testing.T) {
abnormal := 1
config := &model.SimChannelConfigItemCache{
Channel: &entity.TSimChannels{Id: 42, TenantId: "tenant-a", ChanKey: "abnormal-channel", TempKey: "test-template"},
Template: &entity.TSimChannelTemplate{TempKey: "test-template", TempModel: "direct"},
IsNormal: &abnormal,
}
source := &channelTestConfigSource{config: config}
oldConfig := service.ChannelConfig()
service.RegisterChannelConfig(source)
t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) })
api := NewUpstreamAPI()
template := &requestScopedTestTemplate{}
api.RegisterTemp("test-template", template)
output, err := api.TestChannel(context.Background(), &model.ChannelTestInput{
ChannelID: 42, TenantID: "tenant-a", InputMode: model.ChannelTestInputModeSingle,
CardValueType: "iccid", CardValue: "iccid-only", Items: []string{"simBasicInfo", "simSession"},
})
if err != nil {
t.Fatal(err)
}
if source.loadCall != 1 || template.calls != 1 {
t.Fatalf("config loads=%d instance builds=%d", source.loadCall, template.calls)
}
if template.channel.basicCalls != 2 || template.channel.sessionCalls != 1 {
t.Fatalf("basic calls=%d session calls=%d", template.channel.basicCalls, template.channel.sessionCalls)
}
if len(output.Items) != 2 || output.Items[0].Status != "success" || output.Items[1].Status != "success" {
t.Fatalf("unexpected test output: %+v", output)
}
if _, ok := api.GetChan("abnormal-channel"); ok {
t.Fatal("temporary test instance entered the runtime channel map")
}
}
func TestChannelPreservesExistingRuntimeInstance(t *testing.T) {
normal := 0
config := &model.SimChannelConfigItemCache{
Channel: &entity.TSimChannels{Id: 42, TenantId: "tenant-a", ChanKey: "normal-channel", TempKey: "test-template"},
Template: &entity.TSimChannelTemplate{TempKey: "test-template", TempModel: "direct"},
IsNormal: &normal,
}
source := &channelTestConfigSource{config: config}
oldConfig := service.ChannelConfig()
service.RegisterChannelConfig(source)
t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) })
api := NewUpstreamAPI()
runtimeInstance := &requestScopedTestChannel{}
runtimeCache := &model.SimCardChannelCache{Instance: runtimeInstance, Version: "runtime-v1"}
api.RegisterChan("normal-channel", runtimeCache.Version, runtimeCache)
template := &requestScopedTestTemplate{}
api.RegisterTemp("test-template", template)
_, err := api.TestChannel(context.Background(), &model.ChannelTestInput{
ChannelID: 42, TenantID: "tenant-a", InputMode: model.ChannelTestInputModeComplete,
ICCID: "i", IMSI: "m", MSISDN: "n", Items: []string{"simSession"},
})
if err != nil {
t.Fatal(err)
}
got, ok := api.GetChan("normal-channel")
if !ok || got != runtimeCache || got.Instance != runtimeInstance {
t.Fatal("channel test replaced the existing runtime instance")
}
}
func TestChannelRejectsTenantMismatchAndMutationItems(t *testing.T) {
normal := 0
source := &channelTestConfigSource{config: &model.SimChannelConfigItemCache{
Channel: &entity.TSimChannels{Id: 42, TenantId: "tenant-a", ChanKey: "channel", TempKey: "test-template"},
Template: &entity.TSimChannelTemplate{TempKey: "test-template", TempModel: "direct"},
IsNormal: &normal,
}}
oldConfig := service.ChannelConfig()
service.RegisterChannelConfig(source)
t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) })
api := NewUpstreamAPI()
api.RegisterTemp("test-template", &requestScopedTestTemplate{})
if _, err := api.TestChannel(context.Background(), &model.ChannelTestInput{ChannelID: 42, TenantID: "tenant-b"}); err == nil || err.Error() != "channel is not accessible" {
t.Fatalf("tenant mismatch error = %v", err)
}
if _, err := api.TestChannel(context.Background(), &model.ChannelTestInput{
ChannelID: 42, TenantID: "tenant-a", InputMode: model.ChannelTestInputModeComplete,
ICCID: "i", IMSI: "m", MSISDN: "n", Items: []string{"changeSimStatus"},
}); err == nil {
t.Fatal("mutation item was accepted")
}
}
func TestUnregisterChanIsIdempotent(t *testing.T) {
api := NewUpstreamAPI()
api.UnregisterChan("missing")
api.RegisterChan("channel", "v1", &model.SimCardChannelCache{Instance: &requestScopedTestChannel{}})
api.UnregisterChan("channel")
api.UnregisterChan("channel")
if _, ok := api.GetChan("channel"); ok {
t.Fatal("channel remains registered")
}
}
@@ -0,0 +1,58 @@
package upstreamapi
import (
"strings"
"time"
)
const (
historicalMonthLayout = "200601"
historicalDailyLayout = "20060102"
historicalMonthFormatError = "queryDate格式错误,应为yyyyMM"
historicalMonthRangeError = "月流量仅支持查询已结束月份;本月累计流量请调用 /sim/query/sim-datausage"
historicalDailyFormatError = "queryDate格式错误,应为yyyyMMdd"
historicalDailyRangeError = "日流量仅支持查询已结束日期,不支持当天或未来日期"
)
var historicalPeriodLocation = time.FixedZone("UTC+8", 8*60*60)
const currentBatchMonthRangeError = "电信月批量接口仅支持当月;历史月份请调用 /sim/query/sim-datausage-monthly"
func validateCurrentBatchMonth(queryDate string, now time.Time) string {
queryDate = strings.TrimSpace(queryDate)
if _, err := time.ParseInLocation(historicalMonthLayout, queryDate, historicalPeriodLocation); err != nil {
return historicalMonthFormatError
}
if queryDate != now.In(historicalPeriodLocation).Format(historicalMonthLayout) {
return currentBatchMonthRangeError
}
return ""
}
func validateHistoricalMonth(queryDate string, now time.Time) string {
queryDate = strings.TrimSpace(queryDate)
if len(queryDate) != len(historicalMonthLayout) {
return historicalMonthFormatError
}
if _, err := time.ParseInLocation(historicalMonthLayout, queryDate, historicalPeriodLocation); err != nil {
return historicalMonthFormatError
}
if queryDate >= now.In(historicalPeriodLocation).Format(historicalMonthLayout) {
return historicalMonthRangeError
}
return ""
}
func validateHistoricalDay(queryDate string, now time.Time) string {
queryDate = strings.TrimSpace(queryDate)
if len(queryDate) != len(historicalDailyLayout) {
return historicalDailyFormatError
}
if _, err := time.ParseInLocation(historicalDailyLayout, queryDate, historicalPeriodLocation); err != nil {
return historicalDailyFormatError
}
if queryDate >= now.In(historicalPeriodLocation).Format(historicalDailyLayout) {
return historicalDailyRangeError
}
return ""
}
@@ -0,0 +1,294 @@
package upstreamapi
import (
"context"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
type historyUsageTestChannel struct {
model.SimCardChannelI
monthlyCalls int
dailyCalls int
monthlyBatchCalls int
dailyBatchCalls int
currentCalls int
}
func (c *historyUsageTestChannel) QuerySimDataUsageMonthly(_ context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
c.monthlyCalls++
output := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
output = append(output, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
})
}
return output, nil
}
func (c *historyUsageTestChannel) QuerySimDataUsageDaily(_ context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
c.dailyCalls++
output := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
output = append(output, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
})
}
return output, nil
}
func (c *historyUsageTestChannel) QuerySimDataUsageMonthlyBatch(_ context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
c.monthlyBatchCalls++
return c.QuerySimDataUsageMonthly(context.Background(), input)
}
func (c *historyUsageTestChannel) QuerySimDataUsageDailyBatch(_ context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
c.dailyBatchCalls++
return c.QuerySimDataUsageDaily(context.Background(), input)
}
func (c *historyUsageTestChannel) QuerySimCardStatusBatch(_ context.Context, _ *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
return nil, nil
}
func (c *historyUsageTestChannel) QuerySimDataUsage(_ context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) {
c.currentCalls++
output := make(model.SimDataUsageOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
output = append(output, &model.SimBaseRes[model.SimDataUsageItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
})
}
return output, nil
}
func TestValidateHistoricalPeriodAtUTC8Boundary(t *testing.T) {
now := time.Date(2026, time.September, 2, 16, 0, 0, 0, time.UTC)
monthCases := []struct {
name string
queryDate string
want string
}{
{name: "previous month", queryDate: "202608", want: ""},
{name: "current month", queryDate: "202609", want: historicalMonthRangeError},
{name: "future month", queryDate: "202610", want: historicalMonthRangeError},
{name: "empty month", queryDate: "", want: historicalMonthFormatError},
{name: "hyphenated month", queryDate: "2026-08", want: historicalMonthFormatError},
{name: "invalid month", queryDate: "202613", want: historicalMonthFormatError},
}
for _, tc := range monthCases {
t.Run("month "+tc.name, func(t *testing.T) {
if got := validateHistoricalMonth(tc.queryDate, now); got != tc.want {
t.Fatalf("validateHistoricalMonth(%q) = %q, want %q", tc.queryDate, got, tc.want)
}
})
}
batchMonthCases := []struct {
name string
queryDate string
want string
}{
{name: "previous month", queryDate: "202608", want: currentBatchMonthRangeError},
{name: "current month", queryDate: "202609", want: ""},
{name: "future month", queryDate: "202610", want: currentBatchMonthRangeError},
{name: "empty month", queryDate: "", want: historicalMonthFormatError},
{name: "invalid month", queryDate: "202613", want: historicalMonthFormatError},
}
for _, tc := range batchMonthCases {
t.Run("batch month "+tc.name, func(t *testing.T) {
if got := validateCurrentBatchMonth(tc.queryDate, now); got != tc.want {
t.Fatalf("validateCurrentBatchMonth(%q) = %q, want %q", tc.queryDate, got, tc.want)
}
})
}
dayCases := []struct {
name string
queryDate string
want string
}{
{name: "previous day", queryDate: "20260902", want: ""},
{name: "current day", queryDate: "20260903", want: historicalDailyRangeError},
{name: "future day", queryDate: "20260904", want: historicalDailyRangeError},
{name: "empty day", queryDate: "", want: historicalDailyFormatError},
{name: "hyphenated day", queryDate: "2026-09-02", want: historicalDailyFormatError},
{name: "invalid day", queryDate: "20260230", want: historicalDailyFormatError},
}
for _, tc := range dayCases {
t.Run("day "+tc.name, func(t *testing.T) {
if got := validateHistoricalDay(tc.queryDate, now); got != tc.want {
t.Fatalf("validateHistoricalDay(%q) = %q, want %q", tc.queryDate, got, tc.want)
}
})
}
}
func TestValidateHistoricalPeriodAcrossMonthEnd(t *testing.T) {
beforeMidnight := time.Date(2026, time.August, 31, 15, 59, 59, 0, time.UTC)
afterMidnight := time.Date(2026, time.August, 31, 16, 0, 0, 0, time.UTC)
if got := validateHistoricalMonth("202608", beforeMidnight); got != historicalMonthRangeError {
t.Fatalf("current month before UTC+8 month end = %q", got)
}
if got := validateHistoricalDay("20260831", beforeMidnight); got != historicalDailyRangeError {
t.Fatalf("current day before UTC+8 month end = %q", got)
}
if got := validateHistoricalMonth("202608", afterMidnight); got != "" {
t.Fatalf("completed month after UTC+8 month end = %q", got)
}
if got := validateHistoricalDay("20260831", afterMidnight); got != "" {
t.Fatalf("completed day after UTC+8 month end = %q", got)
}
if got := validateCurrentBatchMonth("202608", beforeMidnight); got != "" {
t.Fatalf("current batch month before UTC+8 month end = %q", got)
}
if got := validateCurrentBatchMonth("202608", afterMidnight); got != currentBatchMonthRangeError {
t.Fatalf("previous batch month after UTC+8 month end = %q", got)
}
if got := validateCurrentBatchMonth("202609", afterMidnight); got != "" {
t.Fatalf("current batch month after UTC+8 month end = %q", got)
}
}
func TestHistoricalPeriodValidationSkipsChannels(t *testing.T) {
ctx := context.Background()
api := NewUpstreamAPI()
channel := &historyUsageTestChannel{}
api.RegisterChan("test_channel", "v1", &model.SimCardChannelCache{Instance: channel})
simKeys := []*model.SimKey{
{ChanKey: "test_channel", ICCID: "iccid-1"},
{ChanKey: "test_channel", ICCID: "iccid-2"},
}
now := time.Now().In(historicalPeriodLocation)
monthly, err := api.QuerySimDataUsageMonthly(ctx, &model.SimDataUsageMonthlyInput{
SimKeys: simKeys,
ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: now.Format("200601")},
})
if err != nil {
t.Fatalf("QuerySimDataUsageMonthly returned error: %v", err)
}
assertMonthlyFailures(t, monthly, simKeys, historicalMonthRangeError)
daily, err := api.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{
SimKeys: simKeys,
ReqData: model.SimDataUsageDailyInputItem{QueryDate: now.Format("20060102")},
})
if err != nil {
t.Fatalf("QuerySimDataUsageDaily returned error: %v", err)
}
assertDailyFailures(t, daily, simKeys, historicalDailyRangeError)
monthlyBatch, err := api.QuerySimDataUsageMonthlyBatch(ctx, &model.SimDataUsageMonthlyInput{
SimKeys: simKeys,
ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: "999912"},
})
if err != nil {
t.Fatalf("QuerySimDataUsageMonthlyBatch returned error: %v", err)
}
assertMonthlyFailures(t, monthlyBatch, simKeys, currentBatchMonthRangeError)
dailyBatch, err := api.QuerySimDataUsageDailyBatch(ctx, &model.SimDataUsageDailyInput{
SimKeys: simKeys,
ReqData: model.SimDataUsageDailyInputItem{QueryDate: "99991231"},
})
if err != nil {
t.Fatalf("QuerySimDataUsageDailyBatch returned error: %v", err)
}
assertDailyFailures(t, dailyBatch, simKeys, historicalDailyRangeError)
invalidMonthly, err := api.QuerySimDataUsageMonthly(ctx, &model.SimDataUsageMonthlyInput{
SimKeys: simKeys,
ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: "2026-08"},
})
if err != nil {
t.Fatalf("invalid monthly query returned error: %v", err)
}
assertMonthlyFailures(t, invalidMonthly, simKeys, historicalMonthFormatError)
invalidDaily, err := api.QuerySimDataUsageDailyBatch(ctx, &model.SimDataUsageDailyInput{
SimKeys: simKeys,
ReqData: model.SimDataUsageDailyInputItem{QueryDate: ""},
})
if err != nil {
t.Fatalf("invalid daily batch query returned error: %v", err)
}
assertDailyFailures(t, invalidDaily, simKeys, historicalDailyFormatError)
if channel.monthlyCalls != 0 || channel.dailyCalls != 0 || channel.monthlyBatchCalls != 0 || channel.dailyBatchCalls != 0 {
t.Fatalf("rejected periods called channel: %+v", channel)
}
}
func TestHistoricalPeriodsRouteAndCurrentUsageRemainsAvailable(t *testing.T) {
ctx := context.Background()
api := NewUpstreamAPI()
channel := &historyUsageTestChannel{}
api.RegisterChan("test_channel", "v1", &model.SimCardChannelCache{Instance: channel})
simKeys := []*model.SimKey{{ChanKey: "test_channel", ICCID: "iccid-1"}}
currentMonth := time.Now().In(historicalPeriodLocation).Format(historicalMonthLayout)
if _, err := api.QuerySimDataUsageMonthly(ctx, &model.SimDataUsageMonthlyInput{
SimKeys: simKeys,
ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: "200001"},
}); err != nil {
t.Fatalf("historical monthly query returned error: %v", err)
}
if _, err := api.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{
SimKeys: simKeys,
ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20000101"},
}); err != nil {
t.Fatalf("historical daily query returned error: %v", err)
}
if _, err := api.QuerySimDataUsageMonthlyBatch(ctx, &model.SimDataUsageMonthlyInput{
SimKeys: simKeys,
ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: currentMonth},
}); err != nil {
t.Fatalf("current monthly batch query returned error: %v", err)
}
if _, err := api.QuerySimDataUsageDailyBatch(ctx, &model.SimDataUsageDailyInput{
SimKeys: simKeys,
ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20000101"},
}); err != nil {
t.Fatalf("historical daily batch query returned error: %v", err)
}
if _, err := api.QuerySimDataUsage(ctx, &model.SimDataUsageInput{SimKeys: simKeys}); err != nil {
t.Fatalf("current usage query returned error: %v", err)
}
if channel.monthlyCalls != 2 || channel.dailyCalls != 2 || channel.monthlyBatchCalls != 1 || channel.dailyBatchCalls != 1 || channel.currentCalls != 1 {
t.Fatalf("unexpected channel calls: %+v", channel)
}
}
func assertMonthlyFailures(t *testing.T, output model.SimDataUsageMonthlyOutput, simKeys []*model.SimKey, message string) {
t.Helper()
if len(output) != len(simKeys) {
t.Fatalf("monthly output length = %d, want %d", len(output), len(simKeys))
}
for i, item := range output {
if item.Code != consts.ApiStatusError || item.Message != message || item.SimKey != simKeys[i] {
t.Fatalf("monthly output[%d] = %+v", i, item)
}
}
}
func assertDailyFailures(t *testing.T, output model.SimDataUsageDailyOutput, simKeys []*model.SimKey, message string) {
t.Helper()
if len(output) != len(simKeys) {
t.Fatalf("daily output length = %d, want %d", len(output), len(simKeys))
}
for i, item := range output {
if item.Code != consts.ApiStatusError || item.Message != message || item.SimKey != simKeys[i] {
t.Fatalf("daily output[%d] = %+v", i, item)
}
}
}
@@ -0,0 +1,158 @@
package upstreamapi
import (
"context"
"path/filepath"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/channelconfig"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
type normalGateTestChannel struct {
model.SimCardChannelI
}
type normalGateTestTemplate struct {
calls int
lastConfig *model.SimChannelConfigItemCache
}
func (f *normalGateTestTemplate) NewTemp(_ context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
f.calls++
f.lastConfig = cfg
return &normalGateTestChannel{}
}
// Exercise real ORM decoding and config versioning as well as both creation paths.
// All SQL runs against a temporary SQLite file, never a configured environment DB.
func TestChannelNormalCreationGate(t *testing.T) {
ctx := context.Background()
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{
Type: "sqlite", Name: filepath.Join(t.TempDir(), "channels.db"),
}}); err != nil {
t.Fatal(err)
}
db := g.DB()
t.Cleanup(func() { _ = db.Close(ctx) })
execSQL := func(query string, args ...any) {
t.Helper()
if _, err := db.Exec(ctx, query, args...); err != nil {
t.Fatal(err)
}
}
execSQL("CREATE TABLE t_sim_channels (id INTEGER PRIMARY KEY, chan_key TEXT, temp_key TEXT, chan_name TEXT, is_normal INTEGER NULL)")
execSQL("CREATE TABLE t_sim_channel_template (id INTEGER PRIMARY KEY, temp_key TEXT, temp_model TEXT)")
execSQL("INSERT INTO t_sim_channel_template (id, temp_key, temp_model) VALUES (1, 'gate_test', 'direct')")
oldConfig := service.ChannelConfig()
t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) })
for _, path := range []string{"loop", "refresh"} {
t.Run(path, func(t *testing.T) {
for _, tc := range []struct {
name string
value any
eligible bool
}{
{"yes_is_zero", 0, true},
{"no_is_one", 1, false},
{"null", nil, false},
{"unexpected", 2, false},
} {
t.Run(tc.name, func(t *testing.T) {
execSQL("DELETE FROM t_sim_channels")
execSQL("INSERT INTO t_sim_channels (id, chan_key, temp_key, is_normal) VALUES (1, 'gate_channel', 'gate_test', ?)", tc.value)
config := channelconfig.NewChannelConfig()
service.RegisterChannelConfig(config)
api := NewUpstreamAPI()
factory := &normalGateTestTemplate{}
api.RegisterTemp("gate_test", factory)
reload := func() {
t.Helper()
var err error
if path == "loop" {
err = config.LoadAllChannelConfig(ctx)
if err == nil {
err = api.loop(ctx)
}
} else {
err = api.RefreshChannel(ctx, "gate_channel")
}
if err != nil {
t.Fatalf("refresh must succeed: %v", err)
}
}
reload()
cfg, ok := config.GetChannel("gate_channel")
if !ok || cfg.CanCreateInstance() != tc.eligible {
t.Fatalf("wrong eligibility after ORM scan: %+v", cfg)
}
if tc.value == nil && cfg.IsNormal != nil {
t.Fatalf("SQL NULL decoded as non-nil: %v", *cfg.IsNormal)
}
if cfg.Channel.ChanKey != "gate_channel" {
t.Fatal("embedded channel fields were not scanned")
}
_, exists := api.GetChan("gate_channel")
if exists != tc.eligible || (factory.calls == 1) != tc.eligible {
t.Fatalf("instance exists=%v factory calls=%d eligible=%v", exists, factory.calls, tc.eligible)
}
initialVersion := cfg.Version
// Only is_normal changes: the configuration cache must notice it.
execSQL("UPDATE t_sim_channels SET is_normal=0")
reload()
current, ok := api.GetChan("gate_channel")
if !ok || current.Instance == nil {
t.Fatal("eligible channel was not instantiated")
}
cfg, _ = config.GetChannel("gate_channel")
if !tc.eligible && cfg.Version == initialVersion {
t.Fatal("changing only is_normal did not change config version")
}
oldCalls := factory.calls
// Becoming ineligible must remove the old runtime instance.
execSQL("UPDATE t_sim_channels SET is_normal=1, chan_name='changed while disabled', temp_key='missing_template'")
reload()
if _, exists := api.GetChan("gate_channel"); exists || factory.calls != oldCalls {
t.Fatal("ineligible channel retained or rebuilt its runtime instance")
}
cfg, _ = config.GetChannel("gate_channel")
if cfg.CanCreateInstance() {
t.Fatal("ineligible config must remain ineligible")
}
// Repeat the disabled refresh, as a retry would.
reload()
if factory.calls != oldCalls {
t.Fatal("retry created an ineligible instance")
}
// A process restart cannot recreate the retained disabled instance.
restarted := NewUpstreamAPI()
restarted.RegisterTemp("gate_test", factory)
if err := restarted.loop(ctx); err != nil {
t.Fatal(err)
}
if _, ok := restarted.GetChan("gate_channel"); ok {
t.Fatal("restart recreated disabled instance")
}
execSQL("UPDATE t_sim_channels SET is_normal=0, temp_key='gate_test'")
reload()
restored, _ := api.GetChan("gate_channel")
if restored == current || factory.calls != oldCalls+1 ||
factory.lastConfig.Channel.ChanName != "changed while disabled" {
t.Fatal("restored channel did not rebuild using the latest configuration")
}
})
}
})
}
}
@@ -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)
}
}
}
@@ -0,0 +1,53 @@
package upstreamapi
import (
"context"
"testing"
"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"
)
type staleLoopConfigSource struct {
service.IChannelConfig
snapshot *model.SimChannelConfigItemCache
latest *model.SimChannelConfigItemCache
}
func (s *staleLoopConfigSource) GetAllChannel(context.Context) ([]*model.SimChannelConfigItemCache, error) {
return []*model.SimChannelConfigItemCache{s.snapshot}, nil
}
func (s *staleLoopConfigSource) GetChannel(string) (*model.SimChannelConfigItemCache, bool) {
return s.latest, true
}
func TestLoopDoesNotReviveChannelFromStaleNormalSnapshot(t *testing.T) {
normal, abnormal := 0, 1
snapshot := &model.SimChannelConfigItemCache{
Channel: &entity.TSimChannels{ChanKey: "channel", TempKey: "template"},
Template: &entity.TSimChannelTemplate{TempKey: "template", TempModel: "direct"},
IsNormal: &normal,
Version: "old-normal",
}
latest := &model.SimChannelConfigItemCache{
Channel: &entity.TSimChannels{ChanKey: "channel", TempKey: "template"},
IsNormal: &abnormal,
Version: "new-abnormal",
}
source := &staleLoopConfigSource{snapshot: snapshot, latest: latest}
oldConfig := service.ChannelConfig()
service.RegisterChannelConfig(source)
t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) })
api := NewUpstreamAPI()
api.RegisterChan("channel", "old-normal", &model.SimCardChannelCache{Instance: &requestScopedTestChannel{}, Version: "old-normal"})
api.RegisterTemp("template", &requestScopedTestTemplate{})
if err := api.loop(context.Background()); err != nil {
t.Fatal(err)
}
if _, ok := api.GetChan("channel"); ok {
t.Fatal("periodic loop revived or retained an abnormal channel from a stale snapshot")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
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"
)
func (u *sUpstreamAPI) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (output model.SimCardStatusOutput, err error) {
return u.queryStatusBatch(ctx, input)
}
func (u *sUpstreamAPI) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (output model.SimDataUsageMonthlyOutput, err error) {
return u.queryMonthlyBatch(ctx, input)
}
func (u *sUpstreamAPI) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (output model.SimDataUsageDailyOutput, err error) {
return u.queryDailyBatch(ctx, input)
}
func (u *sUpstreamAPI) queryStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (output model.SimCardStatusOutput, err error) {
output = make(model.SimCardStatusOutput, 0)
if input == nil || len(input.SimKeys) == 0 {
return output, nil
}
inputMap := make(map[string]*model.SimCardStatusInput)
chanKeyMap, err := u.packChanKey(ctx, input.SimKeys)
if err != nil {
return nil, err
}
for chanKey, simKeys := range chanKeyMap {
inputMap[chanKey] = &model.SimCardStatusInput{
SimKeys: simKeys,
ReqData: input.ReqData,
}
}
for chanKey, inputNew := range inputMap {
if chanKey == "" {
output = appendBatchStatusFailures(output, inputNew.SimKeys, "通道未配置")
continue
}
channel, ok := u.GetChan(chanKey)
if !ok || channel.Instance == nil {
output = appendBatchStatusFailures(output, inputNew.SimKeys, "通道未实例化")
continue
}
batchChannel, ok := channel.Instance.(model.SimCardBatchQueryI)
if !ok {
output = appendBatchStatusFailures(output, inputNew.SimKeys, "通道不支持批量查询")
continue
}
out, err := batchChannel.QuerySimCardStatusBatch(ctx, inputNew)
if err != nil {
output = appendBatchStatusFailures(output, inputNew.SimKeys, err.Error())
continue
}
output = append(output, out...)
}
return output, nil
}
func (u *sUpstreamAPI) queryMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (output model.SimDataUsageMonthlyOutput, err error) {
output = make(model.SimDataUsageMonthlyOutput, 0)
if input == nil || len(input.SimKeys) == 0 {
return output, nil
}
if message := validateCurrentBatchMonth(input.ReqData.QueryDate, time.Now()); message != "" {
return appendBatchMonthlyFailures(output, input.SimKeys, message), nil
}
inputMap := make(map[string]*model.SimDataUsageMonthlyInput)
chanKeyMap, err := u.packChanKey(ctx, input.SimKeys)
if err != nil {
return nil, err
}
for chanKey, simKeys := range chanKeyMap {
inputMap[chanKey] = &model.SimDataUsageMonthlyInput{
SimKeys: simKeys,
ReqData: input.ReqData,
}
}
for chanKey, inputNew := range inputMap {
if chanKey == "" {
output = appendBatchMonthlyFailures(output, inputNew.SimKeys, "通道未配置")
continue
}
channel, ok := u.GetChan(chanKey)
if !ok || channel.Instance == nil {
output = appendBatchMonthlyFailures(output, inputNew.SimKeys, "通道未实例化")
continue
}
batchChannel, ok := channel.Instance.(model.SimCardBatchQueryI)
if !ok {
output = appendBatchMonthlyFailures(output, inputNew.SimKeys, "通道不支持批量查询")
continue
}
out, err := batchChannel.QuerySimDataUsageMonthlyBatch(ctx, inputNew)
if err != nil {
output = appendBatchMonthlyFailures(output, inputNew.SimKeys, err.Error())
continue
}
output = append(output, out...)
}
return output, nil
}
func (u *sUpstreamAPI) queryDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (output model.SimDataUsageDailyOutput, err error) {
output = make(model.SimDataUsageDailyOutput, 0)
if input == nil || len(input.SimKeys) == 0 {
return output, nil
}
if message := validateHistoricalDay(input.ReqData.QueryDate, time.Now()); message != "" {
return appendBatchDailyFailures(output, input.SimKeys, message), nil
}
inputMap := make(map[string]*model.SimDataUsageDailyInput)
chanKeyMap, err := u.packChanKey(ctx, input.SimKeys)
if err != nil {
return nil, err
}
for chanKey, simKeys := range chanKeyMap {
inputMap[chanKey] = &model.SimDataUsageDailyInput{
SimKeys: simKeys,
ReqData: input.ReqData,
}
}
for chanKey, inputNew := range inputMap {
if chanKey == "" {
output = appendBatchDailyFailures(output, inputNew.SimKeys, "通道未配置")
continue
}
channel, ok := u.GetChan(chanKey)
if !ok || channel.Instance == nil {
output = appendBatchDailyFailures(output, inputNew.SimKeys, "通道未实例化")
continue
}
batchChannel, ok := channel.Instance.(model.SimCardBatchQueryI)
if !ok {
output = appendBatchDailyFailures(output, inputNew.SimKeys, "通道不支持批量查询")
continue
}
out, err := batchChannel.QuerySimDataUsageDailyBatch(ctx, inputNew)
if err != nil {
output = appendBatchDailyFailures(output, inputNew.SimKeys, err.Error())
continue
}
output = append(output, out...)
}
return output, nil
}
func appendBatchStatusFailures(output model.SimCardStatusOutput, simKeys []*model.SimKey, msg string) model.SimCardStatusOutput {
for _, simKey := range simKeys {
output = append(output, &model.SimBaseRes[model.SimCardStatusItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: msg,
})
}
return output
}
func appendBatchMonthlyFailures(output model.SimDataUsageMonthlyOutput, simKeys []*model.SimKey, msg string) model.SimDataUsageMonthlyOutput {
for _, simKey := range simKeys {
output = append(output, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: msg,
})
}
return output
}
func appendBatchDailyFailures(output model.SimDataUsageDailyOutput, simKeys []*model.SimKey, msg string) model.SimDataUsageDailyOutput {
for _, simKey := range simKeys {
output = append(output, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: msg,
})
}
return output
}