sync: 同步 v1.0.4 开源代码并脱敏
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user