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,298 @@
package channelconfig
import (
"context"
"database/sql"
"strings"
"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"
"sync"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao"
"github.com/gogf/gf/v2/crypto/gmd5"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
// channelConfigRow extends the generated entity without losing SQL NULL values.
type channelConfigRow struct {
entity.TSimChannels
IsNormal *int `json:"isNormal" orm:"is_normal"`
}
type sChannelConfig struct {
sync.RWMutex
channelCache map[string]*model.SimChannelConfigItemCache
loadVersion uint8
chgLogMaxId int64
running bool
}
func NewChannelConfig() *sChannelConfig {
return &sChannelConfig{
channelCache: make(map[string]*model.SimChannelConfigItemCache),
}
}
func init() {
service.RegisterChannelConfig(NewChannelConfig())
}
func (s *sChannelConfig) Start(ctx context.Context) error {
err := s.LoadAllChannelConfig(ctx)
if err != nil {
g.Log().Errorf(ctx, "ChannelConfig Start err %+v", err)
return err
}
s.running = true
err = s.Loop(ctx)
if err != nil {
g.Log().Errorf(ctx, "ChannelConfig Start err %+v", err)
return err
}
return nil
}
func (s *sChannelConfig) Stop(ctx context.Context) error {
s.running = false
return nil
}
func (s *sChannelConfig) Loop(ctx context.Context) error {
g.Log().Infof(ctx, "ChannelConfig loop begin.")
lastLoadAllChannelConfigTime := int64(0)
go func() {
for s.running {
now := time.Now().Unix()
loadAllInterval := g.Cfg().MustGet(ctx, "channel.load_all_interval", 2*60*60).Int64()
if now-lastLoadAllChannelConfigTime >= loadAllInterval {
lastLoadAllChannelConfigTime = now
s.LoadAllChannelConfig(ctx)
}
time.Sleep(1 * time.Second)
}
}()
g.Log().Infof(ctx, "ChannelConfig loop background goroutine started.")
return nil
}
func (s *sChannelConfig) LoadAllChannelConfig(ctx context.Context) error {
s.loadVersion++
const warnRowThreshold = 1000
dbSimChannels := make([]*channelConfigRow, 0)
err := dao.TSimChannels.Ctx(ctx).
OrderAsc(dao.TSimChannels.Columns().Id).
Scan(&dbSimChannels)
if err != nil && err != sql.ErrNoRows {
return err
}
if len(dbSimChannels) >= warnRowThreshold {
g.Log().Warningf(ctx, "LoadAllChannelConfig TSimChannels row count %d >= %d, confirm small-table assumption or add pagination",
len(dbSimChannels), warnRowThreshold)
}
dbSimChannelTemplates := make([]*entity.TSimChannelTemplate, 0)
err = dao.TSimChannelTemplate.Ctx(ctx).
OrderAsc(dao.TSimChannelTemplate.Columns().Id).
Scan(&dbSimChannelTemplates)
if err != nil && err != sql.ErrNoRows {
return err
}
if len(dbSimChannelTemplates) >= warnRowThreshold {
g.Log().Warningf(ctx, "LoadAllChannelConfig TSimChannelTemplate row count %d >= %d, confirm small-table assumption or add pagination",
len(dbSimChannelTemplates), warnRowThreshold)
}
templateByKey := make(map[string]*entity.TSimChannelTemplate, len(dbSimChannelTemplates))
for _, tpl := range dbSimChannelTemplates {
if tpl != nil {
templateByKey[tpl.TempKey] = tpl
}
}
s.Lock()
for _, dbSimChannel := range dbSimChannels {
if dbSimChannel == nil {
continue
}
cache := &model.SimChannelConfigItemCache{
Channel: &dbSimChannel.TSimChannels,
IsNormal: dbSimChannel.IsNormal,
Version: s.getChanVersion(ctx, dbSimChannel),
}
oldChannelCache, _ := s.channelCache[dbSimChannel.ChanKey]
if cache.CanCreateInstance() {
template, ok := templateByKey[dbSimChannel.TempKey]
if !ok {
g.Log().Warningf(ctx, "LoadAllChannelConfig no template for tempKey=%s chanKey=%s", dbSimChannel.TempKey, dbSimChannel.ChanKey)
continue
}
cache.Template = template
cache.Version += s.getTemplateVersion(ctx, template)
}
if oldChannelCache != nil && oldChannelCache.Version == cache.Version {
continue
}
s.channelCache[dbSimChannel.ChanKey] = cache
}
s.Unlock()
return nil
}
// RefreshChannel reloads one channel and its template from the database.
func (s *sChannelConfig) RefreshChannel(ctx context.Context, chanKey string) error {
if chanKey == "" {
return gerror.New("chanKey is required")
}
channel := new(channelConfigRow)
err := dao.TSimChannels.Ctx(ctx).Where(dao.TSimChannels.Columns().ChanKey, chanKey).Scan(channel)
if err != nil {
return err
}
if channel.ChanKey == "" {
return gerror.Newf("channel not found: %s", chanKey)
}
cache := &model.SimChannelConfigItemCache{
Channel: &channel.TSimChannels,
IsNormal: channel.IsNormal,
Version: s.getChanVersion(ctx, channel),
}
// Ineligible channels refresh successfully even while their template is incomplete.
if cache.CanCreateInstance() {
template := new(entity.TSimChannelTemplate)
if err := dao.TSimChannelTemplate.Ctx(ctx).Where(dao.TSimChannelTemplate.Columns().TempKey, channel.TempKey).Scan(template); err != nil {
return err
}
if template.TempKey == "" {
return gerror.Newf("channel template not found: %s", channel.TempKey)
}
cache.Template = template
cache.Version += s.getTemplateVersion(ctx, template)
}
s.Lock()
s.channelCache[chanKey] = cache
s.Unlock()
return nil
}
// RemoveChannel removes a channel configuration cache. Missing keys are a
// successful no-op so rename retries are idempotent.
func (s *sChannelConfig) RemoveChannel(ctx context.Context, chanKey string) error {
chanKey = strings.TrimSpace(chanKey)
if chanKey == "" {
return gerror.New("chanKey is required")
}
s.Lock()
delete(s.channelCache, chanKey)
s.Unlock()
g.Log().Infof(ctx, "channel config removed, chanKey: %s", chanKey)
return nil
}
// LoadChannelForTest reads an isolated configuration snapshot. It never
// mutates channelCache, including when the channel is not runtime-eligible.
func (s *sChannelConfig) LoadChannelForTest(ctx context.Context, channelID int64, tenantID string) (*model.SimChannelConfigItemCache, error) {
tenantID = strings.TrimSpace(tenantID)
if channelID <= 0 || tenantID == "" {
return nil, model.ErrChannelNotAccessible
}
channel := new(channelConfigRow)
if err := dao.TSimChannels.Ctx(ctx).
Where(dao.TSimChannels.Columns().Id, channelID).
Where(dao.TSimChannels.Columns().TenantId, tenantID).
Limit(1).
Scan(channel); err != nil {
return nil, err
}
if channel.Id == 0 || strings.TrimSpace(channel.ChanKey) == "" {
return nil, model.ErrChannelNotAccessible
}
template := new(entity.TSimChannelTemplate)
if err := dao.TSimChannelTemplate.Ctx(ctx).
Where(dao.TSimChannelTemplate.Columns().TempKey, channel.TempKey).
Where(dao.TSimChannelTemplate.Columns().TenantId, tenantID).
Limit(1).
Scan(template); err != nil {
return nil, err
}
if strings.TrimSpace(template.TempKey) == "" {
return nil, gerror.Newf("channel template not found: %s", channel.TempKey)
}
return &model.SimChannelConfigItemCache{
Channel: &channel.TSimChannels,
Template: template,
IsNormal: channel.IsNormal,
Version: s.getChanVersion(ctx, channel) + s.getTemplateVersion(ctx, template),
}, nil
}
// get alarm cache by id
func (s *sChannelConfig) GetChannel(chanKey string) (*model.SimChannelConfigItemCache, bool) {
s.RLock()
defer s.RUnlock()
channelCache, ok := s.channelCache[chanKey]
return channelCache, ok
}
func (s *sChannelConfig) GetAllChannel(ctx context.Context) ([]*model.SimChannelConfigItemCache, error) {
s.RLock()
defer s.RUnlock()
channels := make([]*model.SimChannelConfigItemCache, 0)
for _, channelCache := range s.channelCache {
channels = append(channels, channelCache)
}
return channels, nil
}
func (s *sChannelConfig) getChanVersion(ctx context.Context, channel *channelConfigRow) string {
chanKey := channel.ChanKey
channelNew := &channelConfigRow{
IsNormal: channel.IsNormal,
TSimChannels: entity.TSimChannels{
TempKey: channel.TempKey,
ChanKey: channel.ChanKey,
ChanName: channel.ChanName,
ChanConfig: channel.ChanConfig,
ChanStatus: channel.ChanStatus,
ChanAlias: channel.ChanAlias,
RegionCode: channel.RegionCode,
AppKey: channel.AppKey,
AppSecret: channel.AppSecret,
AccessToken: channel.AccessToken,
RefreshToken: channel.RefreshToken,
},
}
chanVersion, err := gmd5.EncryptString(gjson.MustEncodeString(channelNew))
if err != nil {
g.Log().Errorf(ctx, "sChannelConfig.getChanVersion encrypt error %+v, chanKey %s", err, chanKey)
return ""
}
g.Log().Debugf(ctx, "sChannelConfig.getChanVersion chanVersion=%s chanKey=%s", chanVersion, chanKey)
return chanVersion
}
func (s *sChannelConfig) getTemplateVersion(ctx context.Context, template *entity.TSimChannelTemplate) string {
templateKey := template.TempKey
templateNew := &entity.TSimChannelTemplate{
TempKey: template.TempKey,
TempName: template.TempName,
TempModel: template.TempModel,
TempConfig: template.TempConfig,
TempStatus: template.TempStatus,
}
templateVersion, err := gmd5.EncryptString(gjson.MustEncodeString(templateNew))
if err != nil {
g.Log().Errorf(ctx, "sChannelConfig.getTemplateVersion encrypt error %+v, templateKey %s, templateNew %+v", err, templateKey, templateNew)
return ""
}
g.Log().Debugf(ctx, "sChannelConfig.getTemplateVersion templateVersion=%s templateKey=%s", templateVersion, templateKey)
return templateVersion
}
@@ -0,0 +1,37 @@
package channelconfig
import (
"context"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
)
func TestChannelVersionDetectsCredentialChanges(t *testing.T) {
normal := 0
row := &channelConfigRow{IsNormal: &normal, TSimChannels: entity.TSimChannels{
ChanKey: "telecom-test", TempKey: "sim_dx_v1", AppKey: "key", AppSecret: "secret",
AccessToken: "token", RefreshToken: "refresh",
}}
config := NewChannelConfig()
before := config.getChanVersion(context.Background(), row)
for _, field := range []string{"appKey", "appSecret", "accessToken", "refreshToken", "tempKey"} {
t.Run(field, func(t *testing.T) {
changed := *row
switch field {
case "appKey":
changed.AppKey = "new-key"
case "appSecret":
changed.AppSecret = "new-secret"
case "accessToken":
changed.AccessToken = "new-token"
case "refreshToken":
changed.RefreshToken = "new-refresh"
case "tempKey":
changed.TempKey = "sim_dx_v3"
}
if got := config.getChanVersion(context.Background(), &changed); got == before {
t.Fatalf("%s change did not invalidate runtime version", field)
}
})
}
}
@@ -0,0 +1,51 @@
package channelconfig
import (
"context"
"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/do"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
)
type sChannelUpdate struct{}
func NewChannelUpdate() *sChannelUpdate {
return &sChannelUpdate{}
}
func init() {
service.RegisterChannelUpdate(NewChannelUpdate())
}
func (s *sChannelUpdate) UpdateAccessToken(ctx context.Context, input *model.UpdateAccessTokenInput) error {
if input == nil {
return gerror.New("access token update input is required")
}
data := do.TSimChannels{
AccessToken: input.AccessToken,
AccessTokenCreated: input.AccessTokenCreated,
RefreshToken: input.RefreshToken,
}
if input.AccessTokenExpire != nil {
data.AccessTokenExpire = input.AccessTokenExpire
}
return dao.TSimChannels.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Model(dao.TSimChannels.Table()).Where(dao.TSimChannels.Columns().ChanKey, input.ChanKey).Data(data).Update()
if err != nil || input.AccessTokenExpire != nil {
return err
}
// The generated DO field is a *gtime.Time, so it cannot carry
// gdb.Raw. Clear the nullable column explicitly in this transaction
// instead of letting a nil field be treated as an omitted update.
_, err = tx.Exec(
"UPDATE `t_sim_channels` SET `access_token_expire` = NULL WHERE `chan_key` = ?",
input.ChanKey,
)
return err
})
}
+11
View File
@@ -0,0 +1,11 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package logic
import (
_ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/channelconfig"
_ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamapi"
_ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl"
)
@@ -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
}
@@ -0,0 +1,33 @@
package upstreamimpl
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"
)
func TestApikaTemplateRegistration(t *testing.T) {
template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkApikaTemplateKey)
if !ok || template == nil {
t.Fatal("apika template not registered")
}
config := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkApikaTemplateKey,
TempConfig: `{"token":{"provider":"apika","baseUrl":"https://example.com","path":"/iot-api/iot-card-info/getRefreshToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "apika-test",
ChanConfig: `{"appId":"appid"}`,
AppKey: "app-key",
AppSecret: "app-secret",
},
}
if instance := template.NewTemp(context.Background(), config); instance == nil {
t.Fatal("apika template failed to instantiate")
}
}
@@ -0,0 +1,130 @@
package upstreamimpl
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
const apikaTokenPath = "/iot-api/iot-card-info/getRefreshToken"
type ApikaTokenConfig struct {
BaseURL string
Path string
AppKey string
AppSecret string
AppID string
Timeout time.Duration
}
type ApikaTokenProvider struct {
baseURL string
path string
appKey string
appSecret string
appID string
client *http.Client
}
type apikaTokenResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
ShareToken string `json:"shareToken"`
} `json:"data"`
}
type apikaFetcher struct{ provider *ApikaTokenProvider }
func NewApikaTokenProvider(config ApikaTokenConfig) *ApikaTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &ApikaTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: strings.TrimSpace(normalizeProviderPath(config.Path, apikaTokenPath)),
appKey: strings.TrimSpace(config.AppKey), appSecret: config.AppSecret,
appID: strings.TrimSpace(config.AppID), client: &http.Client{Timeout: timeout},
}
}
func apikaSignature(appKey, appSecret string) string {
data := "{" + appKey + "}:" + "{" + appSecret + "}"
sum := sha256.Sum256([]byte(data))
hexValue := strings.ToUpper(hex.EncodeToString(sum[:]))
return base64.StdEncoding.EncodeToString([]byte(hexValue))
}
func (f apikaFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx)
}
func (p *ApikaTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
if p == nil || p.client == nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token provider is not initialized")
}
if p.baseURL == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token base URL is required")
}
if p.appKey == "" || strings.TrimSpace(p.appSecret) == "" || p.appID == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika appKey, appSecret and appid are required")
}
signature := apikaSignature(p.appKey, p.appSecret)
payload, err := json.Marshal(struct {
AppID string `json:"appid"`
AppKey string `json:"appKey"`
AppSecret string `json:"appSecret"`
Signature string `json:"signature"`
}{AppID: p.appID, AppKey: p.appKey, AppSecret: p.appSecret, Signature: signature})
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode apika token request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), bytes.NewReader(payload))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("create apika token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("appKey", p.appKey)
req.Header.Set("appSecret", p.appSecret)
req.Header.Set("signature", signature)
resp, err := p.client.Do(req)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("request apika token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("read apika token response: %w", err)
}
if len(body) > 4*1024*1024 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token response is too large")
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token HTTP status %d", resp.StatusCode)
}
var output apikaTokenResponse
if err := json.Unmarshal(body, &output); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode apika token response: %w", err)
}
if output.Code != http.StatusOK {
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token failed: code=%d msg=%s", output.Code, strings.TrimSpace(output.Msg))
}
value := strings.TrimSpace(output.Data.ShareToken)
if value == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika shareToken is empty")
}
now := time.Now()
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute)}, nil
}
@@ -0,0 +1,52 @@
package upstreamimpl
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestApikaSignature(t *testing.T) {
got := apikaSignature("00009ac515b442d6812f892d6e4c9f6a", "7d9264596bea4c5e9f4bcde7a314eb9c")
want := "MTY0MzE2QjgzRjg3MDFEQzlCNjRBNDA0QzMyRjM4QURGRDhCN0Y5ODY5Qjc0QjNFOEI3NkVEMUJCQ0IwMEU3OQ=="
if got != want {
t.Fatalf("signature = %q, want %q", got, want)
}
}
func TestApikaTokenProviderFetch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != apikaTokenPath {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("appKey") != "key" || r.Header.Get("appSecret") != "secret" || r.Header.Get("signature") != apikaSignature("key", "secret") {
t.Fatalf("headers = %#v", r.Header)
}
var input struct {
AppID string `json:"appid"`
AppKey string `json:"appKey"`
AppSecret string `json:"appSecret"`
Signature string `json:"signature"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
t.Fatal(err)
}
if input.AppID != "appid" || input.AppKey != "key" || input.AppSecret != "secret" || input.Signature != apikaSignature("key", "secret") {
t.Fatalf("payload = %#v", input)
}
_, _ = w.Write([]byte(`{"code":200,"msg":"success","data":{"shareToken":"token-1","transid":"tx-1"}}`))
}))
defer server.Close()
provider := NewApikaTokenProvider(ApikaTokenConfig{BaseURL: server.URL, AppKey: "key", AppSecret: "secret", AppID: "appid", Timeout: time.Second})
token, err := provider.Fetch(context.Background())
if err != nil {
t.Fatalf("fetch token: %v", err)
}
if token.Value != "token-1" || !token.ExpiresAt.After(token.CreatedAt) {
t.Fatalf("token = %#v", token)
}
}
@@ -0,0 +1,255 @@
package upstreamimpl
import (
"context"
"fmt"
"os"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
)
// 真实上游时延测量(默认跳过)。
//
// 目的:实测移动 OneLink「批量提交返回 jobId」与「按 jobId 查到终态」各自的耗时,
// 用于校准平台超时与补查节奏。
//
// 仅在 APN_REAL_CARRIER_TEST=1 时执行。
//
// APN_REAL_CREDS_FILE=<path> 两行:appid / appsecret
// APN_REAL_BASE_URL=<url> 默认 https://api.iot.10086.cn/v5
// APN_REAL_APN=CMIOT
// APN_REAL_BATCHES=<b1>|<b2>|... 每批:operType,iccid:msisdn,iccid:msisdn,...
// APN_REAL_POLL_INTERVAL=15s 查询间隔
// APN_REAL_MAX_WAIT=15m 最长等待终态
func TestRealCarrierApnLatencyMeasure(t *testing.T) {
if strings.TrimSpace(os.Getenv("APN_REAL_CARRIER_TEST")) != "1" {
t.Skip("真实上游测量未启用:设置 APN_REAL_CARRIER_TEST=1 才执行")
}
appID, appSecret, err := loadRealCarrierCreds()
if err != nil {
t.Fatalf("读取凭证失败:%v", err)
}
baseURL := strings.TrimSpace(os.Getenv("APN_REAL_BASE_URL"))
if baseURL == "" {
baseURL = "https://api.iot.10086.cn/v5"
}
apn := strings.TrimSpace(os.Getenv("APN_REAL_APN"))
if apn == "" {
apn = "CMIOT"
}
batches, operTypes, err := parseRealBatches(os.Getenv("APN_REAL_BATCHES"))
if err != nil {
t.Fatalf("解析批次失败:%v", err)
}
pollInterval := 15 * time.Second
if raw := strings.TrimSpace(os.Getenv("APN_REAL_POLL_INTERVAL")); raw != "" {
if d, derr := time.ParseDuration(raw); derr == nil && d > 0 {
pollInterval = d
}
}
maxWait := 15 * time.Minute
if raw := strings.TrimSpace(os.Getenv("APN_REAL_MAX_WAIT")); raw != "" {
if d, derr := time.ParseDuration(raw); derr == nil && d > 0 {
maxWait = d
}
}
channel := &sSimYD{
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "real-carrier-measure", AppKey: appID}},
tokenProvider: &realCarrierToken{appID: appID, appSecret: appSecret, baseURL: baseURL},
apiConfig: &oneLinkAPIConfig{BaseURL: baseURL, Timeout: 60 * time.Second},
}
type batchOutcome struct {
index int
operType string
cards int
elapsed time.Duration
jobID string
failed string
}
start := time.Now()
ctx := context.Background()
results := make([]batchOutcome, len(batches))
var wg sync.WaitGroup
for i := range batches {
wg.Add(1)
go func(i int) {
defer wg.Done()
outcome := batchOutcome{index: i, operType: operTypes[i], cards: len(batches[i])}
begin := time.Now()
items, callErr := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
SimKeys: batches[i],
ReqData: model.SimCardOperateChangeApnInputItem{
OperType: operTypes[i],
ApnName: apn,
SubmitOnly: true,
OperationID: fmt.Sprintf("measure-%d-%d", i, begin.UnixNano()),
},
})
outcome.elapsed = time.Since(begin)
switch {
case callErr != nil:
outcome.failed = callErr.Error()
case len(items) == 0:
outcome.failed = "空返回"
default:
outcome.jobID = items[0].ResData.OrderNum
for _, item := range items {
if item == nil {
continue
}
if item.ResData.OrderNum != "" && item.ResData.OrderNum != outcome.jobID {
outcome.failed = "同批出现多个jobId"
}
if !item.ResData.Pending && !item.ResData.Uncertain {
outcome.failed = fmt.Sprintf("提交即终态 code=%d msg=%s", item.Code, item.Message)
}
}
}
results[i] = outcome
}(i)
}
wg.Wait()
submitWall := time.Since(start)
fmt.Printf("MEASURE_SUBMIT batches=%d cards=%d wall=%s\n", len(batches), totalRealCards(batches), submitWall)
for _, r := range results {
fmt.Printf("MEASURE_SUBMIT_BATCH idx=%d operType=%s cards=%d elapsed=%s jobId=%s failed=%s\n",
r.index, r.operType, r.cards, r.elapsed, r.jobID, r.failed)
}
// 汇总待查任务
pendingJobs := map[string][]*model.SimKey{}
for _, r := range results {
if r.jobID != "" {
pendingJobs[r.jobID] = batches[r.index]
}
}
terminalAt := map[string]time.Duration{}
fmt.Printf("MEASURE_JOBS distinct=%d %v\n", len(pendingJobs), sortedJobIDs(pendingJobs))
deadline := start.Add(maxWait)
for len(terminalAt) < len(pendingJobs) && time.Now().Before(deadline) {
time.Sleep(pollInterval)
elapsed := time.Since(start)
for jobID, keys := range pendingJobs {
if _, done := terminalAt[jobID]; done {
continue
}
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
SimKeys: keys,
ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: jobID},
})
if err != nil {
fmt.Printf("MEASURE_QUERY_ERR jobId=%s at=%s err=%v\n", jobID, elapsed.Round(time.Second), err)
continue
}
allTerminal := len(items) > 0
for _, item := range items {
if item == nil || item.ResData.Pending {
allTerminal = false
break
}
}
if allTerminal {
terminalAt[jobID] = elapsed
fmt.Printf("MEASURE_TERMINAL jobId=%s at=%s\n", jobID, elapsed.Round(time.Second))
for _, item := range items {
fmt.Printf("MEASURE_CARD jobId=%s iccid=%s msisdn=%s code=%d uncertain=%v message=%s\n",
jobID, item.SimKey.ICCID, item.SimKey.MSISDN, item.Code, item.ResData.Uncertain, item.Message)
}
} else {
fmt.Printf("MEASURE_PENDING jobId=%s at=%s note=%s\n", jobID, elapsed.Round(time.Second), firstMessage(items))
}
}
}
fmt.Printf("MEASURE_SUMMARY\n")
for _, jobID := range sortedJobIDs(pendingJobs) {
if at, ok := terminalAt[jobID]; ok {
fmt.Printf("MEASURE_RESULT jobId=%s terminalAt=%s\n", jobID, at.Round(time.Second))
} else {
fmt.Printf("MEASURE_RESULT jobId=%s terminalAt=NONE within %s\n", jobID, maxWait)
}
}
}
func totalRealCards(batches [][]*model.SimKey) int {
total := 0
for _, b := range batches {
total += len(b)
}
return total
}
func sortedJobIDs(jobs map[string][]*model.SimKey) []string {
ids := make([]string, 0, len(jobs))
for id := range jobs {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
func firstMessage(items model.SimCardOperateChangeApnOutput) string {
for _, item := range items {
if item != nil {
return item.Message
}
}
return ""
}
// parseRealBatches 解析 APN_REAL_BATCHES:批之间用 | 分隔,每批首项为 operType,其余为 iccid:msisdn。
func parseRealBatches(raw string) ([][]*model.SimKey, []string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil, fmt.Errorf("未设置 APN_REAL_BATCHES")
}
batches := make([][]*model.SimKey, 0)
operTypes := make([]string, 0)
for _, batchRaw := range strings.Split(raw, "|") {
batchRaw = strings.TrimSpace(batchRaw)
if batchRaw == "" {
continue
}
fields := strings.Split(batchRaw, ",")
operType := strings.TrimSpace(fields[0])
if _, err := strconv.Atoi(operType); err != nil {
return nil, nil, fmt.Errorf("批次首项应为 operType:%q", batchRaw)
}
keys := make([]*model.SimKey, 0, len(fields)-1)
for _, card := range fields[1:] {
card = strings.TrimSpace(card)
if card == "" {
continue
}
parts := strings.Split(card, ":")
if len(parts) < 2 {
return nil, nil, fmt.Errorf("卡项格式应为 iccid:msisdn:%q", card)
}
keys = append(keys, &model.SimKey{
ChanKey: "real-carrier-measure",
ICCID: strings.TrimSpace(parts[0]),
MSISDN: strings.TrimSpace(parts[1]),
})
}
if len(keys) == 0 {
continue
}
batches = append(batches, keys)
operTypes = append(operTypes, operType)
}
if len(batches) == 0 {
return nil, nil, fmt.Errorf("批次为空")
}
return batches, operTypes, nil
}
@@ -0,0 +1,269 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
// 真实移动 OneLink 上游功能验证(默认跳过)。
//
// 仅在显式设置 APN_REAL_CARRIER_TEST=1 时执行,并且只做本文件声明的动作,
// 用于小范围真实卡的功能验证。凭证从文件读取(APN_REAL_CREDS_FILE),
// 不出现在命令行或代码里。
//
// 环境变量:
//
// APN_REAL_CARRIER_TEST=1 必需,否则整包跳过
// APN_REAL_CREDS_FILE=<path> 两行:appid / appsecret
// APN_REAL_BASE_URL=<url> 默认 https://api.iot.10086.cn/v5
// APN_REAL_CARDS=<iccid:msisdn:imsi;...> 卡清单
// APN_REAL_ACTION=apninfo|submit|query
// APN_REAL_OPERTYPE=0|1 submit 时使用:0=开 1=停
// APN_REAL_APN=CMIOT submit 时使用
// APN_REAL_JOBID=<jobId> query 时使用
func TestRealCarrierApnFunctional(t *testing.T) {
if strings.TrimSpace(os.Getenv("APN_REAL_CARRIER_TEST")) != "1" {
t.Skip("真实上游测试未启用:设置 APN_REAL_CARRIER_TEST=1 才执行")
}
appID, appSecret, err := loadRealCarrierCreds()
if err != nil {
t.Fatalf("读取凭证失败:%v", err)
}
baseURL := strings.TrimSpace(os.Getenv("APN_REAL_BASE_URL"))
if baseURL == "" {
baseURL = "https://api.iot.10086.cn/v5"
}
keys, err := loadRealCarrierKeys()
if err != nil {
t.Fatalf("解析卡清单失败:%v", err)
}
channel := &sSimYD{
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "real-carrier-verify", AppKey: appID}},
tokenProvider: &realCarrierToken{appID: appID, appSecret: appSecret, baseURL: baseURL},
apiConfig: &oneLinkAPIConfig{BaseURL: baseURL, Timeout: 30 * time.Second},
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
switch strings.TrimSpace(os.Getenv("APN_REAL_ACTION")) {
case "apninfo":
apnInfoProbe(t, ctx, channel, keys)
case "submit":
submitProbe(t, ctx, channel, keys)
case "query":
queryProbe(t, ctx, channel, keys)
default:
t.Fatalf("APN_REAL_ACTION 必须为 apninfo|submit|query")
}
}
// apninfoProbe 只读:查询每张卡当前已开通 APN 列表及状态。
func apnInfoProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
for _, key := range keys {
params := g.Map{"transid": channel.getTransId(ctx), "iccid": key.ICCID}
resp, err := channel.requestYd(ctx, "QuerySimApnInfo", channel.getUrl(ctx, "ec/query/apn-info"), params)
if err != nil {
t.Errorf("apn-info %s 请求失败:%v", key.ICCID, err)
continue
}
body := resp.ReadAllString()
fmt.Printf("APNINFO iccid=%s msisdn=%s body=%s\n", key.ICCID, key.MSISDN, oneLine(body))
}
}
// submitProbe 真实提交一次批量开/停,只取受理结果(SubmitOnly),不轮询。
func submitProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
operType := strings.TrimSpace(os.Getenv("APN_REAL_OPERTYPE"))
if operType == "" {
t.Fatalf("submit 必须指定 APN_REAL_OPERTYPE(0开/1停)")
}
apn := strings.TrimSpace(os.Getenv("APN_REAL_APN"))
if apn == "" {
apn = "CMIOT"
}
checkpointed := make(model.SimCardOperateChangeApnOutput, 0)
ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(partial model.SimCardOperateChangeApnOutput) error {
checkpointed = append(checkpointed, partial...)
return nil
})
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
SimKeys: keys,
ReqData: model.SimCardOperateChangeApnInputItem{OperType: operType, ApnName: apn, SubmitOnly: true, OperationID: "real-verify-" + fmt.Sprint(time.Now().Unix())},
})
if err != nil {
t.Fatalf("提交失败:%v", err)
}
fmt.Printf("SUBMIT operType=%s apn=%s cards=%d checkpointed=%d\n", operType, apn, len(items), len(checkpointed))
jobIDs := map[string]int{}
for _, item := range items {
if item == nil || item.SimKey == nil {
t.Errorf("返回项缺少卡关联")
continue
}
jobIDs[item.ResData.OrderNum]++
fmt.Printf("SUBMIT_ITEM iccid=%s code=%d pending=%v uncertain=%v jobId=%s message=%s\n",
item.SimKey.ICCID, item.Code, item.ResData.Pending, item.ResData.Uncertain, item.ResData.OrderNum, item.Message)
}
fmt.Printf("SUBMIT_JOBS %v\n", jobIDs)
}
// queryProbe 真实查询一次批量任务结果(单次,不轮询)。
func queryProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
jobID := strings.TrimSpace(os.Getenv("APN_REAL_JOBID"))
if jobID == "" {
t.Fatalf("query 必须指定 APN_REAL_JOBID")
}
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
SimKeys: keys,
ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: jobID},
})
if err != nil {
t.Fatalf("查询失败:%v", err)
}
fmt.Printf("QUERY jobId=%s cards=%d\n", jobID, len(items))
for _, item := range items {
if item == nil || item.SimKey == nil {
t.Errorf("返回项缺少卡关联")
continue
}
fmt.Printf("QUERY_ITEM iccid=%s msisdn=%s code=%d pending=%v uncertain=%v jobId=%s message=%s\n",
item.SimKey.ICCID, item.SimKey.MSISDN, item.Code, item.ResData.Pending, item.ResData.Uncertain, item.ResData.OrderNum, item.Message)
}
}
// realCarrierToken 直接从 OneLink 换 token,不落库、不依赖数据库。
type realCarrierToken struct {
appID string
appSecret string
baseURL string
mu sync.Mutex
token string
expireAt time.Time
seq int64
}
func (p *realCarrierToken) GetToken(ctx context.Context) (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.token != "" && time.Now().Before(p.expireAt.Add(-2*time.Minute)) {
return p.token, nil
}
return p.fetch(ctx)
}
func (p *realCarrierToken) RecoverToken(ctx context.Context, rejected string) (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.token != "" && p.token != rejected {
return p.token, nil
}
p.token = ""
return p.fetch(ctx)
}
func (p *realCarrierToken) fetch(ctx context.Context) (string, error) {
p.seq++
transid := p.appID + time.Now().Format("20060102150405") + fmt.Sprintf("%08d", p.seq)
endpoint := strings.TrimRight(p.baseURL, "/") + "/ec/get/token?" + url.Values{
"appid": {p.appID},
"password": {p.appSecret},
"transid": {transid},
"refresh": {"0"},
}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var parsed struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
Token string `json:"token"`
Ttl int `json:"ttl"`
} `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return "", err
}
if parsed.Status != "0" || len(parsed.Result) == 0 || parsed.Result[0].Token == "" {
return "", fmt.Errorf("换 token 失败 status=%s message=%s", parsed.Status, parsed.Message)
}
p.token = parsed.Result[0].Token
ttl := parsed.Result[0].Ttl
if ttl <= 0 {
ttl = 3600
}
p.expireAt = time.Now().Add(time.Duration(ttl) * time.Second)
return p.token, nil
}
func loadRealCarrierCreds() (string, string, error) {
path := strings.TrimSpace(os.Getenv("APN_REAL_CREDS_FILE"))
if path == "" {
return "", "", fmt.Errorf("未设置 APN_REAL_CREDS_FILE")
}
raw, err := os.ReadFile(path)
if err != nil {
return "", "", err
}
lines := make([]string, 0, 2)
for _, line := range strings.Split(string(raw), "\n") {
if line = strings.TrimSpace(line); line != "" {
lines = append(lines, line)
}
}
if len(lines) < 2 {
return "", "", fmt.Errorf("凭证文件需要两行:appid / appsecret")
}
return lines[0], lines[1], nil
}
func loadRealCarrierKeys() ([]*model.SimKey, error) {
raw := strings.TrimSpace(os.Getenv("APN_REAL_CARDS"))
if raw == "" {
return nil, fmt.Errorf("未设置 APN_REAL_CARDS")
}
keys := make([]*model.SimKey, 0)
for _, entry := range strings.Split(raw, ";") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.Split(entry, ":")
if len(parts) < 2 {
return nil, fmt.Errorf("卡项格式应为 iccid:msisdn[:imsi]:%q", entry)
}
key := &model.SimKey{ChanKey: "real-carrier-verify", ICCID: strings.TrimSpace(parts[0]), MSISDN: strings.TrimSpace(parts[1])}
if len(parts) > 2 {
key.IMSI = strings.TrimSpace(parts[2])
}
keys = append(keys, key)
}
if len(keys) == 0 {
return nil, fmt.Errorf("卡清单为空")
}
return keys, nil
}
func oneLine(body string) string {
return strings.ReplaceAll(strings.ReplaceAll(body, "\n", " "), "\r", " ")
}
@@ -0,0 +1,149 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
// beixinOneLinkTokenPath is the fallback used when Path is empty. On the
// template path this default is unreachable: parseSharedOneLinkConfig rejects
// an empty token.path before any provider is built, so Path always carries the
// value declared in temp_config. It only applies to direct construction
// (tests). The same shape exists in every shared-token provider.
const beixinOneLinkTokenPath = "/prod-api/open/api/oneLinkToken"
// beixinTokenTTL is the local validity window. The upstream returns only the
// token string and no expiry field, so the window is held locally; a token
// rejected earlier than this is recovered through RecoverToken's invalidation
// path instead of being trusted until the TTL elapses.
const beixinTokenTTL = time.Hour
// BeixinTokenConfig contains all values required by the Beixin (江苏倍信)
// shared-token protocol. BaseURL and Path come from temp_config
// token.baseUrl/token.path (with the per-channel tokenBaseUrl override
// applied by parseSharedOneLinkConfig); the credentials come from chan_config.
type BeixinTokenConfig struct {
BaseURL string
Path string
PwdKey string
ChannelNo string
Timeout time.Duration
}
// BeixinTokenProvider fetches a Beixin shared OneLink token.
//
// Wire contract (verified against the live endpoint on 2026-10-19):
//
// GET {baseUrl}{path}?pwdkey=<pwdKey>&c_no=<channelNo>
// → {"code":200,"msg":"请求成功","data":"<64 chars>"}
//
// The token is consumed by the standard OneLink business API
// (https://api.iot.10086.cn/v5/), so all /sim/* routes work unchanged.
type BeixinTokenProvider struct {
baseURL string
path string
pwdKey string
channelNo string
client *http.Client
}
type beixinTokenResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
func NewBeixinTokenProvider(config BeixinTokenConfig) *BeixinTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &BeixinTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, beixinOneLinkTokenPath),
pwdKey: config.PwdKey,
channelNo: strings.TrimSpace(config.ChannelNo),
client: &http.Client{Timeout: timeout},
}
}
func (p *BeixinTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
if p == nil || p.client == nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token provider is not initialized")
}
if p.baseURL == "" || strings.TrimSpace(p.pwdKey) == "" || p.channelNo == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin baseUrl, pwdKey and channelNo are required")
}
endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("parse beixin token URL: %w", err)
}
query := endpoint.Query()
query.Set("pwdkey", p.pwdKey)
query.Set("c_no", p.channelNo)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("create beixin token request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("request beixin token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("read beixin token response: %w", err)
}
if len(body) > 4*1024*1024 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token response is too large")
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var output beixinTokenResponse
if err := json.Unmarshal(body, &output); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode beixin token response: %w", err)
}
if output.Code != http.StatusOK {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token failed: code=%d, message=%s", output.Code, output.Msg)
}
// data is a bare JSON string, not an object.
var value string
if err := json.Unmarshal(output.Data, &value); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode beixin shared token: %w", err)
}
value = strings.TrimSpace(value)
if value == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin shared token is empty")
}
now := time.Now()
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: now.Add(beixinTokenTTL)}, nil
}
func (p *BeixinTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return p.Fetch(ctx)
}
// beixinFetcher adapts BeixinTokenProvider to the shared token TokenFetcher
// contract, mirroring tengyuFetcher.
type beixinFetcher struct{ provider *BeixinTokenProvider }
func (f beixinFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx)
}
@@ -0,0 +1,335 @@
package upstreamimpl
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
const (
beixinTestPwdKey = "test-pwdkey"
beixinTestChannelNo = "chanel-202607100639054511"
beixinTestToken = "v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4"
)
func newBeixinTestProvider(t *testing.T, baseURL string) *BeixinTokenProvider {
t.Helper()
return NewBeixinTokenProvider(BeixinTokenConfig{
BaseURL: baseURL, Path: beixinOneLinkTokenPath,
PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo, Timeout: time.Second,
})
}
// TestBeixinTokenProviderSendRequestContract pins the exact wire shape as
// observed against the live endpoint on 2026-10-19: a GET with lowercase
// pwdkey/c_no query parameters, and a bare JSON string in data.
func TestBeixinTokenProviderSendRequestContract(t *testing.T) {
var (
gotMethod string
gotPath string
gotPwdKey string
gotCNo string
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotPwdKey = r.URL.Query().Get("pwdkey")
gotCNo = r.URL.Query().Get("c_no")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
}))
defer server.Close()
if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err != nil {
t.Fatalf("fetch token: %v", err)
}
if gotMethod != http.MethodGet {
t.Fatalf("method = %q, want GET", gotMethod)
}
if gotPath != beixinOneLinkTokenPath {
t.Fatalf("path = %q, want %q", gotPath, beixinOneLinkTokenPath)
}
if gotPwdKey != beixinTestPwdKey {
t.Fatalf("pwdkey = %q, want %q", gotPwdKey, beixinTestPwdKey)
}
if gotCNo != beixinTestChannelNo {
t.Fatalf("c_no = %q, want %q", gotCNo, beixinTestChannelNo)
}
}
// TestBeixinTokenProviderReturnsValueAndLocalTTL pins that the bare string in
// data is unwrapped and that the local one-hour window is applied, since the
// upstream reports no expiry.
func TestBeixinTokenProviderReturnsValueAndLocalTTL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
}))
defer server.Close()
before := time.Now()
token, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
if err != nil {
t.Fatalf("fetch token: %v", err)
}
if token.Value != beixinTestToken {
t.Fatalf("token value = %q, want %q", token.Value, beixinTestToken)
}
// Allow a second of slack for slow CI clocks.
if delta := token.ExpiresAt.Sub(before); delta < beixinTokenTTL-time.Second || delta > beixinTokenTTL+time.Second {
t.Fatalf("expiresAt - before = %s, want ~%s", delta, beixinTokenTTL)
}
}
// TestBeixinTokenProviderRejectsBusinessFailure pins that a non-200 code is an
// error rather than an empty-but-successful token.
func TestBeixinTokenProviderRejectsBusinessFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":500,"msg":"获取token失败","data":null}`))
}))
defer server.Close()
_, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
if err == nil {
t.Fatal("business failure must return an error")
}
if !strings.Contains(err.Error(), "获取token失败") {
t.Fatalf("error = %v, want the upstream message", err)
}
}
// TestBeixinTokenProviderRejectsEmptyToken pins that a successful envelope with
// an empty data string is still an error.
func TestBeixinTokenProviderRejectsEmptyToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":""}`))
}))
defer server.Close()
_, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
if err == nil {
t.Fatal("empty token must return an error")
}
if !strings.Contains(err.Error(), "beixin shared token is empty") {
t.Fatalf("error = %v", err)
}
}
// TestBeixinTokenProviderRejectsObjectData pins that data is expected to be a
// JSON string. A future upstream change to an object shape must fail loudly
// instead of silently yielding an empty token.
func TestBeixinTokenProviderRejectsObjectData(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":{"token":"abc"}}`))
}))
defer server.Close()
if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err == nil {
t.Fatal("object-shaped data must return an error")
}
}
// TestBeixinTokenProviderRequiresCredentials pins the guard so a channel
// missing chan_config.pwdKey or channelNo cannot build a provider that fails
// on every request.
func TestBeixinTokenProviderRequiresCredentials(t *testing.T) {
cases := map[string]BeixinTokenConfig{
"missing baseUrl": {Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo},
"missing pwdKey": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, ChannelNo: beixinTestChannelNo},
"missing channelNo": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey},
}
for name, cfg := range cases {
t.Run(name, func(t *testing.T) {
start := time.Now()
_, err := NewBeixinTokenProvider(cfg).Fetch(context.Background())
if err == nil {
t.Fatal("incomplete credentials must return an error")
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("validation must not hit the network, took %s", elapsed)
}
if !strings.Contains(err.Error(), "beixin baseUrl, pwdKey and channelNo are required") {
t.Fatalf("error = %v, want the credential guard message", err)
}
})
}
}
// TestBeixinTemplateRegistration pins that a provider=beixin template
// instantiates through the shared_token model with a chan_config carrying the
// two required credentials.
func TestBeixinTemplateRegistration(t *testing.T) {
template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkBeixinTemplateKey)
if !ok || template == nil {
t.Fatal("beixin template not registered")
}
config := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkBeixinTemplateKey,
TempModel: templateModelSharedToken,
TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` +
`"path":"/prod-api/open/api/oneLinkToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "252",
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`,
},
}
if instance := template.NewTemp(context.Background(), config); instance == nil {
t.Fatal("beixin template failed to instantiate")
}
}
// TestBeixinTokenPathComesFromTemplate pins that the request path is whatever
// the template declares, not a hardcoded default. This is the same property
// TestLanShuTokenPathComesFromTemplate pins for lanshu, and it is what makes a
// template reusable across upstream hosts.
func TestBeixinTokenPathComesFromTemplate(t *testing.T) {
const templatePath = "/some/other/open/route"
if templatePath == beixinOneLinkTokenPath {
t.Fatal("test precondition broken: templatePath must differ from beixinOneLinkTokenPath")
}
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
}))
defer server.Close()
channelConfig := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkBeixinTemplateKey,
TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + server.URL + `",` +
`"path":"` + templatePath + `"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "252",
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`,
},
}
cfg, _, err := parseSharedOneLinkConfig(context.Background(), channelConfig)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if cfg.TokenPath != templatePath {
t.Fatalf("parsed TokenPath = %q, want the template-declared %q", cfg.TokenPath, templatePath)
}
provider := NewBeixinTokenProvider(BeixinTokenConfig{
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath,
PwdKey: cfg.PwdKey, ChannelNo: cfg.ChannelNo, Timeout: cfg.Timeout,
})
if _, err := provider.Fetch(context.Background()); err != nil {
t.Fatalf("fetch token: %v", err)
}
if gotPath != templatePath {
t.Fatalf("request path = %q, want the template-declared %q", gotPath, templatePath)
}
}
// TestBeixinChannelConfigRejectsIncompleteCredentials pins the parse-time
// guard, so the gate fires before any network call.
func TestBeixinChannelConfigRejectsIncompleteCredentials(t *testing.T) {
base := func(chanConfig string) *model.SimChannelConfigItemCache {
return &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkBeixinTemplateKey,
TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` +
`"path":"/prod-api/open/api/oneLinkToken"}}`,
},
Channel: &entity.TSimChannels{ChanKey: "252", ChanConfig: chanConfig},
}
}
if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{"pwdKey":"only"}`)); err == nil {
t.Fatal("chan_config without channelNo must be rejected")
}
if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{}`)); err == nil {
t.Fatal("empty chan_config must be rejected")
}
parsed, _, err := parseSharedOneLinkConfig(context.Background(), base(
`{"pwdKey":"`+beixinTestPwdKey+`","channelNo":"`+beixinTestChannelNo+`"}`))
if err != nil {
t.Fatalf("complete chan_config must parse: %v", err)
}
if parsed.PwdKey != beixinTestPwdKey || parsed.ChannelNo != beixinTestChannelNo {
t.Fatalf("parsed credentials = %q / %q", parsed.PwdKey, parsed.ChannelNo)
}
}
// TestBeixinChannelTokenBaseURLOverride pins the per-channel host override that
// lets several Beixin accounts (different c_no values, possibly different
// hosts) share one template without a code change.
func TestBeixinChannelTokenBaseURLOverride(t *testing.T) {
const templateHost = "http://template-host.example.com"
const channelHost = "http://channel-host.example.com"
config := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkBeixinTemplateKey,
TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + templateHost + `",` +
`"path":"/prod-api/open/api/oneLinkToken"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "252",
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `",` +
`"tokenBaseUrl":"` + channelHost + `"}`,
},
}
parsed, _, err := parseSharedOneLinkConfig(context.Background(), config)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if parsed.TokenBaseURL != channelHost {
t.Fatalf("TokenBaseURL = %q, want the channel override %q", parsed.TokenBaseURL, channelHost)
}
// Without the override the template host must win.
config.Channel.ChanConfig = `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`
parsed, _, err = parseSharedOneLinkConfig(context.Background(), config)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if parsed.TokenBaseURL != templateHost {
t.Fatalf("TokenBaseURL = %q, want the template default %q", parsed.TokenBaseURL, templateHost)
}
}
// TestBeixinResponseShapeMatchesObservedBody replays the exact body captured
// from the live endpoint, so field-case drift breaks the build rather than
// production.
func TestBeixinResponseShapeMatchesObservedBody(t *testing.T) {
const observed = `{"code":200,"msg":"请求成功","data":"v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4"}`
var output beixinTokenResponse
if err := json.Unmarshal([]byte(observed), &output); err != nil {
t.Fatalf("decode observed body: %v", err)
}
if output.Code != 200 || output.Msg != "请求成功" {
t.Fatalf("envelope = %+v", output)
}
var value string
if err := json.Unmarshal(output.Data, &value); err != nil {
t.Fatalf("decode data: %v", err)
}
if len(value) != 64 || !strings.HasPrefix(value, "v5") {
t.Fatalf("token shape changed: %q", value)
}
}
// silence unused-import drift if the file is trimmed later.
var _ = io.Discard
@@ -0,0 +1,60 @@
package upstreamimpl
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/daihe"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
type DaiheTokenConfig struct {
BaseURL string
Path string
AppID string
AppSecret string
CMCCAppID string
Timeout time.Duration
}
type DaiheTokenProvider struct{ config DaiheTokenConfig }
type daiheTokenFetcher struct{ provider *DaiheTokenProvider }
func NewDaiheTokenProvider(config DaiheTokenConfig) *DaiheTokenProvider {
if strings.TrimSpace(config.Path) == "" {
config.Path = "/sim/query/cmccToken"
}
if strings.TrimSpace(config.BaseURL) == "" {
config.BaseURL = "https://open.flymodem.com.cn"
}
return &DaiheTokenProvider{config: config}
}
func (p *DaiheTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
if p == nil || p.config.AppID == "" || p.config.AppSecret == "" || p.config.CMCCAppID == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe token credentials and cmccAppId are required")
}
client := daihe.NewClient(daihe.Config{BaseURL: p.config.BaseURL, AppID: p.config.AppID, AppSecret: p.config.AppSecret, Timeout: p.config.Timeout})
var response daihe.CMCCToken
if err := client.QueryCMCCToken(ctx, p.config.CMCCAppID, &response); err != nil {
return sharedtoken.OneLinkToken{}, err
}
token := strings.TrimSpace(response.Token)
deviceToken := strings.TrimSpace(response.TokenDevice)
if token == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe cmcc token response is empty")
}
ttl, err := strconv.ParseInt(strings.TrimSpace(response.DeviceTTL), 10, 64)
if err != nil || ttl <= 0 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe device_ttl must be positive: %q", response.DeviceTTL)
}
now := time.Now()
return sharedtoken.OneLinkToken{Value: token, DeviceValue: deviceToken, CreatedAt: now, ExpiresAt: now.Add(time.Duration(ttl) * time.Second)}, nil
}
func (f daiheTokenFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx)
}
@@ -0,0 +1,199 @@
package dx
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// HTTPStatusError preserves the telecom response body for non-200 responses.
// RespCode is the raw carrier error code when the gateway supplies one.
type HTTPStatusError struct {
StatusCode int
Body string
RespCode string
}
func (e *HTTPStatusError) Error() string {
if e == nil {
return "telecom HTTP status error"
}
return fmt.Sprintf("http status %d: %s", e.StatusCode, e.Body)
}
// Config 网关鉴权与请求配置
type Config struct {
BaseURL string // 默认 https://cmp-api.ctwing.cn:20164
AppKey string
AppSecret string
Timeout time.Duration
UppercaseSign bool
}
// Client API 客户端
type Client struct {
baseURL string
appKey string
appSecret string
http *http.Client
uppercaseSign bool
}
// New 创建客户端
func NewClient(cfg Config) *Client {
base := cfg.BaseURL
if base == "" {
base = DefaultBaseURL
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
return &Client{
baseURL: strings.TrimSuffix(base, "/"),
appKey: cfg.AppKey,
appSecret: cfg.AppSecret,
uppercaseSign: cfg.UppercaseSign,
http: &http.Client{
Timeout: timeout,
},
}
}
// DoGetRequest 通用GET请求(自动处理签名)
// endpoint: API路径,如 "/api/v1/openbill/queryTraffic"
// params: 查询参数,需调用方自行构建
func (c *Client) DoGetRequest(ctx context.Context, endpoint string, params url.Values) ([]byte, error) {
req, err := c.doGetRequest(ctx, endpoint, params)
if err != nil {
return nil, err
}
return c.doRequest(req)
}
// DoGetWriteRequest 用独立无复用连接发送非幂等GET,禁止运输层重放与跳转。
func (c *Client) DoGetWriteRequest(ctx context.Context, endpoint string, params url.Values) ([]byte, error) {
req, err := c.doGetRequest(ctx, endpoint, params)
if err != nil {
return nil, err
}
return c.doWriteRequest(req)
}
func (c *Client) doWriteRequest(req *http.Request) ([]byte, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
if original, ok := c.http.Transport.(*http.Transport); ok {
transport = original.Clone()
}
transport.DisableKeepAlives = true
defer transport.CloseIdleConnections()
client := *c.http
client.Transport = transport
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return c.doRequestWithClient(&client, req)
}
func (c *Client) DoPostJSONRequest(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
req, err := c.doPostJSONRequest(ctx, endpoint, body)
if err != nil {
return nil, err
}
return c.doRequest(req)
}
// DoPostJSONWriteRequest 仅用于办理写请求,禁止连接复用及自动跳转重放。
func (c *Client) DoPostJSONWriteRequest(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
req, err := c.doPostJSONRequest(ctx, endpoint, body)
if err != nil {
return nil, err
}
return c.doWriteRequest(req)
}
func (c *Client) doPostJSONRequest(ctx context.Context, endpoint string, body []byte) (*http.Request, error) {
timestamp := GetTimestampGMT()
signStr, err := BuildSign("", string(body), c.appSecret, timestamp)
if c.uppercaseSign {
signStr, err = BuildSignV3("", string(body), c.appSecret, timestamp)
}
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+endpoint, strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("AppKey", c.appKey)
req.Header.Set("Sign", c.formatSign(signStr))
req.Header.Set("Timestamp", timestamp)
req.Header.Set("Content-Type", "application/json")
return req, nil
}
// doGetRequest 封装通用的 GET 请求构建逻辑
func (c *Client) doGetRequest(ctx context.Context, endpoint string, params url.Values) (*http.Request, error) {
queryString := params.Encode()
timestamp := GetTimestampGMT()
signStr, err := BuildSign(queryString, "", c.appSecret, timestamp)
if c.uppercaseSign {
signStr, err = BuildSignV3(queryString, "", c.appSecret, timestamp)
}
if err != nil {
return nil, err
}
reqURL := c.baseURL + endpoint + "?" + queryString
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("AppKey", c.appKey)
req.Header.Set("Sign", c.formatSign(signStr))
req.Header.Set("Timestamp", timestamp)
return req, nil
}
func (c *Client) formatSign(sign string) string {
if c.uppercaseSign {
return strings.ToUpper(sign)
}
return sign
}
// doRequest 执行HTTP请求
func (c *Client) doRequest(req *http.Request) ([]byte, error) {
return c.doRequestWithClient(c.http, req)
}
func (c *Client) doRequestWithClient(client *http.Client, req *http.Request) ([]byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
var errorBody struct {
RespCode string `json:"respCode"`
}
_ = json.Unmarshal(body, &errorBody)
if c.uppercaseSign && strings.TrimSpace(errorBody.RespCode) == "" {
errorBody.RespCode = resp.Header.Get("gw-errorCode")
}
return nil, &HTTPStatusError{
StatusCode: resp.StatusCode,
Body: string(body),
RespCode: strings.TrimSpace(errorBody.RespCode),
}
}
return body, nil
}
@@ -0,0 +1,69 @@
package dx
import (
"crypto/md5"
"encoding/hex"
"sort"
"strings"
"time"
)
const (
// DefaultBaseURL 电信API基础地址
DefaultBaseURL = "https://cmp-api.ctwing.cn:20164"
)
// GetTimestampGMT 返回 GMT 时区的 yyyyMMddHHmmss 格式时间戳
func GetTimestampGMT() string {
return time.Now().UTC().Format("20060102150405")
}
// OrderParams 将 URL params 按 key 字典序排序
func OrderParams(params string) (string, error) {
if params == "" {
return "", nil
}
kvs := strings.Split(params, "&")
m := make(map[string]string, len(kvs))
for _, kv := range kvs {
idx := strings.Index(kv, "=")
if idx < 0 {
continue
}
m[kv[:idx]] = kv[idx+1:]
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
var parts []string
for _, k := range keys {
parts = append(parts, k+"="+m[k])
}
return strings.Join(parts, "&"), nil
}
// SignMD5 对 reqStr 做 MD5 加密,返回小写十六进制字符串
func SignMD5(reqStr string) string {
h := md5.Sum([]byte(reqStr))
return strings.ToLower(hex.EncodeToString(h[:]))
}
// BuildSign 构造签名:params + body + secretKey + timestamp → MD5 → hex
func BuildSign(params, body, secretKey, timestamp string) (string, error) {
ordered, err := OrderParams(params)
if err != nil {
return "", err
}
reqStr := ordered + body + secretKey + timestamp
md5Str := SignMD5(reqStr)
return hex.EncodeToString([]byte(md5Str)), nil
}
// BuildSignV3 follows the V3 Java sample: encode the lowercase MD5 digest
// string as hex a second time, then uppercase that outer encoding.
func BuildSignV3(params, body, secretKey, timestamp string) (string, error) {
sign, err := BuildSign(params, body, secretKey, timestamp)
return strings.ToUpper(sign), err
}
@@ -0,0 +1,15 @@
package dx
import "testing"
func TestV3SignatureKeepsLegacyEncodingAndUsesUppercase(t *testing.T) {
// MD5("abc") is 900150983cd24fb0d6963f7d28e17f72.
legacy, err := BuildSign("", "abc", "", "")
if err != nil || legacy != "3930303135303938336364323466623064363936336637643238653137663732" {
t.Fatalf("legacy signature: %s, %v", legacy, err)
}
v3, err := BuildSignV3("", "abc", "", "")
if err != nil || v3 != "3930303135303938336364323466623064363936336637643238653137663732" {
t.Fatalf("v3 signature: %s, %v", v3, err)
}
}
@@ -0,0 +1,256 @@
package upstreamimpl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const (
henanLelianLoginPath = "/api/v2/loginVerify"
henanLelianSharePath = "/api/v2/getShareToken"
henanLelianPlatformTTL = 23 * time.Hour
henanLelianShareInterval = time.Minute
)
// HenanLelianTokenConfig contains the public provider endpoints and the
// per-channel credentials required to obtain a shared OneLink token.
type HenanLelianTokenConfig struct {
BaseURL string
LoginPath string
Path string
UserName string
Password string
PasswayUID string
Timeout time.Duration
}
// HenanLelianTokenProvider first obtains a platform access token and then
// exchanges it for the carrier-issued OneLink token.
type HenanLelianTokenProvider struct {
baseURL string
loginPath string
path string
userName string
password string
passwayUID string
client *http.Client
now func() time.Time
platformMu sync.Mutex
platformToken string
platformTokenExp time.Time
shareMu sync.Mutex
nextShareAt time.Time
shareInterval time.Duration
}
type henanLelianResponse struct {
Code json.RawMessage `json:"code"`
Message string `json:"message"`
Payload struct {
AccessToken string `json:"access_token"`
Token string `json:"token"`
} `json:"payload"`
}
func NewHenanLelianTokenProvider(config HenanLelianTokenConfig) *HenanLelianTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &HenanLelianTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
loginPath: normalizeProviderPath(config.LoginPath, henanLelianLoginPath),
path: normalizeProviderPath(config.Path, henanLelianSharePath),
userName: strings.TrimSpace(config.UserName),
password: config.Password,
passwayUID: strings.TrimSpace(config.PasswayUID),
client: &http.Client{Timeout: timeout},
now: time.Now,
shareInterval: henanLelianShareInterval,
}
}
func (p *HenanLelianTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) {
if p == nil || p.client == nil || p.now == nil {
return OneLinkToken{}, fmt.Errorf("Henan Lelian token provider is not initialized")
}
if p.baseURL == "" {
return OneLinkToken{}, fmt.Errorf("Henan Lelian token base URL is required")
}
if p.userName == "" || strings.TrimSpace(p.password) == "" {
return OneLinkToken{}, fmt.Errorf("Henan Lelian username and password are required")
}
if p.passwayUID == "" {
return OneLinkToken{}, fmt.Errorf("Henan Lelian passwayUid is required")
}
platformToken, err := p.getPlatformToken(ctx)
if err != nil {
return OneLinkToken{}, err
}
value, authFailed, err := p.fetchSharedToken(ctx, platformToken)
if authFailed {
p.clearPlatformToken(platformToken)
}
if err != nil {
return OneLinkToken{}, err
}
now := p.now()
return OneLinkToken{Value: value, CreatedAt: now}, nil
}
func (p *HenanLelianTokenProvider) getPlatformToken(ctx context.Context) (string, error) {
p.platformMu.Lock()
defer p.platformMu.Unlock()
now := p.now()
if p.platformToken != "" && now.Before(p.platformTokenExp) {
return p.platformToken, nil
}
body, err := json.Marshal(map[string]string{
"userName": p.userName,
"password": p.password,
})
if err != nil {
return "", fmt.Errorf("encode Henan Lelian login request: %w", err)
}
responseBody, status, err := p.postJSON(ctx, p.loginPath, body, "")
if err != nil {
return "", fmt.Errorf("request Henan Lelian login: %w", err)
}
if status < http.StatusOK || status >= http.StatusMultipleChoices {
return "", fmt.Errorf("Henan Lelian login HTTP status %d: %s", status, strings.TrimSpace(string(responseBody)))
}
var output henanLelianResponse
if err = json.Unmarshal(responseBody, &output); err != nil {
return "", fmt.Errorf("decode Henan Lelian login response: %w", err)
}
code, err := parseHenanLelianCode(output.Code)
if err != nil {
return "", fmt.Errorf("decode Henan Lelian login code: %w", err)
}
if code != "0" {
return "", fmt.Errorf("Henan Lelian login failed: code=%s, message=%s", code, output.Message)
}
token := strings.TrimSpace(output.Payload.AccessToken)
if token == "" {
return "", fmt.Errorf("Henan Lelian login access_token is empty")
}
p.platformToken = token
p.platformTokenExp = now.Add(henanLelianPlatformTTL)
return token, nil
}
func (p *HenanLelianTokenProvider) fetchSharedToken(ctx context.Context, platformToken string) (string, bool, error) {
if err := p.reserveShareAttempt(); err != nil {
return "", false, err
}
body, err := json.Marshal(map[string]string{"passwayUid": p.passwayUID})
if err != nil {
return "", false, fmt.Errorf("encode Henan Lelian shared token request: %w", err)
}
responseBody, status, err := p.postJSON(ctx, p.path, body, platformToken)
if err != nil {
return "", false, fmt.Errorf("request Henan Lelian shared token: %w", err)
}
if status < http.StatusOK || status >= http.StatusMultipleChoices {
authFailed := status == http.StatusUnauthorized || status == http.StatusForbidden
return "", authFailed, fmt.Errorf("Henan Lelian shared token HTTP status %d: %s", status, strings.TrimSpace(string(responseBody)))
}
var output henanLelianResponse
if err = json.Unmarshal(responseBody, &output); err != nil {
return "", false, fmt.Errorf("decode Henan Lelian shared token response: %w", err)
}
code, err := parseHenanLelianCode(output.Code)
if err != nil {
return "", false, fmt.Errorf("decode Henan Lelian shared token code: %w", err)
}
if code != "0" {
authFailed := isHenanLelianAuthFailure(code, output.Message)
return "", authFailed, fmt.Errorf("Henan Lelian shared token failed: code=%s, message=%s", code, output.Message)
}
token := strings.TrimSpace(output.Payload.Token)
if token == "" {
return "", false, fmt.Errorf("Henan Lelian shared token is empty")
}
return token, false, nil
}
func (p *HenanLelianTokenProvider) reserveShareAttempt() error {
p.shareMu.Lock()
defer p.shareMu.Unlock()
now := p.now()
if now.Before(p.nextShareAt) {
return fmt.Errorf("Henan Lelian shared token is rate limited until %s", p.nextShareAt.Format(time.RFC3339))
}
p.nextShareAt = now.Add(p.shareInterval)
return nil
}
func (p *HenanLelianTokenProvider) clearPlatformToken(rejected string) {
p.platformMu.Lock()
if p.platformToken == rejected {
p.platformToken = ""
p.platformTokenExp = time.Time{}
}
p.platformMu.Unlock()
}
func (p *HenanLelianTokenProvider) postJSON(ctx context.Context, path string, body []byte, platformToken string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, path), bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
if platformToken != "" {
req.Header.Set("access_token", platformToken)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
return responseBody, resp.StatusCode, err
}
func parseHenanLelianCode(raw json.RawMessage) (string, error) {
var value string
if err := json.Unmarshal(raw, &value); err == nil {
return strings.TrimSpace(value), nil
}
var number json.Number
if err := json.Unmarshal(raw, &number); err != nil {
return "", err
}
if _, err := strconv.ParseInt(number.String(), 10, 64); err != nil {
return "", err
}
return number.String(), nil
}
func isHenanLelianAuthFailure(code, message string) bool {
switch strings.TrimSpace(code) {
case "401", "40101", "40102", "403":
return true
}
message = strings.ToLower(strings.TrimSpace(message))
return strings.Contains(message, "access_token") ||
strings.Contains(message, "access token") ||
strings.Contains(message, "token expired") ||
strings.Contains(message, "token invalid") ||
strings.Contains(message, "鉴权") ||
strings.Contains(message, "认证失败")
}
@@ -0,0 +1,134 @@
package upstreamimpl
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// henanXinjingClient is the direct HTTP client for the Henan Xinjing SIM
// platform. Authentication is userId+apikey+times+sign where
// sign = UPPERCASE(MD5("userId=<id>&apikey=<key>&times=<unix_seconds>")).
type henanXinjingClient struct {
baseURL string
userID string
apiKey string
httpClient *http.Client
}
// henanXinjingResponse matches the platform's standard envelope.
// Code can be either a JSON number or a JSON string (the platform
// returns both shapes for the same logical value), so keep it raw.
type henanXinjingResponse struct {
Code json.RawMessage `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
func newHenanXinjingClient(baseURL, userID, apiKey string, timeout time.Duration) *henanXinjingClient {
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &henanXinjingClient{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
userID: strings.TrimSpace(userID),
apiKey: strings.TrimSpace(apiKey),
httpClient: &http.Client{Timeout: timeout},
}
}
// buildSign returns UPPERCASE(MD5(...)) over the document-defined form:
//
// userId=<id>&apikey=<key>&times=<unix_seconds>
func henanXinjingSign(userID, apiKey, times string) string {
raw := "userId=" + userID + "&apikey=" + apiKey + "&times=" + times
sum := md5.Sum([]byte(raw))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
// decodeCode normalizes the response code. The platform sometimes returns
// `"code":0` and sometimes `"code":"-2"`. Both are valid; we treat any
// numeric zero (in either form) as success.
func henanXinjingDecodeCode(raw json.RawMessage) (int, error) {
if len(raw) == 0 {
return -1, fmt.Errorf("missing code field")
}
var n int
if err := json.Unmarshal(raw, &n); err == nil {
return n, nil
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return -1, fmt.Errorf("code is neither number nor string: %s", string(raw))
}
s = strings.TrimSpace(s)
if s == "" {
return -1, fmt.Errorf("empty code string")
}
v, err := strconv.Atoi(s)
if err != nil {
return -1, fmt.Errorf("invalid code string %q: %w", s, err)
}
return v, nil
}
// get performs a signed GET request and unmarshals the `data` field into out
// when out is non-nil. Business-layer errors (code != 0) become errors.
func (c *henanXinjingClient) get(ctx context.Context, path string, params map[string]string, out any) error {
if c == nil || c.baseURL == "" || c.userID == "" || c.apiKey == "" {
return fmt.Errorf("henan xinjing: baseURL, userID and apiKey are required")
}
values := url.Values{}
for k, v := range params {
if strings.TrimSpace(v) != "" {
values.Set(k, v)
}
}
times := strconv.FormatInt(time.Now().Unix(), 10)
values.Set("userId", c.userID)
values.Set("times", times)
values.Set("sign", henanXinjingSign(c.userID, c.apiKey, times))
endpoint := c.baseURL + "/" + strings.TrimLeft(path, "/") + "?" + values.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("henan xinjing: build request %s: %w", path, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("henan xinjing: request %s: %w", path, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("henan xinjing: read body %s: %w", path, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("henan xinjing: HTTP %d from %s: %s", resp.StatusCode, path, string(body))
}
var envelope henanXinjingResponse
if err = json.Unmarshal(body, &envelope); err != nil {
return fmt.Errorf("henan xinjing: decode envelope %s: %w (body=%s)", path, err, string(body))
}
code, err := henanXinjingDecodeCode(envelope.Code)
if err != nil {
return fmt.Errorf("henan xinjing: %s: %w (body=%s)", path, err, string(body))
}
if code != 0 {
return fmt.Errorf("henan xinjing: %s failed: code=%d msg=%s", path, code, envelope.Msg)
}
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
if err = json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("henan xinjing: decode data %s: %w (data=%s)", path, err, string(envelope.Data))
}
}
return nil
}
@@ -0,0 +1,224 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
// HenanXinjingTokenConfig carries the credentials needed to call the
// platform's `/api/v1/searchToken` endpoint and convert the response into a
// sharedtoken.OneLinkToken for the sharedtoken manager.
//
// The channel card-no is optional at construction time because the
// underlying platform scopes tokens to (channel_id, channel_pname), not to
// a specific card. When CardNo is empty the fetcher falls back to the
// chanKey passed by the token manager (matching how zibo/tengyu providers
// treat the per-channel seed value).
type HenanXinjingTokenConfig struct {
BaseURL string
Path string // defaults to /api/v1/searchToken
UserID string
APIKey string
ChannelID string // e.g. "80", "97", "194"
ChannelPName string // e.g. "cmiotonelinkqx"
CardNo string // optional seed card number; may be overridden by manager
Timeout time.Duration
}
// HenanXinjingTokenProvider fetches a shared token for a given
// (userId, channel_id, channel_pname) triple from the Henan Xinjing
// platform.
type HenanXinjingTokenProvider struct {
baseURL, path, userID, apiKey, channelID, channelPName, cardNo string
client *http.Client
}
// henanXinjingSearchTokenResponse matches the upstream response shape:
//
// {
// "code": 0,
// "msg": "查询成功",
// "data": {
// "id": 80,
// "chl_username": "C5010371A16640006582238",
// "accesstoken": "v1b74e73d741cea620f0ceecaf7a733fa6964aa53fffcbf8d3a5a4c8715aabe3",
// "accesstoken_at": "2026-09-16 13:19:29"
// }
// }
//
// `code` may be either a JSON number or string; decode via henanXinjingDecodeCode.
type henanXinjingSearchTokenResponse struct {
Code json.RawMessage `json:"code"`
Msg string `json:"msg"`
Data struct {
ID json.RawMessage `json:"id"`
ChlUsername string `json:"chl_username"`
AccessToken string `json:"accesstoken"`
AccessTokenAt string `json:"accesstoken_at"`
AccessTokenSB string `json:"accesstokensb"`
AccessTokenAlias string `json:"access_token"`
} `json:"data"`
}
const (
henanXinjingSearchTokenPath = "/api/v1/searchToken"
henanXinjingTokenExpiryLayout = "2006-01-02 15:04:05"
henanXinjingTokenRefreshBackoff = 5 * time.Minute
)
func NewHenanXinjingTokenProvider(config HenanXinjingTokenConfig) *HenanXinjingTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &HenanXinjingTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, henanXinjingSearchTokenPath),
userID: strings.TrimSpace(config.UserID),
apiKey: strings.TrimSpace(config.APIKey),
channelID: strings.TrimSpace(config.ChannelID),
channelPName: strings.TrimSpace(config.ChannelPName),
cardNo: strings.TrimSpace(config.CardNo),
client: &http.Client{Timeout: timeout},
}
}
// parseExpiry interprets the platform's `accesstoken_at` field. The
// platform uses local China time without an explicit zone, so parse in
// Asia/Shanghai.
func henanXinjingParseExpiry(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, fmt.Errorf("empty accesstoken_at")
}
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
loc = time.Local
}
t, err := time.ParseInLocation(henanXinjingTokenExpiryLayout, raw, loc)
if err != nil {
return time.Time{}, fmt.Errorf("parse accesstoken_at %q: %w", raw, err)
}
return t, nil
}
// firstToken returns the first non-empty token field. The platform has been
// observed returning the token under `accesstoken`, but earlier docs
// referenced `accesstokensb` / `access_token`; accept all three.
func henanXinjingFirstToken(resp *henanXinjingSearchTokenResponse) string {
if v := strings.TrimSpace(resp.Data.AccessToken); v != "" {
return v
}
if v := strings.TrimSpace(resp.Data.AccessTokenSB); v != "" {
return v
}
return strings.TrimSpace(resp.Data.AccessTokenAlias)
}
// fetch performs the signed GET against /api/v1/searchToken for the given
// card number. Card number may come from the manager's chanKey seed.
func (p *HenanXinjingTokenProvider) fetch(ctx context.Context, cardNo string) (sharedtoken.OneLinkToken, error) {
if p == nil || p.client == nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing token provider is not initialized")
}
if p.baseURL == "" || p.userID == "" || p.apiKey == "" || p.channelID == "" || p.channelPName == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: baseURL, userID, apiKey, channelID and channelPName are required")
}
cardNo = strings.TrimSpace(cardNo)
if cardNo == "" {
cardNo = p.cardNo
}
// cardno is optional on the real platform (verified live): the token is
// scoped to (userId, channel_id, channel_pname), so omit the param when
// no seed card number is configured instead of failing the fetch.
times := strconv.FormatInt(time.Now().Unix(), 10)
values := url.Values{}
values.Set("userId", p.userID)
if cardNo != "" {
values.Set("cardno", cardNo)
}
values.Set("channel_id", p.channelID)
values.Set("channel_pname", p.channelPName)
values.Set("times", times)
values.Set("sign", henanXinjingSign(p.userID, p.apiKey, times))
endpoint := p.baseURL + p.path + "?" + values.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: build searchToken request: %w", err)
}
resp, err := p.client.Do(req)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: request searchToken: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: read searchToken body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken HTTP %d: %s", resp.StatusCode, string(body))
}
var output henanXinjingSearchTokenResponse
if err := json.Unmarshal(body, &output); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: decode searchToken response: %w (body=%s)", err, string(body))
}
code, err := henanXinjingDecodeCode(output.Code)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken: %w (body=%s)", err, string(body))
}
if code != 0 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken failed: code=%d msg=%s", code, output.Msg)
}
tokenValue := henanXinjingFirstToken(&output)
if tokenValue == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken returned empty accesstoken")
}
expiresAt, err := henanXinjingParseExpiry(output.Data.AccessTokenAt)
if err != nil {
return sharedtoken.OneLinkToken{}, err
}
// Refresh slightly ahead of the declared expiry to absorb clock skew.
if until := time.Until(expiresAt); until > henanXinjingTokenRefreshBackoff {
expiresAt = expiresAt.Add(-henanXinjingTokenRefreshBackoff)
}
return sharedtoken.OneLinkToken{
Value: tokenValue,
CreatedAt: time.Now(),
ExpiresAt: expiresAt,
}, nil
}
// Fetch satisfies the sharedtoken.Fetcher-style interface used by the
// token manager. The chanKey argument is unused here because Henan Xinjing
// scopes tokens to the (channel_id, channel_pname) pair rather than to a
// specific chan_key; we always fetch with the configured cardNo.
func (p *HenanXinjingTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
return p.fetch(ctx, "")
}
// FetchToken matches the sharedtoken.TokenFetcher interface used by
// sharedtoken.NewTokenManager. chanKey is the token manager's cache key
// (typically the channel's chan_key); it does not influence the upstream
// request but lets the manager cache one token per channel.
func (p *HenanXinjingTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return p.fetch(ctx, "")
}
// henanXinjingFetcher adapts HenanXinjingTokenProvider to the sharedtoken
// fetcher shape used by RegisterTokenProviderFactory.
type henanXinjingFetcher struct{ provider *HenanXinjingTokenProvider }
func (f henanXinjingFetcher) FetchToken(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) {
return f.provider.fetch(ctx, "")
}
@@ -0,0 +1,101 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
const (
defaultUpstreamHTTPTimeout = 10 * time.Second
upstreamHTTPTimeoutKey = "dependencies.upstream_http.timeout"
)
func templateConfigJSON(channelConfig *model.SimChannelConfigItemCache) string {
if channelConfig == nil || channelConfig.Template == nil {
return ""
}
return channelConfig.Template.TempConfig
}
// parseUpstreamDuration accepts Go duration strings and positive numeric
// seconds so template and application configuration use the same contract.
func parseUpstreamDuration(raw json.RawMessage, field string) (time.Duration, error) {
if len(raw) == 0 || string(raw) == "null" {
return 0, fmt.Errorf("%s is empty", field)
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return 0, fmt.Errorf("invalid %s: %w", field, err)
}
switch v := value.(type) {
case float64:
if v <= 0 || v != float64(int64(v)) {
return 0, fmt.Errorf("invalid %s: expected positive seconds", field)
}
return time.Duration(v) * time.Second, nil
case string:
value := strings.TrimSpace(v)
if value == "" {
return 0, fmt.Errorf("invalid %s: empty value", field)
}
duration, err := time.ParseDuration(value)
if err != nil || duration <= 0 {
return 0, fmt.Errorf("invalid %s: expected positive duration", field)
}
return duration, nil
default:
return 0, fmt.Errorf("invalid %s: expected duration or seconds", field)
}
}
func parseUpstreamConfigDuration(value any, field string) (time.Duration, error) {
raw, err := json.Marshal(value)
if err != nil {
return 0, fmt.Errorf("encode %s: %w", field, err)
}
return parseUpstreamDuration(raw, field)
}
func loadDefaultUpstreamHTTPTimeout(ctx context.Context) time.Duration {
value := g.Cfg().MustGet(ctx, upstreamHTTPTimeoutKey, defaultUpstreamHTTPTimeout.String()).Val()
timeout, err := parseUpstreamConfigDuration(value, upstreamHTTPTimeoutKey)
if err != nil {
g.Log().Warningf(ctx, "%v, fallback to %s", err, defaultUpstreamHTTPTimeout)
return defaultUpstreamHTTPTimeout
}
return timeout
}
// resolveUpstreamHTTPTimeout applies the single timeout override for a
// template. Missing or invalid template values fall back to application
// configuration and finally the code default.
func resolveUpstreamHTTPTimeout(ctx context.Context, tempConfig string) time.Duration {
defaultTimeout := loadDefaultUpstreamHTTPTimeout(ctx)
if strings.TrimSpace(tempConfig) == "" {
return defaultTimeout
}
var raw struct {
HTTP struct {
Timeout json.RawMessage `json:"timeout"`
} `json:"http"`
}
if err := json.Unmarshal([]byte(tempConfig), &raw); err != nil {
g.Log().Warningf(ctx, "parse upstream template config failed: %v, fallback to %s", err, defaultTimeout)
return defaultTimeout
}
if len(raw.HTTP.Timeout) == 0 || string(raw.HTTP.Timeout) == "null" {
return defaultTimeout
}
timeout, err := parseUpstreamDuration(raw.HTTP.Timeout, "http.timeout")
if err != nil {
g.Log().Warningf(ctx, "%v, fallback to %s", err, defaultTimeout)
return defaultTimeout
}
return timeout
}
@@ -0,0 +1,112 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
// LanShangTokenConfig contains the endpoint from the template and credentials
// from the channel configuration.
type LanShangTokenConfig struct {
BaseURL string
Path string
SubAccount string
Secret string
Timeout time.Duration
}
type LanShangTokenProvider struct {
baseURL string
path string
subAccount string
secret string
client *http.Client
}
type lanShangTokenResponse struct {
Token string `json:"token"`
ExpireIn int `json:"expire_in"`
}
type lanshangFetcher struct{ provider *LanShangTokenProvider }
func NewLanShangTokenProvider(config LanShangTokenConfig) *LanShangTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &LanShangTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, "/get_token"),
subAccount: strings.TrimSpace(config.SubAccount),
secret: config.Secret,
client: &http.Client{Timeout: timeout},
}
}
func (f lanshangFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx)
}
func (p *LanShangTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
if p == nil || p.client == nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token provider is not initialized")
}
if p.baseURL == "" || p.subAccount == "" || p.secret == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token endpoint and credentials are required")
}
endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("parse lanshang token URL: %w", err)
}
query := endpoint.Query()
query.Set("sub_account", p.subAccount)
query.Set("secret", p.secret)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("create lanshang token request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("request lanshang token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("read lanshang token response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token HTTP status %d", resp.StatusCode)
}
var output lanShangTokenResponse
if err := json.Unmarshal(body, &output); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode lanshang token response: %w", err)
}
value := strings.TrimSpace(output.Token)
if value == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token is empty")
}
now := time.Now()
expiresAt := time.Time{}
if output.ExpireIn > 0 {
expiresAt = now.Add(time.Duration(output.ExpireIn) * time.Second)
}
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
}
func (p *LanShangTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return p.Fetch(ctx)
}
@@ -0,0 +1,55 @@
package upstreamimpl
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestLanShangTokenProviderFetch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/get_token" {
t.Fatalf("path = %q", r.URL.Path)
}
if got := r.URL.Query().Get("sub_account"); got != "\u66fc\u76db\u7269\u8054\u7f51" {
t.Fatalf("sub_account = %q", got)
}
if got := r.URL.Query().Get("secret"); got != "Qw@0371252" {
t.Fatalf("secret = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"token":"abc123","expire_in":120}`))
}))
defer server.Close()
provider := NewLanShangTokenProvider(LanShangTokenConfig{
BaseURL: server.URL, Path: "/get_token", SubAccount: "\u66fc\u76db\u7269\u8054\u7f51",
Secret: "Qw@0371252", Timeout: time.Second,
})
token, err := provider.Fetch(context.Background())
if err != nil {
t.Fatal(err)
}
if token.Value != "abc123" {
t.Fatalf("token = %q", token.Value)
}
if token.ExpiresAt.Before(time.Now().Add(100 * time.Second)) {
t.Fatalf("expiry too short: %s", token.ExpiresAt)
}
}
func TestLanShangTokenProviderRejectsEmptyToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"expire_in":120}`))
}))
defer server.Close()
provider := NewLanShangTokenProvider(LanShangTokenConfig{
BaseURL: server.URL, SubAccount: "a", Secret: "b", Timeout: time.Second,
})
if _, err := provider.Fetch(context.Background()); err == nil {
t.Fatal("expected empty token error")
}
}
@@ -0,0 +1,184 @@
package upstreamimpl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
// lanshuTokenPath is the fallback used when Path is empty. On the template
// path this default is unreachable: parseSharedOneLinkConfig rejects an empty
// token.path before any provider is built, so Path always carries the value
// declared in temp_config. It only applies to direct construction (tests).
// The same unreachable-fallback shape exists in every shared-token provider.
const lanshuTokenPath = "/route/open/sharedToken"
// LanShuTokenConfig contains the endpoint from the template and the
// credentials from the channel configuration. BaseURL and Path come from
// temp_config token.baseUrl/token.path; the credentials come from chan_config.
// Sign is a fixed value issued by the upstream and is passed through
// unchanged: the operator does not derive it from appId/password/timeStamp.
type LanShuTokenConfig struct {
BaseURL string
Path string
AppID string
Password string
Sign string
TokenType string
Timeout time.Duration
}
// LanShuTokenProvider fetches a shared token from the LanShu open route.
type LanShuTokenProvider struct {
baseURL string
path string
appID string
password string
sign string
tokenType string
client *http.Client
}
// lanshuTokenRequest is the documented request envelope. Note the capitalized
// Param/Verify keys.
type lanshuTokenRequest struct {
Param lanshuTokenParam `json:"Param"`
Verify lanshuTokenVerify `json:"verify"`
}
type lanshuTokenParam struct {
TokenType string `json:"tokenType"`
}
type lanshuTokenVerify struct {
TimeStamp int64 `json:"timeStamp"`
Password string `json:"password"`
AppID string `json:"appId"`
Sign string `json:"sign"`
}
// lanshuTokenResponse mirrors the upstream envelope. The data object is
// uppercase on the wire ("result.Data"), so the JSON tag must match exactly.
type lanshuTokenResponse struct {
Result struct {
Data struct {
ExpireTime int64 `json:"expireTime"`
Token string `json:"token"`
} `json:"Data"`
} `json:"result"`
Message string `json:"message"`
Status string `json:"status"`
}
func NewLanShuTokenProvider(config LanShuTokenConfig) *LanShuTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &LanShuTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, lanshuTokenPath),
appID: strings.TrimSpace(config.AppID),
password: config.Password,
sign: strings.TrimSpace(config.Sign),
tokenType: strings.TrimSpace(config.TokenType),
client: &http.Client{Timeout: timeout},
}
}
func (p *LanShuTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
if p == nil || p.client == nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token provider is not initialized")
}
if p.baseURL == "" || p.appID == "" || p.password == "" || p.sign == "" || p.tokenType == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu baseUrl, appId, password, sign and tokenType are required")
}
// The upstream does not validate timeStamp freshness, but it must be a
// real millisecond timestamp for the request to be well-formed.
payload, err := json.Marshal(lanshuTokenRequest{
Param: lanshuTokenParam{TokenType: p.tokenType},
Verify: lanshuTokenVerify{
TimeStamp: time.Now().UnixMilli(),
Password: p.password,
AppID: p.appID,
Sign: p.sign,
},
})
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode lanshu token request: %w", err)
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
joinProviderURL(p.baseURL, p.path),
bytes.NewReader(payload),
)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("create lanshu token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("request lanshu token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("read lanshu token response: %w", err)
}
if len(body) > 4*1024*1024 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token response is too large")
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token HTTP status %d", resp.StatusCode)
}
var output lanshuTokenResponse
if err := json.Unmarshal(body, &output); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode lanshu token response: %w", err)
}
if status := strings.TrimSpace(output.Status); status != "0" {
return sharedtoken.OneLinkToken{}, fmt.Errorf(
"lanshu token failed: status=%s message=%s",
status,
strings.TrimSpace(output.Message),
)
}
value := strings.TrimSpace(output.Result.Data.Token)
if value == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token is empty")
}
// expireTime is reported in seconds (observed 1162~1565s), not
// milliseconds, so the refresh-ahead window in the factory can rely on it.
now := time.Now()
var expiresAt time.Time
if output.Result.Data.ExpireTime > 0 {
expiresAt = now.Add(time.Duration(output.Result.Data.ExpireTime) * time.Second)
}
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
}
func (p *LanShuTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return p.Fetch(ctx)
}
// lanshuFetcher adapts LanShuTokenProvider to the shared token TokenFetcher
// contract, mirroring qianxunFetcher.
type lanshuFetcher struct{ provider *LanShuTokenProvider }
func (f lanshuFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx)
}
@@ -0,0 +1,336 @@
package upstreamimpl
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
const (
lanshuTestAppID = "HeNanPuWeiTongXun10W"
lanshuTestPassword = "test-password"
lanshuTestSign = "7F4F56AEF91980FEBEAA9EC28E958EAA"
lanshuTestTokenType = "EC11_XY"
lanshuTestToken = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
)
func newLanshuTestProvider(t *testing.T, baseURL string) *LanShuTokenProvider {
t.Helper()
return NewLanShuTokenProvider(LanShuTokenConfig{
BaseURL: baseURL, Path: "/route/open/sharedToken",
AppID: lanshuTestAppID, Password: lanshuTestPassword,
Sign: lanshuTestSign, TokenType: lanshuTestTokenType, Timeout: time.Second,
})
}
// TestLanShuTokenProviderSendRequestContract pins the exact wire shape that
// the upstream expects: capitalized Param, verify.* fields passed through
// verbatim, and a real millisecond timeStamp.
func TestLanShuTokenProviderSendRequestContract(t *testing.T) {
var (
gotMethod string
gotPath string
gotContentType string
gotBody []byte
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotContentType = r.Header.Get("Content-Type")
body, _ := io.ReadAll(r.Body)
gotBody = body
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":1162,"token":"` + lanshuTestToken + `"}},"message":"success","status":"0"}`))
}))
defer server.Close()
provider := newLanshuTestProvider(t, server.URL)
before := time.Now().UnixMilli()
token, err := provider.Fetch(context.Background())
after := time.Now().UnixMilli()
if err != nil {
t.Fatalf("fetch token: %v", err)
}
if gotMethod != http.MethodPost {
t.Fatalf("method = %q, want POST", gotMethod)
}
if gotPath != "/route/open/sharedToken" {
t.Fatalf("path = %q", gotPath)
}
if gotContentType != "application/json" {
t.Fatalf("Content-Type = %q", gotContentType)
}
var payload struct {
Param struct {
TokenType string `json:"tokenType"`
} `json:"Param"`
Verify struct {
TimeStamp int64 `json:"timeStamp"`
Password string `json:"password"`
AppID string `json:"appId"`
Sign string `json:"sign"`
} `json:"verify"`
}
if err := json.Unmarshal(gotBody, &payload); err != nil {
t.Fatalf("decode request body %s: %v", gotBody, err)
}
if payload.Param.TokenType != lanshuTestTokenType {
t.Fatalf("Param.tokenType = %q", payload.Param.TokenType)
}
if payload.Verify.AppID != lanshuTestAppID {
t.Fatalf("verify.appId = %q", payload.Verify.AppID)
}
if payload.Verify.Password != lanshuTestPassword {
t.Fatalf("verify.password = %q", payload.Verify.Password)
}
if payload.Verify.Sign != lanshuTestSign {
t.Fatalf("verify.sign = %q (must be passed through verbatim)", payload.Verify.Sign)
}
if payload.Verify.TimeStamp < before || payload.Verify.TimeStamp > after {
t.Fatalf("verify.timeStamp = %d, want millis in [%d, %d]", payload.Verify.TimeStamp, before, after)
}
if token.Value != lanshuTestToken {
t.Fatalf("token = %q", token.Value)
}
// expireTime is seconds, so the expiry must land near now+1162s rather
// than now+1162ms.
wantExpiry := time.Now().Add(1162 * time.Second)
if diff := token.ExpiresAt.Sub(wantExpiry); diff > 5*time.Second || diff < -5*time.Second {
t.Fatalf("ExpiresAt = %s, want ~%s", token.ExpiresAt, wantExpiry)
}
if token.CreatedAt.IsZero() {
t.Fatal("CreatedAt must be set")
}
}
func TestLanShuTokenProviderRejectsBusinessFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"result":{},"message":"未找到对应token获取","status":"500"}`))
}))
defer server.Close()
provider := newLanshuTestProvider(t, server.URL)
token, err := provider.Fetch(context.Background())
if err == nil {
t.Fatalf("expected error for status=500, got token %q", token.Value)
}
if token.Value != "" {
t.Fatalf("token must not be returned on failure, got %q", token.Value)
}
}
func TestLanShuTokenProviderRejectsEmptyToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":1162,"token":""}},"message":"success","status":"0"}`))
}))
defer server.Close()
provider := newLanshuTestProvider(t, server.URL)
if _, err := provider.Fetch(context.Background()); err == nil {
t.Fatal("expected empty token error")
}
}
func TestLanShuTokenProviderRequiresCredentials(t *testing.T) {
// The base URL points at an unroutable TEST-NET-1 address, so a missing
// validation guard would surface as a dial timeout instead of the
// credential error this test asserts on.
const deadURL = "http://192.0.2.1:1"
cases := map[string]LanShuTokenConfig{
"missing sign": {
BaseURL: deadURL, Path: "/route/open/sharedToken",
AppID: lanshuTestAppID, Password: lanshuTestPassword, TokenType: lanshuTestTokenType,
},
"missing password": {
BaseURL: deadURL, Path: "/route/open/sharedToken",
AppID: lanshuTestAppID, Sign: lanshuTestSign, TokenType: lanshuTestTokenType,
},
"missing appId": {
BaseURL: deadURL, Path: "/route/open/sharedToken",
Password: lanshuTestPassword, Sign: lanshuTestSign, TokenType: lanshuTestTokenType,
},
"missing tokenType": {
BaseURL: deadURL, Path: "/route/open/sharedToken",
AppID: lanshuTestAppID, Password: lanshuTestPassword, Sign: lanshuTestSign,
},
"missing baseUrl": {
Path: "/route/open/sharedToken", AppID: lanshuTestAppID,
Password: lanshuTestPassword, Sign: lanshuTestSign, TokenType: lanshuTestTokenType,
},
}
for name, config := range cases {
t.Run(name, func(t *testing.T) {
provider := NewLanShuTokenProvider(config)
start := time.Now()
_, err := provider.Fetch(context.Background())
if err == nil {
t.Fatal("expected credential validation error")
}
// The guard must reject before any HTTP round trip.
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("validation must not hit the network, took %s", elapsed)
}
if !strings.Contains(err.Error(), "lanshu baseUrl, appId, password, sign and tokenType are required") {
t.Fatalf("error = %v, want the credential guard message", err)
}
})
}
}
// TestLanShuTemplateRegistration pins that a provider=lanshu template
// instantiates with a chan_config carrying the four required credentials.
func TestLanShuTemplateRegistration(t *testing.T) {
template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkLanshuTemplateKey)
if !ok || template == nil {
t.Fatal("lanshu template not registered")
}
config := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkLanshuTemplateKey,
TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com",` +
`"path":"/route/open/sharedToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "LSH-001",
ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword +
`","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`,
},
}
if instance := template.NewTemp(context.Background(), config); instance == nil {
t.Fatal("lanshu template failed to instantiate")
}
}
// TestLanShuTokenPathComesFromTemplate pins the answer to "is the token path
// read from the template, or hardcoded in the provider?": the provider's
// normalizeProviderPath fallback is unreachable on the template path, so the
// request must land on whatever token.path the template declares. The path
// used here deliberately differs from lanshuTokenPath, so a hardcoded default
// would fail this assertion.
//
// This stops one layer below the token manager: going through GetSharedToken
// would also exercise the DB-backed persister, which is out of scope here.
func TestLanShuTokenPathComesFromTemplate(t *testing.T) {
// A path that is deliberately NOT lanshuTokenPath.
const templatePath = "/some/other/open/route"
if templatePath == lanshuTokenPath {
t.Fatal("test precondition broken: templatePath must differ from lanshuTokenPath")
}
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":600,"token":"` + lanshuTestToken + `"}},"message":"success","status":"0"}`))
}))
defer server.Close()
channelConfig := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkLanshuTemplateKey,
TempConfig: `{"token":{"provider":"lanshu","baseUrl":"` + server.URL + `",` +
`"path":"` + templatePath + `"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "LSH-001",
ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword +
`","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`,
},
}
// The parsed config must carry the template path, not the fallback.
cfg, _, err := parseSharedOneLinkConfig(context.Background(), channelConfig)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if cfg.TokenPath != templatePath {
t.Fatalf("parsed TokenPath = %q, want the template-declared %q", cfg.TokenPath, templatePath)
}
// And the provider built from it must issue the request to that path.
provider := NewLanShuTokenProvider(LanShuTokenConfig{
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID,
Password: cfg.Password, Sign: cfg.Sign, TokenType: cfg.TokenType, Timeout: cfg.Timeout,
})
if _, err := provider.Fetch(context.Background()); err != nil {
t.Fatalf("fetch token: %v", err)
}
if gotPath != templatePath {
t.Fatalf("request path = %q, want the template-declared %q", gotPath, templatePath)
}
}
// TestLanShuTokenPathIsRequiredFromTemplate pins that a template without
// token.path is rejected rather than silently falling back to a default.
func TestLanShuTokenPathIsRequiredFromTemplate(t *testing.T) {
config := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkLanshuTemplateKey,
TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "LSH-001",
ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword +
`","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`,
},
}
if _, _, err := parseSharedOneLinkConfig(context.Background(), config); err == nil {
t.Fatal("missing token.path must be rejected")
}
}
// TestLanShuChannelConfigRejectsIncompleteCredentials pins the parse-time
// guard so a channel missing chan_config.sign cannot silently build a
// provider that fails on every request.
func TestLanShuChannelConfigRejectsIncompleteCredentials(t *testing.T) {
base := func(chanConfig string) *model.SimChannelConfigItemCache {
return &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkLanshuTemplateKey,
TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com",` +
`"path":"/route/open/sharedToken"}}`,
},
Channel: &entity.TSimChannels{ChanKey: "LSH-001", ChanConfig: chanConfig},
}
}
complete := `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword +
`","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`
parsed, _, err := parseSharedOneLinkConfig(context.Background(), base(complete))
if err != nil {
t.Fatalf("complete chan_config must parse: %v", err)
}
if parsed.Sign != lanshuTestSign {
t.Fatalf("parsed sign = %q", parsed.Sign)
}
if parsed.TokenType != lanshuTestTokenType {
t.Fatalf("parsed tokenType = %q", parsed.TokenType)
}
for name, incomplete := range map[string]string{
"missing sign": `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","tokenType":"` + lanshuTestTokenType + `"}`,
"missing password": `{"appId":"` + lanshuTestAppID + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`,
"missing tokenType": `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","sign":"` + lanshuTestSign + `"}`,
} {
t.Run(name, func(t *testing.T) {
if _, _, err := parseSharedOneLinkConfig(context.Background(), base(incomplete)); err == nil {
t.Fatal("expected chan_config validation error")
}
})
}
}
@@ -0,0 +1,21 @@
package upstreamimpl_test
import (
"context"
"testing"
_ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
func TestMingqianRegistration(t *testing.T) {
template, ok := service.UpstreamAPI().GetTemp("sim_mingqian_v3")
if !ok || template == nil {
t.Fatal("mingqian template not registered")
}
cfg := &model.SimChannelConfigItemCache{Template: &entity.TSimChannelTemplate{}, Channel: &entity.TSimChannels{AppKey: "test", AppSecret: "test"}}
if instance := template.NewTemp(context.Background(), cfg); instance == nil {
t.Fatal("mingqian instance creation failed")
}
}
@@ -0,0 +1,145 @@
package upstreamimpl
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
const mobilePublicTokenPath = "/api/get/yd/api/msg"
// MobilePublicTokenConfig contains the credentials used to request a shared
// China Mobile OneLink token. Secret is sourced from t_sim_channels.app_secret.
type MobilePublicTokenConfig struct {
BaseURL string
Path string
UserID string
Secret string
Timeout time.Duration
}
// MobilePublicTokenProvider fetches a shared OneLink token from the mobile
// public-token upstream.
type MobilePublicTokenProvider struct {
baseURL string
path string
userID string
secret string
client *http.Client
now func() time.Time
}
type mobilePublicTokenResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Token string `json:"token"`
ExpireTime int64 `json:"expire_time"`
} `json:"data"`
}
func NewMobilePublicTokenProvider(config MobilePublicTokenConfig) *MobilePublicTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &MobilePublicTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, mobilePublicTokenPath),
userID: strings.TrimSpace(config.UserID),
secret: config.Secret,
client: &http.Client{Timeout: timeout},
now: time.Now,
}
}
func mobilePublicSign(secret, userID string, timestamp int64) string {
source := secret + userID + strconv.FormatInt(timestamp, 10) + secret
sum := md5.Sum([]byte(source))
return hex.EncodeToString(sum[:])
}
func (p *MobilePublicTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
if p == nil || p.client == nil || p.now == nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token provider is not initialized")
}
if p.baseURL == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token base URL is required")
}
if p.userID == "" || p.secret == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token userId and secret are required")
}
userID, err := strconv.ParseInt(p.userID, 10, 64)
if err != nil || userID <= 0 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token userId must be a positive integer")
}
now := p.now()
payload := struct {
UserID int64 `json:"userid"`
Time int64 `json:"time"`
Sign string `json:"sign"`
}{
UserID: userID,
Time: now.Unix(),
Sign: mobilePublicSign(p.secret, strconv.FormatInt(userID, 10), now.Unix()),
}
body, err := json.Marshal(payload)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode mobile public token request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), strings.NewReader(string(body)))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("create mobile public token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("request mobile public token: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("read mobile public token response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token HTTP status %d", resp.StatusCode)
}
var output mobilePublicTokenResponse
if err := json.Unmarshal(responseBody, &output); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode mobile public token response: %w", err)
}
if output.Code != http.StatusOK {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token failed: code=%d msg=%s", output.Code, strings.TrimSpace(output.Msg))
}
value := strings.TrimSpace(output.Data.Token)
if value == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token is empty")
}
expiresAt := time.Unix(output.Data.ExpireTime, 0)
if output.Data.ExpireTime <= 0 || !expiresAt.After(now) {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token expire_time must be in the future: %d", output.Data.ExpireTime)
}
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
}
func (p *MobilePublicTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return p.Fetch(ctx)
}
type mobilePublicFetcher struct{ provider *MobilePublicTokenProvider }
func (f mobilePublicFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx)
}
@@ -0,0 +1,112 @@
package upstreamimpl
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
func TestMobilePublicTemplateRegistration(t *testing.T) {
for _, templateKey := range []string{
sharedOneLinkMobilePublicTemplateKey,
sharedOneLinkMobilePublicPreviousKey,
sharedOneLinkMobilePublicLegacyKey,
} {
t.Run(templateKey, func(t *testing.T) {
template, ok := service.UpstreamAPI().GetTemp(templateKey)
if !ok || template == nil {
t.Fatal("mobile public template not registered")
}
config := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: templateKey,
TempConfig: `{"token":{"provider":"mobile_public","baseUrl":"https://example.com","path":"/api/get/yd/api/msg"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "daliuliang-test",
ChanConfig: `{"userId":"102369"}`,
AppSecret: "test-secret",
},
}
if instance := template.NewTemp(context.Background(), config); instance == nil {
t.Fatal("mobile public channel failed to instantiate")
}
})
}
}
func TestMobilePublicSign(t *testing.T) {
const timestamp = int64(1699606000)
if got, want := mobilePublicSign("bbb", "aaa", timestamp), "2337f8d0d5c0cd9e415a3b3c27c4a595"; got != want {
t.Fatalf("sign = %q, want %q", got, want)
}
}
func TestMobilePublicTokenProviderFetch(t *testing.T) {
now := time.Unix(1700000000, 0)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != mobilePublicTokenPath {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
var input struct {
UserID int64 `json:"userid"`
Time int64 `json:"time"`
Sign string `json:"sign"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
t.Fatal(err)
}
if input.UserID != 102369 || input.Time != now.Unix() {
t.Fatalf("payload = %#v", input)
}
if want := mobilePublicSign("secret", "102369", input.Time); input.Sign != want {
t.Fatalf("sign = %q, want %q", input.Sign, want)
}
_, _ = w.Write([]byte(`{"code":200,"msg":"success","data":{"token":"token-1","expire_time":1700003600}}`))
}))
defer server.Close()
provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{
BaseURL: server.URL, UserID: "102369", Secret: "secret", Timeout: time.Second,
})
provider.now = func() time.Time { return now }
token, err := provider.Fetch(context.Background())
if err != nil {
t.Fatalf("fetch token: %v", err)
}
if token.Value != "token-1" || !token.ExpiresAt.Equal(time.Unix(1700003600, 0)) {
t.Fatalf("token = %#v", token)
}
}
func TestMobilePublicTokenProviderRejectsExpiredToken(t *testing.T) {
now := time.Unix(1700000000, 0)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":200,"data":{"token":"token-1","expire_time":1699999999}}`))
}))
defer server.Close()
provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{
BaseURL: server.URL, UserID: "102369", Secret: "secret", Timeout: time.Second,
})
provider.now = func() time.Time { return now }
if _, err := provider.Fetch(context.Background()); err == nil {
t.Fatal("expected expired token error")
}
}
func TestMobilePublicTokenProviderRejectsNonNumericUserID(t *testing.T) {
provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{
BaseURL: "http://127.0.0.1", UserID: "not-a-number", Secret: "secret", Timeout: time.Second,
})
if _, err := provider.Fetch(context.Background()); err == nil {
t.Fatal("expected invalid userId error")
}
}
@@ -0,0 +1,116 @@
package upstreamimpl
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
type QianxunTokenConfig struct {
BaseURL string
Path string
OperatorID string
AppID string
TokenType string
Key string
Timeout time.Duration
}
type QianxunTokenProvider struct {
baseURL, path, operatorID, appID, tokenType, key string
client *http.Client
}
type qianxunTokenResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Token string `json:"token"`
ExpireDate int64 `json:"expireDate"`
} `json:"data"`
}
func NewQianxunTokenProvider(config QianxunTokenConfig) *QianxunTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &QianxunTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, "/"), operatorID: strings.TrimSpace(config.OperatorID),
appID: strings.TrimSpace(config.AppID), tokenType: strings.TrimSpace(config.TokenType), key: config.Key,
client: &http.Client{Timeout: timeout},
}
}
func qianxunSharedSign(operatorID, appID, key string) string {
sum := md5.Sum([]byte(operatorID + appID + key))
return hex.EncodeToString(sum[:])
}
func (p *QianxunTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
if p == nil || p.client == nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token provider is not initialized")
}
if p.baseURL == "" || p.operatorID == "" || p.appID == "" || p.tokenType == "" || p.key == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token endpoint and credentials are required")
}
payload := map[string]string{"operatorId": p.operatorID, "appId": p.appID, "tokenType": p.tokenType, "sign": qianxunSharedSign(p.operatorID, p.appID, p.key)}
body, err := json.Marshal(payload)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode qianxun token request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), strings.NewReader(string(body)))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("create qianxun token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("request qianxun token: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("read qianxun token response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token HTTP status %d", resp.StatusCode)
}
var output qianxunTokenResponse
if err := json.Unmarshal(responseBody, &output); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode qianxun token response: %w", err)
}
if output.Code != 0 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token failed: code=%d msg=%s", output.Code, output.Msg)
}
value := strings.TrimSpace(output.Data.Token)
if value == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token is empty")
}
now := time.Now()
var expiresAt time.Time
if output.Data.ExpireDate > 0 {
expiresAt = now.Add(time.Duration(output.Data.ExpireDate) * time.Second)
}
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
}
func (p *QianxunTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return p.Fetch(ctx)
}
type qianxunFetcher struct{ provider *QianxunTokenProvider }
func (f qianxunFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx)
}
@@ -0,0 +1,50 @@
package upstreamimpl
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestQianxunTokenProviderAcceptsTokenWithoutExpiry(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/oauth/access_token" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"token":"token-1","expireDate":0}}`))
}))
defer server.Close()
provider := NewQianxunTokenProvider(QianxunTokenConfig{
BaseURL: server.URL, Path: "/oauth/access_token", OperatorID: "operator-1",
AppID: "app-1", TokenType: "card", Key: "key-1", Timeout: time.Second,
})
token, err := provider.Fetch(context.Background())
if err != nil {
t.Fatalf("fetch token: %v", err)
}
if token.Value != "token-1" {
t.Fatalf("token = %q", token.Value)
}
if !token.ExpiresAt.IsZero() {
t.Fatalf("expireDate=0 must produce a token without expiry: %s", token.ExpiresAt)
}
}
func TestQianxunTokenProviderRejectsEmptyToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"token":"","expireDate":0}}`))
}))
defer server.Close()
provider := NewQianxunTokenProvider(QianxunTokenConfig{
BaseURL: server.URL, OperatorID: "operator-1", AppID: "app-1",
TokenType: "card", Key: "key-1", Timeout: time.Second,
})
if _, err := provider.Fetch(context.Background()); err == nil {
t.Fatal("expected empty token error")
}
}
@@ -0,0 +1,177 @@
package recharge
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const DefaultBaseURL = "http://www.sdtywl.cn/"
const requestInterval = time.Second
var requestGates sync.Map
// 上游业务码(综合充值平台API文档-通用反馈状态码)
const (
CodeSuccess = 200 // 成功
CodeMissingParam = 400 // 缺少参数
CodeSignError = 401 // sign错误
CodeForbidden = 403 // 无权访问
CodePwdKeyError = 404 // pwdkey错误
CodeNotWhiteList = 407 // 非白名单IP
CodeRateLimited = 429 // 访问超频(单接口60次/分)
CodeServerError = 500 // 其他错误
)
// BusinessError 上游业务错误(HTTP 200 但 code != 200)
type BusinessError struct {
Code int
Msg string
}
func (e *BusinessError) Error() string {
return fmt.Sprintf("recharge upstream business error, code %d, msg %s", e.Code, e.Msg)
}
// Response 上游统一响应信封
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
type Config struct {
BaseURL string
PwdKey string
Pwdiv string
Timeout time.Duration
}
type Client struct {
baseURL string
pwdKey string
pwdiv string
http *http.Client
}
type requestGate struct {
mu sync.Mutex
next time.Time
}
func (gate *requestGate) wait(ctx context.Context) error {
for {
if err := ctx.Err(); err != nil {
return err
}
gate.mu.Lock()
delay := time.Until(gate.next)
if delay <= 0 {
gate.next = time.Now().Add(requestInterval)
gate.mu.Unlock()
return nil
}
gate.mu.Unlock()
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
func NewClient(cfg Config) *Client {
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = DefaultBaseURL
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
return &Client{
baseURL: strings.TrimSuffix(baseURL, "/"),
pwdKey: cfg.PwdKey,
pwdiv: cfg.Pwdiv,
http: &http.Client{Timeout: timeout},
}
}
// DoPost 调用上游 POST 接口,返回业务信封;HTTP 层错误直接返回 error,
// 业务码(code != 200)由调用方判断
func (c *Client) DoPost(ctx context.Context, endpoint string, data any) (*Response, error) {
if err := c.requestGate(endpoint).wait(ctx); err != nil {
return nil, err
}
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
payload := map[string]any{
"pwdkey": c.pwdKey,
"timestamp": timestamp,
"sign": c.sign(timestamp),
}
if data != nil {
payload["data"] = data
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return c.do(ctx, http.MethodPost, c.baseURL+"/prod-api/open/api"+endpoint, body)
}
func (c *Client) requestGate(endpoint string) *requestGate {
key := c.baseURL + "|" + c.pwdKey + "|" + strings.TrimSpace(endpoint)
gate, _ := requestGates.LoadOrStore(key, &requestGate{})
return gate.(*requestGate)
}
func (c *Client) sign(timestamp string) string {
sum := md5.Sum([]byte(c.pwdKey + c.pwdiv + timestamp))
return strings.ToLower(hex.EncodeToString(sum[:]))
}
func (c *Client) do(ctx context.Context, method, endpoint string, body []byte) (*Response, error) {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("recharge upstream http status %d: %s", resp.StatusCode, string(respBody))
}
var output Response
if err := json.Unmarshal(respBody, &output); err != nil {
return nil, fmt.Errorf("decode recharge upstream response: %w, body=%s", err, string(respBody))
}
return &output, nil
}
@@ -0,0 +1,33 @@
package recharge
import (
"context"
"errors"
"testing"
"time"
)
func TestRequestGateHonorsCancellation(t *testing.T) {
gate := &requestGate{}
if err := gate.wait(context.Background()); err != nil {
t.Fatalf("first wait failed: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if err := gate.wait(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("wait error = %v, want deadline exceeded", err)
}
}
func TestClientSharesGatePerAccountAndEndpoint(t *testing.T) {
first := NewClient(Config{BaseURL: "http://example.com", PwdKey: "account"})
second := NewClient(Config{BaseURL: "http://example.com", PwdKey: "account"})
if first.requestGate("/findCard") != second.requestGate("/findCard") {
t.Fatal("same account and endpoint must share the rate limiter")
}
if first.requestGate("/findCard") == first.requestGate("/changeState") {
t.Fatal("different endpoints must not share the rate limiter")
}
}
@@ -0,0 +1,265 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/daihe"
)
const daiheTemplateKey = "sim_daihe_v1"
type daiheConfig struct {
AppID string `json:"appId"`
AppSecret string `json:"appSecret"`
BaseURL string `json:"baseUrl"`
Timeout string `json:"timeout"`
API struct {
BaseURL string `json:"baseUrl"`
Timeout string `json:"timeout"`
} `json:"api"`
}
type daiheUsage struct {
ICCID string `json:"iccid"`
Month int `json:"month"`
Used float64 `json:"used"`
}
type daiheStatus struct {
ICCID string `json:"iccid"`
Status string `json:"status"`
}
type sSimDaihe struct {
channelConfig *model.SimChannelConfigItemCache
client *daihe.Client
}
func init() { service.UpstreamAPI().RegisterTemp(daiheTemplateKey, NewSimDaihe()) }
func NewSimDaihe() *sSimDaihe { return &sSimDaihe{} }
func parseDaiheConfig(raw string) daiheConfig {
var cfg daiheConfig
_ = json.Unmarshal([]byte(raw), &cfg)
return cfg
}
func pickDaiheConfig(cfg *model.SimChannelConfigItemCache) (daiheConfig, bool) {
if cfg == nil || cfg.Channel == nil || cfg.Template == nil {
return daiheConfig{}, false
}
channel := parseDaiheConfig(cfg.Channel.ChanConfig)
template := parseDaiheConfig(cfg.Template.TempConfig)
out := template
if strings.TrimSpace(channel.AppID) != "" {
out.AppID = channel.AppID
}
if strings.TrimSpace(channel.AppSecret) != "" {
out.AppSecret = channel.AppSecret
}
if strings.TrimSpace(channel.BaseURL) != "" {
out.BaseURL = channel.BaseURL
}
if strings.TrimSpace(channel.API.BaseURL) != "" {
out.BaseURL = channel.API.BaseURL
}
if strings.TrimSpace(channel.Timeout) != "" {
out.Timeout = channel.Timeout
}
if strings.TrimSpace(channel.API.Timeout) != "" {
out.Timeout = channel.API.Timeout
}
if out.AppID == "" {
out.AppID = cfg.Channel.AppKey
}
if out.AppSecret == "" {
out.AppSecret = cfg.Channel.AppSecret
}
if out.BaseURL == "" {
out.BaseURL = "https://open.flymodem.com.cn"
}
return out, out.AppID != "" && out.AppSecret != ""
}
func (s *sSimDaihe) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
raw, ok := pickDaiheConfig(cfg)
if !ok {
g.Log().Warning(ctx, "daihe.NewTemp: missing appId/appSecret")
return nil
}
timeout := 15 * time.Second
if d, err := time.ParseDuration(raw.Timeout); err == nil && d > 0 {
timeout = d
}
return &sSimDaihe{channelConfig: cfg, client: daihe.NewClient(daihe.Config{BaseURL: raw.BaseURL, AppID: raw.AppID, AppSecret: raw.AppSecret, Timeout: timeout})}
}
func daiheICCID(key *model.SimKey) string {
if key == nil {
return ""
}
return strings.TrimSpace(key.ICCID)
}
func daiheStatusCode(status string) (int, bool) {
switch strings.TrimSpace(status) {
case "库存":
return 7, true
case "可测试", "测试激活", "测试去激活":
return 6, true
case "可激活", "开始":
return 1, true
case "已激活":
return 2, true
case "已停用":
return 4, true
case "预清除":
return 8, true
case "已清除", "已失效", "已更换":
return 9, true
default:
return 0, false
}
}
func daiheUnsupported[T any](keys []*model.SimKey, name string) (out []*model.SimBaseRes[T], err error) {
for _, key := range keys {
out = append(out, &model.SimBaseRes[T]{Code: consts.ApiStatusError, SimKey: key, Message: "daihe does not support " + name})
}
return
}
func (s *sSimDaihe) QuerySimCardStatus(ctx context.Context, in *model.SimCardStatusInput) (out model.SimCardStatusOutput, err error) {
for _, key := range in.SimKeys {
r := &model.SimBaseRes[model.SimCardStatusItem]{SimKey: key}
iccid := daiheICCID(key)
var data daiheStatus
if iccid == "" {
r.Code = consts.ApiStatusError
r.Message = "iccid is required"
} else if e := s.client.QueryStatus(ctx, iccid, &data); e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else if status, ok := daiheStatusCode(data.Status); !ok {
r.Code = consts.ApiStatusError
r.Message = fmt.Sprintf("unsupported daihe card status: %s", data.Status)
} else {
r.Code = consts.ApiStatusSuccess
r.ResData.CardStatus = status
}
out = append(out, r)
}
return
}
func (s *sSimDaihe) QuerySimDataUsage(ctx context.Context, in *model.SimDataUsageInput) (out model.SimDataUsageOutput, err error) {
return s.queryUsage(ctx, in, "")
}
func (s *sSimDaihe) QuerySimDataUsageMonthly(ctx context.Context, in *model.SimDataUsageMonthlyInput) (out model.SimDataUsageMonthlyOutput, err error) {
month := ""
if in != nil {
month = strings.TrimSpace(in.ReqData.QueryDate)
}
result := make(model.SimDataUsageMonthlyOutput, 0, len(in.SimKeys))
for _, key := range in.SimKeys {
r := &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: key}
iccid := daiheICCID(key)
var data daiheUsage
if iccid == "" {
r.Code = consts.ApiStatusError
r.Message = "iccid is required"
} else if e := s.client.QueryUsage(ctx, iccid, month, &data); e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = consts.ApiStatusSuccess
r.ResData.DeadLine = fmt.Sprintf("%06d", data.Month)
r.ResData.DataAmount = data.Used
}
result = append(result, r)
}
return result, nil
}
func (s *sSimDaihe) queryUsage(ctx context.Context, in *model.SimDataUsageInput, month string) (out model.SimDataUsageOutput, err error) {
for _, key := range in.SimKeys {
r := &model.SimBaseRes[model.SimDataUsageItem]{SimKey: key}
iccid := daiheICCID(key)
var data daiheUsage
if iccid == "" {
r.Code = consts.ApiStatusError
r.Message = "iccid is required"
} else if e := s.client.QueryUsage(ctx, iccid, month, &data); e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = consts.ApiStatusSuccess
r.ResData.DataUsage = data.Used
}
out = append(out, r)
}
return
}
func (s *sSimDaihe) ChangeSimStatus(ctx context.Context, in *model.SimCardStatusChangeInput) (out model.SimCardStatusChangeOutput, err error) {
op, ok := normalizeCardStatusChangeOperType(ctx, in.ReqData.OperType)
if !ok {
return newInvalidCardStatusChangeOutput(in.SimKeys), nil
}
for _, key := range in.SimKeys {
r := &model.SimBaseRes[model.SimCardStatusChangeItem]{SimKey: key}
iccid := daiheICCID(key)
var e error
if iccid == "" {
e = fmt.Errorf("iccid is required")
} else if op == consts.CardStatusChangeOperTypeApplyStop {
e = s.client.Deactivate(ctx, iccid)
} else {
e = s.client.Activate(ctx, iccid)
}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = consts.ApiStatusSuccess
}
out = append(out, r)
}
return
}
func (s *sSimDaihe) QuerySimBasicInfo(ctx context.Context, in *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
return daiheUnsupported[model.SimBasicInfoItem](in.SimKeys, "basic info query")
}
func (s *sSimDaihe) QuerySimSession(ctx context.Context, in *model.SimSessionInput) (model.SimSessionOutput, error) {
return daiheUnsupported[model.SimSessionItem](in.SimKeys, "session query")
}
func (s *sSimDaihe) QuerySimStopReason(ctx context.Context, in *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
return daiheUnsupported[model.SimStopReasonItem](in.SimKeys, "stop reason query")
}
func (s *sSimDaihe) QuerySimFrozenState(ctx context.Context, in *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
return daiheUnsupported[model.SimFrozenStateItem](in.SimKeys, "frozen state query")
}
func (s *sSimDaihe) QuerySimCardBindStatus(ctx context.Context, in *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
return daiheUnsupported[model.SimCardBindStatusItem](in.SimKeys, "card binding query")
}
func (s *sSimDaihe) QuerySimDeviceStatus(ctx context.Context, in *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
return daiheUnsupported[model.SimDeviceStatusItem](in.SimKeys, "device status query")
}
func (s *sSimDaihe) QuerySimRegionLimitStatus(ctx context.Context, in *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
return daiheUnsupported[model.SimRegionLimitStatusItem](in.SimKeys, "region limit query")
}
func (s *sSimDaihe) QuerySimImei(ctx context.Context, in *model.SimImeiInput) (model.SimImeiOutput, error) {
return daiheUnsupported[model.SimImeiItem](in.SimKeys, "IMEI query")
}
func (s *sSimDaihe) QuerySimDataDiagnosis(ctx context.Context, in *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
return daiheUnsupported[any](in.SimKeys, "data diagnosis")
}
func (s *sSimDaihe) QuerySimDataUsageDaily(ctx context.Context, in *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
return daiheUnsupported[model.SimDataUsageDailyOutputItem](in.SimKeys, "daily usage query")
}
func (s *sSimDaihe) QuerySimApnInfo(ctx context.Context, in *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
return daiheUnsupported[model.SimApnInfoItemList](in.SimKeys, "APN query")
}
func (s *sSimDaihe) ChangeSimApnFunction(ctx context.Context, in *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
return daiheUnsupported[model.SimCardOperateChangeApnItem](in.SimKeys, "APN change")
}
@@ -0,0 +1,102 @@
package upstreamimpl
import (
"context"
"encoding/json"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
type ydDataDiagnosisRequestFunc func(ctx context.Context, operation, url string, params g.Map) (*ydHTTPResponse, error)
type ydDataDiagnosisResp struct {
Status string `json:"status"`
Message string `json:"message"`
}
// QuerySimDataDiagnosis 物联卡达量限速档位查询
func (s *sSimYD) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
return queryYdSimDataDiagnosis(ctx, input, s.getUrl(ctx, "ec/query/sim-data-diagnosis"), s.getTransId, s.requestYd)
}
// QuerySimDataDiagnosis 物联卡达量限速档位查询
func (s *sSimYDMock) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
return queryYdSimDataDiagnosis(ctx, input, s.getUrl(ctx, "ec/query/sim-data-diagnosis"), s.getTransId, s.requestYd)
}
// QuerySimDataDiagnosis 物联卡达量限速档位查询
func (s *sSimDX) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
items := make(model.SimDataDiagnosisOutput, 0)
if input == nil {
return items, nil
}
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[any]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: "电信官方接口暂不提供当前达量限速查询能力",
})
}
return items, nil
}
func queryYdSimDataDiagnosis(
ctx context.Context,
input *model.SimDataDiagnosisInput,
url string,
getTransID func(context.Context) string,
request ydDataDiagnosisRequestFunc,
) (model.SimDataDiagnosisOutput, error) {
items := make(model.SimDataDiagnosisOutput, 0)
if input == nil {
return items, nil
}
for _, simKey := range input.SimKeys {
params := g.Map{"transid": getTransID(ctx)}
paramName, paramValue := pickSimQueryParam(simKey)
if paramName == "" {
items = append(items, &model.SimBaseRes[any]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: "missing iccid/msisdn/imsi",
})
continue
}
params[paramName] = paramValue
resp, err := request(ctx, "QuerySimDataDiagnosis", url, params)
if err != nil {
g.Log().Errorf(ctx, "QuerySimDataDiagnosis get error %+v,url %s, params %+v", err, url, params)
items = append(items, &model.SimBaseRes[any]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: err.Error(),
})
continue
}
body := resp.ReadAllString()
var ydResp ydDataDiagnosisResp
if err = json.Unmarshal([]byte(body), &ydResp); err != nil {
g.Log().Errorf(ctx, "QuerySimDataDiagnosis unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body)
items = append(items, &model.SimBaseRes[any]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: err.Error(),
})
continue
}
items = append(items, &model.SimBaseRes[any]{
SimKey: simKey,
Code: gconv.Int(ydResp.Status),
Message: ydResp.Message,
ResData: json.RawMessage(body),
})
}
return items, nil
}
@@ -0,0 +1,288 @@
package upstreamimpl
import (
"strings"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"github.com/gogf/gf/v2/util/gconv"
)
func pickSimQueryParam(simKey *model.SimKey) (string, string) {
if simKey == nil {
return "", ""
}
if value := strings.TrimSpace(simKey.ICCID); value != "" {
return "iccid", value
}
if value := strings.TrimSpace(simKey.MSISDN); value != "" {
return "msisdn", value
}
if value := strings.TrimSpace(simKey.IMSI); value != "" {
return "imsi", value
}
return "", ""
}
func ydBasicInfoHasIdentity(iccid, msisdn, imsi string) bool {
return strings.TrimSpace(iccid) != "" || strings.TrimSpace(msisdn) != "" || strings.TrimSpace(imsi) != ""
}
func mapYdStopReasonText(stopReason string) string {
value := strings.TrimSpace(stopReason)
if len(value) == 12 && strings.Trim(value, "02") == "" {
if value == "000000000000" {
return "未停机或系统暂无停机原因"
}
var reasons []string
for _, code := range []struct {
position int
label string
}{
{0, "窄带网商品到期失效停机"},
{1, "机卡分离停机"},
{3, "M2M管理停机"},
{4, "信控双向停机"},
{8, "主动申请双向停机"},
{9, "区域限制管理型停机"},
{10, "强制双向停机"},
} {
if value[code.position] == '2' {
reasons = append(reasons, code.label)
}
}
if len(reasons) > 0 {
if strings.Count(value, "2") == len(reasons) {
return strings.Join(reasons, "、")
}
return strings.Join(reasons, "、") + " (原始码: " + value + ")"
}
}
switch value {
case "":
return "未停机"
case "000000000000":
return "未停机或系统暂无停机原因"
case "000000000020":
return "强制双向停机"
case "000000002000":
return "主动申请双向停机"
case "000020000000":
return "信控双向停机"
case "020000000000":
return "机卡分离停机"
case "000200000000":
return "M2M管理停机"
case "000000000200":
return "区域限制管理型停机"
default:
return value
}
}
func mapYdFrozenStatusText(frozenStatus string) string {
value := strings.TrimSpace(frozenStatus)
switch value {
case "", "0":
return "未冻结"
case "1":
return "已冻结"
default:
return value
}
}
func mapYdOperatingStatus(status string) string {
switch strings.ToUpper(strings.TrimSpace(status)) {
case "1", "01", "ON", "ONLINE":
return "1"
case "0", "00", "OFF", "OFFLINE", "-1":
return "0"
default:
return consts.CardSessionStatusUnknown
}
}
func mapYdDeviceStatus(status string) string {
return mapYdOperatingStatus(status)
}
func mapDxOperatingStatus(status string) string {
switch strings.TrimSpace(status) {
case "0":
// Telecom NQ007: 0 = power on, 1 = power off.
return "1"
case "1":
return "0"
default:
return consts.CardSessionStatusUnknown
}
}
func pickYdCardBindStatus(result map[string]any) string {
if len(result) == 0 {
return ""
}
if value := strings.TrimSpace(toString(result["result"])); value != "" {
switch value {
case "0", "1", "2":
return value
case "OK", "ok":
return "1"
}
}
for _, key := range []string{"bind_status", "bindStatus", "cardBindStatus", "cardBindingType", "status"} {
value := strings.TrimSpace(toString(result[key]))
switch value {
case "0", "1":
return value
case "OK", "ok":
return "1"
}
}
return ""
}
func pickYdCardBindStatusErrorCode(result map[string]any) string {
if len(result) == 0 {
return ""
}
for _, key := range []string{"errorCode", "error_code", "errCode", "errcode"} {
value := strings.TrimSpace(toString(result[key]))
if value != "" {
return value
}
}
return ""
}
func pickYdCardBindStatusErrorDes(result map[string]any) string {
if len(result) == 0 {
return ""
}
for _, key := range []string{"errorDes", "error_des", "errDes", "errMsg", "message"} {
value := strings.TrimSpace(toString(result[key]))
if value != "" {
return value
}
}
return ""
}
func toString(value any) string {
return strings.TrimSpace(gconv.String(value))
}
func mapDxStopTypeListText(stopTypeList []string) string {
if len(stopTypeList) == 0 {
return "未停机"
}
texts := make([]string, 0, len(stopTypeList))
for _, stopType := range stopTypeList {
switch strings.TrimSpace(stopType) {
case "":
continue
case "100000":
texts = append(texts, "申请停机")
case "110000":
texts = append(texts, "挂失停机")
case "120000":
texts = append(texts, "停机保号")
case "130000":
texts = append(texts, "欠费停机")
case "130001":
texts = append(texts, "欠费单停")
case "130002":
texts = append(texts, "欠费双停")
case "140000":
texts = append(texts, "预拆机停机")
case "150000":
texts = append(texts, "违章停机")
case "150001":
texts = append(texts, "未实名制违规停机")
case "160000":
texts = append(texts, "数据异常停机")
case "170000":
texts = append(texts, "局方原因停机")
case "180000":
texts = append(texts, "强制停机")
case "190000":
texts = append(texts, "超范围使用违规停机")
case "200000":
texts = append(texts, "去激活停机")
case "210000":
texts = append(texts, "紧急停机")
case "220000":
texts = append(texts, "安全事件关停")
case "230000":
texts = append(texts, "到期停机")
case "240000":
texts = append(texts, "公免到期停机")
case "250000":
texts = append(texts, "限额管控停机")
case "260000":
texts = append(texts, "欺诈停机")
case "270000":
texts = append(texts, "模组融合停机")
case "280100":
texts = append(texts, "区域限制停机")
default:
texts = append(texts, strings.TrimSpace(stopType))
}
}
if len(texts) == 0 {
return "未停机"
}
return strings.Join(texts, "、")
}
func mapDxRegionLimitStatus(areaLimitType string, areaLimitInfo string) string {
areaLimitType = strings.TrimSpace(areaLimitType)
areaLimitInfo = strings.TrimSpace(areaLimitInfo)
if areaLimitType == "" || areaLimitType == "-1" || areaLimitType == "0" {
if areaLimitInfo == "" {
return "1"
}
}
return "0"
}
func mapDxRegionLimitStatusByOrder(orderStatus string, limitStatus string) string {
orderStatus = strings.TrimSpace(orderStatus)
limitStatus = strings.TrimSpace(limitStatus)
switch {
case orderStatus == "1":
return "2"
case orderStatus == "0" && limitStatus == "0":
return "1"
case orderStatus == "0" && limitStatus == "1":
return "0"
case orderStatus == "0" && limitStatus == "-":
return "2"
default:
return "2"
}
}
func normalizeDxRegionLimitDetails(orderStatus string, limitStatus string, limitType string, limitArea string) (string, string, string) {
if strings.TrimSpace(orderStatus) != "0" {
return "", "", ""
}
switch strings.TrimSpace(limitStatus) {
case "0", "1", "-":
limitStatus = strings.TrimSpace(limitStatus)
default:
limitStatus = ""
}
switch strings.TrimSpace(limitType) {
case "1", "2", "3", "4":
limitType = strings.TrimSpace(limitType)
default:
limitType = ""
}
return limitStatus, limitType, strings.TrimSpace(limitArea)
}
@@ -0,0 +1,104 @@
package upstreamimpl
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
)
type diagnosisTestToken struct{}
func (diagnosisTestToken) GetToken(context.Context) (string, error) { return "test-token", nil }
func (diagnosisTestToken) RecoverToken(context.Context, string) (string, error) {
return "test-token", nil
}
func TestYdImeiUsesTerminalDeviceInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v5/ec/query/sim-terminal-device-info" || r.URL.Query().Get("iccid") != "89860000000000000004" {
t.Errorf("unexpected request %s", r.URL.String())
}
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"terminalDeviceImei":"123456789012345"}]}`))
}))
defer server.Close()
sim := &sSimYD{
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "test", AppKey: "test"}},
tokenProvider: diagnosisTestToken{}, apiConfig: &oneLinkAPIConfig{BaseURL: server.URL + "/v5"},
}
result, err := sim.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "89860000000000000004"}}})
if err != nil {
t.Fatal(err)
}
if len(result) != 1 || result[0].Code != 0 || result[0].ResData.Imei != "123456789012345" {
t.Fatalf("unexpected IMEI response: %+v", result)
}
}
func TestYdStopReasonCombination(t *testing.T) {
for _, tc := range []struct{ input, want string }{
{"020000002000", "机卡分离停机、主动申请双向停机"},
{"200000000000", "窄带网商品到期失效停机"},
{"002000000000", "002000000000"},
} {
if got := mapYdStopReasonText(tc.input); got != tc.want {
t.Errorf("%s => %s, want %s", tc.input, got, tc.want)
}
}
}
func TestDxRegionLimitStatusByOrder(t *testing.T) {
for _, tc := range []struct{ order, limit, want string }{
{"1", "", "2"}, {"0", "0", "1"}, {"0", "1", "0"}, {"0", "-", "2"},
} {
if got := mapDxRegionLimitStatusByOrder(tc.order, tc.limit); got != tc.want {
t.Errorf("%s/%s => %s, want %s", tc.order, tc.limit, got, tc.want)
}
}
}
func TestDxCardBindingUsesDedicatedEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/corp/connect/chkDeviceBindingInfo" || r.URL.Query().Get("iccid") != "8986000000000000007" {
t.Errorf("unexpected request %s", r.URL.String())
}
_, _ = w.Write([]byte(`{"code":"0","data":{"orderStaus":"0","bindType":"1","bindImei":"123456789012345","bindStaus":"1"}}`))
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
result, err := sim.QuerySimCardBindStatus(context.Background(), &model.SimCardBindStatusInput{SimKeys: []*model.SimKey{{ICCID: "8986000000000000007"}}})
if err != nil {
t.Fatal(err)
}
if len(result) != 1 || result[0].Code != 0 || result[0].ResData.OrderStatus != "0" || result[0].ResData.BindStatus != "1" || result[0].ResData.BindImei != "123456789012345" {
t.Fatalf("unexpected response: %+v", result)
}
}
func TestDxImeiReadsNQ003Response(t *testing.T) {
for _, tc := range []struct {
body string
wantCode int
wantImei, wantMessage string
}{
{`{"RESULT":"0","MESSAGE":"查询成功","IMEI":"863644077218049"}`, 0, "863644077218049", "查询成功"},
{`{"RESULT":"S00004","MESSAGE":"没有查询权限"}`, 9999, "", "没有查询权限"},
} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/openapi/v1/prodinst/gete2einfo" || r.URL.Query().Get("iccid") != "8986000000000000007" {
t.Errorf("unexpected request: %s", r.URL.String())
}
_, _ = w.Write([]byte(tc.body))
}))
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
results, err := sim.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "8986000000000000007"}}})
server.Close()
if err != nil || len(results) != 1 || results[0].Code != tc.wantCode || results[0].ResData.Imei != tc.wantImei || results[0].Message != tc.wantMessage {
t.Errorf("response = %+v, error = %v", results, err)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,247 @@
package upstreamimpl
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"net/url"
"strconv"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
type dxAPNResponse[T any] struct {
Code json.RawMessage `json:"code"`
Message string `json:"message"`
Result *T `json:"result"`
}
type dxAPNInfo struct {
ApnInfoList []struct {
ApnName string `json:"apnName"`
Status *string `json:"status"`
ApnStatus *string `json:"apnStatus"`
} `json:"apnInfoList"`
}
func dxAPNParams(simKey *model.SimKey) url.Values {
params := url.Values{}
if simKey == nil {
return params
}
if value := strings.TrimSpace(simKey.MSISDN); value != "" {
params.Set("msisdn", value)
} else if value := strings.TrimSpace(simKey.ICCID); value != "" {
params.Set("iccid", value)
} else if value := strings.TrimSpace(simKey.IMSI); value != "" {
params.Set("imsi", value)
}
return params
}
func decodeDXAPNResponse[T any](body []byte) (*dxAPNResponse[T], int, string, error) {
var response dxAPNResponse[T]
if err := json.Unmarshal(body, &response); err != nil {
return nil, consts.ApiStatusError, "", gerror.Wrap(err, "电信 APN 响应格式错误")
}
rawCode := strings.TrimSpace(string(response.Code))
if strings.HasPrefix(rawCode, "\"") {
if err := json.Unmarshal(response.Code, &rawCode); err != nil {
return nil, consts.ApiStatusError, "", gerror.Wrap(err, "电信 APN 返回码格式错误")
}
}
code, err := strconv.Atoi(rawCode)
if err != nil || (code == 0 && rawCode != "0") {
return &response, consts.ApiStatusError, rawCode, gerror.Newf("电信 APN 返回码无效:%s,%s", rawCode, response.Message)
}
if code != 0 {
return &response, code, rawCode, nil
}
if response.Result == nil {
return &response, consts.ApiStatusError, "", gerror.New("电信 APN 成功响应缺少 result")
}
return &response, code, "", nil
}
func (s *sSimDX) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
items := make(model.SimApnInfoOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
item := &model.SimBaseRes[model.SimApnInfoItemList]{SimKey: simKey, ResData: model.SimApnInfoItemList{ApnList: []model.SimApnInfoItem{}}}
items = append(items, item)
params := dxAPNParams(simKey)
if len(params) == 0 {
item.Code, item.Message = consts.ApiStatusError, "电信 APN 查询缺少卡标识"
continue
}
body, err := s.client.DoGetRequest(ctx, "/api/v3/getApnInfo", params)
if err != nil {
item.Code, item.Message = consts.ApiStatusError, err.Error()
continue
}
response, code, rawCode, err := decodeDXAPNResponse[dxAPNInfo](body)
item.Code, item.ErrCode = code, rawCode
if err != nil {
item.Message = err.Error()
continue
}
item.Message = response.Message
if code != 0 {
continue
}
if response.Result.ApnInfoList == nil {
item.Code, item.Message = consts.ApiStatusError, "电信 APN 成功响应缺少 apnInfoList"
continue
}
for _, apn := range response.Result.ApnInfoList {
status := ""
if apn.Status != nil {
status = strings.TrimSpace(*apn.Status)
} else if apn.ApnStatus != nil {
status = strings.TrimSpace(*apn.ApnStatus)
}
if apn.Status != nil && apn.ApnStatus != nil && strings.TrimSpace(*apn.Status) != strings.TrimSpace(*apn.ApnStatus) {
status = ""
}
if status != consts.CardApnStatusNormal && status != consts.CardApnStatusPaused {
status = ""
}
item.ResData.ApnList = append(item.ResData.ApnList, model.SimApnInfoItem{ApnName: strings.TrimSpace(apn.ApnName), Status: status})
}
}
return items, nil
}
// dxCutNetSuccessCode 是 singleCutNet 响应中表示成功接收的 RspCode。
const dxCutNetSuccessCode = "0000"
// ChangeSimApnFunction 添加或取消单独断网
// CTIOT_5GCMP_BA004 - 添加或取消单独断网
// - URL: /5gcmp/openapi/v1/common/singleCutNet
// - 参数: access_number + action (ADD/DEL)
// - 响应: XML,RspCode=0000 表示成功接收,GROUP_TRANSACTIONID 为流水号
//
// 这是整卡数据开停语义(不是按 APN 定向):
// - 平台 0(开通数据) -> action=DEL(取消单独断网,恢复联网)
// - 平台 1(停用数据) -> action=ADD(单独断网)
//
// 电信侧没有「按 APN 定向」这一维度,因此忽略调用方携带的 ApnName
// (预警自动断网、批量数据开停都会固定带 apnName="CMIOT"),不能因此拒绝请求。
func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
checkpoint, durable := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
if !durable {
return s.changeSimApnFunctionCards(ctx, input)
}
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
one := *input
one.SimKeys = []*model.SimKey{key}
result, err := s.changeSimApnFunctionCards(ctx, &one)
if err != nil {
return nil, err
}
items = append(items, result...)
if err := checkpoint(items); err != nil {
return nil, fmt.Errorf("电信 v1 第%d张卡办理结果持久化失败,停止后续写入:%w", len(items), err)
}
}
return items, nil
}
func (s *sSimDX) changeSimApnFunctionCards(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
// 响应结构 (XML)
type dxCutNetResponse struct {
XMLName xml.Name `xml:"SvcCont"`
GroupTransactionID string `xml:"GROUP_TRANSACTIONID"`
Response struct {
RspType string `xml:"RspType"`
RspCode string `xml:"RspCode"`
RspDesc string `xml:"RspDesc"`
} `xml:"Response"`
}
action, ok := convertDxApnOperType(ctx, input.ReqData.OperType)
_, durable := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
if !ok {
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: "APN 操作类型必须为 0(开通)或 1(停止)",
ResData: model.SimCardOperateChangeApnItem{},
})
}
return items, nil
}
for _, simKey := range input.SimKeys {
item := &model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: simKey}
items = append(items, item)
// singleCutNet 只按物联网接入号定位,缺接入号无法定位卡。
accessNumber := strings.TrimSpace(simKey.MSISDN)
if accessNumber == "" {
item.Code, item.Message = consts.ApiStatusError, "电信单独断网缺少物联网接入号(MSISDN)"
continue
}
params := url.Values{
"access_number": []string{accessNumber},
"action": []string{action},
}
// singleCutNet 虽使用 GET,但为非幂等写请求,禁止运输层重放和重定向。
respBody, err := s.client.DoGetWriteRequest(ctx, "/5gcmp/openapi/v1/common/singleCutNet", params)
if err != nil {
g.Log().Errorf(ctx, "电信单独断网请求异常,办理结果未确认,chanKey=%s,错误类型=%T", simKey.ChanKey, err)
item.Code, item.Message = consts.ApiStatusError, err.Error()
item.ResData.Uncertain = true
continue
}
g.Log().Debugf(ctx, "ChangeSimApnFunction response: %s", string(respBody))
// 去除XML命名空间前缀
xmlBody := xmlNamespaceRegex.ReplaceAllString(string(respBody), "")
var dxResp dxCutNetResponse
if err = xml.Unmarshal([]byte(xmlBody), &dxResp); err != nil {
g.Log().Errorf(ctx, "ChangeSimApnFunction unmarshal error %+v, body %s", err, xmlBody)
item.Code, item.Message = consts.ApiStatusError, err.Error()
item.ResData.Uncertain = true
continue
}
item.Message = strings.TrimSpace(dxResp.Response.RspDesc)
rawCode := strings.TrimSpace(dxResp.Response.RspCode)
item.ErrCode = rawCode
// 缺返回码属于未确认;明确拒绝无需按未知结果重试。
if rawCode != dxCutNetSuccessCode {
g.Log().Warningf(ctx, "ChangeSimApnFunction upstream error %s, %s, simKey %s", rawCode, item.Message, simKey.ChanKey)
item.Code = consts.ApiStatusError
if rawCode == "" {
item.ResData.Uncertain = true
item.Message = "电信单独断网未返回结果码,办理结果未确认,请核对上游"
} else if item.Message == "" {
item.Message = "电信单独断网请求被上游拒绝"
}
continue
}
item.Code = consts.ApiStatusSuccess
item.ResData.OrderNum = strings.TrimSpace(dxResp.GroupTransactionID)
if item.ResData.OrderNum == "" {
item.Code, item.Message = consts.ApiStatusError, "电信单独断网未返回订单号,办理结果未确认,请核对上游"
item.ResData.Uncertain = true
} else if input.ReqData.SubmitOnly || durable {
item.ResData.Uncertain = true
item.Message = "电信单独断网请求已受理,最终办理结果未确认,请核对上游"
}
}
return items, nil
}
@@ -0,0 +1,333 @@
package upstreamimpl
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
func TestDXQueryAPNV3(t *testing.T) {
tests := []struct {
name string
body string
wantCode int
wantStatus string
wantCount int
wantErrCode string
}{
{name: "table status enabled", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","status":"1"}]}}`, wantStatus: "1", wantCount: 1},
{name: "example apnStatus stopped", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","apnStatus":"0"}]}}`, wantStatus: "0", wantCount: 1},
{name: "missing status stays unknown", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet"}]}}`, wantCount: 1},
{name: "conflicting aliases stay unknown", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","status":"1","apnStatus":"0"}]}}`, wantCount: 1},
{name: "invalid status stays unknown", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","status":"2"}]}}`, wantCount: 1},
{name: "empty list", body: `{"code":0,"result":{"apnInfoList":[]}}`},
{name: "missing code", body: `{"result":{"apnInfoList":[]}}`, wantCode: -1},
{name: "null code", body: `{"code":null,"result":{"apnInfoList":[]}}`, wantCode: -1, wantErrCode: "null"},
{name: "missing result", body: `{"code":"0"}`, wantCode: -1},
{name: "missing list", body: `{"code":"0","result":{}}`, wantCode: -1},
{name: "carrier failure", body: `{"code":"14008","message":"not found","result":null}`, wantCode: 14008, wantErrCode: "14008"},
{name: "symbolic carrier failure", body: `{"code":"S00004","message":"denied"}`, wantCode: -1, wantErrCode: "S00004"},
{name: "malformed", body: `<xml/>`, wantCode: -1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/api/v3/getApnInfo" || request.Method != http.MethodGet {
t.Errorf("unexpected request: %s %s", request.Method, request.URL.Path)
}
if request.URL.Query().Get("msisdn") != "13300000000" || request.URL.Query().Get("access_number") != "" {
t.Errorf("query = %s", request.URL.RawQuery)
}
if request.Header.Get("AppKey") != "test" || request.Header.Get("Sign") == "" || request.Header.Get("Timestamp") == "" {
t.Error("missing signed request headers")
}
fmt.Fprint(writer, test.body)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
output, err := sim.QuerySimApnInfo(context.Background(), &model.SimApnInfoInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}})
if err != nil || len(output) != 1 {
t.Fatalf("output = %+v, error = %v", output, err)
}
item := output[0]
if (test.wantCode == -1 && item.Code == 0) || (test.wantCode != -1 && item.Code != test.wantCode) || item.ErrCode != test.wantErrCode {
t.Fatalf("code = %d, raw = %q", item.Code, item.ErrCode)
}
if len(item.ResData.ApnList) != test.wantCount {
t.Fatalf("APNs = %+v", item.ResData.ApnList)
}
if test.wantCount != 0 && (item.ResData.ApnList[0].Status != test.wantStatus || item.ResData.ApnList[0].ApnName != "ctnet") {
t.Fatalf("APN = %+v", item.ResData.ApnList[0])
}
})
}
}
func TestDXAPNIdentifierFallback(t *testing.T) {
tests := []struct {
name string
key *model.SimKey
param string
value string
}{
{name: "iccid", key: &model.SimKey{ICCID: " 8986000000000000006 "}, param: "iccid", value: "8986000000000000006"},
{name: "imsi", key: &model.SimKey{IMSI: "460000000000001"}, param: "imsi", value: "460000000000001"},
{name: "nil", key: nil},
{name: "empty", key: &model.SimKey{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
if request.URL.Query().Get(test.param) != test.value {
t.Errorf("query = %s", request.URL.RawQuery)
}
fmt.Fprint(writer, `{"code":"0","result":{"apnInfoList":[]}}`)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
output, err := sim.QuerySimApnInfo(context.Background(), &model.SimApnInfoInput{SimKeys: []*model.SimKey{test.key}})
if err != nil || len(output) != 1 {
t.Fatalf("output = %+v, error = %v", output, err)
}
if test.param == "" {
if output[0].Code == 0 || requests != 0 {
t.Fatal("missing identifier must not request upstream")
}
} else if output[0].Code != 0 || requests != 1 {
t.Fatalf("code = %d, requests = %d", output[0].Code, requests)
}
})
}
}
// TestDXChangeAPNSingleCutNet 验证电信数据开停走 singleCutNet,且 ADD/DEL 方向与平台语义相反。
func TestDXChangeAPNSingleCutNet(t *testing.T) {
tests := []struct {
name string
operType string
wantAction string
}{
{name: "platform open resumes network", operType: "0", wantAction: "DEL"},
{name: "platform stop cuts network", operType: "1", wantAction: "ADD"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
if request.URL.Path != "/5gcmp/openapi/v1/common/singleCutNet" || request.Method != http.MethodGet {
t.Errorf("unexpected request: %s %s", request.Method, request.URL.Path)
}
query := request.URL.Query()
if query.Get("action") != test.wantAction {
t.Errorf("action = %q, want %q", query.Get("action"), test.wantAction)
}
if query.Get("access_number") != "13300000000" {
t.Errorf("access_number = %q", query.Get("access_number"))
}
// 不能携带 APN 定向参数,singleCutNet 是整卡语义。
if query.Get("apnName") != "" || query.Get("operType") != "" {
t.Errorf("APN-scoped params must not be sent: %s", request.URL.RawQuery)
}
if request.Header.Get("AppKey") != "test" || request.Header.Get("Sign") == "" || request.Header.Get("Timestamp") == "" {
t.Error("missing signed request headers")
}
fmt.Fprint(writer, `<?xml version="1.0" encoding="UTF-8"?><SvcCont><Response><RspType>0</RspType><RspCode>0000</RspCode><RspDesc>成功接收消息</RspDesc></Response><GROUP_TRANSACTIONID>order-1</GROUP_TRANSACTIONID></SvcCont>`)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
// 即使调用方传了移动默认的 apnName="CMIOT",也必须正常执行。
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{MSISDN: "13300000000", ChanKey: "71"}},
ReqData: model.SimCardOperateChangeApnInputItem{ApnName: "CMIOT", OperType: test.operType},
})
if err != nil || len(output) != 1 || output[0].Code != 0 || output[0].ResData.OrderNum != "order-1" || requests != 1 {
t.Fatalf("output = %+v, error = %v, requests = %d", output, err, requests)
}
})
}
}
func TestDXSingleCutNetAcceptanceModes(t *testing.T) {
for _, mode := range []string{"同步兼容", "仅提交", "持久化回调"} {
for _, accepted := range []bool{true, false} {
t.Run(fmt.Sprintf("%s/受理=%t", mode, accepted), func(t *testing.T) {
writes, checkpoints := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writes++
code := "1000"
if accepted {
code = "0000"
}
fmt.Fprintf(w, `<SvcCont><Response><RspCode>%s</RspCode><RspDesc>响应</RspDesc></Response><GROUP_TRANSACTIONID>order-%d</GROUP_TRANSACTIONID></SvcCont>`, code, writes)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
wantUncertain := accepted && mode != "同步兼容"
check := func(items model.SimCardOperateChangeApnOutput) {
t.Helper()
for i, item := range items {
if (item.Code == 0) != accepted || item.ResData.Uncertain != wantUncertain || item.ResData.Pending {
t.Fatalf("受理与完成分类错误:%+v", item)
}
if accepted && (item.ErrCode != "0000" || item.ResData.OrderNum != fmt.Sprintf("order-%d", i+1)) {
t.Fatalf("未保留逐卡受理流水与原始码:%+v", item)
}
if wantUncertain && !strings.Contains(item.Message, "最终办理结果未确认") {
t.Fatalf("受理消息未明确最终结果未知:%s", item.Message)
}
if !accepted && (item.ErrCode != "1000" || item.ResData.OrderNum != "") {
t.Fatalf("明确拒绝不应携带受理流水:%+v", item)
}
}
}
ctx := context.Background()
if mode == "持久化回调" {
ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error {
checkpoints++
check(items)
return nil
})
}
items, err := sim.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "card-1", MSISDN: "test-msisdn-1"}, {ChanKey: "test", ICCID: "card-2", MSISDN: "test-msisdn-2"}},
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: mode == "仅提交"},
})
if err != nil || len(items) != 2 || writes != 2 || (mode == "持久化回调" && checkpoints != 2) {
t.Fatalf("逐卡处理异常:items=%+v writes=%d checkpoints=%d err=%v", items, writes, checkpoints, err)
}
check(items)
})
}
}
}
func TestDXSingleCutNetRedirectDoesNotReplay(t *testing.T) {
for _, status := range []int{301, 302, 307, 308} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
writes, redirected := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/redirected" {
redirected++
return
}
writes++
w.Header().Set("Location", "/redirected")
w.WriteHeader(status)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{MSISDN: "13300000000"}},
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"},
})
if err != nil || len(output) != 1 {
t.Fatalf("output=%+v err=%v", output, err)
}
if writes != 1 || redirected != 0 || output[0].Code == 0 || !output[0].ResData.Uncertain {
t.Fatalf("writes=%d redirected=%d output=%+v", writes, redirected, output)
}
})
}
}
func TestDXSingleCutNetResponseLossIsUncertain(t *testing.T) {
writes := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writes++
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
_ = conn.Close()
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{MSISDN: "13300000000"}},
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"},
})
if err != nil || len(output) != 1 {
t.Fatalf("output=%+v err=%v", output, err)
}
if writes != 1 || output[0].Code == 0 || !output[0].ResData.Uncertain {
t.Fatalf("writes=%d output=%+v", writes, output)
}
}
func TestDXSingleCutNetDurableCheckpoint(t *testing.T) {
writes, checkpoints := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writes++
fmt.Fprint(w, `<?xml version="1.0"?><SvcCont><Response><RspCode>0000</RspCode></Response><GROUP_TRANSACTIONID>job-1</GROUP_TRANSACTIONID></SvcCont>`)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
ctx := context.WithValue(context.Background(), model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error {
checkpoints++
if len(items) != 1 || items[0].ResData.OrderNum != "job-1" {
t.Errorf("checkpoint must preserve the first card job ID: %+v", items)
}
return fmt.Errorf("stop after durable checkpoint")
})
_, err := sim.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{MSISDN: "13300000000"}, {MSISDN: "13300000001"}},
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"},
})
if err == nil || writes != 1 || checkpoints != 1 {
t.Fatalf("must stop before second write: writes=%d checkpoints=%d err=%v", writes, checkpoints, err)
}
}
// TestDXChangeAPNSingleCutNetFailClosed 验证失败保护:不成功不重试、不盲目视为成功。
func TestDXChangeAPNSingleCutNetFailClosed(t *testing.T) {
tests := []struct {
name string
operType string
msisdn string
body string
httpStatus int
wantRequests int
}{
{name: "missing access number", operType: "0"},
{name: "invalid operation", operType: "2", msisdn: "13300000000"},
{name: "carrier rejected", operType: "1", msisdn: "13300000000", body: `<?xml version="1.0"?><SvcCont><Response><RspCode>1000</RspCode><RspDesc>rejected</RspDesc></Response></SvcCont>`, wantRequests: 1},
{name: "missing result code", operType: "0", msisdn: "13300000000", body: `<?xml version="1.0"?><SvcCont><Response><RspDesc>ok</RspDesc></Response><GROUP_TRANSACTIONID>order</GROUP_TRANSACTIONID></SvcCont>`, wantRequests: 1},
{name: "missing order number", operType: "0", msisdn: "13300000000", body: `<?xml version="1.0"?><SvcCont><Response><RspCode>0000</RspCode><RspDesc>ok</RspDesc></Response></SvcCont>`, wantRequests: 1},
{name: "malformed response", operType: "0", msisdn: "13300000000", body: `{"code":"0"}`, wantRequests: 1},
{name: "HTTP failed no retry", operType: "0", msisdn: "13300000000", body: `gateway down`, httpStatus: 503, wantRequests: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
if request.URL.Path != "/5gcmp/openapi/v1/common/singleCutNet" {
t.Errorf("unexpected endpoint: %s", request.URL.Path)
}
if test.httpStatus != 0 {
writer.WriteHeader(test.httpStatus)
}
fmt.Fprint(writer, test.body)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{MSISDN: test.msisdn, ChanKey: "71"}},
ReqData: model.SimCardOperateChangeApnInputItem{ApnName: "CMIOT", OperType: test.operType},
})
if err != nil || len(output) != 1 || output[0].Code == 0 || output[0].ResData.OrderNum != "" || requests != test.wantRequests {
t.Fatalf("output = %+v, error = %v, requests = %d", output, err, requests)
}
})
}
}
@@ -0,0 +1,641 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
const (
dxBatchStatusLimit = 10000
dxBatchUsageLimit = 100
)
type telecomBatchKey struct {
index int
simKey *model.SimKey
accessNumber string
}
type dxBatchRequest struct {
AccessNumbers []string `json:"accessNumbers"`
CustNumber string `json:"custNumber"`
GroupID string `json:"groupId,omitempty"`
SimStatus string `json:"simStatus,omitempty"`
FlowDate string `json:"flowDate,omitempty"`
}
type dxBatchStatusResponse struct {
Code any `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"`
Data struct {
QrySimStatusList []dxBatchStatusItem `json:"qrySimStatusList"`
StatusList []dxBatchStatusItem `json:"statusList"`
} `json:"data"`
}
type dxBatchStatusItem struct {
AccessNumber string `json:"accessNumber"`
AccNum string `json:"accNum"`
Result any `json:"result"`
Iccid string `json:"iccid"`
Imsi string `json:"imsi"`
SimStatus any `json:"simStatus"`
}
type dxBatchFlowResponse struct {
Code any `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"`
Data []dxBatchFlowItem `json:"data"`
}
type dxBatchFlowItem struct {
AccessNumber string `json:"accessNumber"`
AccNum string `json:"accNum"`
Result any `json:"result"`
Unit string `json:"unit"`
FlowAmount any `json:"flowAmount"`
FlowDate string `json:"flowDate"`
FlowDay string `json:"flowDay"`
}
func parseDXCustNumber(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) string {
if channelConfig == nil || channelConfig.Channel == nil {
return ""
}
var cfg struct {
CustNumber string `json:"custNumber"`
}
if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &cfg); err != nil {
g.Log().Warningf(ctx, "parse telecom channel config failed, chanKey=%s err=%+v", channelConfig.Channel.ChanKey, err)
return ""
}
return strings.TrimSpace(cfg.CustNumber)
}
func parseDXBaseURL(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) string {
if channelConfig == nil || channelConfig.Channel == nil {
return ""
}
if channelConfig.Template != nil {
var template struct {
API struct {
BaseURL string `json:"baseUrl"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &template); err == nil && strings.TrimSpace(template.API.BaseURL) != "" {
return strings.TrimRight(strings.TrimSpace(template.API.BaseURL), "/")
}
}
var cfg struct {
BaseURL string `json:"baseURL"`
URL string `json:"url"`
}
if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &cfg); err != nil {
g.Log().Warningf(ctx, "parse telecom channel config failed, chanKey=%s err=%+v", channelConfig.Channel.ChanKey, err)
return ""
}
baseURL := strings.TrimSpace(firstNonEmpty(cfg.BaseURL, cfg.URL))
if baseURL == "" {
baseURL = strings.TrimSpace(g.Cfg().MustGet(ctx, "dependencies.sim_dx_v1.url", dx.DefaultBaseURL).String())
}
return strings.TrimRight(baseURL, "/")
}
func (s *sSimDX) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
return s.queryCardStatusBatch(ctx, input)
}
func (s *sSimDX) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
return s.queryDataUsageMonthlyBatch(ctx, input)
}
func (s *sSimDX) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
return s.queryDataUsageDailyBatch(ctx, input)
}
func (s *sSimDX) queryCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
if err := s.ensureDXBatchConfig(); err != nil {
return nil, err
}
if input == nil || len(input.SimKeys) == 0 {
return make(model.SimCardStatusOutput, 0), nil
}
results := make(model.SimCardStatusOutput, len(input.SimKeys))
pending := make([]telecomBatchKey, 0, len(input.SimKeys))
for i, simKey := range input.SimKeys {
if simKey == nil || strings.TrimSpace(simKey.MSISDN) == "" {
results[i] = &model.SimBaseRes[model.SimCardStatusItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: "MSISDN not found",
}
continue
}
pending = append(pending, telecomBatchKey{
index: i,
simKey: simKey,
accessNumber: strings.TrimSpace(simKey.MSISDN),
})
}
for _, chunk := range chunkTelecomBatchKeys(pending, dxBatchStatusLimit) {
accessNumbers := make([]string, 0, len(chunk))
for _, item := range chunk {
accessNumbers = append(accessNumbers, item.accessNumber)
}
resp, err := s.doDXBatchStatusPost(ctx, "/api/v1/prod/batchQrySimStatus", dxBatchRequest{
AccessNumbers: accessNumbers,
CustNumber: s.custNumber,
GroupID: "",
SimStatus: "",
})
if err != nil {
for _, item := range chunk {
results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{
SimKey: item.simKey,
Code: consts.ApiStatusError,
Message: err.Error(),
}
}
continue
}
batchItems := resp.Data.QrySimStatusList
if len(batchItems) == 0 {
batchItems = resp.Data.StatusList
}
itemMap := make(map[string]dxBatchStatusItem, len(batchItems))
for _, item := range batchItems {
accessNumber := telecomBatchAccessNumber(item.AccessNumber, item.AccNum)
if accessNumber == "" {
continue
}
itemMap[accessNumber] = item
}
for _, item := range chunk {
batchItem, ok := itemMap[item.accessNumber]
if !ok {
results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{
SimKey: item.simKey,
Code: consts.ApiStatusError,
Message: "batch response missing accessNumber",
}
continue
}
itemResult := gconv.String(batchItem.Result)
itemStatus := gconv.String(batchItem.SimStatus)
if itemResult != "1" {
results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{
SimKey: item.simKey,
Code: telecomBatchResultCode(itemResult),
Message: firstNonEmpty(resp.Message, resp.Msg, "telecom batch status query failed"),
ResData: model.SimCardStatusItem{
CardStatus: 0,
LastChangeDate: "",
},
}
continue
}
if itemStatus == "" {
results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{
SimKey: item.simKey,
Code: consts.ApiStatusError,
Message: "simStatus missing in batch response",
}
continue
}
results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{
SimKey: item.simKey,
Code: 0,
Message: "",
ResData: model.SimCardStatusItem{
CardStatus: convertDXBatchCardStatus(ctx, itemStatus),
LastChangeDate: "",
},
}
}
}
return compactStatusBatchResults(results), nil
}
func (s *sSimDX) queryDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
if err := s.ensureDXBatchConfig(); err != nil {
return nil, err
}
if input == nil || len(input.SimKeys) == 0 {
return make(model.SimDataUsageMonthlyOutput, 0), nil
}
queryMonth := strings.TrimSpace(input.ReqData.QueryDate)
if queryMonth == "" {
return nil, fmt.Errorf("queryDate is required")
}
if _, err := time.Parse("200601", queryMonth); err != nil {
return nil, fmt.Errorf("invalid queryDate: %s", queryMonth)
}
if !dxBatchMonthAvailable(queryMonth, time.Now()) {
return nil, fmt.Errorf("电信月批量接口仅支持当月;历史月份请使用 /sim/query/sim-datausage-monthly")
}
results := make(model.SimDataUsageMonthlyOutput, len(input.SimKeys))
pending := make([]telecomBatchKey, 0, len(input.SimKeys))
for i, simKey := range input.SimKeys {
if simKey == nil || strings.TrimSpace(simKey.MSISDN) == "" {
results[i] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: "MSISDN not found",
ResData: model.SimDataUsageMonthlyOutputItem{},
}
continue
}
pending = append(pending, telecomBatchKey{
index: i,
simKey: simKey,
accessNumber: strings.TrimSpace(simKey.MSISDN),
})
}
for _, chunk := range chunkTelecomBatchKeys(pending, dxBatchUsageLimit) {
accessNumbers := make([]string, 0, len(chunk))
for _, item := range chunk {
accessNumbers = append(accessNumbers, item.accessNumber)
}
resp, err := s.doDXBatchFlowPost(ctx, "/api/v1/batchQry/batchQryFlowByMonth", dxBatchRequest{
AccessNumbers: accessNumbers,
CustNumber: s.custNumber,
})
if err != nil {
for _, item := range chunk {
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
SimKey: item.simKey,
Code: consts.ApiStatusError,
Message: err.Error(),
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: queryMonth},
}
}
continue
}
itemMap := make(map[string]dxBatchFlowItem, len(resp.Data))
for _, item := range resp.Data {
accessNumber := telecomBatchAccessNumber(item.AccessNumber, item.AccNum)
if accessNumber == "" {
continue
}
itemMap[accessNumber] = item
}
for _, item := range chunk {
batchItem, ok := itemMap[item.accessNumber]
if !ok {
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
SimKey: item.simKey,
Code: consts.ApiStatusError,
Message: "batch response missing accessNumber",
ResData: model.SimDataUsageMonthlyOutputItem{},
}
continue
}
itemResult := gconv.String(batchItem.Result)
dataAmount, amountErr := parseDXBatchAmount(batchItem)
if itemResult == "1" && amountErr != nil {
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: amountErr.Error()}
continue
}
if itemResult != "1" {
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
SimKey: item.simKey,
Code: telecomBatchResultCode(itemResult),
Message: firstNonEmpty(resp.Message, resp.Msg, "telecom batch monthly usage query failed"),
ResData: model.SimDataUsageMonthlyOutputItem{
DeadLine: queryMonth,
DataAmount: dataAmount,
},
}
continue
}
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
SimKey: item.simKey,
Code: 0,
Message: "",
ResData: model.SimDataUsageMonthlyOutputItem{
DeadLine: queryMonth,
DataAmount: dataAmount,
},
}
}
}
return compactMonthlyBatchResults(results), nil
}
func (s *sSimDX) queryDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
if err := s.ensureDXBatchConfig(); err != nil {
return nil, err
}
if input == nil || len(input.SimKeys) == 0 {
return make(model.SimDataUsageDailyOutput, 0), nil
}
queryDate := strings.TrimSpace(input.ReqData.QueryDate)
if queryDate == "" {
return nil, fmt.Errorf("queryDate is required")
}
if _, err := time.Parse("20060102", queryDate); err != nil {
return nil, fmt.Errorf("invalid queryDate: %s", queryDate)
}
if !dxBatchDayAvailable(queryDate, time.Now()) {
return nil, fmt.Errorf("电信日批量接口仅支持最近7个已结束日期")
}
results := make(model.SimDataUsageDailyOutput, len(input.SimKeys))
pending := make([]telecomBatchKey, 0, len(input.SimKeys))
for i, simKey := range input.SimKeys {
if simKey == nil || strings.TrimSpace(simKey.MSISDN) == "" {
results[i] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: "MSISDN not found",
ResData: model.SimDataUsageDailyOutputItem{QueryDate: queryDate},
}
continue
}
pending = append(pending, telecomBatchKey{
index: i,
simKey: simKey,
accessNumber: strings.TrimSpace(simKey.MSISDN),
})
}
for _, chunk := range chunkTelecomBatchKeys(pending, dxBatchUsageLimit) {
accessNumbers := make([]string, 0, len(chunk))
for _, item := range chunk {
accessNumbers = append(accessNumbers, item.accessNumber)
}
resp, err := s.doDXBatchFlowPost(ctx, "/api/v1/batchQry/batchQryFlowByDate", dxBatchRequest{
AccessNumbers: accessNumbers,
CustNumber: s.custNumber,
FlowDate: queryDate,
})
if err != nil {
for _, item := range chunk {
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
SimKey: item.simKey,
Code: consts.ApiStatusError,
Message: err.Error(),
ResData: model.SimDataUsageDailyOutputItem{QueryDate: queryDate},
}
}
continue
}
itemMap := make(map[string]dxBatchFlowItem, len(resp.Data))
for _, item := range resp.Data {
accessNumber := telecomBatchAccessNumber(item.AccessNumber, item.AccNum)
if accessNumber == "" {
continue
}
itemMap[accessNumber] = item
}
for _, item := range chunk {
batchItem, ok := itemMap[item.accessNumber]
if !ok {
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
SimKey: item.simKey,
Code: consts.ApiStatusError,
Message: "batch response missing accessNumber",
ResData: model.SimDataUsageDailyOutputItem{QueryDate: queryDate},
}
continue
}
itemResult := gconv.String(batchItem.Result)
dataAmount, amountErr := parseDXBatchAmount(batchItem)
if itemResult == "1" && amountErr != nil {
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: amountErr.Error()}
continue
}
batchQueryDate := firstNonEmpty(strings.TrimSpace(batchItem.FlowDate), strings.TrimSpace(batchItem.FlowDay))
if batchQueryDate == "" {
batchQueryDate = queryDate
}
if batchQueryDate != queryDate {
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: "batch response date mismatched"}
continue
}
if itemResult != "1" {
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
SimKey: item.simKey,
Code: telecomBatchResultCode(itemResult),
Message: firstNonEmpty(resp.Message, resp.Msg, "telecom batch daily usage query failed"),
ResData: model.SimDataUsageDailyOutputItem{
QueryDate: batchQueryDate,
DataAmount: dataAmount,
},
}
continue
}
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
SimKey: item.simKey,
Code: 0,
Message: "",
ResData: model.SimDataUsageDailyOutputItem{
QueryDate: batchQueryDate,
DataAmount: dataAmount,
},
}
}
}
return compactDailyBatchResults(results), nil
}
func (s *sSimDX) ensureDXBatchConfig() error {
if strings.TrimSpace(s.custNumber) == "" {
return fmt.Errorf("telecom custNumber not configured")
}
return nil
}
var dxBatchLocation = time.FixedZone("UTC+8", 8*60*60)
func parseDXBatchAmount(item dxBatchFlowItem) (float64, error) {
amount, err := strconv.ParseFloat(strings.TrimSpace(gconv.String(item.FlowAmount)), 64)
if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
return 0, fmt.Errorf("invalid telecom batch flowAmount")
}
if unit := strings.ToUpper(strings.TrimSpace(item.Unit)); unit != "" && unit != "MB" {
return 0, fmt.Errorf("unexpected telecom batch usage unit: %s", unit)
}
return amount, nil
}
func dailyBatchOutputSuccessful(input *model.SimDataUsageDailyInput, output model.SimDataUsageDailyOutput) bool {
if input == nil || len(output) != len(input.SimKeys) {
return false
}
queryDate := strings.TrimSpace(input.ReqData.QueryDate)
for _, item := range output {
if item == nil || item.Code != 0 || item.ResData.QueryDate != queryDate {
return false
}
}
return true
}
func dxBatchDayAvailable(queryDate string, now time.Time) bool {
date, err := time.ParseInLocation("20060102", strings.TrimSpace(queryDate), dxBatchLocation)
if err != nil {
return false
}
now = now.In(dxBatchLocation)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, dxBatchLocation)
// CTIOT_5GCMP_UQ013 only accepts one completed day within the latest 7 days.
return !date.Before(today.AddDate(0, 0, -7)) && date.Before(today)
}
func dxBatchMonthAvailable(queryMonth string, now time.Time) bool {
month, err := time.ParseInLocation("200601", strings.TrimSpace(queryMonth), dxBatchLocation)
if err != nil {
return false
}
now = now.In(dxBatchLocation)
// CTIOT_5GCMP_UQ014 is explicitly a current-month batch endpoint.
return month.Year() == now.Year() && month.Month() == now.Month()
}
func (s *sSimDX) doDXBatchPost(ctx context.Context, endpoint string, req dxBatchRequest) ([]byte, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
return s.client.DoPostJSONRequest(ctx, endpoint, body)
}
func (s *sSimDX) doDXBatchStatusPost(ctx context.Context, endpoint string, req dxBatchRequest) (*dxBatchStatusResponse, error) {
respBody, err := s.doDXBatchPost(ctx, endpoint, req)
if err != nil {
return nil, err
}
var resp dxBatchStatusResponse
if err := json.Unmarshal(respBody, &resp); err != nil {
return nil, err
}
if gconv.String(resp.Code) != "0" {
return nil, fmt.Errorf("telecom batch status request failed: code=%s message=%s", gconv.String(resp.Code), firstNonEmpty(resp.Message, resp.Msg))
}
return &resp, nil
}
func (s *sSimDX) doDXBatchFlowPost(ctx context.Context, endpoint string, req dxBatchRequest) (*dxBatchFlowResponse, error) {
respBody, err := s.doDXBatchPost(ctx, endpoint, req)
if err != nil {
return nil, err
}
var resp dxBatchFlowResponse
if err := json.Unmarshal(respBody, &resp); err != nil {
return nil, err
}
if gconv.String(resp.Code) != "0" {
return nil, fmt.Errorf("telecom batch flow request failed: code=%s message=%s", gconv.String(resp.Code), firstNonEmpty(resp.Message, resp.Msg))
}
return &resp, nil
}
func chunkTelecomBatchKeys(items []telecomBatchKey, size int) [][]telecomBatchKey {
if size <= 0 {
size = 1
}
if len(items) == 0 {
return nil
}
chunks := make([][]telecomBatchKey, 0, (len(items)+size-1)/size)
for start := 0; start < len(items); start += size {
end := start + size
if end > len(items) {
end = len(items)
}
chunks = append(chunks, items[start:end])
}
return chunks
}
func telecomBatchAccessNumber(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func telecomBatchResultCode(result string) int {
result = strings.TrimSpace(result)
if result == "" {
return consts.ApiStatusError
}
return gconv.Int(result)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func compactStatusBatchResults(items model.SimCardStatusOutput) model.SimCardStatusOutput {
output := make(model.SimCardStatusOutput, 0, len(items))
for _, item := range items {
if item != nil {
output = append(output, item)
}
}
return output
}
func compactMonthlyBatchResults(items model.SimDataUsageMonthlyOutput) model.SimDataUsageMonthlyOutput {
output := make(model.SimDataUsageMonthlyOutput, 0, len(items))
for _, item := range items {
if item != nil {
output = append(output, item)
}
}
return output
}
func compactDailyBatchResults(items model.SimDataUsageDailyOutput) model.SimDataUsageDailyOutput {
output := make(model.SimDataUsageDailyOutput, 0, len(items))
for _, item := range items {
if item != nil {
output = append(output, item)
}
}
return output
}
@@ -0,0 +1,36 @@
package upstreamimpl
import (
"strings"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
func dxSessionAccessMode(netModel string, accessType string) string {
switch strings.TrimSpace(netModel) {
case "1":
return "4G"
case "2":
return "5G"
default:
return strings.TrimSpace(accessType)
}
}
func dxNetworkBlockStatus(name string, types []model.SimNetBlockType) *string {
var status string
switch strings.TrimSpace(name) {
case "未断网":
status = "0"
case "已断网":
status = "1"
default:
return nil
}
for _, item := range types {
blockType := strings.TrimSpace(item.BlockType)
if (status == "0" && blockType != "" && blockType != "0") || (status == "1" && blockType == "0") {
return nil
}
}
return &status
}
@@ -0,0 +1,68 @@
package upstreamimpl
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
func TestDXNetworkBlockStatus(t *testing.T) {
tests := []struct {
name string
types []model.SimNetBlockType
want string
}{
{name: "未断网", types: []model.SimNetBlockType{{BlockType: "0"}}, want: "0"},
{name: "已断网", types: []model.SimNetBlockType{{BlockType: "1", BlockTypeName: "达量断网"}}, want: "1"},
{name: "已断网", want: "1"},
{name: ""},
{name: "未知"},
{name: "未断网", types: []model.SimNetBlockType{{BlockType: "1"}}},
{name: "已断网", types: []model.SimNetBlockType{{BlockType: "0"}}},
}
for index, test := range tests {
got := dxNetworkBlockStatus(test.name, test.types)
if test.want == "" {
if got != nil {
t.Fatalf("case %d: status = %q", index, *got)
}
} else if got == nil || *got != test.want {
t.Fatalf("case %d: status = %v, want %s", index, got, test.want)
}
}
}
func TestDXCardStatusIncludesNetworkBlock(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/openapi/v1/prodinst/queryCardMainStatus" || request.URL.Query().Get("access_number") != "1440000000001" {
t.Errorf("request = %s", request.URL)
}
fmt.Fprint(writer, `{"result":"0","resultMsg":"处理成功","productInfo":[{"productMainStatusCd":"4"}],"netBlockStatusName":"已断网","netBlockTypeList":[{"blockType":"1","blockTypeName":"达量断网"}]}`)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "1440000000001"}}})
if err != nil || len(output) != 1 {
t.Fatalf("output = %v, err = %v", output, err)
}
item := output[0]
if item.Code != 0 || item.ResData.CardStatus != 2 || item.ResData.NetBlockStatus == nil || *item.ResData.NetBlockStatus != "1" || len(item.ResData.NetBlockTypes) != 1 || item.ResData.NetBlockTypes[0].BlockTypeName != "达量断网" {
t.Fatalf("item = %+v", item)
}
}
func TestDXSessionAccessMode(t *testing.T) {
if got := dxSessionAccessMode("1", ""); got != "4G" {
t.Fatalf("mode = %s", got)
}
if got := dxSessionAccessMode("2", ""); got != "5G" {
t.Fatalf("mode = %s", got)
}
if got := dxSessionAccessMode("", "NB-IOT"); got != "NB-IOT" {
t.Fatalf("mode = %s", got)
}
}
@@ -0,0 +1,136 @@
package upstreamimpl
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
func TestMapDxRegionLimitStatusByOrder(t *testing.T) {
tests := []struct {
name string
orderStatus string
limitStatus string
want string
}{
{name: "not ordered", orderStatus: "1", limitStatus: "0", want: "2"},
{name: "ordered and disconnected", orderStatus: "0", limitStatus: "0", want: "1"},
{name: "ordered and connected", orderStatus: "0", limitStatus: "1", want: "0"},
{name: "network unknown", orderStatus: "0", limitStatus: "-", want: "2"},
{name: "unexpected values", orderStatus: "", limitStatus: "", want: "2"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := mapDxRegionLimitStatusByOrder(test.orderStatus, test.limitStatus); got != test.want {
t.Fatalf("mapDxRegionLimitStatusByOrder(%q, %q) = %q, want %q", test.orderStatus, test.limitStatus, got, test.want)
}
})
}
}
func TestNormalizeDxRegionLimitDetails(t *testing.T) {
tests := []struct {
name string
orderStatus string
limitStatus string
limitType string
limitArea string
want []string
}{
{name: "not ordered hides details", orderStatus: "1", limitStatus: "0", limitType: "4", limitArea: "park", want: []string{"", "", ""}},
{name: "ordered preserves documented values", orderStatus: "0", limitStatus: "-", limitType: "4", limitArea: " park ", want: []string{"-", "4", "park"}},
{name: "ordered rejects undocumented values", orderStatus: "0", limitStatus: "9", limitType: "0", limitArea: "", want: []string{"", "", ""}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
limitStatus, limitType, limitArea := normalizeDxRegionLimitDetails(test.orderStatus, test.limitStatus, test.limitType, test.limitArea)
if got := []string{limitStatus, limitType, limitArea}; !reflect.DeepEqual(got, test.want) {
t.Fatalf("normalizeDxRegionLimitDetails() = %#v, want %#v", got, test.want)
}
})
}
}
func TestQuerySimRegionLimitStatusUsesDocumentedParameter(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/openapi/v1/areaLimit/qryAreaLimitInfo" {
t.Fatalf("path = %q", r.URL.Path)
}
if got := r.URL.Query().Get("accNum"); got != "13300000000" {
t.Fatalf("accNum = %q", got)
}
if got := r.URL.Query().Get("access_number"); got != "" {
t.Fatalf("access_number = %q, want empty", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"0","message":"ok","data":{"orderStatus":1,"limitType":0,"limitArea":"","limitStatus":"0"}}`))
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
output, err := sim.QuerySimRegionLimitStatus(context.Background(), &model.SimRegionLimitStatusInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}})
if err != nil {
t.Fatal(err)
}
if len(output) != 1 {
t.Fatalf("results = %d", len(output))
}
data := output[0].ResData
if data.RegionLimitStatus != "2" || data.OrderStatus != "1" || data.LimitStatus != "" || data.LimitType != "" || data.LimitArea != "" {
t.Fatalf("result = %+v", data)
}
}
func TestQuerySimCardStatusPreservesTelecomHTTPRespCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/openapi/v1/prodinst/queryCardMainStatus" {
t.Fatalf("path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(797)
_, _ = w.Write([]byte(`{"respCode":"S00004","respMsg":"request limit exceeded"}`))
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}})
if err != nil {
t.Fatal(err)
}
if len(output) != 1 {
t.Fatalf("results = %d", len(output))
}
result := output[0]
if result.Code != consts.ApiStatusError || result.ErrCode != "S00004" {
t.Fatalf("result = %+v, want gateway code with S00004", result)
}
}
func TestQuerySimCardStatusPreservesTelecomBusinessCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"result":"11011","resultMsg":"card does not exist"}`))
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}})
if err != nil {
t.Fatal(err)
}
if len(output) != 1 {
t.Fatalf("results = %d", len(output))
}
result := output[0]
if result.Code != 11011 || result.ErrCode != "11011" {
t.Fatalf("result = %+v, want business code 11011", result)
}
}
@@ -0,0 +1,205 @@
package upstreamimpl
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
func TestDXBatchDayAvailable(t *testing.T) {
now := time.Date(2026, 9, 20, 12, 0, 0, 0, dxBatchLocation)
tests := map[string]bool{
"20260913": true,
"20260919": true,
"20260912": false,
"20260920": false,
"20260921": false,
}
for queryDate, want := range tests {
if got := dxBatchDayAvailable(queryDate, now); got != want {
t.Fatalf("dxBatchDayAvailable(%q) = %v, want %v", queryDate, got, want)
}
}
}
func TestDXBatchMonthAvailable(t *testing.T) {
now := time.Date(2026, 9, 20, 12, 0, 0, 0, dxBatchLocation)
if !dxBatchMonthAvailable("202609", now) {
t.Fatal("current month should use telecom batch API")
}
if dxBatchMonthAvailable("202608", now) || dxBatchMonthAvailable("invalid", now) {
t.Fatal("historical or invalid month must not use telecom current-month batch API")
}
}
func TestQuerySimDataUsageMonthlyUsesBatchForCurrentMonth(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/api/v1/batchQry/batchQryFlowByMonth" {
t.Fatalf("path = %q", r.URL.Path)
}
var request dxBatchRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
if len(request.AccessNumbers) != 2 || request.CustNumber != "cust-1" {
t.Fatalf("request = %+v", request)
}
_, _ = w.Write([]byte(`{"code":0,"msg":"查询成功","data":[{"accessNumber":"13300000001","result":"1","unit":"MB","flowAmount":"12.5"},{"accessNumber":"13300000002","result":"1","unit":"MB","flowAmount":"7.5"}]}`))
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"}), custNumber: "cust-1"}
queryMonth := time.Now().In(dxBatchLocation).Format("200601")
output, err := sim.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{
SimKeys: []*model.SimKey{{MSISDN: "13300000001"}, {MSISDN: "13300000002"}},
ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: queryMonth},
})
if err != nil {
t.Fatal(err)
}
if calls.Load() != 1 || len(output) != 2 || output[0].ResData.DataAmount != 12.5 || output[1].ResData.DataAmount != 7.5 {
t.Fatalf("calls=%d output=%+v", calls.Load(), output)
}
}
func TestQuerySimDataUsageDailyLegacyFallbackIsBounded(t *testing.T) {
var active atomic.Int32
var maximum atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
current := active.Add(1)
defer active.Add(-1)
for {
previous := maximum.Load()
if current <= previous || maximum.CompareAndSwap(previous, current) {
break
}
}
time.Sleep(30 * time.Millisecond)
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<root><NEW_DATA_TICKET_QRsp><IRESULT>0</IRESULT><SMSG>OK</SMSG><TOTAL_BYTES_CNT>1MB</TOTAL_BYTES_CNT></NEW_DATA_TICKET_QRsp></root>`))
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret", Timeout: time.Second})}
keys := make([]*model.SimKey, 12)
for index := range keys {
keys[index] = &model.SimKey{MSISDN: "13300000000"}
}
output, err := sim.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{
SimKeys: keys,
ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20260101"},
})
if err != nil {
t.Fatal(err)
}
if len(output) != len(keys) {
t.Fatalf("results = %d, want %d", len(output), len(keys))
}
if maximum.Load() > dxLegacyUsageConcurrency || maximum.Load() < 2 {
t.Fatalf("maximum concurrency = %d, want 2..%d", maximum.Load(), dxLegacyUsageConcurrency)
}
}
func TestQuerySimDataUsageDailyRejectsMalformedXML(t *testing.T) {
cases := []struct {
name string
body string
}{
{name: "missing business node", body: `<root><ERROR>denied</ERROR></root>`},
{name: "missing result", body: `<root><NEW_DATA_TICKET_QRsp><TOTAL_BYTES_CNT>12MB</TOTAL_BYTES_CNT></NEW_DATA_TICKET_QRsp></root>`},
{name: "missing amount", body: `<root><NEW_DATA_TICKET_QRsp><IRESULT>0</IRESULT></NEW_DATA_TICKET_QRsp></root>`},
{name: "invalid amount", body: `<root><NEW_DATA_TICKET_QRsp><IRESULT>0</IRESULT><TOTAL_BYTES_CNT>invalidMB</TOTAL_BYTES_CNT></NEW_DATA_TICKET_QRsp></root>`},
{name: "non numeric result", body: `<root><NEW_DATA_TICKET_QRsp><IRESULT>ERROR</IRESULT></NEW_DATA_TICKET_QRsp></root>`},
}
for _, item := range cases {
t.Run(item.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/xml")
_, _ = writer.Write([]byte(item.body))
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "test"})}
output, err := sim.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{
SimKeys: []*model.SimKey{{ICCID: "audit-card", MSISDN: "13300000001"}},
ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20260101"},
})
if err != nil {
t.Fatal(err)
}
if len(output) != 1 || output[0].Code == 0 {
t.Fatalf("malformed response accepted: %+v", output)
}
})
}
}
func TestQuerySimDataUsageDailyBatchFailureFallsBackToLegacy(t *testing.T) {
var batchCalls atomic.Int32
var singleCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/api/v1/batchQry/batchQryFlowByDate" {
batchCalls.Add(1)
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"code":403,"msg":"denied"}`))
return
}
singleCalls.Add(1)
writer.Header().Set("Content-Type", "application/xml")
_, _ = writer.Write([]byte(`<root><NEW_DATA_TICKET_QRsp><IRESULT>0</IRESULT><TOTAL_BYTES_CNT>12MB</TOTAL_BYTES_CNT></NEW_DATA_TICKET_QRsp></root>`))
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "test"}), custNumber: "audit-customer"}
queryDate := time.Now().In(dxBatchLocation).AddDate(0, 0, -1).Format("20060102")
output, err := sim.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{
SimKeys: []*model.SimKey{{MSISDN: "13300000001"}},
ReqData: model.SimDataUsageDailyInputItem{QueryDate: queryDate},
})
if err != nil {
t.Fatal(err)
}
if batchCalls.Load() != 1 || singleCalls.Load() != 1 {
t.Fatalf("batch calls=%d, single calls=%d, want 1 each", batchCalls.Load(), singleCalls.Load())
}
if len(output) != 1 || output[0].Code != 0 || output[0].ResData.DataAmount != 12 {
t.Fatalf("unexpected fallback output: %+v", output)
}
}
func TestParseDXDataUsage(t *testing.T) {
cases := []struct {
value string
want float64
invalid bool
}{
{value: "0MB", want: 0},
{value: "12.5MB", want: 12.5},
{value: " 1 gb ", want: 1024},
{value: "1024KB", want: 1},
{value: "12", want: 12},
{value: "", invalid: true},
{value: "invalidMB", invalid: true},
{value: "-1MB", invalid: true},
{value: "NaNMB", invalid: true},
{value: "InfMB", invalid: true},
{value: "1e308GB", invalid: true},
}
for _, item := range cases {
t.Run(item.value, func(t *testing.T) {
amount, err := parseDXDataUsage(item.value)
if (err != nil) != item.invalid || (!item.invalid && amount != item.want) {
t.Fatalf("parseDXDataUsage(%q) = %v, %v, want %v, invalid=%v", item.value, amount, err, item.want, item.invalid)
}
})
}
}
@@ -0,0 +1,597 @@
package upstreamimpl
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/url"
"strconv"
"strings"
"sync"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
"github.com/gogf/gf/v2/frame/g"
)
type sSimDXV3 struct {
client *dx.Client
custNbr string
enableOperations bool
}
func init() {
service.UpstreamAPI().RegisterTemp("sim_dx_v3", &sSimDXV3{})
}
func (s *sSimDXV3) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
if cfg == nil || cfg.Channel == nil || cfg.Template == nil {
return nil
}
var template struct {
API struct {
BaseURL string `json:"baseUrl"`
EnableOperations bool `json:"enableOperations"`
} `json:"api"`
}
var channel struct {
CustNbr string `json:"custNbr"`
CustNumber string `json:"custNumber"`
}
channelConfig := strings.TrimSpace(cfg.Channel.ChanConfig)
if channelConfig == "" {
channelConfig = "{}"
}
if json.Unmarshal([]byte(cfg.Template.TempConfig), &template) != nil || json.Unmarshal([]byte(channelConfig), &channel) != nil {
g.Log().Error(ctx, "invalid telecom v3 JSON configuration")
return nil
}
baseURL := strings.TrimRight(strings.TrimSpace(template.API.BaseURL), "/")
if baseURL == "" {
baseURL = dx.DefaultBaseURL
}
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.RawQuery != "" || u.Fragment != "" || u.User != nil || (u.Path != "" && u.Path != "/") {
g.Log().Error(ctx, "telecom v3 baseUrl must be a gateway origin")
return nil
}
if strings.TrimSpace(cfg.Channel.AppKey) == "" || strings.TrimSpace(cfg.Channel.AppSecret) == "" {
g.Log().Error(ctx, "telecom v3 credentials are incomplete")
return nil
}
return &sSimDXV3{
client: dx.NewClient(dx.Config{BaseURL: baseURL, AppKey: cfg.Channel.AppKey, AppSecret: cfg.Channel.AppSecret,
Timeout: resolveUpstreamHTTPTimeout(ctx, cfg.Template.TempConfig), UppercaseSign: true}),
custNbr: firstNonEmpty(channel.CustNbr, channel.CustNumber), enableOperations: template.API.EnableOperations,
}
}
type dxV3Error struct{ code, message string }
func (e *dxV3Error) Error() string { return e.message }
func (s *sSimDXV3) request(ctx context.Context, path string, params url.Values, body any) (map[string]any, error) {
var data []byte
var err error
if body == nil {
data, err = s.client.DoGetRequest(ctx, "/api/v3/"+path, params)
} else {
data, err = json.Marshal(body)
if err == nil {
if path == "singleCutNet" {
data, err = s.client.DoPostJSONWriteRequest(ctx, "/api/v3/"+path, data)
} else {
data, err = s.client.DoPostJSONRequest(ctx, "/api/v3/"+path, data)
}
}
}
if err != nil {
return nil, err
}
var envelope struct {
Code json.RawMessage `json:"code"`
Message string `json:"message"`
Result json.RawMessage `json:"result"`
}
if err = json.Unmarshal(data, &envelope); err != nil {
return nil, fmt.Errorf("invalid telecom v3 JSON response: %w", err)
}
code := dxV3RawCode(envelope.Code)
if code == "" {
return nil, fmt.Errorf("telecom v3 response is missing code")
}
if code != "0" {
return nil, &dxV3Error{code: code, message: firstNonEmpty(envelope.Message, "telecom v3 business error")}
}
var result map[string]any
decoder := json.NewDecoder(bytes.NewReader(envelope.Result))
decoder.UseNumber()
if err = decoder.Decode(&result); err != nil {
return nil, fmt.Errorf("invalid telecom v3 result: %w", err)
}
if result == nil {
return nil, fmt.Errorf("telecom v3 response has no result")
}
return result, nil
}
func dxV3RawCode(raw json.RawMessage) string {
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" {
return ""
}
var str string
if json.Unmarshal(raw, &str) == nil {
return strings.TrimSpace(str)
}
var number json.Number
if json.Unmarshal(raw, &number) == nil {
return number.String()
}
return ""
}
func dxV3String(item map[string]any, name string) string {
switch value := item[name].(type) {
case string:
return strings.TrimSpace(value)
case json.Number:
return value.String()
default:
return ""
}
}
func dxV3Params(key *model.SimKey) (url.Values, error) {
if key != nil {
for _, value := range []struct{ name, value string }{{"msisdn", key.MSISDN}, {"iccid", key.ICCID}, {"imsi", key.IMSI}} {
if strings.TrimSpace(value.value) != "" {
return url.Values{value.name: {strings.TrimSpace(value.value)}}, nil
}
}
}
return nil, fmt.Errorf("missing msisdn/iccid/imsi")
}
func dxV3Failure[T any](key *model.SimKey, err error) *model.SimBaseRes[T] {
item := &model.SimBaseRes[T]{SimKey: key, Code: consts.ApiStatusError, Message: err.Error()}
var business *dxV3Error
var httpError *dx.HTTPStatusError
if errors.As(err, &business) {
item.ErrCode = business.code
if code, parseErr := strconv.Atoi(business.code); parseErr == nil && code != 0 {
item.Code = code
}
} else if errors.As(err, &httpError) {
item.ErrCode = httpError.RespCode
}
return item
}
// dxV3LegacyUsageConcurrency 是 v3 逐卡回退查询的并发度,与 v1 保持一致,
// 避免多卡查询退化成串行 N 次请求。
const dxV3LegacyUsageConcurrency = 5
func dxV3QueryOne[T any](ctx context.Context, s *sSimDXV3, key *model.SimKey, path string,
prepare func(url.Values) error, convert func(map[string]any) (T, error), post bool) *model.SimBaseRes[T] {
params, err := dxV3Params(key)
if err == nil && prepare != nil {
err = prepare(params)
}
var result map[string]any
apnWriteStarted := err == nil && post && path == "singleCutNet"
if err == nil {
var body any
if post {
values := make(map[string]string, len(params))
for name := range params {
values[name] = params.Get(name)
}
body = values
}
result, err = s.request(ctx, path, params, body)
}
if err == nil {
for _, identity := range []struct{ name, value string }{{"msisdn", key.MSISDN}, {"iccid", key.ICCID}, {"imsi", key.IMSI}} {
if returned := dxV3String(result, identity.name); returned != "" && identity.value != "" && returned != strings.TrimSpace(identity.value) {
err = fmt.Errorf("telecom v3 returned a mismatched card identifier")
break
}
}
}
var output T
if err == nil {
output, err = convert(result)
}
if err != nil {
item := dxV3Failure[T](key, err)
var business *dxV3Error
if apnWriteStarted && !errors.As(err, &business) {
if apn, ok := any(&item.ResData).(*model.SimCardOperateChangeApnItem); ok {
apn.Uncertain = true
}
}
return item
}
return &model.SimBaseRes[T]{SimKey: key, ResData: output}
}
func dxV3Query[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path string,
prepare func(url.Values) error, convert func(map[string]any) (T, error), post bool) []*model.SimBaseRes[T] {
items := make([]*model.SimBaseRes[T], 0, len(keys))
for _, key := range keys {
items = append(items, dxV3QueryOne(ctx, s, key, path, prepare, convert, post))
}
return items
}
// dxV3QueryConcurrent 与 dxV3Query 结果完全一致(保持输入顺序),只是并行发出单卡请求。
func dxV3QueryConcurrent[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path string,
prepare func(url.Values) error, convert func(map[string]any) (T, error), post bool) []*model.SimBaseRes[T] {
results := make([]*model.SimBaseRes[T], len(keys))
if len(keys) == 0 {
return results
}
jobs := make(chan int)
var group sync.WaitGroup
workers := min(dxV3LegacyUsageConcurrency, len(keys))
for worker := 0; worker < workers; worker++ {
group.Add(1)
go func() {
defer group.Done()
for index := range jobs {
results[index] = dxV3QueryOne(ctx, s, keys[index], path, prepare, convert, post)
}
}()
}
for index := range keys {
jobs <- index
}
close(jobs)
group.Wait()
return results
}
// dxV3BatchThenFill 让批量接口只承担"加速",正确性以单卡查询为基准:
// 先按批量取一轮结果,再把其中失败或缺失的卡用单卡并发查询补齐,保留批量成功的项。
// 这样批量级故障(鉴权、报文、网关错误)或某张卡被批量接口遗漏时,不会整批/逐卡报废;
// 批量整体不可用(参数/窗口/报文异常)时直接退化为逐卡并发,不丢卡、不错位。
// 本方法只用于查询类接口(幂等),重复一次调用是安全的。
func dxV3BatchThenFill[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path string,
prepare func(url.Values) error, convert func(map[string]any) (T, error),
batch func() ([]*model.SimBaseRes[T], error)) []*model.SimBaseRes[T] {
results, err := batch()
if err != nil || len(results) != len(keys) {
return dxV3QueryConcurrent(ctx, s, keys, path, prepare, convert, false)
}
retryKeys := make([]*model.SimKey, 0, len(keys))
retryIndex := make([]int, 0, len(keys))
for index, item := range results {
if item == nil || item.Code != consts.ApiStatusSuccess {
retryKeys = append(retryKeys, keys[index])
retryIndex = append(retryIndex, index)
}
}
if len(retryKeys) == 0 {
return results
}
filled := dxV3QueryConcurrent(ctx, s, retryKeys, path, prepare, convert, false)
for offset, index := range retryIndex {
results[index] = filled[offset]
}
return results
}
func dxV3BasicInfo(item map[string]any) (model.SimBasicInfoItem, error) {
output := model.SimBasicInfoItem{ICCID: dxV3String(item, "iccid"), IMSI: dxV3String(item, "imsi"), MSISDN: dxV3String(item, "msisdn"),
OpenDate: dxV3String(item, "createTime"), ActiveDate: dxV3String(item, "activeTime"), SimStatus: dxV3String(item, "simStatus"),
MainOfferName: firstNonEmpty(dxV3String(item, "mainPackageName"), dxV3String(item, "prodName")), CardBindingType: dxV3String(item, "cardBindingType"),
IsDirectVisit: dxV3String(item, "isDirectVisit"), IsHumanLimit: dxV3String(item, "isHumanLimit"), VpdnGreDoamin: dxV3String(item, "vpdnGreDoamin"),
VpdnL2tpDoamin: dxV3String(item, "vpdnL2tpDoamin"), AreaLimitType: dxV3String(item, "areaLimitType"), AreaLimitInfo: dxV3String(item, "areaLimitInfo")}
if output.ICCID == "" && output.MSISDN == "" && output.IMSI == "" {
return output, fmt.Errorf("telecom v3 basic info is missing card identifiers")
}
output.StopTypeList = normalizeDXStringList(item["stopTypeList"])
return output, nil
}
func (s *sSimDXV3) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
return dxV3Query(ctx, s, input.SimKeys, "getSimInfo", nil, dxV3BasicInfo, false), nil
}
func dxV3Status(item map[string]any) (model.SimCardStatusItem, error) {
status := dxV3String(item, "simStatus")
switch status {
case "1", "2", "3", "4", "5", "6", "7":
default:
return model.SimCardStatusItem{}, fmt.Errorf("invalid telecom v3 simStatus: %s", status)
}
return model.SimCardStatusItem{CardStatus: convertDxCardStatus(context.Background(), status), LastChangeDate: dxV3String(item, "lastChangeTime")}, nil
}
func (s *sSimDXV3) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
return dxV3Query(ctx, s, input.SimKeys, "getSimStatus", nil, dxV3Status, false), nil
}
func (s *sSimDXV3) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) {
return dxV3Query(ctx, s, input.SimKeys, "getOnlineStatus", nil, func(item map[string]any) (model.SimSessionItem, error) {
status := consts.CardSessionStatusUnknown
switch dxV3String(item, "onlineStatus") {
case "1":
status = consts.CardSessionStatusOnline
case "2":
status = consts.CardSessionStatusOffline
}
return model.SimSessionItem{Status: status, ApnId: dxV3String(item, "apnName"), Imei: dxV3String(item, "imei"), Rat: dxV3String(item, "ratType"),
Ipv4: dxV3String(item, "ipv4Address"), Ipv6: dxV3String(item, "ipv6Address"), Ipv6Prefix: dxV3String(item, "ipv6PrefixLen"),
CreateTime: firstNonEmpty(dxV3String(item, "startTime"), dxV3String(item, "eventTime"))}, nil
}, false), nil
}
func (s *sSimDXV3) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) {
return dxV3Query(ctx, s, input.SimKeys, "getImei", nil, func(item map[string]any) (model.SimImeiItem, error) {
return model.SimImeiItem{Imei: dxV3String(item, "imei")}, nil
}, false), nil
}
func dxV3Amount(item map[string]any) (float64, error) {
value := firstNonEmpty(dxV3String(item, "dataAmount"), dxV3String(item, "totalUsageAmount"))
amount, err := strconv.ParseFloat(value, 64)
if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
return 0, fmt.Errorf("invalid telecom v3 dataAmount")
}
if unit := strings.ToUpper(dxV3String(item, "unit")); unit != "" && unit != "MB" {
return 0, fmt.Errorf("unexpected telecom v3 usage unit: %s", unit)
}
return amount, nil
}
func (s *sSimDXV3) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) {
return dxV3Query(ctx, s, input.SimKeys, "getDataUsage", nil, func(item map[string]any) (model.SimDataUsageItem, error) {
amount, err := dxV3Amount(item)
return model.SimDataUsageItem{DataUsage: amount}, err
}, false), nil
}
func (s *sSimDXV3) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
prepare := func(params url.Values) error {
if _, err := time.Parse("200601", input.ReqData.QueryDate); err != nil {
return fmt.Errorf("invalid queryDate: expected yyyyMM")
}
params.Set("month", input.ReqData.QueryDate)
return nil
}
convert := func(item map[string]any) (model.SimDataUsageMonthlyOutputItem, error) {
amount, err := dxV3Amount(item)
return model.SimDataUsageMonthlyOutputItem{DataAmount: amount}, err
}
if s.monthlyBatchEligible(input) {
return dxV3BatchThenFill(ctx, s, input.SimKeys, "getDataUsageByMonth", prepare, convert,
func() (model.SimDataUsageMonthlyOutput, error) { return s.QuerySimDataUsageMonthlyBatch(ctx, input) }), nil
}
if len(input.SimKeys) > 1 {
return dxV3QueryConcurrent(ctx, s, input.SimKeys, "getDataUsageByMonth", prepare, convert, false), nil
}
return dxV3Query(ctx, s, input.SimKeys, "getDataUsageByMonth", prepare, convert, false), nil
}
// monthlyBatchEligible 判断多卡月流量查询能否升级为批量接口:需要配置 custNbr、每张卡都有
// MSISDN,且查询月份为当月。与 v1 一致:v1 的自动批量同样只在当月生效,历史月份继续逐卡查询。
// 任一不满足则回退逐卡(并发)查询。
func (s *sSimDXV3) monthlyBatchEligible(input *model.SimDataUsageMonthlyInput) bool {
if input == nil || len(input.SimKeys) < 2 || strings.TrimSpace(s.custNbr) == "" {
return false
}
if !dxBatchMonthAvailable(input.ReqData.QueryDate, time.Now()) {
return false
}
for _, key := range input.SimKeys {
if key == nil || strings.TrimSpace(key.MSISDN) == "" {
return false
}
}
return true
}
func (s *sSimDXV3) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
prepare := func(params url.Values) error {
day, err := time.ParseInLocation("20060102", input.ReqData.QueryDate, dxBatchLocation)
now := time.Now().In(dxBatchLocation)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, dxBatchLocation)
if err != nil || !day.Before(today) {
return fmt.Errorf("queryDate must be a completed day in yyyyMMdd format")
}
params.Set("beginDate", input.ReqData.QueryDate)
params.Set("endDate", input.ReqData.QueryDate)
return nil
}
convert := func(item map[string]any) (model.SimDataUsageDailyOutputItem, error) {
amount, err := dxV3Amount(item)
return model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate, DataAmount: amount}, err
}
if s.dailyBatchEligible(input) {
return dxV3BatchThenFill(ctx, s, input.SimKeys, "getDataUsageBetweenDate", prepare, convert,
func() (model.SimDataUsageDailyOutput, error) { return s.QuerySimDataUsageDailyBatch(ctx, input) }), nil
}
if len(input.SimKeys) > 1 {
return dxV3QueryConcurrent(ctx, s, input.SimKeys, "getDataUsageBetweenDate", prepare, convert, false), nil
}
return dxV3Query(ctx, s, input.SimKeys, "getDataUsageBetweenDate", prepare, convert, false), nil
}
// dailyBatchEligible 与 monthlyBatchEligible 同构:批量仅在已结束且落在最近七天窗口内的日期可用。
func (s *sSimDXV3) dailyBatchEligible(input *model.SimDataUsageDailyInput) bool {
if input == nil || len(input.SimKeys) < 2 || strings.TrimSpace(s.custNbr) == "" {
return false
}
if !dxBatchDayAvailable(input.ReqData.QueryDate, time.Now()) {
return false
}
for _, key := range input.SimKeys {
if key == nil || strings.TrimSpace(key.MSISDN) == "" {
return false
}
}
return true
}
func (s *sSimDXV3) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
return dxV3Query(ctx, s, input.SimKeys, "getSimInfo", nil, func(item map[string]any) (model.SimStopReasonItem, error) {
reason := strings.Join(trimNonEmptyStrings([]string{dxV3String(item, "stopStatusName"), dxV3String(item, "stopTypeName")}), "; ")
if reason == "" {
reason = mapDxStopTypeListText(normalizeDXStringList(item["stopTypeList"]))
}
return model.SimStopReasonItem{StopReason: reason}, nil
}, false), nil
}
func (s *sSimDXV3) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
return dxV3Query(ctx, s, input.SimKeys, "getApnInfo", nil, func(item map[string]any) (model.SimApnInfoItemList, error) {
list, ok := item["apnInfoList"].([]any)
if !ok {
return model.SimApnInfoItemList{}, fmt.Errorf("telecom v3 response is missing apnInfoList")
}
output := model.SimApnInfoItemList{ApnList: make([]model.SimApnInfoItem, 0, len(list))}
for _, entry := range list {
apn, ok := entry.(map[string]any)
if !ok {
return output, fmt.Errorf("invalid telecom v3 APN entry")
}
output.ApnList = append(output.ApnList, model.SimApnInfoItem{ApnName: dxV3String(apn, "apnName"), Status: dxV3String(apn, "status")})
}
return output, nil
}, false), nil
}
func (s *sSimDXV3) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
return dxV3Query(ctx, s, input.SimKeys, "getSimBindingInfo", nil, func(item map[string]any) (model.SimCardBindStatusItem, error) {
return model.SimCardBindStatusItem{BindStatus: dxV3String(item, "simSeparationStatus"), CardBindingType: dxV3String(item, "bindType"),
OrderStatus: dxV3String(item, "isOrdered"), BindImei: dxV3String(item, "bindImei"), LastImei: dxV3String(item, "lastImei")}, nil
}, false), nil
}
// QuerySimDeviceStatus 查询终端开关机状态(电信口径为“仅 3G”)。
//
// 注意方法必须是 GET:官方文档把 /api/v3/getDevicePowerStatus 写成 POST,但网关实际只接受
// GET(实测 POST 返回 799 S10010“请求方法不匹配”,GET 返回 200)。同一网关下 v1 的
// /api/v1/prod/getDevicePowerStatus 反而只接受 POST,两者不可互推。
//
// 响应有两种形态,需兼容键名:
// - 有记录:{"status":"0","statusName":"开机"}(文档口径,小写)
// - 无记录:{"Result":"-1","resultName":"无终端开关机信息"}(大写 R,仅 3G 接口对 4G/5G 卡常见)
func (s *sSimDXV3) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
return dxV3Query(ctx, s, input.SimKeys, "getDevicePowerStatus", nil, func(item map[string]any) (model.SimDeviceStatusItem, error) {
status := firstNonEmpty(dxV3String(item, "status"), dxV3String(item, "Status"), dxV3String(item, "Result"))
return model.SimDeviceStatusItem{DeviceStatus: consts.CardSessionStatusUnknown, OperatingStatus: mapDxOperatingStatus(status)}, nil
}, false), nil
}
func (s *sSimDXV3) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
return dxV3Query(ctx, s, input.SimKeys, "getSimRegionLimitStatus", nil, func(item map[string]any) (model.SimRegionLimitStatusItem, error) {
order, limit := dxV3String(item, "orderStatus"), dxV3String(item, "regionLimitStatus")
return model.SimRegionLimitStatusItem{RegionLimitStatus: mapDxRegionLimitStatusByOrder(order, limit), OrderStatus: order, LimitStatus: limit, LimitType: dxV3String(item, "limitType"), LimitArea: dxV3String(item, "restrictedArea")}, nil
}, false), nil
}
// QuerySimFrozenState 电信没有独立的冻结状态查询接口。与 v1 一致,返回成功项并附说明文案,
// 避免诊断页与通道自检把它当成上游错误(v1 返回 code=0 + “不支持查询”)。
func (s *sSimDXV3) QuerySimFrozenState(_ context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
output := make(model.SimFrozenStateOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
output = append(output, &model.SimBaseRes[model.SimFrozenStateItem]{
SimKey: key,
Code: consts.ApiStatusSuccess,
ResData: model.SimFrozenStateItem{
FrozenStatus: "不支持查询",
FrozenReason: "电信当前无独立冻结查询接口",
},
})
}
return output, nil
}
// QuerySimDataDiagnosis 电信不提供达量限速档位查询。沿用 v1 的约定返回 ApiInvokeException,
// 让上层按“暂不支持”降级为跳过(诊断页 skipped)而不是报错。
func (s *sSimDXV3) QuerySimDataDiagnosis(_ context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
output := make(model.SimDataDiagnosisOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
output = append(output, &model.SimBaseRes[any]{
SimKey: key,
Code: consts.ApiInvokeException,
Message: "电信官方接口暂不提供当前达量限速查询能力",
})
}
return output, nil
}
func dxV3Order(item map[string]any) (model.SimCardStatusChangeItem, error) {
order := dxV3String(item, "transactionId")
if order == "" {
return model.SimCardStatusChangeItem{}, fmt.Errorf("telecom v3 operation returned no transactionId")
}
return model.SimCardStatusChangeItem{OrderNum: order}, nil
}
func (s *sSimDXV3) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) {
return dxV3Query(ctx, s, input.SimKeys, "updateSimStatus", func(params url.Values) error {
if !s.enableOperations {
return fmt.Errorf("telecom v3 operations are disabled")
}
oper, ok := convertDxCardStatusChangeOperType(ctx, input.ReqData.OperType)
if !ok {
return fmt.Errorf("unsupported status operType")
}
params.Set("operType", oper)
return nil
}, dxV3Order, false), nil
}
func (s *sSimDXV3) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
prepare := func(params url.Values) error {
if !s.enableOperations {
return fmt.Errorf("telecom v3 operations are disabled")
}
// singleCutNet 是整卡数据开停语义,电信侧没有“按 APN 定向”这一维度。
// 与 v1 保持一致:忽略调用方携带的 ApnName(预警自动断网、批量数据开停都会固定带
// apnName="CMIOT"),不能因此拒绝请求。
if params.Get("msisdn") == "" {
return fmt.Errorf("singleCutNet requires msisdn")
}
switch strings.TrimSpace(input.ReqData.OperType) {
case "0":
params.Set("operType", "1")
case "1":
params.Set("operType", "2")
default:
return fmt.Errorf("unsupported data operType")
}
return nil
}
convert := func(item map[string]any) (model.SimCardOperateChangeApnItem, error) {
order, err := dxV3Order(item)
return model.SimCardOperateChangeApnItem{OrderNum: order.OrderNum}, err
}
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
checkpoint, durable := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
for _, key := range input.SimKeys {
item := dxV3QueryOne(ctx, s, key, "singleCutNet", prepare, convert, true)
if item.Code == consts.ApiStatusSuccess && (input.ReqData.SubmitOnly || durable) {
item.ResData.Uncertain = true
item.Message = "电信单独断网请求已受理,最终办理结果未确认,请核对上游"
}
items = append(items, item)
if durable {
if err := checkpoint(items); err != nil {
return nil, fmt.Errorf("电信 v3 第%d张卡办理结果持久化失败,停止后续写入:%w", len(items), err)
}
}
}
return items, nil
}
var _ model.SimCardChannelI = (*sSimDXV3)(nil)
@@ -0,0 +1,148 @@
package upstreamimpl
import (
"context"
"fmt"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
const dxV3BatchLimit = 100
// dxV3BatchMonthAvailable 电信 v3 月批量接口仅支持最近六个含当月的月份。
func dxV3BatchMonthAvailable(month time.Time) bool {
now := time.Now().In(dxBatchLocation)
current := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, dxBatchLocation)
return !month.Before(current.AddDate(0, -5, 0)) && !month.After(current)
}
func dxV3Batch[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path, listName, dateName, date string,
convert func(map[string]any) (T, error)) []*model.SimBaseRes[T] {
output := make([]*model.SimBaseRes[T], len(keys))
pending := make([]int, 0, len(keys))
for index, key := range keys {
if key == nil || strings.TrimSpace(key.MSISDN) == "" {
output[index] = dxV3Failure[T](key, fmt.Errorf("telecom v3 batch requires msisdn"))
continue
}
pending = append(pending, index)
}
for offset := 0; offset < len(pending); offset += dxV3BatchLimit {
end := offset + dxV3BatchLimit
if end > len(pending) {
end = len(pending)
}
chunk := pending[offset:end]
msisdns := make([]string, 0, len(chunk))
for _, index := range chunk {
msisdns = append(msisdns, strings.TrimSpace(keys[index].MSISDN))
}
body := map[string]any{"msisdnList": msisdns, "custNbr": s.custNbr}
if dateName != "" {
body[dateName] = date
}
var err error
var result map[string]any
if s.custNbr == "" {
err = fmt.Errorf("telecom v3 custNbr not configured")
} else {
result, err = s.request(ctx, path, nil, body)
}
byNumber := make(map[string]map[string]any)
if err == nil {
list, ok := result[listName].([]any)
if !ok {
err = fmt.Errorf("telecom v3 result is missing %s", listName)
} else {
for _, entry := range list {
item, ok := entry.(map[string]any)
if !ok {
err = fmt.Errorf("invalid telecom v3 batch entry")
break
}
number := dxV3String(item, "msisdn")
if number == "" {
err = fmt.Errorf("telecom v3 batch entry is missing msisdn")
break
}
if _, duplicate := byNumber[number]; duplicate {
err = fmt.Errorf("duplicate telecom v3 batch result for msisdn")
break
}
byNumber[number] = item
}
}
}
for _, index := range chunk {
key := keys[index]
itemErr := err
item := byNumber[strings.TrimSpace(key.MSISDN)]
if itemErr == nil && item == nil {
itemErr = fmt.Errorf("telecom v3 batch omitted requested card")
}
if itemErr == nil {
code := dxV3String(item, "result")
if code == "" {
itemErr = fmt.Errorf("telecom v3 batch entry is missing result")
} else if code != "0" {
itemErr = &dxV3Error{code: code, message: firstNonEmpty(dxV3String(item, "resultDesc"), "telecom v3 card query failed")}
}
}
var value T
if itemErr == nil && dateName != "" && dxV3String(item, dateName) != date {
itemErr = fmt.Errorf("telecom v3 batch returned a different query period")
}
if itemErr == nil {
value, itemErr = convert(item)
}
if itemErr != nil {
output[index] = dxV3Failure[T](key, itemErr)
} else {
output[index] = &model.SimBaseRes[T]{SimKey: key, ResData: value}
}
}
}
return output
}
func (s *sSimDXV3) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
if input == nil {
return model.SimCardStatusOutput{}, nil
}
return dxV3Batch(ctx, s, input.SimKeys, "getSimStatusBatch", "simStatusList", "", "", dxV3Status), nil
}
func (s *sSimDXV3) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
if input == nil {
return model.SimDataUsageMonthlyOutput{}, nil
}
month, err := time.ParseInLocation("200601", input.ReqData.QueryDate, dxBatchLocation)
if err != nil {
return nil, fmt.Errorf("invalid queryDate: expected yyyyMM")
}
if !dxV3BatchMonthAvailable(month) {
return nil, fmt.Errorf("telecom v3 monthly batch supports the latest six months")
}
return dxV3Batch(ctx, s, input.SimKeys, "getDataUsageByMonthBatch", "batchTrafficList", "month", input.ReqData.QueryDate,
func(item map[string]any) (model.SimDataUsageMonthlyOutputItem, error) {
amount, err := dxV3Amount(item)
return model.SimDataUsageMonthlyOutputItem{DataAmount: amount}, err
}), nil
}
func (s *sSimDXV3) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
if input == nil {
return model.SimDataUsageDailyOutput{}, nil
}
if !dxBatchDayAvailable(input.ReqData.QueryDate, time.Now()) {
return nil, fmt.Errorf("telecom v3 daily batch requires a completed day within the latest seven days")
}
return dxV3Batch(ctx, s, input.SimKeys, "getDataUsageByDayBatch", "batchTrafficList", "date", input.ReqData.QueryDate,
func(item map[string]any) (model.SimDataUsageDailyOutputItem, error) {
amount, err := dxV3Amount(item)
return model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate, DataAmount: amount}, err
}), nil
}
var _ model.SimCardBatchQueryI = (*sSimDXV3)(nil)
@@ -0,0 +1,630 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
func newDXV3TestClient(t *testing.T, handler http.HandlerFunc) *sSimDXV3 {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return &sSimDXV3{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test-key", AppSecret: "test-secret", UppercaseSign: true}), custNbr: "test-customer"}
}
func TestDXV3APNWriteUncertainAndNoReplay(t *testing.T) {
for _, tc := range []struct {
name, body string
drop, uncertain bool
redirect int
}{
{name: "断响应", drop: true, uncertain: true},
{name: "坏JSON", body: "{", uncertain: true},
{name: "缺result", body: `{"code":0}`, uncertain: true},
{name: "卡号不符", body: `{"code":0,"result":{"msisdn":"other","transactionId":"job"}}`, uncertain: true},
{name: "缺transactionId", body: `{"code":0,"result":{}}`, uncertain: true},
{name: "明确拒绝", body: `{"code":9999,"message":"拒绝"}`},
{name: "明确成功", body: `{"code":0,"result":{"transactionId":"job"}}`},
{name: "跳转307", redirect: 307, uncertain: true},
{name: "跳转308", redirect: 308, uncertain: true},
} {
t.Run(tc.name, func(t *testing.T) {
var writes, targetWrites atomic.Int32
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("办理方法被改变:%s", r.Method)
}
writes.Add(1)
if r.URL.Path == "/redirect-target" {
targetWrites.Add(1)
fmt.Fprint(w, `{"code":0,"result":{"transactionId":"unexpected"}}`)
return
}
if r.URL.Path != "/api/v3/singleCutNet" {
t.Errorf("错误办理路径:%s", r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["msisdn"] != "fake-msisdn" || body["operType"] != "1" {
t.Errorf("办理请求不符:%+v %v", body, err)
}
if tc.drop {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
_ = conn.Close()
return
}
if tc.redirect != 0 {
w.Header().Set("Location", "/redirect-target")
w.WriteHeader(tc.redirect)
return
}
fmt.Fprint(w, tc.body)
})
client.enableOperations = true
out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{MSISDN: "fake-msisdn"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0"}})
if err != nil || len(out) != 1 || writes.Load() != 1 || targetWrites.Load() != 0 || out[0].ResData.Uncertain != tc.uncertain || out[0].ResData.Pending {
t.Fatalf("写后分类/重放错误:out=%+v writes=%d target=%d err=%v", out, writes.Load(), targetWrites.Load(), err)
}
if (tc.name == "明确成功") != (out[0].Code == 0) {
t.Fatalf("成功失败分类错误:%+v", out[0])
}
t.Logf("writes=%d targetWrites=%d uncertain=%v code=%d", writes.Load(), targetWrites.Load(), out[0].ResData.Uncertain, out[0].Code)
})
}
}
func TestDXV3SingleCutNetAcceptanceModes(t *testing.T) {
for _, mode := range []string{"同步兼容", "仅提交", "持久化回调"} {
for _, accepted := range []bool{true, false} {
t.Run(fmt.Sprintf("%s/受理=%t", mode, accepted), func(t *testing.T) {
writes, checkpoints := 0, 0
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
writes++
if !accepted {
fmt.Fprint(w, `{"code":9999,"message":"明确拒绝"}`)
return
}
fmt.Fprintf(w, `{"code":0,"result":{"transactionId":"order-%d"}}`, writes)
})
client.enableOperations = true
wantUncertain := accepted && mode != "同步兼容"
check := func(items model.SimCardOperateChangeApnOutput) {
t.Helper()
for i, item := range items {
if (item.Code == 0) != accepted || item.ResData.Uncertain != wantUncertain || item.ResData.Pending {
t.Fatalf("受理与完成分类错误:%+v", item)
}
if accepted && item.ResData.OrderNum != fmt.Sprintf("order-%d", i+1) {
t.Fatalf("未保留逐卡受理流水:%+v", item)
}
if wantUncertain && !strings.Contains(item.Message, "最终办理结果未确认") {
t.Fatalf("受理消息未明确最终结果未知:%s", item.Message)
}
if !accepted && (item.ErrCode != "9999" || item.ResData.OrderNum != "") {
t.Fatalf("明确拒绝不应携带受理流水:%+v", item)
}
}
}
ctx := context.Background()
if mode == "持久化回调" {
ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error {
checkpoints++
check(items)
return nil
})
}
items, err := client.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "card-1", MSISDN: "test-msisdn-1"}, {ChanKey: "test", ICCID: "card-2", MSISDN: "test-msisdn-2"}},
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: mode == "仅提交"},
})
if err != nil || len(items) != 2 || writes != 2 || (mode == "持久化回调" && checkpoints != 2) {
t.Fatalf("逐卡处理异常:items=%+v writes=%d checkpoints=%d err=%v", items, writes, checkpoints, err)
}
check(items)
})
}
}
}
func TestDXV3APNPrewriteValidationSendsNothing(t *testing.T) {
for _, tc := range []struct {
name string
enabled bool
key *model.SimKey
oper string
}{
{"禁用办理", false, &model.SimKey{MSISDN: "fake"}, "0"},
{"缺卡号", true, nil, "0"},
{"缺msisdn", true, &model.SimKey{ICCID: "fake"}, "0"},
{"错误操作", true, &model.SimKey{MSISDN: "fake"}, "invalid"},
} {
t.Run(tc.name, func(t *testing.T) {
var writes atomic.Int32
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { writes.Add(1) })
client.enableOperations = tc.enabled
out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{tc.key}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: tc.oper}})
if err != nil || len(out) != 1 || writes.Load() != 0 || out[0].Code == 0 || out[0].ResData.Uncertain {
t.Fatalf("预写拒绝错误:out=%+v writes=%d err=%v", out, writes.Load(), err)
}
})
}
}
func TestDXV3ReadRedirectUnchanged(t *testing.T) {
var calls atomic.Int32
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.Method != http.MethodGet {
t.Errorf("查询方法改变:%s", r.Method)
}
if r.URL.Path != "/read-target" {
w.Header().Set("Location", "/read-target")
w.WriteHeader(http.StatusTemporaryRedirect)
return
}
fmt.Fprint(w, `{"code":0,"result":{"dataAmount":"2"}}`)
})
out, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{MSISDN: "fake"}}})
if err != nil || len(out) != 1 || out[0].Code != 0 || calls.Load() != 2 {
t.Fatalf("查询跳转被改变:%+v %v calls=%d", out, err, calls.Load())
}
}
func TestDXV3TemplateCoexistsWithV1(t *testing.T) {
for _, key := range []string{"sim_dx_v1", "sim_dx_v3"} {
if _, ok := service.UpstreamAPI().GetTemp(key); !ok {
t.Fatalf("missing template %s", key)
}
}
factory := &sSimDXV3{}
config := &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{AppKey: "key", AppSecret: "secret", ChanConfig: `{"custNumber":"customer"}`}, Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"baseUrl":"https://cmp-api.ctwing.cn:20164"}}`}}
instance, ok := factory.NewTemp(context.Background(), config).(*sSimDXV3)
if !ok || instance.enableOperations || instance.custNbr != "customer" {
t.Fatalf("unexpected v3 configuration: %+v", instance)
}
config.Channel.ChanConfig = ""
if factory.NewTemp(context.Background(), config) == nil {
t.Fatal("single-card configuration should not require custNbr")
}
config.Template.TempConfig = `{"api":{"baseUrl":"https://cmp-api.ctwing.cn:20164/api/v3"}}`
if factory.NewTemp(context.Background(), config) != nil {
t.Fatal("accepted base URL containing protocol path")
}
}
func TestDXV3SessionAndRealTimeUsageContracts(t *testing.T) {
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("msisdn") != "106123" || len(r.URL.Query()) != 1 {
t.Errorf("unexpected query: %s", r.URL.RawQuery)
}
want, _ := dx.BuildSignV3(r.URL.RawQuery, "", "test-secret", r.Header.Get("Timestamp"))
if r.Header.Get("Sign") != want || r.Header.Get("AppKey") != "test-key" {
t.Error("unexpected signed request")
}
switch r.URL.Path {
case "/api/v3/getOnlineStatus":
fmt.Fprint(w, `{"code":"0","result":{"onlineStatus":"1","apnName":"iot.apn","ipv4Address":"127.0.0.1"}}`)
case "/api/v3/getDataUsage":
fmt.Fprint(w, `{"code":0,"result":{"dataAmount":"12.5"}}`)
default:
t.Errorf("unexpected path: %s", r.URL.Path)
}
})
keys := []*model.SimKey{{MSISDN: "106123", ICCID: "8986", IMSI: "46011"}}
session, err := client.QuerySimSession(context.Background(), &model.SimSessionInput{SimKeys: keys})
if err != nil || len(session) != 1 || session[0].Code != 0 || session[0].ResData.Status != consts.CardSessionStatusOnline || session[0].ResData.ApnId != "iot.apn" {
t.Fatalf("session: %+v, %v", session, err)
}
usage, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: keys})
if err != nil || usage[0].Code != 0 || usage[0].ResData.DataUsage != 12.5 {
t.Fatalf("usage: %+v, %v", usage, err)
}
}
func TestDXV3ErrorsNeverBecomeZeroUsage(t *testing.T) {
for _, test := range []struct {
name, body, code string
httpStatus int
}{
{"missing code", `{"result":{"dataAmount":"0"}}`, "", 200},
{"null code", `{"code":null,"result":{"dataAmount":"0"}}`, "", 200},
{"mismatched card", `{"code":"0","result":{"msisdn":"2","dataAmount":"5"}}`, "", 200},
{"invalid amount", `{"code":"0","result":{"dataAmount":"garbage"}}`, "", 200},
{"missing result", `{"code":"0","result":null}`, "", 200},
{"business failure", `{"code":"S00004","message":"limited"}`, "S00004", 200},
{"gateway failure", `{}`, "S00004", 799},
} {
t.Run(test.name, func(t *testing.T) {
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
if test.httpStatus == 799 {
w.Header().Set("gw-errorCode", "S00004")
}
w.WriteHeader(test.httpStatus)
fmt.Fprint(w, test.body)
})
items, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}})
if err != nil || items[0].Code == 0 || items[0].ErrCode != test.code {
t.Fatalf("unexpected error result: %+v, %v", items[0], err)
}
})
}
}
func TestDXV3HistoryDayAndDiagnosticMappings(t *testing.T) {
day := time.Now().In(dxBatchLocation).AddDate(0, 0, -1).Format("20060102")
calls := 0
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
switch r.URL.Path {
case "/api/v3/getDataUsageBetweenDate":
if r.URL.Query().Get("beginDate") != day || r.URL.Query().Get("endDate") != day {
t.Error("history day must use the same beginning and ending date")
}
fmt.Fprint(w, `{"code":"0","result":{"totalUsageAmount":"4.5"}}`)
case "/api/v3/getApnInfo":
fmt.Fprint(w, `{"code":"0","result":{"apnInfoList":[{"apnName":"iot","status":"1"}]}}`)
case "/api/v3/getSimBindingInfo":
fmt.Fprint(w, `{"code":"0","result":{"isOrdered":"0","bindType":"2","simSeparationStatus":"0","bindImei":"123"}}`)
case "/api/v3/getSimRegionLimitStatus":
fmt.Fprint(w, `{"code":"0","result":{"orderStatus":0,"limitType":3,"regionLimitStatus":"0","restrictedArea":"110000"}}`)
case "/api/v3/getDevicePowerStatus":
// 网关实测只接受 GET(文档写 POST 与网关不符),参数走 query。
if r.Method != "GET" || r.URL.Query().Get("msisdn") != "1" {
t.Errorf("invalid power-status request: %s %s", r.Method, r.URL.RawQuery)
}
fmt.Fprint(w, `{"code":"0","result":{"status":"0"}}`)
default:
t.Errorf("unexpected diagnostic path: %s", r.URL.Path)
}
})
ctx := context.Background()
keys := []*model.SimKey{{MSISDN: "1"}}
usage, err := client.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: day}})
if err != nil || usage[0].Code != 0 || usage[0].ResData.QueryDate != day || usage[0].ResData.DataAmount != 4.5 {
t.Fatalf("history day: %+v, %v", usage, err)
}
today := time.Now().In(dxBatchLocation).Format("20060102")
usage, _ = client.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: today}})
if usage[0].Code == 0 || calls != 1 {
t.Fatal("unfinished day reached the historical endpoint")
}
apns, _ := client.QuerySimApnInfo(ctx, &model.SimApnInfoInput{SimKeys: keys})
if apns[0].Code != 0 || len(apns[0].ResData.ApnList) != 1 || apns[0].ResData.ApnList[0].Status != "1" {
t.Fatalf("APN mapping: %+v", apns)
}
bind, _ := client.QuerySimCardBindStatus(ctx, &model.SimCardBindStatusInput{SimKeys: keys})
if bind[0].Code != 0 || bind[0].ResData.BindStatus != "0" || bind[0].ResData.OrderStatus != "0" || bind[0].ResData.CardBindingType != "2" || bind[0].ResData.BindImei != "123" {
t.Fatalf("binding mapping: %+v", bind)
}
region, _ := client.QuerySimRegionLimitStatus(ctx, &model.SimRegionLimitStatusInput{SimKeys: keys})
if region[0].Code != 0 || region[0].ResData.RegionLimitStatus != "1" || region[0].ResData.LimitType != "3" || region[0].ResData.LimitArea != "110000" {
t.Fatalf("region mapping: %+v", region)
}
power, _ := client.QuerySimDeviceStatus(ctx, &model.SimDeviceStatusInput{SimKeys: keys})
if power[0].Code != 0 || power[0].ResData.OperatingStatus != "1" || power[0].ResData.DeviceStatus != consts.CardSessionStatusUnknown {
t.Fatalf("power mapping: %+v", power)
}
}
// TestDXV3DevicePowerStatusSupportsEmptyRecordShape 锁定该接口的真实返回形态:
// 仅 3G 接口对 4G/5G 卡返回 {"Result":"-1","resultName":"无终端开关机信息"}(值是 Result,
// 大写 R),应映射为“未知”而不是报错;同时确认请求走 GET(文档写 POST 与网关不符)。
func TestDXV3DevicePowerStatusSupportsEmptyRecordShape(t *testing.T) {
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Errorf("device power status must be sent with GET, got %s", r.Method)
}
fmt.Fprint(w, `{"code":"0","result":{"Result":"-1","resultName":"无终端开关机信息"}}`)
})
items, err := client.QuerySimDeviceStatus(context.Background(), &model.SimDeviceStatusInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}})
if err != nil || items[0].Code != 0 || items[0].ResData.OperatingStatus != consts.CardSessionStatusUnknown {
t.Fatalf("empty power record must map to unknown: %+v, %v", items, err)
}
}
func TestDXV3BasicInfoAllowsDocumentedPartialIdentifiers(t *testing.T) {
for _, body := range []string{
`{"code":"0","result":{"msisdn":"1","createTime":"2023-03-16 11:07:50","activeTime":"2023-03-16 11:07:50","prodName":"test-product"}}`,
`{"code":"0","result":{"msisdn":"1","iccid":"8986","imsi":"46011","prodName":"test-product"}}`,
} {
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) })
items, err := client.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}})
if err != nil || items[0].Code != 0 || items[0].ResData.MSISDN != "1" || items[0].ResData.MainOfferName != "test-product" {
t.Fatalf("basic info: %+v, %v", items, err)
}
}
if _, err := dxV3BasicInfo(map[string]any{}); err == nil {
t.Fatal("accepted basic info with no identifiers")
}
}
func TestDXV3StatusBatchChunksAndMatchesCards(t *testing.T) {
calls := 0
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Method != "POST" || r.URL.Path != "/api/v3/getSimStatusBatch" {
t.Errorf("unexpected batch request")
}
body, _ := io.ReadAll(r.Body)
want, _ := dx.BuildSignV3("", string(body), "test-secret", r.Header.Get("Timestamp"))
if r.Header.Get("Sign") != want {
t.Error("body differs from signed body")
}
var request struct {
Numbers []string `json:"msisdnList"`
Customer string `json:"custNbr"`
}
if err := json.Unmarshal(body, &request); err != nil {
t.Fatal(err)
}
if len(request.Numbers) > 100 || request.Customer != "test-customer" {
t.Fatalf("invalid batch: %+v", request)
}
list := make([]map[string]string, 0, len(request.Numbers))
for i := len(request.Numbers) - 1; i >= 0; i-- {
number := request.Numbers[i]
if number == "7" {
continue
}
item := map[string]string{"msisdn": number, "result": "0", "simStatus": "4"}
if number == "8" {
item["result"] = "-1"
}
list = append(list, item)
}
json.NewEncoder(w).Encode(map[string]any{"code": "0", "result": map[string]any{"simStatusList": list}})
})
keys := make([]*model.SimKey, 101)
for i := range keys {
keys[i] = &model.SimKey{MSISDN: fmt.Sprint(i)}
}
items, err := client.QuerySimCardStatusBatch(context.Background(), &model.SimCardStatusInput{SimKeys: keys})
if err != nil || len(items) != 101 || calls != 2 {
t.Fatalf("batch size/calls: %d/%d err=%v", len(items), calls, err)
}
for i, item := range items {
if item.SimKey != keys[i] {
t.Fatal("card identity/order changed")
}
if i == 7 || i == 8 {
if item.Code == 0 {
t.Fatalf("partial failure %d was successful", i)
}
} else if item.Code != 0 || item.ResData.CardStatus != 2 {
t.Fatalf("card %d: %+v", i, item)
}
}
}
func TestDXV3BatchUsageChecksPeriodAndItemCode(t *testing.T) {
month := time.Now().In(dxBatchLocation).Format("200601")
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"},{"msisdn":"2","result":"1","month":%q,"dataAmount":"9"},{"msisdn":"3","result":"0","month":"200001","dataAmount":"9"}]}}`, month, month)
})
items, err := client.QuerySimDataUsageMonthlyBatch(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}, {MSISDN: "3"}}, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}})
if err != nil || items[0].Code != 0 || items[0].ResData.DataAmount != 2.5 || items[1].Code == 0 || items[2].Code == 0 {
t.Fatalf("unexpected usage batch: %+v, %v", items, err)
}
}
func TestDXV3OperationsDefaultOffAndUseNewSemantics(t *testing.T) {
calls := 0
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
switch r.URL.Path {
case "/api/v3/singleCutNet":
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if r.Method != "POST" || body["operType"] != "2" || body["msisdn"] != "1" {
t.Errorf("invalid cut-net request: %+v", body)
}
case "/api/v3/updateSimStatus":
if r.URL.Query().Get("operType") != "19" {
t.Error("wrong status operation")
}
default:
t.Error("unexpected operation path")
}
fmt.Fprint(w, `{"code":"0","result":{"transactionId":"order-1"}}`)
})
status := &model.SimCardStatusChangeInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}}
items, _ := client.ChangeSimStatus(context.Background(), status)
if items[0].Code == 0 || calls != 0 {
t.Fatal("disabled operation sent a request")
}
client.enableOperations = true
items, _ = client.ChangeSimStatus(context.Background(), status)
if items[0].Code != 0 || items[0].ResData.OrderNum != "order-1" {
t.Fatalf("status: %+v", items[0])
}
data, _ := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: status.SimKeys, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"}})
if data[0].Code != 0 || calls != 2 {
t.Fatal("new cut-net operation did not succeed")
}
if _, err := dxV3Params(nil); err == nil {
t.Fatal("nil key accepted")
}
if !strings.Contains(items[0].ResData.OrderNum, "order") {
t.Fatal("missing operation reference")
}
}
func TestDXV3CutNetIgnoresApnNameLikeV1(t *testing.T) {
calls := 0
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/api/v3/singleCutNet" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["operType"] != "2" || body["msisdn"] != "1" {
t.Errorf("invalid cut-net request: %+v", body)
}
if _, ok := body["apnName"]; ok {
t.Errorf("ApnName must not be forwarded: %+v", body)
}
fmt.Fprint(w, `{"code":"0","result":{"transactionId":"order-9"}}`)
})
client.enableOperations = true
items, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{MSISDN: "1"}},
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", ApnName: "CMIOT"},
})
if err != nil || calls != 1 || len(items) != 1 || items[0].Code != 0 || items[0].ResData.OrderNum != "order-9" {
t.Fatalf("named APN must be ignored, not rejected: %+v, %v", items, err)
}
}
func TestDXV3UnsupportedQueriesDegradeLikeV1(t *testing.T) {
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unsupported queries must not reach the upstream: %s", r.URL.Path)
})
keys := []*model.SimKey{{MSISDN: "1"}}
frozen, err := client.QuerySimFrozenState(context.Background(), &model.SimFrozenStateInput{SimKeys: keys})
if err != nil || len(frozen) != 1 || frozen[0].Code != consts.ApiStatusSuccess || frozen[0].ResData.FrozenStatus != "不支持查询" {
t.Fatalf("frozen state should degrade gracefully: %+v, %v", frozen, err)
}
diagnosis, err := client.QuerySimDataDiagnosis(context.Background(), &model.SimDataDiagnosisInput{SimKeys: keys})
if err != nil || len(diagnosis) != 1 || diagnosis[0].Code != consts.ApiInvokeException || diagnosis[0].Message == "" {
t.Fatalf("data diagnosis should report as unsupported: %+v, %v", diagnosis, err)
}
}
func TestDXV3MultiCardUsageUpgradesToBatch(t *testing.T) {
month := time.Now().In(dxBatchLocation).Format("200601")
day := time.Now().In(dxBatchLocation).AddDate(0, 0, -1).Format("20060102")
paths := make([]string, 0, 4)
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
switch r.URL.Path {
case "/api/v3/getDataUsageByMonthBatch":
fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"},{"msisdn":"2","result":"0","month":%q,"unit":"MB","dataAmount":"3.5"}]}}`, month, month)
case "/api/v3/getDataUsageByDayBatch":
fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","date":%q,"unit":"MB","dataAmount":"1.5"},{"msisdn":"2","result":"0","date":%q,"unit":"MB","dataAmount":"2.5"}]}}`, day, day)
default:
fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"1","unit":"MB"}}`)
}
})
keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}}
monthly, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}})
if err != nil || len(monthly) != 2 || monthly[0].ResData.DataAmount != 2.5 || monthly[1].ResData.DataAmount != 3.5 {
t.Fatalf("monthly upgrade: %+v, %v", monthly, err)
}
if len(paths) != 1 || paths[0] != "/api/v3/getDataUsageByMonthBatch" {
t.Fatalf("monthly query should collapse into one batch call, got %v", paths)
}
paths = paths[:0]
daily, err := client.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: day}})
if err != nil || len(daily) != 2 || daily[0].ResData.DataAmount != 1.5 {
t.Fatalf("daily upgrade: %+v, %v", daily, err)
}
if len(paths) != 1 || paths[0] != "/api/v3/getDataUsageByDayBatch" {
t.Fatalf("daily query should collapse into one batch call, got %v", paths)
}
}
func TestDXV3MultiCardUsageFallsBackToConcurrentPerCard(t *testing.T) {
month := time.Now().In(dxBatchLocation).Format("200601")
calls := 0
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/api/v3/getDataUsageByMonth" {
t.Errorf("batch must not be used without custNbr: %s", r.URL.Path)
}
fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"1","unit":"MB"}}`)
})
client.custNbr = ""
keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}, {MSISDN: "3"}}
items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}})
if err != nil || len(items) != 3 || calls != 3 {
t.Fatalf("fallback should query every card once: %d, %+v, %v", calls, items, err)
}
for index, item := range items {
if item.SimKey != keys[index] || item.Code != 0 {
t.Fatalf("fallback lost order or failed: %+v", item)
}
}
}
// 批量接口整批不可用(网关/鉴权/报文错误)时,必须回退单卡查询,而不是整批报废。
func TestDXV3BatchOutageFallsBackToSingleCard(t *testing.T) {
month := time.Now().In(dxBatchLocation).Format("200601")
batchCalls, singleCalls := 0, 0
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/getDataUsageByMonthBatch":
batchCalls++
w.Header().Set("gw-errorCode", "S00004")
w.WriteHeader(http.StatusBadGateway)
fmt.Fprint(w, `{}`)
case "/api/v3/getDataUsageByMonth":
singleCalls++
fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"4","unit":"MB"}}`)
default:
t.Errorf("unexpected path: %s", r.URL.Path)
}
})
keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}}
items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{
SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}})
if err != nil || len(items) != 2 {
t.Fatalf("batch outage must not fail the whole query: %+v, %v", items, err)
}
for index, item := range items {
if item.SimKey != keys[index] || item.Code != consts.ApiStatusSuccess || item.ResData.DataAmount != 4 {
t.Fatalf("card %d was not recovered by the single-card fallback: %+v", index, item)
}
}
if batchCalls != 1 || singleCalls != 2 {
t.Fatalf("unexpected call split: batch=%d single=%d", batchCalls, singleCalls)
}
}
// 批量只漏/错某张卡时,只对该卡用单卡补齐,批量成功项保留(不重复请求)。
func TestDXV3BatchOnlyRefillsFailedCards(t *testing.T) {
month := time.Now().In(dxBatchLocation).Format("200601")
singleCalls := 0
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/getDataUsageByMonthBatch":
fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"}]}}`, month)
case "/api/v3/getDataUsageByMonth":
singleCalls++
if r.URL.Query().Get("msisdn") != "2" {
t.Errorf("only the failed card may be refilled, got %s", r.URL.Query().Get("msisdn"))
}
fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"7","unit":"MB"}}`)
default:
t.Errorf("unexpected path: %s", r.URL.Path)
}
})
keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}}
items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{
SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}})
if err != nil || len(items) != 2 || singleCalls != 1 {
t.Fatalf("only failed cards should be refilled: single=%d, %+v, %v", singleCalls, items, err)
}
if items[0].Code != consts.ApiStatusSuccess || items[0].ResData.DataAmount != 2.5 {
t.Fatalf("batch success must be kept: %+v", items[0])
}
if items[1].Code != consts.ApiStatusSuccess || items[1].ResData.DataAmount != 7 {
t.Fatalf("omitted card must be refilled by single query: %+v", items[1])
}
}
@@ -0,0 +1,179 @@
package upstreamimpl
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"github.com/gogf/gf/v2/frame/g"
)
func convertDxSessionStatus(ctx context.Context, upstreamStatus string) string {
switch strings.TrimSpace(upstreamStatus) {
case "0":
return consts.CardSessionStatusOnline
case "1":
return consts.CardSessionStatusOffline
case "2", "":
return consts.CardSessionStatusUnknown
default:
g.Log().Warningf(ctx, "Unknown DX session status %s from upstream", upstreamStatus)
return consts.CardSessionStatusUnknown
}
}
func convertYdSessionStatus(ctx context.Context, upstreamStatus string) string {
switch strings.ToUpper(strings.TrimSpace(upstreamStatus)) {
case "01", "1", "0", "ONLINE":
return consts.CardSessionStatusOnline
case "00", "-1", "OFFLINE":
return consts.CardSessionStatusOffline
case "02", "2", "-2", "", "UNKNOWN":
return consts.CardSessionStatusUnknown
default:
g.Log().Warningf(ctx, "Unknown YD session status %s from upstream", upstreamStatus)
return consts.CardSessionStatusUnknown
}
}
func convertYdApnStatus(ctx context.Context, upstreamStatus string) string {
switch strings.TrimSpace(upstreamStatus) {
case consts.CardApnStatusPaused:
return consts.CardApnStatusPaused
case consts.CardApnStatusNormal:
return consts.CardApnStatusNormal
default:
g.Log().Warningf(ctx, "Unknown YD APN status %s from upstream", upstreamStatus)
return upstreamStatus
}
}
// convertDxApnOperType 把平台 APN 开停编码转换为电信 singleCutNet 的 action 取值,语义相反。
// 平台编码(全平台统一「开=0 / 停=1」,见 consts.CardApnOperType*):
//
// 0 = 开通数据(恢复联网)
// 1 = 停用数据(断网,含达量断网)
//
// 电信 singleCutNet(CTIOT_5GCMP_BA004)action:
//
// ADD = 单独断网(添加断网)
// DEL = 取消单独断网(恢复联网)
//
// 两者恰好相反:平台 0(开) -> 电信 DEL(取消断网),平台 1(停) -> 电信 ADD(断网)。禁止透传。
//
// 注意:singleCutNet 是整卡数据开停语义,电信侧没有「按 APN 定向」这一维度,
// 因此忽略调用方携带的 ApnName(预警自动断网、批量数据开停都会固定带 apnName="CMIOT")。
func convertDxApnOperType(ctx context.Context, operType string) (string, bool) {
switch strings.TrimSpace(operType) {
case consts.CardApnOperTypeOpen:
return "DEL", true
case consts.CardApnOperTypeStop:
return "ADD", true
default:
g.Log().Warningf(ctx, "Unknown APN operType %s for DX upstream", operType)
return "", false
}
}
// convertYdCommunicationBusinessType 把平台 APN 开停编码转换为移动 OneLink「通信业务开停-单卡」
// (7.2.1 CMIOT_API25M19,ec/manage/sim-communication-service)的 businessType。两者同向,无需翻转。
//
// 平台编码(全平台统一「开=0 / 停=1」,见 consts.CardApnOperType*):
//
// 0 = 开通数据(启用 APN)
// 1 = 停用数据(停用 APN,含达量断网)
//
// 移动 7.2.1 businessType:0:开 1:停(同向,直接透传)
func convertYdCommunicationBusinessType(ctx context.Context, operType string) (string, bool) {
switch strings.TrimSpace(operType) {
case consts.CardApnOperTypeOpen:
return "0", true
case consts.CardApnOperTypeStop:
return "1", true
default:
g.Log().Warningf(ctx, "Unknown APN operType %s for YD sim-communication-service", operType)
return "", false
}
}
// convertYdCommunicationOperType 把平台 APN 开停编码转换为移动 OneLink「通信功能开停-批量」
// (7.2.2 CMIOT_API23M09,ec/operate/sim-communication-function/batch)的 operType。语义相反,必须翻转。
//
// 平台编码(全平台统一「开=0 / 停=1」,见 consts.CardApnOperType*):
//
// 0 = 开通数据(启用 APN)
// 1 = 停用数据(停用 APN,含达量断网)
//
// 移动 7.2.2 operType:0:暂停(停) 1:恢复(开)(与平台相反)
//
// 平台 0(开) -> 移动 1(恢复),平台 1(停) -> 移动 0(暂停)。禁止透传。
func convertYdCommunicationOperType(ctx context.Context, operType string) (string, bool) {
switch strings.TrimSpace(operType) {
case consts.CardApnOperTypeOpen:
return "1", true
case consts.CardApnOperTypeStop:
return "0", true
default:
g.Log().Warningf(ctx, "Unknown APN operType %s for YD sim-communication-function", operType)
return "", false
}
}
func normalizeCardStatusChangeOperType(ctx context.Context, operType string) (string, bool) {
switch strings.TrimSpace(operType) {
case consts.CardStatusChangeOperTypeApplyStop:
return consts.CardStatusChangeOperTypeApplyStop, true
case consts.CardStatusChangeOperTypeApplyResume:
return consts.CardStatusChangeOperTypeApplyResume, true
default:
g.Log().Warningf(ctx, "Unknown card status change operType %s", operType)
return "", false
}
}
func convertDxCardStatusChangeOperType(ctx context.Context, operType string) (string, bool) {
switch strings.TrimSpace(operType) {
case consts.CardStatusChangeOperTypeApplyStop:
return "19", true
case consts.CardStatusChangeOperTypeApplyResume:
return "20", true
default:
g.Log().Warningf(ctx, "Unknown card status change operType %s for DX upstream", operType)
return "", false
}
}
func newInvalidCardStatusChangeOutput(simKeys []*model.SimKey) model.SimCardStatusChangeOutput {
items := make(model.SimCardStatusChangeOutput, 0, len(simKeys))
for _, simKey := range simKeys {
items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: "unsupported operType, expected 0(stop) or 1(resume)",
ResData: model.SimCardStatusChangeItem{},
})
}
return items
}
// convertTibiotCardStatus 转换Tibiot卡状态码
// Tibiot: 1正常 2停机 3沉默期 5测试期 6停机保号
// 系统: 1待激活 2已激活 4停机 6可测试 7库存 8预销户 9已销户
func convertTibiotCardStatus(ctx context.Context, upstreamStatus int) int {
switch upstreamStatus {
case 1: // 正常
return 2 // 已激活
case 2: // 停机
return 4 // 停机
case 3: // 沉默期
return 1 // 待激活
case 5: // 测试期
return 6 // 可测试
case 6: // 停机保号
return 4 // 停机
default:
g.Log().Warningf(ctx, "Unknown Tibiot card status %d", upstreamStatus)
return 0 // 未知
}
}
@@ -0,0 +1,521 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
const henanXinjingDirectTemplateKey = "sim_henan_xinjing_direct_v1"
// henanXinjingConfig is the channel-level config for the direct Henan Xinjing
// template. The top-level userId/apiKey form is the canonical shape; the
// nested `credentials` block is kept for backward compatibility with earlier
// drafts of this template.
type henanXinjingConfig struct {
UserID string `json:"userId"`
APIKey string `json:"apiKey"`
APIKeyLegacy string `json:"apikey"`
API struct {
BaseURL string `json:"baseUrl"`
Timeout string `json:"timeout"`
} `json:"api"`
Credentials struct {
UserID string `json:"userId"`
APIKey string `json:"apiKey"`
APIKeyLegacy string `json:"apikey"`
} `json:"credentials"`
}
// henanXinjingParseConfig unmarshals one JSON config blob (channel or
// template) into henanXinjingConfig. Empty input yields a zero config.
func henanXinjingParseConfig(raw string) henanXinjingConfig {
var c henanXinjingConfig
raw = strings.TrimSpace(raw)
if raw == "" {
return c
}
_ = json.Unmarshal([]byte(raw), &c)
return c
}
// henanXinjingPickCreds resolves (userId, apiKey) with precedence:
// channel.chan_config > channel.app_key/app_secret > template.temp_config.
// This matches how the LCM admin stores credentials: per-channel in
// t_sim_channels, not in the shared template row.
func henanXinjingPickCreds(chanCfg, tplCfg henanXinjingConfig, appKey, appSecret string) (user, key string) {
firstNonEmptyStr := func(vals ...string) string {
for _, v := range vals {
if v = strings.TrimSpace(v); v != "" {
return v
}
}
return ""
}
user = firstNonEmptyStr(
chanCfg.UserID, chanCfg.Credentials.UserID,
appKey,
tplCfg.UserID, tplCfg.Credentials.UserID,
)
key = firstNonEmptyStr(
chanCfg.APIKey, chanCfg.APIKeyLegacy,
chanCfg.Credentials.APIKey, chanCfg.Credentials.APIKeyLegacy,
appSecret,
tplCfg.APIKey, tplCfg.APIKeyLegacy,
tplCfg.Credentials.APIKey, tplCfg.Credentials.APIKeyLegacy,
)
return
}
// henanXinjingFlexFloat accepts either a JSON number or a JSON string and
// normalizes to float64. The platform returns `used` as a number and
// `surplus`/`canused` as strings in some responses.
type henanXinjingFlexFloat float64
func (f *henanXinjingFlexFloat) UnmarshalJSON(data []byte) error {
if len(data) == 0 || string(data) == "null" {
*f = 0
return nil
}
var n float64
if err := json.Unmarshal(data, &n); err == nil {
*f = henanXinjingFlexFloat(n)
return nil
}
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
s = strings.TrimSpace(s)
if s == "" {
*f = 0
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return fmt.Errorf("henan xinjing: parse float %q: %w", s, err)
}
*f = henanXinjingFlexFloat(v)
return nil
}
// henanXinjingCard mirrors `data` in `/api/v1/getChaxun`. Only fields we
// actually use are decoded; the platform returns many more.
//
// Documented field meanings (per upstream doc):
// - state : string card status code ("00" ok, "02" stopped, etc.)
// - status : int online/auth status (NOT the card lifecycle status)
// - used : KB (current month)
// - surplus : KB
// - expired_at / shouchong_time : yyyy-MM-dd HH:mm:ss strings
// - gprs_status : 0 unknown 1 on 2 off
// - power_status: 0 unknown 1 online 2 offline
// - display_status: 1 pending, 2 normal, 3 abnormal, 4 pre-close, 5 closed
type henanXinjingCard struct {
ICCID string `json:"iccid"`
CardNo string `json:"cardno"`
IMEI string `json:"imei"`
Used henanXinjingFlexFloat `json:"used"`
Surplus henanXinjingFlexFloat `json:"surplus"`
CanUsed henanXinjingFlexFloat `json:"canused"`
State string `json:"state"`
Status int `json:"status"`
OnlineState int `json:"onlinestate"`
GPRSStatus int `json:"gprs_status"`
PowerStatus int `json:"power_status"`
DisplayStatus int `json:"display_status"`
ExpiredAt string `json:"expired_at"`
FirstChargeAt string `json:"shouchong_time"`
Message string `json:"message"`
AutoName string `json:"autoname"`
MonthUseds []struct {
MonthDate string `json:"month_date"` // yyyyMM
UsedFlow string `json:"used_flow"` // MB per upstream doc
} `json:"month_useds"`
Package []struct {
OrderNo string `json:"orderno"`
PackageName string `json:"package_name"`
State int `json:"state"`
ExpiredAt string `json:"expired_at"`
CreatedAt string `json:"created_at"`
Flow string `json:"flow"`
MonthlyFlow henanXinjingFlexFloat `json:"monthly_flow"`
Surplus henanXinjingFlexFloat `json:"surplus"`
Used henanXinjingFlexFloat `json:"used"`
} `json:"package"`
}
type sSimHenanXinjingDirect struct {
channelConfig *model.SimChannelConfigItemCache
client *henanXinjingClient
}
func init() {
service.UpstreamAPI().RegisterTemp(henanXinjingDirectTemplateKey, NewSimHenanXinjingDirect())
}
func NewSimHenanXinjingDirect() *sSimHenanXinjingDirect { return &sSimHenanXinjingDirect{} }
func (s *sSimHenanXinjingDirect) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
chanKey := ""
if cfg != nil && cfg.Channel != nil {
chanKey = cfg.Channel.ChanKey
}
if cfg == nil || cfg.Channel == nil || cfg.Template == nil {
g.Log().Warningf(ctx, "henan_xinjing_direct.NewTemp: nil cfg/channel/template, chanKey=%s", chanKey)
return nil
}
chanCfg := henanXinjingParseConfig(cfg.Channel.ChanConfig)
tplCfg := henanXinjingParseConfig(cfg.Template.TempConfig)
user, key := henanXinjingPickCreds(chanCfg, tplCfg, cfg.Channel.AppKey, cfg.Channel.AppSecret)
baseURL := strings.TrimSpace(chanCfg.API.BaseURL)
if baseURL == "" {
baseURL = strings.TrimSpace(tplCfg.API.BaseURL)
}
if user == "" || key == "" || baseURL == "" {
g.Log().Warningf(ctx,
"henan_xinjing_direct.NewTemp: missing required config, chanKey=%s userIdEmpty=%t apiKeyEmpty=%t baseURLEmpty=%t",
chanKey, user == "", key == "", baseURL == "")
return nil
}
timeout := defaultUpstreamHTTPTimeout
for _, raw := range []string{chanCfg.API.Timeout, tplCfg.API.Timeout} {
if raw == "" {
continue
}
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
timeout = d
break
}
}
return &sSimHenanXinjingDirect{
channelConfig: cfg,
client: newHenanXinjingClient(baseURL, user, key, timeout),
}
}
// simKeyCardNo picks the identifier Henan Xinjing accepts for `cardno`.
// The platform keys on its own card-number column, which corresponds to
// MSISDN for these cards; fall back to ICCID/IMSI when MSISDN is absent.
func henanXinjingCardNo(k *model.SimKey) string {
if k == nil {
return ""
}
if v := strings.TrimSpace(k.MSISDN); v != "" {
return v
}
if v := strings.TrimSpace(k.ICCID); v != "" {
return v
}
return strings.TrimSpace(k.IMSI)
}
// fetchCard calls /api/v1/getChaxun for a single SimKey.
func (s *sSimHenanXinjingDirect) fetchCard(ctx context.Context, k *model.SimKey) (henanXinjingCard, error) {
if k == nil {
return henanXinjingCard{}, fmt.Errorf("sim key is required")
}
no := henanXinjingCardNo(k)
if no == "" {
return henanXinjingCard{}, fmt.Errorf("msisdn/iccid/imsi is required")
}
var c henanXinjingCard
err := s.client.get(ctx, "/api/v1/getChaxun", map[string]string{"cardno": no}, &c)
return c, err
}
// henanXinjingKBToMB converts upstream KB to the MB unit SimDataUsageItem
// requires. Rounds to 4 decimal places to keep values readable.
func henanXinjingKBToMB(kb float64) float64 {
if kb <= 0 {
return 0
}
mb := kb / 1024
return float64(int(mb*10000+0.5)) / 10000
}
// henanXinjingMapCardStatus translates the platform's `state` string into the
// internal CardStatus code used across the gateway.
//
// Upstream doc:
//
// 0 unknown, 00 normal, 01 one-way stop, 02 stopped, 03 pre-close,
// 04 closed, 05 transfer, 06 dormant, 07 pending, 08 disabled,
// 09 inventory, 10 expired, 11 violation stop, 12 lost, 13 user-stop,
// 14 testing, 99 not-exists
//
// Internal consts (from existing adapters like tibiot):
//
// 1待激活 2已激活 4停机 6可测试 7库存 8预销户 9已销户
func henanXinjingMapCardStatus(state string) int {
switch strings.TrimSpace(state) {
case "00":
return 2 // 已激活/正常
case "01", "02", "08", "11", "12", "13":
return 4 // 停机
case "03":
return 8 // 预销户
case "04", "10":
return 9 // 已销户
case "06":
return 1 // 休眠 → 待激活
case "07":
return 1 // 待激活
case "09":
return 7 // 库存
case "14":
return 6 // 可测试
default:
return 0 // 未知
}
}
func henanXinjingStatusChangePath(operType string) (string, bool) {
switch operType {
case consts.CardStatusChangeOperTypeApplyStop:
return "/api/v1/stop", true
case consts.CardStatusChangeOperTypeApplyResume:
return "/api/v1/start", true
default:
return "", false
}
}
func henanXinjingMainOfferName(card henanXinjingCard) string {
if name := strings.TrimSpace(card.AutoName); name != "" {
return name
}
for _, item := range card.Package {
if name := strings.TrimSpace(item.PackageName); name != "" {
return name
}
}
return ""
}
func (s *sSimHenanXinjingDirect) QuerySimBasicInfo(ctx context.Context, in *model.SimBasicInfoInput) (out model.SimBasicInfoOutput, err error) {
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimBasicInfoItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
r.ResData = model.SimBasicInfoItem{
ICCID: firstNonEmpty(c.ICCID, k.ICCID),
IMSI: k.IMSI,
MSISDN: firstNonEmpty(c.CardNo, k.MSISDN),
ActiveDate: c.FirstChargeAt,
OpenDate: c.FirstChargeAt, // use 首充时间 as the open/activate timestamp; expired_at goes to remark
Remark: c.Message,
SimStatus: strconv.Itoa(henanXinjingMapCardStatus(c.State)),
MainOfferName: henanXinjingMainOfferName(c),
}
}
out = append(out, r)
}
return
}
func (s *sSimHenanXinjingDirect) QuerySimImei(ctx context.Context, in *model.SimImeiInput) (out model.SimImeiOutput, err error) {
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimImeiItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
r.ResData.Imei = c.IMEI
}
out = append(out, r)
}
return
}
func (s *sSimHenanXinjingDirect) QuerySimCardStatus(ctx context.Context, in *model.SimCardStatusInput) (out model.SimCardStatusOutput, err error) {
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimCardStatusItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
r.ResData.CardStatus = henanXinjingMapCardStatus(c.State)
}
out = append(out, r)
}
return
}
func (s *sSimHenanXinjingDirect) QuerySimDataUsage(ctx context.Context, in *model.SimDataUsageInput) (out model.SimDataUsageOutput, err error) {
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimDataUsageItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
r.ResData.DataUsage = henanXinjingKBToMB(float64(c.Used))
total := henanXinjingKBToMB(float64(c.Used) + float64(c.Surplus))
if total > 0 {
r.ResData.TotalFlow = &total
}
}
out = append(out, r)
}
return
}
func (s *sSimHenanXinjingDirect) ChangeSimStatus(ctx context.Context, in *model.SimCardStatusChangeInput) (out model.SimCardStatusChangeOutput, err error) {
operType, ok := normalizeCardStatusChangeOperType(ctx, in.ReqData.OperType)
if !ok {
return newInvalidCardStatusChangeOutput(in.SimKeys), nil
}
for _, k := range in.SimKeys {
r := &model.SimBaseRes[model.SimCardStatusChangeItem]{SimKey: k}
path, _ := henanXinjingStatusChangePath(operType)
no := henanXinjingCardNo(k)
var e error
if no == "" {
e = fmt.Errorf("msisdn/iccid/imsi is required")
} else {
e = s.client.get(ctx, path, map[string]string{"cardno": no}, nil)
}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
}
out = append(out, r)
}
return
}
func henanXinjingUnsupported[T any](keys []*model.SimKey, name string) (out []*model.SimBaseRes[T], err error) {
for _, k := range keys {
out = append(out, &model.SimBaseRes[T]{SimKey: k, Code: consts.ApiStatusError, Message: "henan xinjing direct does not support " + name})
}
return
}
func (s *sSimHenanXinjingDirect) QuerySimSession(ctx context.Context, in *model.SimSessionInput) (model.SimSessionOutput, error) {
return henanXinjingUnsupported[model.SimSessionItem](in.SimKeys, "session query")
}
func (s *sSimHenanXinjingDirect) QuerySimStopReason(ctx context.Context, in *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
return henanXinjingUnsupported[model.SimStopReasonItem](in.SimKeys, "stop reason query")
}
func (s *sSimHenanXinjingDirect) QuerySimFrozenState(ctx context.Context, in *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
return henanXinjingUnsupported[model.SimFrozenStateItem](in.SimKeys, "frozen state query")
}
func (s *sSimHenanXinjingDirect) QuerySimCardBindStatus(ctx context.Context, in *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
return henanXinjingUnsupported[model.SimCardBindStatusItem](in.SimKeys, "card binding query")
}
func (s *sSimHenanXinjingDirect) QuerySimDeviceStatus(ctx context.Context, in *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
out := make(model.SimDeviceStatusOutput, 0, len(in.SimKeys))
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimDeviceStatusItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
// gprs_status: 0 unknown 1 power-on 2 power-off
// power_status: 0 unknown 1 online 2 offline
// DeviceStatus (compatible semantics): 0 abnormal 1 normal.
r.ResData.DeviceStatus = henanXinjingMapDeviceStatus(c.GPRSStatus, c.PowerStatus)
switch c.GPRSStatus {
case 1:
r.ResData.OperatingStatus = "1"
case 2:
r.ResData.OperatingStatus = "0"
}
}
out = append(out, r)
}
return out, nil
}
// henanXinjingMapDeviceStatus merges the platform's gprs_status (power on/off)
// and power_status (online/offline) into the gateway's binary device status.
// Only a card that is powered on AND online is "normal" (1); anything else,
// including unknown, maps to abnormal (0).
func henanXinjingMapDeviceStatus(gprsStatus, powerStatus int) string {
if gprsStatus == 1 && powerStatus == 1 {
return "1"
}
return "0"
}
func (s *sSimHenanXinjingDirect) QuerySimRegionLimitStatus(ctx context.Context, in *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
return henanXinjingUnsupported[model.SimRegionLimitStatusItem](in.SimKeys, "region limit query")
}
func (s *sSimHenanXinjingDirect) QuerySimDataDiagnosis(ctx context.Context, in *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
return henanXinjingUnsupported[any](in.SimKeys, "data diagnosis")
}
func (s *sSimHenanXinjingDirect) QuerySimDataUsageMonthly(ctx context.Context, in *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
queryMonth := ""
if in != nil {
queryMonth = strings.TrimSpace(in.ReqData.QueryDate)
}
out := make(model.SimDataUsageMonthlyOutput, 0, len(in.SimKeys))
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
var selected *struct {
MonthDate string
UsedFlow string
}
for i := range c.MonthUseds {
m := strings.TrimSpace(c.MonthUseds[i].MonthDate)
if queryMonth == "" || strings.Contains(m, queryMonth) {
selected = &struct {
MonthDate string
UsedFlow string
}{c.MonthUseds[i].MonthDate, c.MonthUseds[i].UsedFlow}
break
}
}
if selected == nil {
r.Code = consts.ApiStatusError
r.Message = "monthly usage data not found"
} else {
r.Code = 0
r.ResData.DeadLine = strings.TrimSpace(selected.MonthDate)
// month_useds.used_flow is MB per the platform doc, unlike the
// KB used/surplus fields on the same response.
if v, perr := strconv.ParseFloat(strings.TrimSpace(selected.UsedFlow), 64); perr == nil {
r.ResData.DataAmount = v
}
}
}
out = append(out, r)
}
return out, nil
}
func (s *sSimHenanXinjingDirect) QuerySimDataUsageDaily(ctx context.Context, in *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
return henanXinjingUnsupported[model.SimDataUsageDailyOutputItem](in.SimKeys, "historical daily usage query")
}
func (s *sSimHenanXinjingDirect) QuerySimApnInfo(ctx context.Context, in *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
return henanXinjingUnsupported[model.SimApnInfoItemList](in.SimKeys, "APN query")
}
func (s *sSimHenanXinjingDirect) ChangeSimApnFunction(ctx context.Context, in *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
return henanXinjingUnsupported[model.SimCardOperateChangeApnItem](in.SimKeys, "APN change")
}
@@ -0,0 +1,22 @@
package upstreamimpl
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/mingqian"
)
type simMingqianTemplate struct{}
func init() { service.UpstreamAPI().RegisterTemp(mingqian.TemplateKey, &simMingqianTemplate{}) }
func (*simMingqianTemplate) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
adapter, err := mingqian.New(cfg)
if err != nil {
g.Log().Errorf(ctx, "create mingqian channel failed: %v", err)
return nil
}
return adapter
}
@@ -0,0 +1,615 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
const (
sharedOneLinkTengyuTemplateKey = "sim_onelink_tengyu_v1"
sharedOneLinkXuzhouTemplateKey = "sim_onelink_xuzhou_mobile_v1"
sharedOneLinkLelianTemplateKey = "sim_onelink_lelian_v1"
sharedOneLinkQianxunTemplateKey = "sim_onelink_qianxun_v1"
sharedOneLinkHenanXinjingTemplateKey = "sim_onelink_henan_xinjing_v1"
sharedOneLinkDaiheTemplateKey = "sim_onelink_daihe_v1"
sharedOneLinkMobilePublicTemplateKey = "sim_onelink_daliuliang_v1"
sharedOneLinkMobilePublicPreviousKey = "sim__daliuliang_v1"
sharedOneLinkMobilePublicLegacyKey = "sim_onelink_mobile_public_v1"
sharedOneLinkApikaTemplateKey = "sim_onelink_kedang_v1"
sharedOneLinkYunzeTemplateKey = "sim_onelink_yunze_v1"
sharedOneLinkLanshuTemplateKey = "sim_onelink_lanshu_v1"
sharedOneLinkBeixinTemplateKey = "sim_onelink_beixin_v1"
sharedOneLinkAPIBaseURL = "https://api.iot.10086.cn/v5/"
sharedOneLinkTransIDPrefix = "ONELINK"
sharedOneLinkXuzhouTokenTTL = 40 * time.Minute
)
// SharedTokenConfig is the per-channel configuration passed to a provider
// factory. It intentionally contains no provider implementation details.
type SharedTokenConfig struct {
Provider string
TokenBaseURL string
LoginPath string
TokenPath string
CarrierType int
PwdKey string
ChannelNo string
AppKey string
AppID string
AppSecret string
SubAccount string
Secret string
OperatorID string
UserID string
TokenType string
UserName string
Password string
PasswayUID string
Sign string
ChannelID string
ChannelPName string
CardNo string
CMCCAppID string
Timeout time.Duration
}
// TokenProviderFactory is the adapter point for the token-core/provider
// integration. Provider branches can register factories without changing this
// channel implementation.
type TokenProviderFactory func(context.Context, *model.SimChannelConfigItemCache, SharedTokenConfig) (ydTokenProvider, error)
var sharedTokenFactories = struct {
sync.RWMutex
m map[string]TokenProviderFactory
}{m: make(map[string]TokenProviderFactory)}
func RegisterTokenProviderFactory(provider string, factory TokenProviderFactory) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" || factory == nil {
return
}
sharedTokenFactories.Lock()
sharedTokenFactories.m[provider] = factory
sharedTokenFactories.Unlock()
}
func tokenProviderFactory(provider string) (TokenProviderFactory, bool) {
sharedTokenFactories.RLock()
f, ok := sharedTokenFactories.m[strings.ToLower(strings.TrimSpace(provider))]
sharedTokenFactories.RUnlock()
return f, ok
}
type oneLinkAPIConfig struct {
BaseURL string
TokenPath string
Timeout time.Duration
transIDPrefix string
}
type sharedOneLinkChannelConfig struct {
PwdKey string `json:"pwdKey"`
ChannelNo string `json:"channelNo"`
AppID string `json:"appId"`
SubAccount string `json:"subAccount"`
Secret string `json:"secret"`
OperatorID string `json:"operatorId"`
UserID string `json:"userId"`
TokenType string `json:"tokenType"`
UserName string `json:"userName"`
Password string `json:"password"`
PasswayUID string `json:"passwayUid"`
ChannelID string `json:"channelId"`
ChannelPName string `json:"channelPname"`
CardNo string `json:"cardNo"`
CMCCAppID string `json:"cmccAppId"`
// Sign is the fixed signature issued by the LanShu open route. It is
// passed through verbatim and is never derived locally.
Sign string `json:"sign"`
// TokenBaseURL lets a channel override the template's token.baseUrl, so
// accounts living on a different upstream host can share one template.
TokenBaseURL string `json:"tokenBaseUrl"`
LegacyCredentials *struct {
PwdKey string `json:"pwdKey"`
ChannelNo string `json:"channelNo"`
AppID string `json:"appId"`
UserName string `json:"userName"`
Password string `json:"password"`
PasswayUID string `json:"passwayUid"`
} `json:"credentials"`
}
type sharedOneLinkTemplateConfig struct {
API struct {
BaseURL string `json:"baseUrl"`
} `json:"api"`
Token struct {
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
Path string `json:"path"`
Params struct {
LoginPath string `json:"loginPath"`
CarrierType *int `json:"carrierType"`
} `json:"params"`
LegacyLoginPath string `json:"loginPath"`
LegacyCarrierType *int `json:"carrierType"`
} `json:"token"`
}
type sharedTokenManagerProvider struct {
manager sharedtoken.TokenManager
chanKey string
includeDeviceToken bool
}
func (p *sharedTokenManagerProvider) GetToken(ctx context.Context) (string, error) {
return p.manager.GetToken(ctx, p.chanKey)
}
func (p *sharedTokenManagerProvider) GetDeviceToken(ctx context.Context) (string, error) {
if !p.includeDeviceToken {
return "", nil
}
token, err := p.manager.Get(ctx, p.chanKey)
if err != nil {
return "", err
}
return token.DeviceValue, nil
}
func (p *sharedTokenManagerProvider) RecoverToken(ctx context.Context, rejected string) (string, error) {
p.manager.InvalidateIfCurrent(p.chanKey, rejected)
return p.manager.GetToken(ctx, p.chanKey)
}
// GetSharedToken returns the shared token together with its expiry, fetching
// from the provider on demand when the cached value is missing or expired.
// Expiry is zero when the provider does not report one.
func (p *sharedTokenManagerProvider) GetSharedToken(ctx context.Context) (sharedtoken.OneLinkToken, error) {
return p.manager.Get(ctx, p.chanKey)
}
type tengyuFetcher struct{ provider *TengyuTokenProvider }
func (f tengyuFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
t, err := f.provider.Fetch(ctx)
if err != nil {
return sharedtoken.OneLinkToken{}, err
}
return sharedtoken.OneLinkToken{Value: t.Value, CreatedAt: t.CreatedAt, ExpiresAt: t.ExpiresAt}, nil
}
type xuzhouMobileFetcher struct{ provider *XuzhouMobileTokenProvider }
func (f xuzhouMobileFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
t, err := f.provider.Fetch(ctx)
if err != nil {
return sharedtoken.OneLinkToken{}, err
}
return sharedtoken.OneLinkToken{Value: t.Value, ExpiresAt: t.ExpiresAt}, nil
}
type henanLelianFetcher struct{ provider *HenanLelianTokenProvider }
func (f henanLelianFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
t, err := f.provider.Fetch(ctx)
if err != nil {
return sharedtoken.OneLinkToken{}, err
}
return sharedtoken.OneLinkToken{Value: t.Value, CreatedAt: t.CreatedAt}, nil
}
func parseSharedOneLinkConfig(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) (SharedTokenConfig, *oneLinkAPIConfig, error) {
if channelConfig == nil || channelConfig.Channel == nil || channelConfig.Template == nil {
return SharedTokenConfig{}, nil, fmt.Errorf("channel and template config are required")
}
var template sharedOneLinkTemplateConfig
if strings.TrimSpace(channelConfig.Template.TempConfig) == "" {
return SharedTokenConfig{}, nil, fmt.Errorf("temp_config is required")
}
if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &template); err != nil {
return SharedTokenConfig{}, nil, fmt.Errorf("invalid temp_config: %w", err)
}
var channel sharedOneLinkChannelConfig
if strings.TrimSpace(channelConfig.Channel.ChanConfig) != "" {
if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &channel); err != nil {
return SharedTokenConfig{}, nil, fmt.Errorf("invalid chan_config: %w", err)
}
}
legacy := channel.LegacyCredentials
provider := strings.ToLower(strings.TrimSpace(template.Token.Provider))
if provider == "" {
provider = "tengyu"
}
tokenBaseURL := strings.TrimSpace(template.Token.BaseURL)
// A channel may point token fetches at a different upstream host than the
// template default (e.g. the 361 account lives on gxhy.ge-xing.cn while
// the template points at hywx.xjict.com).
if override := strings.TrimSpace(channel.TokenBaseURL); override != "" {
tokenBaseURL = override
}
if tokenBaseURL == "" {
return SharedTokenConfig{}, nil, fmt.Errorf("token.baseUrl is required")
}
if strings.TrimSpace(template.Token.Path) == "" {
return SharedTokenConfig{}, nil, fmt.Errorf("token.path is required")
}
pwdKey := strings.TrimSpace(channel.PwdKey)
channelNo := strings.TrimSpace(channel.ChannelNo)
appID := strings.TrimSpace(channel.AppID)
appSecret := strings.TrimSpace(channelConfig.Channel.AppSecret)
subAccount := strings.TrimSpace(channel.SubAccount)
secret := channel.Secret
operatorID := strings.TrimSpace(channel.OperatorID)
userID := strings.TrimSpace(channel.UserID)
tokenType := strings.TrimSpace(channel.TokenType)
channelID := strings.TrimSpace(channel.ChannelID)
channelPName := strings.TrimSpace(channel.ChannelPName)
cardNo := strings.TrimSpace(channel.CardNo)
cmccAppID := strings.TrimSpace(channel.CMCCAppID)
sign := strings.TrimSpace(channel.Sign)
userName := strings.TrimSpace(channel.UserName)
password := channel.Password
passwayUID := strings.TrimSpace(channel.PasswayUID)
if legacy != nil {
if pwdKey == "" {
pwdKey = strings.TrimSpace(legacy.PwdKey)
}
if channelNo == "" {
channelNo = strings.TrimSpace(legacy.ChannelNo)
}
if appID == "" {
appID = strings.TrimSpace(legacy.AppID)
}
if userName == "" {
userName = strings.TrimSpace(legacy.UserName)
}
if password == "" {
password = legacy.Password
}
if passwayUID == "" {
passwayUID = strings.TrimSpace(legacy.PasswayUID)
}
g.Log().Warningf(ctx, "shared token channel %s uses deprecated chan_config.credentials; migrate fields to the root", channelConfig.Channel.ChanKey)
}
carrierType := 1
if template.Token.Params.CarrierType != nil {
carrierType = *template.Token.Params.CarrierType
} else if template.Token.LegacyCarrierType != nil {
carrierType = *template.Token.LegacyCarrierType
g.Log().Warningf(ctx, "shared token template %s uses deprecated token.carrierType; move it to token.params.carrierType", channelConfig.Template.TempKey)
}
loginPath := strings.TrimSpace(template.Token.Params.LoginPath)
if loginPath == "" && strings.TrimSpace(template.Token.LegacyLoginPath) != "" {
loginPath = strings.TrimSpace(template.Token.LegacyLoginPath)
g.Log().Warningf(ctx, "shared token template %s uses deprecated token.loginPath; move it to token.params.loginPath", channelConfig.Template.TempKey)
}
timeout := resolveUpstreamHTTPTimeout(ctx, channelConfig.Template.TempConfig)
config := SharedTokenConfig{
Provider: provider, TokenBaseURL: tokenBaseURL, LoginPath: loginPath, TokenPath: template.Token.Path,
CarrierType: carrierType, PwdKey: pwdKey, ChannelNo: channelNo, AppKey: strings.TrimSpace(channelConfig.Channel.AppKey), AppID: appID,
AppSecret: appSecret, SubAccount: subAccount, Secret: secret, OperatorID: operatorID, UserID: userID, TokenType: tokenType,
UserName: userName, Password: password, PasswayUID: passwayUID,
ChannelID: channelID, ChannelPName: channelPName, CardNo: cardNo, CMCCAppID: cmccAppID,
Sign: sign, Timeout: timeout,
}
if provider == "daihe" && config.AppID == "" {
config.AppID = strings.TrimSpace(channelConfig.Channel.AppKey)
}
if provider == "daihe" && (config.AppID == "" || config.AppSecret == "" || config.CMCCAppID == "") {
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId, app_secret and chan_config.cmccAppId are required for daihe")
}
if provider == "zibo" {
config.AppID = strings.TrimSpace(channelConfig.Channel.AppKey)
config.AppSecret = strings.TrimSpace(channelConfig.Channel.AppSecret)
if config.AppID == "" || config.AppSecret == "" {
return SharedTokenConfig{}, nil, fmt.Errorf("app_key and app_secret are required for zibo")
}
}
if provider == "yunze" {
config.AppID = strings.TrimSpace(channelConfig.Channel.AppKey)
if config.AppID == "" {
return SharedTokenConfig{}, nil, fmt.Errorf("app_key is required for yunze")
}
}
if provider == "tengyu" && (config.PwdKey == "" || config.ChannelNo == "") {
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.pwdKey and chan_config.channelNo are required")
}
if provider == "xuzhou_mobile" && config.AppID == "" {
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId is required for xuzhou_mobile")
}
if provider == "lanshang" && (config.SubAccount == "" || config.Secret == "") {
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.subAccount and chan_config.secret are required for lanshang")
}
if provider == "qianxun" && (config.OperatorID == "" || config.AppID == "" || config.TokenType == "" || config.AppSecret == "") {
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.operatorId, chan_config.appId, chan_config.tokenType and app_secret are required for qianxun")
}
if provider == "lanshu" && (config.AppID == "" || config.Password == "" || config.Sign == "" || config.TokenType == "") {
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId, chan_config.password, chan_config.sign and chan_config.tokenType are required for lanshu")
}
if provider == "beixin" && (config.PwdKey == "" || config.ChannelNo == "") {
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.pwdKey and chan_config.channelNo are required for beixin")
}
if provider == "mobile_public" && (config.UserID == "" || config.AppSecret == "") {
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.userId and app_secret are required for mobile_public")
}
if provider == "apika" && (config.AppKey == "" || config.AppSecret == "" || config.AppID == "") {
return SharedTokenConfig{}, nil, fmt.Errorf("app_key, app_secret and chan_config.appId are required for apika")
}
if provider == "henan_lelian" {
if strings.TrimSpace(config.LoginPath) == "" {
return SharedTokenConfig{}, nil, fmt.Errorf("token.params.loginPath is required for henan_lelian")
}
if config.UserName == "" || strings.TrimSpace(config.Password) == "" || config.PasswayUID == "" {
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.userName, chan_config.password and chan_config.passwayUid are required for henan_lelian")
}
}
if provider == "henan_xinjing" {
if config.AppID == "" || config.AppSecret == "" {
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId (userId) and app_secret (apikey) are required for henan_xinjing")
}
if config.ChannelID == "" || config.ChannelPName == "" {
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.channelId and chan_config.channelPname are required for henan_xinjing")
}
}
transIDPrefix := sharedOneLinkTransIDPrefix
if provider == "daihe" {
transIDPrefix = config.CMCCAppID
}
if provider == "zibo" {
transIDPrefix = config.AppID
}
businessBaseURL := strings.TrimSpace(template.API.BaseURL)
if businessBaseURL == "" {
businessBaseURL = sharedOneLinkAPIBaseURL
}
return config, &oneLinkAPIConfig{
BaseURL: businessBaseURL, transIDPrefix: transIDPrefix, Timeout: timeout,
}, nil
}
func parseOneLinkTemplateAPI(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) *oneLinkAPIConfig {
if channelConfig == nil || channelConfig.Template == nil || strings.TrimSpace(channelConfig.Template.TempConfig) == "" {
return nil
}
var raw sharedOneLinkTemplateConfig
if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &raw); err != nil {
g.Log().Warningf(ctx, "parse OneLink template config failed: %v", err)
return nil
}
baseURL := strings.TrimSpace(raw.API.BaseURL)
if baseURL == "" {
baseURL = gUrlBase
}
timeout := resolveUpstreamHTTPTimeout(ctx, channelConfig.Template.TempConfig)
return &oneLinkAPIConfig{
BaseURL: baseURL, TokenPath: raw.Token.Path, Timeout: timeout,
}
}
type sSimOneLinkShared struct{}
func NewSimOneLinkShared() *sSimOneLinkShared { return &sSimOneLinkShared{} }
func init() {
shared := NewSimOneLinkShared()
service.UpstreamAPI().RegisterTemp(sharedOneLinkTengyuTemplateKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkXuzhouTemplateKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkLelianTemplateKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkQianxunTemplateKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkHenanXinjingTemplateKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkDaiheTemplateKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkMobilePublicTemplateKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkMobilePublicPreviousKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkMobilePublicLegacyKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkApikaTemplateKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkYunzeTemplateKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkLanshuTemplateKey, shared)
service.UpstreamAPI().RegisterTemp(sharedOneLinkBeixinTemplateKey, shared)
}
func (s *sSimOneLinkShared) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI {
providerConfig, apiConfig, err := parseSharedOneLinkConfig(ctx, channelConfig)
if err != nil {
g.Log().Errorf(ctx, "create shared_token_onelink failed: %v", err)
return nil
}
factory, ok := tokenProviderFactory(providerConfig.Provider)
if !ok || factory == nil {
g.Log().Errorf(ctx, "create shared_token_onelink failed: unsupported provider %s", providerConfig.Provider)
return nil
}
provider, err := factory(ctx, channelConfig, providerConfig)
if err != nil || provider == nil {
if err == nil {
err = fmt.Errorf("factory returned nil provider")
}
g.Log().Errorf(ctx, "create shared_token_onelink failed: %v", err)
return nil
}
return &sSimYD{channelConfig: channelConfig, tokenProvider: provider, apiConfig: apiConfig}
}
func init() {
RegisterTokenProviderFactory("mobile_public", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
fetcher := mobilePublicFetcher{provider: NewMobilePublicTokenProvider(MobilePublicTokenConfig{
BaseURL: cfg.TokenBaseURL,
Path: cfg.TokenPath,
UserID: cfg.UserID,
Secret: cfg.AppSecret,
Timeout: cfg.Timeout,
})}
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second))
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
RegisterTokenProviderFactory("apika", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
fetcher := apikaFetcher{provider: NewApikaTokenProvider(ApikaTokenConfig{
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppKey: cfg.AppKey,
AppSecret: cfg.AppSecret, AppID: cfg.AppID, Timeout: cfg.Timeout,
})}
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second))
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
RegisterTokenProviderFactory("qianxun", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
fetcher := qianxunFetcher{provider: NewQianxunTokenProvider(QianxunTokenConfig{BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, OperatorID: cfg.OperatorID, AppID: cfg.AppID, TokenType: cfg.TokenType, Key: cfg.AppSecret, Timeout: cfg.Timeout})}
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithUntilInvalidated())
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
RegisterTokenProviderFactory("henan_xinjing", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
fetcher := henanXinjingFetcher{provider: NewHenanXinjingTokenProvider(HenanXinjingTokenConfig{
BaseURL: cfg.TokenBaseURL,
Path: cfg.TokenPath,
UserID: cfg.AppID,
APIKey: cfg.AppSecret,
ChannelID: cfg.ChannelID,
ChannelPName: cfg.ChannelPName,
CardNo: cfg.CardNo,
Timeout: cfg.Timeout,
})}
manager := sharedtoken.NewTokenManager(fetcher)
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
RegisterTokenProviderFactory("tengyu", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
fetcher := tengyuFetcher{provider: NewTengyuTokenProvider(TengyuTokenConfig{BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, PwdKey: cfg.PwdKey, ChannelNo: cfg.ChannelNo, Timeout: cfg.Timeout})}
// Tengyu does not document a refresh-ahead mechanism. Reuse the cached
// token until its local one-hour validity expires, then fetch on demand.
manager := sharedtoken.NewTokenManager(fetcher)
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
RegisterTokenProviderFactory("xuzhou_mobile", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
fetcher := xuzhouMobileFetcher{provider: NewXuzhouMobileTokenProvider(XuzhouMobileTokenConfig{
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID,
CarrierType: cfg.CarrierType, TTL: sharedOneLinkXuzhouTokenTTL, Timeout: cfg.Timeout,
})}
manager := sharedtoken.NewTokenManager(fetcher)
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
RegisterTokenProviderFactory("daihe", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
fetcher := daiheTokenFetcher{provider: NewDaiheTokenProvider(DaiheTokenConfig{
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID, AppSecret: cfg.AppSecret,
CMCCAppID: cfg.CMCCAppID, Timeout: cfg.Timeout,
})}
manager := sharedtoken.NewTokenManager(fetcher)
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey, includeDeviceToken: true}, nil
})
RegisterTokenProviderFactory("zibo", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
fetcher := ziboFetcher{provider: NewZiboTokenProvider(ZiboTokenConfig{
BaseURL: cfg.TokenBaseURL,
Path: cfg.TokenPath,
AppID: cfg.AppID,
Password: cfg.AppSecret,
Timeout: cfg.Timeout,
})}
manager := sharedtoken.NewTokenManager(fetcher)
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
RegisterTokenProviderFactory("yunze", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
fetcher := yunzeFetcher{provider: NewYunzeTokenProvider(YunzeTokenConfig{
BaseURL: cfg.TokenBaseURL,
Path: cfg.TokenPath,
AppID: cfg.AppID,
Timeout: cfg.Timeout,
})}
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second))
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
RegisterTokenProviderFactory("lanshu", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
fetcher := lanshuFetcher{provider: NewLanShuTokenProvider(LanShuTokenConfig{
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID,
Password: cfg.Password, Sign: cfg.Sign, TokenType: cfg.TokenType, Timeout: cfg.Timeout,
})}
// The upstream reports a real expireTime (~19-26 minutes) and issues a
// new token per request, so refresh proactively inside the window.
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(60*time.Second))
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
RegisterTokenProviderFactory("beixin", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
fetcher := beixinFetcher{provider: NewBeixinTokenProvider(BeixinTokenConfig{
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, PwdKey: cfg.PwdKey,
ChannelNo: cfg.ChannelNo, Timeout: cfg.Timeout,
})}
// The upstream returns no expireTime, so the provider holds a local
// one-hour window (mirroring tengyu). Refresh proactively inside it
// rather than waiting for a 12021 rejection.
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second))
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
RegisterTokenProviderFactory("lanshang", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
if cfg.SubAccount == "" || cfg.Secret == "" {
return nil, fmt.Errorf("lanshang subAccount and secret are required")
}
fetcher := lanshangFetcher{provider: NewLanShangTokenProvider(LanShangTokenConfig{
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath,
SubAccount: cfg.SubAccount, Secret: cfg.Secret, Timeout: cfg.Timeout,
})}
manager := sharedtoken.NewTokenManager(fetcher)
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
RegisterTokenProviderFactory("henan_lelian", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
if channelConfig == nil || channelConfig.Channel == nil {
return nil, fmt.Errorf("channel config is required")
}
fetcher := henanLelianFetcher{provider: NewHenanLelianTokenProvider(HenanLelianTokenConfig{
BaseURL: cfg.TokenBaseURL, LoginPath: cfg.LoginPath, Path: cfg.TokenPath,
UserName: cfg.UserName, Password: cfg.Password, PasswayUID: cfg.PasswayUID,
Timeout: cfg.Timeout,
})}
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithUntilInvalidated())
manager.SeedFromConfig(channelConfig)
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
})
}
@@ -0,0 +1,880 @@
package upstreamimpl
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
const (
qianxunTempKey = "sim_qianxun_v3"
qianxunSuccessCode = "000000"
qianxunTimeLayout = "20060102150405"
qianxunBatchLimit = 200
)
var qianxunEast8 = time.FixedZone("UTC+8", 8*60*60)
type sSimQianxun struct {
channelConfig *model.SimChannelConfigItemCache
baseURL string
appKey string
appSecret string
userName string
http *http.Client
}
var _ model.SimCardChannelI = (*sSimQianxun)(nil)
var _ model.SimCardBatchQueryI = (*sSimQianxun)(nil)
type qianxunResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Result json.RawMessage `json:"result"`
}
type qianxunCardInfo struct {
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
IMEI string `json:"imei"`
MSISDN string `json:"msisdn"`
AccessNumber string `json:"accessNumber"`
CardNo string `json:"cardNo"`
CardStatus any `json:"cardStatus"`
Status any `json:"status"`
SimStatus any `json:"simStatus"`
RealStatus any `json:"realStatus"`
StatusName string `json:"statusName"`
OpenDate string `json:"openDate"`
OpenTime string `json:"openTime"`
ActiveDate string `json:"activeDate"`
ActiveTime string `json:"activeTime"`
ActivateTime string `json:"activateTime"`
PackageName string `json:"packageName"`
MainOfferName string `json:"mainOfferName"`
ProdName string `json:"prodName"`
Remark string `json:"remark"`
UsageTotal any `json:"usageTotal"`
UsedGprs any `json:"usedGprs"`
UsedFlow any `json:"usedFlow"`
UseFlow any `json:"useFlow"`
CardFlow any `json:"cardFlow"`
Flow any `json:"flow"`
TotalFlow any `json:"totalFlow"`
ProdTotal any `json:"prodTotal"`
PackageFlow any `json:"packageFlow"`
LeftFlow any `json:"leftFlow"`
LastChangeDate string `json:"lastChangeDate"`
UpdateTime string `json:"updateTime"`
}
type qianxunFlowHistory struct {
ICCID string `json:"iccid"`
BillingMonth string `json:"billingMonth"`
UsageTotal any `json:"usageTotal"`
Status string `json:"status"`
}
type qianxunBatchCardResult struct {
ICCID string `json:"iccid"`
ICCIDData json.RawMessage `json:"iccidData"`
ICCIDResult json.RawMessage `json:"iccidResult"`
}
func NewSimQianxun() *sSimQianxun {
return &sSimQianxun{}
}
func init() {
service.UpstreamAPI().RegisterTemp(qianxunTempKey, NewSimQianxun())
}
func (s *sSimQianxun) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI {
baseURL := ""
if channelConfig != nil && channelConfig.Template != nil {
var templateConfig struct {
API struct {
BaseURL string `json:"baseUrl"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &templateConfig); err != nil {
g.Log().Warningf(ctx, "parse Qianxun template config failed: %v", err)
} else {
baseURL = strings.TrimRight(strings.TrimSpace(templateConfig.API.BaseURL), "/")
}
}
var channelExtra struct {
UserName string `json:"userName"`
}
if channelConfig != nil && channelConfig.Channel != nil {
if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &channelExtra); err != nil {
g.Log().Warningf(ctx, "parse Qianxun channel config failed, chanKey=%s err=%v", channelConfig.Channel.ChanKey, err)
}
}
appKey := ""
appSecret := ""
if channelConfig != nil && channelConfig.Channel != nil {
appKey = strings.TrimSpace(channelConfig.Channel.AppKey)
appSecret = strings.TrimSpace(channelConfig.Channel.AppSecret)
}
return &sSimQianxun{
channelConfig: channelConfig,
baseURL: baseURL,
appKey: appKey,
appSecret: appSecret,
userName: strings.TrimSpace(channelExtra.UserName),
http: &http.Client{Timeout: resolveUpstreamHTTPTimeout(ctx, templateConfigJSON(channelConfig))},
}
}
func qianxunSign(appKey, appSecret, userName, timestamp string) string {
plain := fmt.Sprintf("appKey=%s&timeStamp=%s&userName=%s&appSecret=%s", appKey, timestamp, userName, appSecret)
sum := md5.Sum([]byte(plain))
return hex.EncodeToString(sum[:])
}
func (s *sSimQianxun) authParams() (url.Values, error) {
if s == nil {
return nil, fmt.Errorf("Qianxun client is nil")
}
if strings.TrimSpace(s.baseURL) == "" {
return nil, fmt.Errorf("Qianxun baseUrl is not configured")
}
if strings.TrimSpace(s.appKey) == "" || strings.TrimSpace(s.appSecret) == "" || strings.TrimSpace(s.userName) == "" {
return nil, fmt.Errorf("Qianxun credentials are not configured")
}
timestamp := time.Now().In(qianxunEast8).Format(qianxunTimeLayout)
params := url.Values{}
params.Set("appKey", s.appKey)
params.Set("timeStamp", timestamp)
params.Set("userName", s.userName)
params.Set("sign", qianxunSign(s.appKey, s.appSecret, s.userName, timestamp))
return params, nil
}
func (s *sSimQianxun) post(ctx context.Context, endpoint string, params url.Values) (qianxunResponse, error) {
authParams, err := s.authParams()
if err != nil {
return qianxunResponse{}, err
}
for key, values := range params {
for _, value := range values {
authParams.Add(key, value)
}
}
target := strings.TrimRight(s.baseURL, "/") + "/" + strings.TrimLeft(endpoint, "/")
body := authParams.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewBufferString(body))
if err != nil {
return qianxunResponse{}, fmt.Errorf("create Qianxun request %s: %w", endpoint, err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := s.http
if client == nil {
client = &http.Client{Timeout: defaultUpstreamHTTPTimeout}
}
resp, err := client.Do(req)
if err != nil {
return qianxunResponse{}, fmt.Errorf("Qianxun request %s: %w", endpoint, err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return qianxunResponse{}, fmt.Errorf("read Qianxun response %s: %w", endpoint, err)
}
rawResponse := strings.TrimSpace(string(responseBody))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
g.Log().Warningf(ctx, "Qianxun HTTP error endpoint=%s status=%d response=%s", endpoint, resp.StatusCode, rawResponse)
return qianxunResponse{}, fmt.Errorf("Qianxun HTTP status %d", resp.StatusCode)
}
var output qianxunResponse
if err := json.Unmarshal(responseBody, &output); err != nil {
g.Log().Warningf(ctx, "decode Qianxun response failed endpoint=%s response=%s err=%v", endpoint, rawResponse, err)
return qianxunResponse{}, fmt.Errorf("decode Qianxun response %s: %w", endpoint, err)
}
if !qianxunSuccess(output) {
g.Log().Warningf(ctx, "Qianxun business error endpoint=%s status=%s message=%s response=%s", endpoint, output.Status, output.Message, rawResponse)
} else {
g.Log().Infof(ctx, "Qianxun response endpoint=%s status=%s response=%s", endpoint, output.Status, rawResponse)
}
return output, nil
}
func qianxunSuccess(resp qianxunResponse) bool {
return strings.TrimSpace(resp.Status) == qianxunSuccessCode
}
func qianxunBusinessError[T any](simKey *model.SimKey, resp qianxunResponse) *model.SimBaseRes[T] {
return &model.SimBaseRes[T]{
SimKey: simKey,
Code: gconv.Int(resp.Status),
ErrCode: resp.Status,
Message: resp.Message,
}
}
func qianxunError[T any](simKey *model.SimKey, err error) *model.SimBaseRes[T] {
return &model.SimBaseRes[T]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()}
}
func qianxunUnsupported[T any](simKeys []*model.SimKey, message string) []*model.SimBaseRes[T] {
items := make([]*model.SimBaseRes[T], 0, len(simKeys))
for _, simKey := range simKeys {
items = append(items, &model.SimBaseRes[T]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: message,
})
}
return items
}
func qianxunICCID(simKey *model.SimKey) (string, error) {
if simKey == nil || strings.TrimSpace(simKey.ICCID) == "" {
return "", fmt.Errorf("ICCID not found")
}
return strings.TrimSpace(simKey.ICCID), nil
}
func decodeQianxunData[T any](raw json.RawMessage, target *T) error {
if len(raw) == 0 || string(raw) == "null" {
return fmt.Errorf("Qianxun response result is empty")
}
if err := json.Unmarshal(raw, target); err != nil {
return fmt.Errorf("decode Qianxun response result: %w", err)
}
return nil
}
func decodeQianxunStringJSON(raw json.RawMessage, target any) error {
if len(raw) == 0 || string(raw) == "null" {
return fmt.Errorf("Qianxun batch item result is empty")
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
text = strings.TrimSpace(text)
if !strings.HasPrefix(text, "{") && !strings.HasPrefix(text, "[") {
return errors.New(text)
}
return json.Unmarshal([]byte(text), target)
}
return json.Unmarshal(raw, target)
}
func qianxunCardStatus(ctx context.Context, value any) int {
status := strings.TrimSpace(gconv.String(value))
switch status {
case "":
return 0
case "0", "10":
return 2
case "1":
return 1
case "3", "4", "8":
return 4
case "6":
return 6
case "7":
return 7
case "9":
return 9
default:
g.Log().Warningf(ctx, "Unknown Qianxun card status %s from upstream", status)
return 0
}
}
func qianxunUsageMB(values ...any) float64 {
for _, value := range values {
text := strings.TrimSpace(gconv.String(value))
if text != "" && text != "<nil>" {
return parseDataUsage(text)
}
}
return 0
}
func qianxunCardMessage(resp qianxunResponse) string {
return firstNonEmpty(resp.Message, "成功")
}
func qianxunCardToBasic(data qianxunCardInfo) model.SimBasicInfoItem {
status := strconv.Itoa(qianxunCardStatus(context.Background(), qianxunStatusValue(data)))
return model.SimBasicInfoItem{
ICCID: data.ICCID,
IMSI: data.IMSI,
MSISDN: firstNonEmpty(data.MSISDN, data.AccessNumber, data.CardNo),
ActiveDate: firstNonEmpty(data.ActiveDate, data.ActiveTime, data.ActivateTime),
OpenDate: firstNonEmpty(data.OpenDate, data.OpenTime),
Remark: data.Remark,
SimStatus: status,
MainOfferName: firstNonEmpty(data.MainOfferName, data.PackageName, data.ProdName),
}
}
func qianxunStatusValue(data qianxunCardInfo) string {
return firstNonEmpty(gconv.String(data.SimStatus), gconv.String(data.CardStatus), gconv.String(data.Status), gconv.String(data.RealStatus))
}
func (s *sSimQianxun) queryCardInfo(ctx context.Context, simKey *model.SimKey) (qianxunCardInfo, qianxunResponse, error) {
iccid, err := qianxunICCID(simKey)
if err != nil {
return qianxunCardInfo{}, qianxunResponse{}, err
}
params := url.Values{}
params.Set("iccid", iccid)
resp, err := s.post(ctx, "/api/v3/cardInfo", params)
if err != nil || !qianxunSuccess(resp) {
return qianxunCardInfo{}, resp, err
}
var data qianxunCardInfo
if err := decodeQianxunData(resp.Result, &data); err != nil {
return qianxunCardInfo{}, resp, err
}
return data, resp, nil
}
func (s *sSimQianxun) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
if input == nil {
return make(model.SimBasicInfoOutput, 0), nil
}
items := make(model.SimBasicInfoOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
data, resp, err := s.queryCardInfo(ctx, simKey)
if err != nil {
items = append(items, qianxunError[model.SimBasicInfoItem](simKey, err))
continue
}
if !qianxunSuccess(resp) {
items = append(items, qianxunBusinessError[model.SimBasicInfoItem](simKey, resp))
continue
}
items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
Message: qianxunCardMessage(resp),
ResData: qianxunCardToBasic(data),
})
}
return items, nil
}
func (s *sSimQianxun) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
if input == nil {
return make(model.SimCardStatusOutput, 0), nil
}
items := make(model.SimCardStatusOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
data, resp, err := s.queryCardInfo(ctx, simKey)
if err != nil {
items = append(items, qianxunError[model.SimCardStatusItem](simKey, err))
continue
}
if !qianxunSuccess(resp) {
items = append(items, qianxunBusinessError[model.SimCardStatusItem](simKey, resp))
continue
}
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
Message: qianxunCardMessage(resp),
ResData: model.SimCardStatusItem{
CardStatus: qianxunCardStatus(ctx, qianxunStatusValue(data)),
LastChangeDate: firstNonEmpty(data.LastChangeDate, data.UpdateTime),
},
})
}
return items, nil
}
func (s *sSimQianxun) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) {
if input == nil {
return make(model.SimDataUsageOutput, 0), nil
}
items := make(model.SimDataUsageOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
data, resp, err := s.queryCardInfo(ctx, simKey)
if err != nil {
items = append(items, qianxunError[model.SimDataUsageItem](simKey, err))
continue
}
if !qianxunSuccess(resp) {
items = append(items, qianxunBusinessError[model.SimDataUsageItem](simKey, resp))
continue
}
item := model.SimDataUsageItem{
DataUsage: qianxunUsageMB(data.UsedGprs, data.UsageTotal, data.UsedFlow, data.UseFlow, data.CardFlow, data.Flow),
}
total := qianxunUsageMB(data.ProdTotal, data.TotalFlow, data.PackageFlow)
if total > 0 {
item.TotalFlow = &total
}
items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
Message: qianxunCardMessage(resp),
ResData: item,
})
}
return items, nil
}
func (s *sSimQianxun) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
if input == nil {
return make(model.SimDataUsageMonthlyOutput, 0), nil
}
queryMonth, err := qianxunMonth(input.ReqData.QueryDate)
if err != nil {
return nil, err
}
items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
iccid, err := qianxunICCID(simKey)
if err != nil {
items = append(items, qianxunError[model.SimDataUsageMonthlyOutputItem](simKey, err))
continue
}
params := url.Values{}
params.Set("iccid", iccid)
params.Set("billingMonth", queryMonth)
resp, err := s.post(ctx, "/api/v3/queryFlowHistory", params)
if err != nil {
items = append(items, qianxunError[model.SimDataUsageMonthlyOutputItem](simKey, err))
continue
}
if !qianxunSuccess(resp) {
items = append(items, qianxunBusinessError[model.SimDataUsageMonthlyOutputItem](simKey, resp))
continue
}
var data qianxunFlowHistory
if err := decodeQianxunData(resp.Result, &data); err != nil {
items = append(items, qianxunError[model.SimDataUsageMonthlyOutputItem](simKey, err))
continue
}
if strings.EqualFold(strings.TrimSpace(data.Status), "N") {
items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: "Qianxun monthly usage data not synced",
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: firstNonEmpty(data.BillingMonth, queryMonth)},
})
continue
}
items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
Message: qianxunCardMessage(resp),
ResData: model.SimDataUsageMonthlyOutputItem{
DeadLine: firstNonEmpty(data.BillingMonth, queryMonth),
DataAmount: qianxunUsageMB(data.UsageTotal),
},
})
}
return items, nil
}
func qianxunMonth(queryDate string) (string, error) {
queryDate = strings.TrimSpace(queryDate)
if queryDate == "" {
return "", fmt.Errorf("queryDate is required")
}
for _, layout := range []string{"200601", "2006-01", "20060102", "2006-01-02"} {
if parsed, err := time.Parse(layout, queryDate); err == nil {
return parsed.Format("200601"), nil
}
}
return "", fmt.Errorf("invalid queryDate: %s", queryDate)
}
func (s *sSimQianxun) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
if input == nil || len(input.SimKeys) == 0 {
return make(model.SimCardStatusOutput, 0), nil
}
cards, errs := s.queryCardInfoBatch(ctx, input.SimKeys)
items := make(model.SimCardStatusOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
iccid, err := qianxunICCID(simKey)
if err != nil {
items = append(items, qianxunError[model.SimCardStatusItem](simKey, err))
continue
}
if err = errs[iccid]; err != nil {
items = append(items, qianxunError[model.SimCardStatusItem](simKey, err))
continue
}
data, ok := cards[iccid]
if !ok {
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: "batch response missing card"})
continue
}
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
ResData: model.SimCardStatusItem{
CardStatus: qianxunCardStatus(ctx, qianxunStatusValue(data)),
LastChangeDate: firstNonEmpty(data.LastChangeDate, data.UpdateTime),
},
})
}
return items, nil
}
func (s *sSimQianxun) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
if input == nil || len(input.SimKeys) == 0 {
return make(model.SimDataUsageMonthlyOutput, 0), nil
}
queryMonth, err := qianxunMonth(input.ReqData.QueryDate)
if err != nil {
return nil, err
}
results := make(model.SimDataUsageMonthlyOutput, len(input.SimKeys))
for _, chunk := range qianxunChunkSimKeys(input.SimKeys, qianxunBatchLimit) {
iccids := make([]string, 0, len(chunk))
for _, item := range chunk {
iccid, err := qianxunICCID(item.simKey)
if err != nil {
results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, err)
continue
}
iccids = append(iccids, iccid)
}
if len(iccids) == 0 {
continue
}
params := url.Values{}
params.Set("iccids", strings.Join(iccids, "_"))
params.Set("billingMonth", queryMonth)
resp, err := s.post(ctx, "/api/v3/batchQueryFlowHistory", params)
if err != nil {
for _, item := range chunk {
if results[item.index] == nil {
results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, err)
}
}
continue
}
if !qianxunSuccess(resp) {
for _, item := range chunk {
if results[item.index] == nil {
results[item.index] = qianxunBusinessError[model.SimDataUsageMonthlyOutputItem](item.simKey, resp)
}
}
continue
}
history, itemErrs, err := decodeQianxunBatchHistory(resp.Result)
if err != nil {
for _, item := range chunk {
if results[item.index] == nil {
results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, err)
}
}
continue
}
for _, item := range chunk {
if results[item.index] != nil {
continue
}
iccid, _ := qianxunICCID(item.simKey)
if itemErrs[iccid] != nil {
results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, itemErrs[iccid])
continue
}
data, ok := history[iccid]
if !ok {
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: "batch response missing card", ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: queryMonth}}
continue
}
if strings.EqualFold(strings.TrimSpace(data.Status), "N") {
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: "Qianxun monthly usage data not synced", ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: firstNonEmpty(data.BillingMonth, queryMonth)}}
continue
}
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
SimKey: item.simKey,
Code: consts.ApiStatusSuccess,
Message: qianxunCardMessage(resp),
ResData: model.SimDataUsageMonthlyOutputItem{
DeadLine: firstNonEmpty(data.BillingMonth, queryMonth),
DataAmount: qianxunUsageMB(data.UsageTotal),
},
}
}
}
return compactMonthlyBatchResults(results), nil
}
type qianxunBatchKey struct {
index int
simKey *model.SimKey
}
func qianxunChunkSimKeys(simKeys []*model.SimKey, size int) [][]qianxunBatchKey {
if size <= 0 {
size = 1
}
items := make([]qianxunBatchKey, 0, len(simKeys))
for i, simKey := range simKeys {
items = append(items, qianxunBatchKey{index: i, simKey: simKey})
}
chunks := make([][]qianxunBatchKey, 0, (len(items)+size-1)/size)
for start := 0; start < len(items); start += size {
end := start + size
if end > len(items) {
end = len(items)
}
chunks = append(chunks, items[start:end])
}
return chunks
}
func (s *sSimQianxun) queryCardInfoBatch(ctx context.Context, simKeys []*model.SimKey) (map[string]qianxunCardInfo, map[string]error) {
cards := make(map[string]qianxunCardInfo, len(simKeys))
errs := make(map[string]error, len(simKeys))
for _, chunk := range qianxunChunkSimKeys(simKeys, qianxunBatchLimit) {
iccids := make([]string, 0, len(chunk))
for _, item := range chunk {
iccid, err := qianxunICCID(item.simKey)
if err != nil {
continue
}
iccids = append(iccids, iccid)
}
if len(iccids) == 0 {
continue
}
params := url.Values{}
params.Set("iccids", strings.Join(iccids, "_"))
resp, err := s.post(ctx, "/api/v3/cardInfos", params)
if err != nil {
for _, iccid := range iccids {
errs[iccid] = err
}
continue
}
if !qianxunSuccess(resp) {
for _, iccid := range iccids {
errs[iccid] = fmt.Errorf("Qianxun batch card info failed: code=%s message=%s", resp.Status, resp.Message)
}
continue
}
batchCards, itemErrs, err := decodeQianxunBatchCards(resp.Result)
if err != nil {
for _, iccid := range iccids {
errs[iccid] = err
}
continue
}
for iccid, data := range batchCards {
cards[iccid] = data
}
for iccid, itemErr := range itemErrs {
errs[iccid] = itemErr
}
}
return cards, errs
}
func decodeQianxunBatchCards(raw json.RawMessage) (map[string]qianxunCardInfo, map[string]error, error) {
cards := make(map[string]qianxunCardInfo)
errs := make(map[string]error)
var wrapped []qianxunBatchCardResult
if err := decodeQianxunData(raw, &wrapped); err == nil && qianxunHasWrappedData(wrapped) {
for _, item := range wrapped {
iccid := strings.TrimSpace(item.ICCID)
var card qianxunCardInfo
if err := decodeQianxunStringJSON(firstQianxunRaw(item.ICCIDData, item.ICCIDResult), &card); err != nil {
errs[iccid] = err
continue
}
if strings.TrimSpace(card.ICCID) == "" {
card.ICCID = iccid
}
cards[card.ICCID] = card
}
return cards, errs, nil
}
var list []qianxunCardInfo
if err := decodeQianxunData(raw, &list); err != nil {
return nil, nil, err
}
for _, item := range list {
iccid := strings.TrimSpace(item.ICCID)
if iccid == "" {
continue
}
cards[iccid] = item
}
return cards, errs, nil
}
func decodeQianxunBatchHistory(raw json.RawMessage) (map[string]qianxunFlowHistory, map[string]error, error) {
history := make(map[string]qianxunFlowHistory)
errs := make(map[string]error)
var wrapped []qianxunBatchCardResult
if err := decodeQianxunData(raw, &wrapped); err == nil && qianxunHasWrappedData(wrapped) {
for _, item := range wrapped {
iccid := strings.TrimSpace(item.ICCID)
var data qianxunFlowHistory
if err := decodeQianxunStringJSON(firstQianxunRaw(item.ICCIDResult, item.ICCIDData), &data); err != nil {
errs[iccid] = err
continue
}
if strings.TrimSpace(data.ICCID) == "" {
data.ICCID = iccid
}
history[data.ICCID] = data
}
return history, errs, nil
}
var list []qianxunFlowHistory
if err := decodeQianxunData(raw, &list); err != nil {
return nil, nil, err
}
for _, item := range list {
iccid := strings.TrimSpace(item.ICCID)
if iccid == "" {
continue
}
history[iccid] = item
}
return history, errs, nil
}
func qianxunHasWrappedData(items []qianxunBatchCardResult) bool {
for _, item := range items {
if len(item.ICCIDData) > 0 && string(item.ICCIDData) != "null" {
return true
}
if len(item.ICCIDResult) > 0 && string(item.ICCIDResult) != "null" {
return true
}
}
return false
}
func firstQianxunRaw(values ...json.RawMessage) json.RawMessage {
for _, value := range values {
if len(value) > 0 && string(value) != "null" {
return value
}
}
return nil
}
func (s *sSimQianxun) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) {
if input == nil {
return make(model.SimSessionOutput, 0), nil
}
return qianxunUnsupported[model.SimSessionItem](input.SimKeys, "千寻通道暂不支持在线信息查询"), nil
}
func (s *sSimQianxun) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
if input == nil {
return make(model.SimStopReasonOutput, 0), nil
}
return qianxunUnsupported[model.SimStopReasonItem](input.SimKeys, "千寻通道暂不支持停机原因查询"), nil
}
func (s *sSimQianxun) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
if input == nil {
return make(model.SimFrozenStateOutput, 0), nil
}
return qianxunUnsupported[model.SimFrozenStateItem](input.SimKeys, "千寻通道暂不支持冻结状态查询"), nil
}
func (s *sSimQianxun) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
if input == nil {
return make(model.SimCardBindStatusOutput, 0), nil
}
return qianxunUnsupported[model.SimCardBindStatusItem](input.SimKeys, "千寻通道暂不支持机卡绑定状态查询"), nil
}
func (s *sSimQianxun) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
if input == nil {
return make(model.SimDeviceStatusOutput, 0), nil
}
return qianxunUnsupported[model.SimDeviceStatusItem](input.SimKeys, "千寻通道暂不支持设备状态查询"), nil
}
func (s *sSimQianxun) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
if input == nil {
return make(model.SimRegionLimitStatusOutput, 0), nil
}
return qianxunUnsupported[model.SimRegionLimitStatusItem](input.SimKeys, "千寻通道暂不支持地域限制状态查询"), nil
}
func (s *sSimQianxun) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) {
if input == nil {
return make(model.SimImeiOutput, 0), nil
}
return qianxunUnsupported[model.SimImeiItem](input.SimKeys, "千寻通道暂不支持IMEI查询"), nil
}
func (s *sSimQianxun) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
if input == nil {
return make(model.SimDataDiagnosisOutput, 0), nil
}
return qianxunUnsupported[any](input.SimKeys, "千寻通道暂不支持达量限速档位查询"), nil
}
func (s *sSimQianxun) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
if input == nil {
return make(model.SimDataUsageDailyOutput, 0), nil
}
return qianxunUnsupported[model.SimDataUsageDailyOutputItem](input.SimKeys, "千寻通道暂不支持单日流量查询"), nil
}
func (s *sSimQianxun) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
return s.QuerySimDataUsageDaily(ctx, input)
}
func (s *sSimQianxun) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
if input == nil {
return make(model.SimApnInfoOutput, 0), nil
}
return qianxunUnsupported[model.SimApnInfoItemList](input.SimKeys, "千寻通道暂不支持APN信息查询"), nil
}
func (s *sSimQianxun) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) {
if input == nil {
return make(model.SimCardStatusChangeOutput, 0), nil
}
return qianxunUnsupported[model.SimCardStatusChangeItem](input.SimKeys, "千寻通道暂不支持卡状态变更"), nil
}
func (s *sSimQianxun) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
if input == nil {
return make(model.SimCardOperateChangeApnOutput, 0), nil
}
return qianxunUnsupported[model.SimCardOperateChangeApnItem](input.SimKeys, "千寻通道暂不支持APN功能开停"), nil
}
@@ -0,0 +1,519 @@
package upstreamimpl
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/recharge"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"golang.org/x/sync/singleflight"
)
const rechargeTempKey = "sim_sdtywl_v1"
const rechargeFindCardCacheTTL = 30 * time.Second
// 腾宇卡状态 status_show_id -> 系统 cardStatus
// 腾宇: 0未知 1库存 2可测试 3待激活 4已激活 5已停机 6预销户 7已销户
// 系统: 0未知 1待激活 2已激活 4已停机 6可测试 7库存 8预销户 9已销户
var rechargeCardStatusMap = map[int]int{
0: 0,
1: 7,
2: 6,
3: 1,
4: 2,
5: 4,
6: 8,
7: 9,
}
type sSimRecharge struct {
channelConfig *model.SimChannelConfigItemCache
client *recharge.Client
cardCache sync.Map
cardFlight singleflight.Group
}
type rechargeCardCacheEntry struct {
Info model.RechargeCardInfo
UpdatedAt string
ExpiresAt time.Time
}
type rechargeFindCardResult struct {
Info *model.RechargeCardInfo
Cached bool
UpdatedAt string
}
var _ model.SimCardChannelI = (*sSimRecharge)(nil)
type rechargeChannelConfig struct {
PwdKey string `json:"pwdkey"`
Pwdiv string `json:"pwdiv"`
LegacyTimeout json.RawMessage `json:"timeout"`
}
func NewSimRecharge() *sSimRecharge {
return &sSimRecharge{}
}
func init() {
service.UpstreamAPI().RegisterTemp(rechargeTempKey, NewSimRecharge())
}
func (s *sSimRecharge) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI {
cfg := rechargeChannelConfig{}
if channelConfig != nil && channelConfig.Channel != nil {
_ = json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &cfg)
if len(cfg.LegacyTimeout) > 0 && string(cfg.LegacyTimeout) != "null" {
g.Log().Warningf(ctx, "recharge channel %s uses deprecated chan_config.timeout; configure template http.timeout instead", channelConfig.Channel.ChanKey)
}
}
baseURL := ""
if channelConfig != nil && channelConfig.Template != nil {
var templateConfig struct {
API struct {
BaseURL string `json:"baseUrl"`
} `json:"api"`
LegacyBaseURL string `json:"baseUrl"`
}
if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &templateConfig); err != nil {
g.Log().Warningf(ctx, "parse recharge template config failed, chanKey=%s err=%+v", channelConfig.Channel.ChanKey, err)
} else {
baseURL = strings.TrimSpace(templateConfig.API.BaseURL)
if baseURL == "" && strings.TrimSpace(templateConfig.LegacyBaseURL) != "" {
baseURL = strings.TrimSpace(templateConfig.LegacyBaseURL)
g.Log().Warningf(ctx, "recharge template %s uses deprecated root baseUrl; move it to api.baseUrl", channelConfig.Template.TempKey)
}
}
}
if baseURL == "" {
baseURL = recharge.DefaultBaseURL
}
// 字段复用约定: app_key=pwdkey, app_secret=pwdiv
pwdKey := cfg.PwdKey
pwdiv := cfg.Pwdiv
if channelConfig != nil && channelConfig.Channel != nil {
if pwdKey == "" {
pwdKey = channelConfig.Channel.AppKey
}
if pwdiv == "" {
pwdiv = channelConfig.Channel.AppSecret
}
}
timeout := resolveUpstreamHTTPTimeout(ctx, templateConfigJSON(channelConfig))
return &sSimRecharge{
channelConfig: channelConfig,
client: recharge.NewClient(recharge.Config{
BaseURL: baseURL,
PwdKey: pwdKey,
Pwdiv: pwdiv,
Timeout: timeout,
}),
}
}
func convertRechargeCardStatus(ctx context.Context, statusShowID int) int {
if status, ok := rechargeCardStatusMap[statusShowID]; ok {
return status
}
g.Log().Warningf(ctx, "Unknown recharge card status %d from upstream", statusShowID)
return 0
}
// 系统 operType(0停机/1复机) -> 腾宇 changeState type(1停机/0复机),语义相反,禁止透传
func convertRechargeChangeStateType(ctx context.Context, operType string) (int, bool) {
switch operType {
case consts.CardStatusChangeOperTypeApplyStop:
return 1, true
case consts.CardStatusChangeOperTypeApplyResume:
return 0, true
default:
g.Log().Warningf(ctx, "Unknown card status change operType %s for recharge upstream", operType)
return 0, false
}
}
func (s *sSimRecharge) findCard(ctx context.Context, simKey *model.SimKey) (*rechargeFindCardResult, error) {
card := simKey.ICCID
if card == "" {
card = simKey.MSISDN
}
card = strings.TrimSpace(card)
if cached, ok := s.cachedCard(card); ok {
return cached, nil
}
value, err, _ := s.cardFlight.Do(card, func() (any, error) {
if cached, ok := s.cachedCard(card); ok {
return cached, nil
}
output, requestErr := s.client.DoPost(ctx, "/findCard", map[string]any{"card": card})
if requestErr != nil {
return nil, requestErr
}
if output.Code == recharge.CodeRateLimited {
g.Log().Warningf(ctx, "recharge findCard rate limited, chanKey %s, card %s", simKey.ChanKey, card)
}
if output.Code != recharge.CodeSuccess {
return nil, &recharge.BusinessError{Code: output.Code, Msg: output.Msg}
}
var info model.RechargeCardInfo
if unmarshalErr := json.Unmarshal(output.Data, &info); unmarshalErr != nil {
return nil, unmarshalErr
}
updatedAt := time.Now().Format("2006-01-02 15:04:05")
s.cardCache.Store(card, rechargeCardCacheEntry{
Info: info,
UpdatedAt: updatedAt,
ExpiresAt: time.Now().Add(rechargeFindCardCacheTTL),
})
return &rechargeFindCardResult{Info: &info, UpdatedAt: updatedAt}, nil
})
if err != nil {
return nil, err
}
result, ok := value.(*rechargeFindCardResult)
if !ok || result == nil || result.Info == nil {
return nil, gerror.New("recharge findCard returned invalid result")
}
return result, nil
}
func (s *sSimRecharge) cachedCard(card string) (*rechargeFindCardResult, bool) {
value, ok := s.cardCache.Load(strings.TrimSpace(card))
if !ok {
return nil, false
}
entry, ok := value.(rechargeCardCacheEntry)
if !ok || time.Now().After(entry.ExpiresAt) {
s.cardCache.Delete(strings.TrimSpace(card))
return nil, false
}
info := entry.Info
return &rechargeFindCardResult{Info: &info, Cached: true, UpdatedAt: entry.UpdatedAt}, true
}
func (s *sSimRecharge) invalidateCardCache(card string) {
s.cardCache.Delete(strings.TrimSpace(card))
}
func rechargeErrorMessage(err error) string {
return err.Error()
}
// QuerySimBasicInfo 单卡基本信息查询
// findCard -> ActiveDate=activate_date, Remark 附带实名状态
func (s *sSimRecharge) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
items := make(model.SimBasicInfoOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
result, err := s.findCard(ctx, simKey)
if err != nil {
g.Log().Errorf(ctx, "Recharge QuerySimBasicInfo error %+v, simKey %+v", err, simKey)
items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: rechargeErrorMessage(err),
})
continue
}
info := result.Info
remark := "未实名"
if info.RealName == 1 {
remark = "已实名"
}
items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
Cached: result.Cached,
UpdatedAt: result.UpdatedAt,
ResData: model.SimBasicInfoItem{
ActiveDate: info.ActivateDate,
OpenDate: "",
Remark: remark,
},
})
}
return items, nil
}
// QuerySimCardStatus 物联网卡卡状态查询
// findCard.status_show_id -> 系统 cardStatus
func (s *sSimRecharge) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
items := make(model.SimCardStatusOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
result, err := s.findCard(ctx, simKey)
if err != nil {
g.Log().Errorf(ctx, "Recharge QuerySimCardStatus error %+v, simKey %+v", err, simKey)
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: rechargeErrorMessage(err),
})
continue
}
info := result.Info
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
Cached: result.Cached,
UpdatedAt: result.UpdatedAt,
ResData: model.SimCardStatusItem{
CardStatus: convertRechargeCardStatus(ctx, info.StatusShowID),
LastChangeDate: info.ExpireTime,
},
})
}
return items, nil
}
// QuerySimDataUsage 单卡本月流量累计使用量查询
// findCard -> c_used/c_total (单位 MB)
func (s *sSimRecharge) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) {
items := make(model.SimDataUsageOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
result, err := s.findCard(ctx, simKey)
if err != nil {
g.Log().Errorf(ctx, "Recharge QuerySimDataUsage error %+v, simKey %+v", err, simKey)
items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: rechargeErrorMessage(err),
})
continue
}
info := result.Info
totalFlow := info.TotalFlow
items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
Cached: result.Cached,
UpdatedAt: result.UpdatedAt,
ResData: model.SimDataUsageItem{
DataUsage: info.UsedFlow,
TotalFlow: &totalFlow,
},
})
}
return items, nil
}
// QuerySimDataDiagnosis 腾宇通道暂不支持达量限速档位查询。
func (s *sSimRecharge) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
items := make(model.SimDataDiagnosisOutput, 0)
if input == nil {
return items, nil
}
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[any]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("达量限速档位查询"),
})
}
return items, nil
}
// ChangeSimStatus 物联网卡状态变更(停复机)
// 系统 operType(0停机/1复机) -> 腾宇 changeState type(1停机/0复机)
func (s *sSimRecharge) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) {
changeType, ok := convertRechargeChangeStateType(ctx, input.ReqData.OperType)
if !ok {
return newInvalidCardStatusChangeOutput(input.SimKeys), nil
}
items := make(model.SimCardStatusChangeOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
card := simKey.ICCID
if card == "" {
card = simKey.MSISDN
}
output, err := s.client.DoPost(ctx, "/changeState", map[string]any{
"card": card,
"type": changeType,
})
if err != nil {
g.Log().Errorf(ctx, "Recharge ChangeSimStatus error %+v, simKey %+v", err, simKey)
items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: err.Error(),
ResData: model.SimCardStatusChangeItem{},
})
continue
}
if output.Code != recharge.CodeSuccess {
g.Log().Warningf(ctx, "Recharge ChangeSimStatus upstream error code %d msg %s, simKey %+v", output.Code, output.Msg, simKey)
items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: output.Msg,
ResData: model.SimCardStatusChangeItem{},
})
continue
}
s.invalidateCardCache(card)
items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{
SimKey: simKey,
Code: consts.ApiStatusSuccess,
Message: output.Msg,
ResData: model.SimCardStatusChangeItem{
OrderNum: "",
},
})
}
return items, nil
}
// 腾宇通道暂未支持的查询,参照 sim_dx.go 模式返回 ApiInvokeException
func rechargeUnsupportedMessage(name string) string {
return "腾宇通道暂不支持" + name
}
func (s *sSimRecharge) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) {
items := make(model.SimSessionOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimSessionItem]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("在线信息查询"),
ResData: model.SimSessionItem{Status: consts.CardSessionStatusUnknown},
})
}
return items, nil
}
func (s *sSimRecharge) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
items := make(model.SimStopReasonOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("停机原因查询"),
})
}
return items, nil
}
func (s *sSimRecharge) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
items := make(model.SimFrozenStateOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("冻结状态查询"),
})
}
return items, nil
}
func (s *sSimRecharge) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
items := make(model.SimCardBindStatusOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("机卡绑定状态查询"),
})
}
return items, nil
}
func (s *sSimRecharge) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
items := make(model.SimDeviceStatusOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("设备状态查询"),
ResData: model.SimDeviceStatusItem{DeviceStatus: consts.CardSessionStatusUnknown},
})
}
return items, nil
}
func (s *sSimRecharge) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
items := make(model.SimRegionLimitStatusOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("地域限制状态查询"),
})
}
return items, nil
}
func (s *sSimRecharge) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) {
items := make(model.SimImeiOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimImeiItem]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("IMEI查询"),
})
}
return items, nil
}
func (s *sSimRecharge) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("单月流量查询"),
})
}
return items, nil
}
func (s *sSimRecharge) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("单日流量查询"),
})
}
return items, nil
}
func (s *sSimRecharge) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
items := make(model.SimApnInfoOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("APN信息查询"),
ResData: model.SimApnInfoItemList{ApnList: make([]model.SimApnInfoItem, 0)},
})
}
return items, nil
}
// ChangeSimApnFunction 腾宇无 APN 开关语义(changeNet 是整卡开断网,不等价),不做映射
func (s *sSimRecharge) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
for _, simKey := range input.SimKeys {
items = append(items, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
SimKey: simKey,
Code: consts.ApiInvokeException,
Message: rechargeUnsupportedMessage("APN功能开停"),
ResData: model.SimCardOperateChangeApnItem{},
})
}
return items, nil
}
@@ -0,0 +1,46 @@
package upstreamimpl
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/recharge"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
func TestRechargeFindCardCacheIsSharedAcrossDiagnosisQueries(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"msg":"ok","data":{"w_real_name":1,"c_no":"8986000000000000001","status_show_id":4,"c_total":1024,"c_used":128,"c_left":896,"prodinstexp_time":"2026-12-31","activate_date":"2026-01-01"}}`))
}))
defer server.Close()
sim := &sSimRecharge{client: recharge.NewClient(recharge.Config{
BaseURL: server.URL,
PwdKey: "test",
Pwdiv: "secret",
Timeout: time.Second,
})}
simKey := &model.SimKey{ICCID: "8986000000000000001"}
basic, err := sim.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: []*model.SimKey{simKey}})
if err != nil || len(basic) != 1 || basic[0].Code != 0 {
t.Fatalf("basic query failed: output=%#v err=%v", basic, err)
}
status, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{simKey}})
if err != nil || len(status) != 1 || status[0].Code != 0 {
t.Fatalf("status query failed: output=%#v err=%v", status, err)
}
if calls.Load() != 1 {
t.Fatalf("findCard upstream calls = %d, want 1", calls.Load())
}
if !status[0].Cached || status[0].UpdatedAt == "" {
t.Fatalf("cached metadata missing: %#v", status[0])
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
package upstreamimpl
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
)
func TestTibiotAPNWriteResponseLossIsUncertain(t *testing.T) {
for _, tc := range []struct {
name string
body string
drop bool
uncertain bool
wantCode int
}{
{"已办理后断响应", "", true, true, 9999},
{"响应损坏", "{", false, true, 9999},
{"缺status", `{}`, false, true, 9999},
{"畸形status", `{"status":true}`, false, true, 9999},
{"小数status", `{"status":0.5}`, false, true, 9999},
{"明确拒绝", `{"status":9999,"message":"拒绝"}`, false, false, 9999},
{"明确成功", `{"status":0,"message":"成功"}`, false, false, 0},
} {
t.Run(tc.name, func(t *testing.T) {
var writes atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/card/cardNetWork" {
t.Errorf("错误路由:%s", r.URL.Path)
}
writes.Add(1)
if tc.drop {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
_ = conn.Close()
return
}
_, _ = w.Write([]byte(tc.body))
}))
defer server.Close()
client := &sSimTibiot{baseURL: server.URL, userName: "tester", password: "fake", http: server.Client()}
out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{ICCID: "fake-card"}},
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", SubmitOnly: true},
})
if err != nil || len(out) != 1 || writes.Load() != 1 || out[0].Code != tc.wantCode || out[0].ResData.Uncertain != tc.uncertain || out[0].ResData.Pending {
t.Fatalf("写后语义错误:out=%+v writes=%d err=%v", out, writes.Load(), err)
}
t.Logf("writes=%d uncertain=%v code=%d", writes.Load(), out[0].ResData.Uncertain, out[0].Code)
})
}
}
func TestTibiotAPNRedirectDoesNotReplayAndReadUnchanged(t *testing.T) {
for _, status := range []int{307, 308} {
t.Run(http.StatusText(status), func(t *testing.T) {
var writes, targets atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("方法改变:%s", r.Method)
}
writes.Add(1)
if r.URL.Path == "/target" {
targets.Add(1)
_, _ = w.Write([]byte(`{"status":0}`))
return
}
w.Header().Set("Location", "/target")
w.WriteHeader(status)
}))
defer server.Close()
client := &sSimTibiot{baseURL: server.URL, userName: "tester", password: "fake", http: server.Client()}
out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{ICCID: "fake"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0"}})
if err != nil || len(out) != 1 || writes.Load() != 1 || targets.Load() != 0 || out[0].Code == 0 || !out[0].ResData.Uncertain {
t.Fatalf("跳转重放/分类错误:out=%+v writes=%d targets=%d err=%v", out, writes.Load(), targets.Load(), err)
}
t.Logf("APN writes=%d targetWrites=%d uncertain=%v", writes.Load(), targets.Load(), out[0].ResData.Uncertain)
resp, err := client.post(context.Background(), "/read-endpoint", map[string]any{})
if err != nil || !tibiotSuccess(resp) || writes.Load() != 3 || targets.Load() != 1 {
t.Fatalf("只读跳转改变:%+v %v calls=%d targets=%d", resp, err, writes.Load(), targets.Load())
}
})
}
}
func TestTibiotRateLimiterSpacesRequestsAndHonorsCancellation(t *testing.T) {
limiter := newTibiotRateLimiter(25 * time.Millisecond)
if err := limiter.wait(context.Background()); err != nil {
t.Fatalf("first wait failed: %v", err)
}
started := time.Now()
if err := limiter.wait(context.Background()); err != nil {
t.Fatalf("second wait failed: %v", err)
}
if elapsed := time.Since(started); elapsed < 20*time.Millisecond {
t.Fatalf("second slot was granted too early: %s", elapsed)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
reservedBeforeCancel := limiter.next
if err := limiter.wait(ctx); err == nil {
t.Fatal("expected cancellation while waiting for a slot")
}
if !limiter.next.Equal(reservedBeforeCancel) {
t.Fatalf("canceled waiter reserved a slot: before=%s after=%s", reservedBeforeCancel, limiter.next)
}
}
func TestTibiotQuerySimDataUsageRetriesOnceAfterUpstreamFailure(t *testing.T) {
previousLimiter := tibiotSimDataMarginLimiter
tibiotSimDataMarginLimiter = newTibiotRateLimiter(time.Millisecond)
defer func() { tibiotSimDataMarginLimiter = previousLimiter }()
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["flowUnit"] != "MB" {
t.Fatalf("flowUnit missing: %#v", body)
}
if calls.Add(1) == 1 {
_, _ = w.Write([]byte(`{"status":10049,"message":"too many requests","data":null}`))
return
}
_, _ = w.Write([]byte(`{"status":0,"message":"ok","data":"2MB"}`))
}))
defer server.Close()
client := NewSimTibiot().NewTemp(context.Background(), &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempConfig: `{"api":{"baseUrl":"` + server.URL + `"},"http":{"timeout":"2s"}}`,
},
Channel: &entity.TSimChannels{ChanConfig: `{"userName":"tester","password":"secret"}`},
}).(*sSimTibiot)
items, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{
SimKeys: []*model.SimKey{{ICCID: "8986000000000000001"}},
})
if err != nil || len(items) != 1 {
t.Fatalf("unexpected query result: items=%#v err=%v", items, err)
}
if calls.Load() != 2 {
t.Fatalf("upstream calls = %d, want one retry", calls.Load())
}
if items[0].Code != 0 || items[0].ResData.DataUsage != 2 {
t.Fatalf("unexpected successful retry result: %#v", items[0])
}
cachedItems, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{
SimKeys: []*model.SimKey{{ICCID: "8986000000000000001"}},
})
if err != nil || len(cachedItems) != 1 {
t.Fatalf("unexpected cached query result: items=%#v err=%v", cachedItems, err)
}
if calls.Load() != 2 {
t.Fatalf("cached query called upstream again: calls=%d", calls.Load())
}
if !cachedItems[0].Cached || cachedItems[0].UpdatedAt == "" {
t.Fatalf("cached query metadata missing: %#v", cachedItems[0])
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,495 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"github.com/gogf/gf/v2/frame/g"
)
// 移动 OneLink「通信功能开停」接口(2026 接口改版后,替代原 ec/operate/sim-apn-function)。
//
// 7.2.1 单卡(CMIOT_API25M19) GET /v5/ec/manage/sim-communication-service
// functionType=01(APN上网功能),businessType:0=开 1=停,APN 名称字段为 apn
// 7.2.2 批量(CMIOT_API23M09) GET /v5/ec/operate/sim-communication-function/batch
// serviceType=11(数据通信服务),operType:0=暂停(停) 1=恢复(开),APN 名称字段为 apnName
//
// 分流策略(与产品约定):
//
// 卡数 <10 逐张走 7.2.1
// 10~100 一次走 7.2.2
// >100 按 100 拆块,不足 10 的尾块退回 7.2.1
//
// 批量接口 7.2.2 只认 msisdn,缺号码的卡降级走 7.2.1(用 iccid)。
// 两个接口同卡同类型均有限流(3 分钟一次),且 7.2.2 批量中任一张被限流则整批失败(status 13009)。
const (
ydCommunicationServicePath = "ec/manage/sim-communication-service"
ydCommunicationFunctionBatchPath = "ec/operate/sim-communication-function/batch"
ydCommunicationBatchResultPath = "ec/query/sim-batch-result" // 5.1.2 CMIOT_API23M10 批量办理结果查询
ydCommunicationFunctionTypeApn = "01" // 7.2.1 functionType:APN 上网功能
ydCommunicationServiceTypeData = "11" // 7.2.2 serviceType:数据通信服务
ydCommunicationBatchMin = 10 // 7.2.2 每批最少卡数
ydCommunicationBatchMax = 100 // 7.2.2 每批最多卡数
)
// 批量办理任务状态(5.1.2 sim-batch-result 返回的 jobStatus)。
const (
ydJobStatusDone = "2" // 处理完成(全部成功)
ydJobStatusDonePartial = "3" // 处理完成(含失败记录)
ydJobStatusFailed = "4" // 处理失败
)
// ydBatchResultQueryFailedMessage 是批量异步任务结果查询失败(轮询超时 / 查询请求失败)时,
// 逐卡写入执行日志明细的失败原因:该子批次下每张卡都记这一条,代表"结果未确认"而非"办理失败"。
const ydBatchResultQueryFailedMessage = "查询上游通信结果失败,未取得最终办理结果,请稍后核对"
// 批量结果轮询参数;声明为变量以便单测缩短周期。
//
// 两个值均维持原值,原因(实测:上游批量任务约 60s 出终态):
// - 间隔只决定终态被捕获的延迟(0~interval)。整批 2 万张 = 200 子批 / 并发 5 = 40 波,
// 总等待由"每波等上游 job 完成"主导,缩间隔对总时长影响可忽略,却成倍增加查询接口压力。
// - 上限若下调到 60s 会踩在上游耗时边界上,超时即把整个子批 100 张记为失败,而失败结果
// 不回写本地 data_service_status,会造成平台状态与卡真实状态不一致。
//
// 真正压缩总等待应把"提交"与"结果查询"分离(全部子批先拿 jobId,再统一并发轮询),
// 使总等待从 40 × 上限 降到 ≈ 1 × 上限,而不是靠缩短这里的超时。
var (
ydBatchResultPollInterval = 5 * time.Second
ydBatchResultPollTimeout = 180 * time.Second
)
// ydApnRequester 抽出移动通道发起请求所需的最小能力,供真实通道(sSimYD)与 mock 通道(sSimYDMock)
// 共用同一套开停逻辑,避免两份实现走偏。
type ydApnRequester interface {
getUrl(ctx context.Context, serviceUrl string) string
getTransId(ctx context.Context) string
requestYd(ctx context.Context, operation, url string, params g.Map) (*ydHTTPResponse, error)
}
// changeSimApnFunctionYD 平台「开通数据(dataStart)/停用数据(dataStop)/达量断网(dataCut)」在移动侧的统一入口。
func changeSimApnFunctionYD(ctx context.Context, c ydApnRequester, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
if jobID := strings.TrimSpace(input.ReqData.QueryJobID); jobID != "" {
return mapYdQueryResult(input.SimKeys, jobID, queryBatchResultOnceYD(ctx, c, jobID)), nil
}
// 缺 msisdn 的卡不能进批量接口,先分流出来单独走单卡。
withMsisdn := make([]*model.SimKey, 0, len(input.SimKeys))
singleKeys := make([]*model.SimKey, 0)
for _, simKey := range input.SimKeys {
if simKey == nil {
continue
}
if strings.TrimSpace(simKey.MSISDN) == "" {
singleKeys = append(singleKeys, simKey)
continue
}
withMsisdn = append(withMsisdn, simKey)
}
for start := 0; start < len(withMsisdn); start += ydCommunicationBatchMax {
end := start + ydCommunicationBatchMax
if end > len(withMsisdn) {
end = len(withMsisdn)
}
chunk := withMsisdn[start:end]
if len(chunk) < ydCommunicationBatchMin {
singleKeys = append(singleKeys, chunk...)
continue
}
items = append(items, changeApnBatchYD(ctx, c, chunk, input.ReqData)...)
if input.ReqData.SubmitOnly {
if checkpoint, ok := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error); ok {
if err := checkpoint(items); err != nil {
g.Log().Errorf(ctx, "APN批量结果持久化失败 operationId=%s error=%v", input.ReqData.OperationID, err)
return items, fmt.Errorf("APN批量结果持久化失败,停止后续办理:%w", err)
}
}
}
}
if input.ReqData.SubmitOnly {
for _, key := range singleKeys {
items = append(items, changeApnSingleYD(ctx, c, []*model.SimKey{key}, input.ReqData)...)
if checkpoint, ok := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error); ok {
if err := checkpoint(items); err != nil {
g.Log().Errorf(ctx, "APN单卡结果持久化失败 operationId=%s error=%v", input.ReqData.OperationID, err)
return items, fmt.Errorf("APN单卡结果持久化失败,停止后续办理:%w", err)
}
}
}
} else {
items = append(items, changeApnSingleYD(ctx, c, singleKeys, input.ReqData)...)
}
return items, nil
}
// changeApnSingleYD 逐张调用 7.2.1 单卡接口办理。
func changeApnSingleYD(ctx context.Context, c ydApnRequester, simKeys []*model.SimKey, req model.SimCardOperateChangeApnInputItem) model.SimCardOperateChangeApnOutput {
items := make(model.SimCardOperateChangeApnOutput, 0, len(simKeys))
if len(simKeys) == 0 {
return items
}
businessType, ok := convertYdCommunicationBusinessType(ctx, req.OperType)
if !ok {
for _, simKey := range simKeys {
items = append(items, ydApnErrorItem(simKey, "unsupported operType, expected 0(open) or 1(stop)"))
}
return items
}
url := c.getUrl(ctx, ydCommunicationServicePath)
// {"status":"0","message":"正确","result":[{"iccid":"89860000000000000002","orderNum":"xxxxxxx"}]}
type ydSingleResp struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
OrderNum string `json:"orderNum"`
} `json:"result"`
}
for _, simKey := range simKeys {
params := g.Map{
"transid": c.getTransId(ctx),
"functionType": ydCommunicationFunctionTypeApn,
"businessType": businessType,
"apn": req.ApnName,
}
// 卡号三选一,必须有且只有一项:优先 iccid,其次 msisdn,最后 imsi。
switch {
case strings.TrimSpace(simKey.ICCID) != "":
params["iccid"] = strings.TrimSpace(simKey.ICCID)
case strings.TrimSpace(simKey.MSISDN) != "":
params["msisdn"] = strings.TrimSpace(simKey.MSISDN)
case strings.TrimSpace(simKey.IMSI) != "":
params["imsi"] = strings.TrimSpace(simKey.IMSI)
default:
g.Log().Warningf(ctx, "changeApnSingleYD missing iccid/msisdn/imsi, simKey %+v", simKey)
items = append(items, ydApnErrorItem(simKey, "missing iccid/msisdn/imsi"))
continue
}
resp, err := c.requestYd(ctx, "ChangeSimApnFunction", url, params)
if err != nil {
g.Log().Errorf(ctx, "changeApnSingleYD request error %+v, url %s, params %+v", err, url, params)
item := ydApnErrorItem(simKey, err.Error())
item.ResData.Uncertain = req.SubmitOnly
items = append(items, item)
continue
}
body := resp.ReadAllString()
var ydResp ydSingleResp
if err = json.Unmarshal([]byte(body), &ydResp); err != nil {
g.Log().Errorf(ctx, "changeApnSingleYD unmarshal error %+v, url %s, params %+v, body %s", err, url, params, body)
item := ydApnErrorItem(simKey, err.Error())
item.ResData.Uncertain = req.SubmitOnly
items = append(items, item)
continue
}
if ydResp.Status != "0" {
g.Log().Warningf(ctx, "changeApnSingleYD upstream error %s, url %s, params %+v, body %s", ydResp.Message, url, params, body)
items = append(items, ydApnRejectedItem(simKey, ydResp.Status, ydResp.Message))
continue
}
orderNum := ""
if len(ydResp.Result) > 0 {
orderNum = ydResp.Result[0].OrderNum
}
items = append(items, ydApnResultItem(simKey, 0, ydResp.Message, orderNum))
}
return items
}
// changeApnBatchYD 用一批(10~100 张)调用 7.2.2 批量接口一次办理。
// 该接口异步返回 jobId,status=0 即视为本批全部受理成功,逐卡回填同一个 jobId。
func changeApnBatchYD(ctx context.Context, c ydApnRequester, simKeys []*model.SimKey, req model.SimCardOperateChangeApnInputItem) model.SimCardOperateChangeApnOutput {
items := make(model.SimCardOperateChangeApnOutput, 0, len(simKeys))
operType, ok := convertYdCommunicationOperType(ctx, req.OperType)
if !ok {
for _, simKey := range simKeys {
items = append(items, ydApnErrorItem(simKey, "unsupported operType, expected 0(open) or 1(stop)"))
}
return items
}
msisdns := make([]string, 0, len(simKeys))
for _, simKey := range simKeys {
msisdns = append(msisdns, strings.TrimSpace(simKey.MSISDN))
}
url := c.getUrl(ctx, ydCommunicationFunctionBatchPath)
params := g.Map{
"transid": c.getTransId(ctx),
"msisdns": strings.Join(msisdns, "_"),
"serviceType": ydCommunicationServiceTypeData,
"operType": operType,
"apnName": req.ApnName,
}
// {"status":"0","message":"正确","result":[{"jobId":"xxxxxxx"}]}
type ydBatchResp struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
JobId string `json:"jobId"`
} `json:"result"`
}
resp, err := c.requestYd(ctx, "ChangeSimApnFunction", url, params)
if err != nil {
g.Log().Errorf(ctx, "changeApnBatchYD request error %+v, url %s, params %+v", err, url, params)
for _, simKey := range simKeys {
item := ydApnErrorItem(simKey, err.Error())
item.ResData.Uncertain = req.SubmitOnly
items = append(items, item)
}
return items
}
body := resp.ReadAllString()
var ydResp ydBatchResp
if err = json.Unmarshal([]byte(body), &ydResp); err != nil {
g.Log().Errorf(ctx, "changeApnBatchYD unmarshal error %+v, url %s, params %+v, body %s", err, url, params, body)
for _, simKey := range simKeys {
item := ydApnErrorItem(simKey, err.Error())
item.ResData.Uncertain = req.SubmitOnly
items = append(items, item)
}
return items
}
if ydResp.Status != "0" {
g.Log().Warningf(ctx, "changeApnBatchYD upstream error %s, url %s, params %+v, body %s", ydResp.Message, url, params, body)
for _, simKey := range simKeys {
items = append(items, ydApnRejectedItem(simKey, ydResp.Status, ydResp.Message))
}
return items
}
jobId := ""
if len(ydResp.Result) > 0 {
jobId = ydResp.Result[0].JobId
}
if jobId == "" {
g.Log().Warningf(ctx, "changeApnBatchYD accepted without jobId, url %s, params %+v, body %s", url, params, body)
for _, simKey := range simKeys {
item := ydApnResultItem(simKey, consts.ApiStatusError, "上游受理成功但未返回 jobId,需核对受理结果", "")
item.ResData.Uncertain = true
items = append(items, item)
}
return items
}
if req.SubmitOnly {
for _, simKey := range simKeys {
item := ydApnResultItem(simKey, 0, "上游已受理,等待查询最终结果", jobId)
item.ResData.Pending = true
items = append(items, item)
}
return items
}
// 7.2.2 是异步接口:受理(status=0)不等于办理成功。必须轮询结果接口拿到每张卡的最终结果,
// 否则执行日志会把"部分失败"的批次整批误记为成功。
result := queryBatchResultYD(ctx, c, jobId)
switch {
case result.finished && result.failed:
for _, simKey := range simKeys {
items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, "上游批量任务处理失败", jobId))
}
case result.finished:
for _, simKey := range simKeys {
card, exist := result.cards[strings.TrimSpace(simKey.MSISDN)]
if !exist {
item := ydApnResultItem(simKey, consts.ApiStatusError, "上游批量结果未返回该卡", jobId)
item.ResData.Uncertain = true
items = append(items, item)
continue
}
item := ydApnResultItem(simKey, card.code, card.message, jobId)
item.ResData.Uncertain = card.uncertain
items = append(items, item)
}
default:
// 轮询超时或查询失败:该子批下每张卡都按"查询上游通信结果失败"落库,避免误报成功。
// 该文案会逐卡写进执行日志明细;日后若增加"重新查询上游结果"的补偿能力,从这一层补做即可。
g.Log().Warningf(ctx, "changeApnBatchYD result poll not finished, jobId %s, params %+v", jobId, params)
for _, simKey := range simKeys {
item := ydApnResultItem(simKey, consts.ApiStatusError, ydBatchResultQueryFailedMessage+":"+result.reason, jobId)
item.ResData.Uncertain = true
items = append(items, item)
}
}
return items
}
func (s *sSimYD) QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
return mapYdQueryResult(input.SimKeys, input.ReqData.QueryJobID, queryBatchResultOnceYD(ctx, s, input.ReqData.QueryJobID)), nil
}
func (s *sSimYDMock) QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
return mapYdQueryResult(input.SimKeys, input.ReqData.QueryJobID, queryBatchResultOnceYD(ctx, s, input.ReqData.QueryJobID)), nil
}
// ydBatchCardResult 批量任务中单张卡的最终办理结果。
type ydBatchCardResult struct {
code int
message string
uncertain bool
}
// ydBatchQueryResult 批量办理结果查询的结论。
type ydBatchQueryResult struct {
finished bool // 是否已取得终态(jobStatus 2/3/4)
failed bool // jobStatus=4,整批处理失败
cards map[string]ydBatchCardResult // resultId(msisdn)-> 单卡结果
reason string
}
// ydBatchResultItem 5.1.2 sim-batch-result 的逐卡结果项。
type ydBatchResultItem struct {
Status string `json:"status"`
Message string `json:"message"`
ResultType string `json:"resultType"`
ResultId string `json:"resultId"`
}
// ydBatchResultResp 5.1.2 sim-batch-result 的响应体。
type ydBatchResultResp struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
JobStatus string `json:"jobStatus"`
ResultList []ydBatchResultItem `json:"resultList"`
} `json:"result"`
}
// queryBatchResultYD 轮询 7.2.2 批量任务结果,直到取得终态或超时。
// 超时 / 请求失败均返回 finished=false,由调用方按"未确认"处理。
func queryBatchResultYD(ctx context.Context, c ydApnRequester, jobId string) ydBatchQueryResult {
pollCtx, cancel := context.WithTimeout(ctx, ydBatchResultPollTimeout)
defer cancel()
var last ydBatchQueryResult
for {
if err := pollCtx.Err(); err != nil {
last.reason = "查询上游通信结果截止:" + err.Error() + ";" + last.reason
return last
}
last = queryBatchResultOnceYD(pollCtx, c, jobId)
if last.finished {
return last
}
timer := time.NewTimer(ydBatchResultPollInterval)
select {
case <-pollCtx.Done():
timer.Stop()
last.reason = "查询上游通信结果截止:" + pollCtx.Err().Error() + ";" + last.reason
return last
case <-timer.C:
}
}
}
// queryBatchResultOnceYD 只查询一次;持久化调度器负责后续补查。
func queryBatchResultOnceYD(ctx context.Context, c ydApnRequester, jobID string) ydBatchQueryResult {
params := g.Map{"transid": c.getTransId(ctx), "jobId": jobID}
resp, err := c.requestYd(ctx, "QuerySimBatchResult", c.getUrl(ctx, ydCommunicationBatchResultPath), params)
if err != nil {
return ydBatchQueryResult{reason: "查询上游通信结果失败:" + err.Error()}
}
body := resp.ReadAllString()
var parsed ydBatchResultResp
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
return ydBatchQueryResult{reason: "查询上游通信结果解析失败:" + err.Error()}
}
if parsed.Status != "0" {
return ydBatchQueryResult{reason: "查询上游通信结果失败,status=" + parsed.Status + ",message=" + parsed.Message}
}
if len(parsed.Result) == 0 {
return ydBatchQueryResult{reason: "上游查询未返回任务结果"}
}
switch parsed.Result[0].JobStatus {
case ydJobStatusDone, ydJobStatusDonePartial:
return ydBatchQueryResult{finished: true, cards: toYdBatchCardMap(parsed.Result[0].ResultList)}
case ydJobStatusFailed:
return ydBatchQueryResult{finished: true, failed: true, reason: parsed.Message}
default:
return ydBatchQueryResult{reason: "上游任务尚未完成,jobStatus=" + parsed.Result[0].JobStatus}
}
}
func mapYdQueryResult(keys []*model.SimKey, jobID string, result ydBatchQueryResult) model.SimCardOperateChangeApnOutput {
items := make(model.SimCardOperateChangeApnOutput, 0, len(keys))
for _, key := range keys {
if key == nil {
continue
}
item := ydApnResultItem(key, consts.ApiStatusError, result.reason, jobID)
switch {
case !result.finished:
item.ResData.Pending = true
case result.failed:
item.Message = "上游批量任务处理失败:" + result.reason
default:
if card, ok := result.cards[strings.TrimSpace(key.MSISDN)]; ok {
item.Code, item.Message = card.code, card.message
item.ResData.Uncertain = card.uncertain
} else {
item.Message = "上游批量结果未返回该卡,需继续核对"
item.ResData.Uncertain = true
}
}
items = append(items, item)
}
return items
}
// toYdBatchCardMap 把逐卡结果按 resultId(msisdn)建索引。
func toYdBatchCardMap(list []ydBatchResultItem) map[string]ydBatchCardResult {
cards := make(map[string]ydBatchCardResult, len(list))
for _, item := range list {
id := strings.TrimSpace(item.ResultId)
if id == "" {
continue
}
code, err := strconv.Atoi(strings.TrimSpace(item.Status))
if err != nil {
cards[id] = ydBatchCardResult{code: consts.ApiStatusError, message: "上游逐卡结果状态无效,status=" + item.Status + ",message=" + item.Message, uncertain: true}
continue
}
cards[id] = ydBatchCardResult{code: code, message: item.Message}
}
return cards
}
func ydApnRejectedItem(key *model.SimKey, status, message string) *model.SimBaseRes[model.SimCardOperateChangeApnItem] {
code, err := strconv.Atoi(strings.TrimSpace(status))
if err != nil || code == 0 {
item := ydApnErrorItem(key, "上游受理状态无效,status="+status+",message="+message)
item.ResData.Uncertain = true
return item
}
return ydApnResultItem(key, code, message, "")
}
func ydApnErrorItem(simKey *model.SimKey, message string) *model.SimBaseRes[model.SimCardOperateChangeApnItem] {
return &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
SimKey: simKey,
Code: consts.ApiStatusError,
Message: message,
}
}
func ydApnResultItem(simKey *model.SimKey, code int, message, orderNum string) *model.SimBaseRes[model.SimCardOperateChangeApnItem] {
return &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
SimKey: simKey,
Code: code,
Message: message,
ResData: model.SimCardOperateChangeApnItem{OrderNum: orderNum},
}
}
@@ -0,0 +1,557 @@
package upstreamimpl
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
)
func TestYdLegacyPollingHardDeadlineCancelsInflightRequest(t *testing.T) {
defer shortenYdBatchPoll(50 * time.Millisecond)()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(time.Second):
_, _ = w.Write([]byte(`{"status":"0","result":[{"jobStatus":"1"}]}`))
}
}))
defer server.Close()
started := time.Now()
result := queryBatchResultYD(context.Background(), newYdApnChannelWithServer(server), "deadline-job")
if result.finished || time.Since(started) > 500*time.Millisecond || !strings.Contains(result.reason, "deadline") {
t.Fatalf("轮询没有硬截止:elapsed=%s result=%+v", time.Since(started), result)
}
}
func TestYdAsyncCheckpointFailureStopsFurtherCarrierWrites(t *testing.T) {
for _, tc := range []struct {
name string
keys []*model.SimKey
firstPath string
firstLen int
}{
{name: "single_then_single", keys: ydSimKeysWithMsisdn(2), firstPath: ydCommunicationServicePath, firstLen: 1},
{name: "batch_then_single", keys: func() []*model.SimKey {
keys := ydSimKeysWithMsisdn(11)
keys[10].MSISDN = ""
return keys
}(), firstPath: ydCommunicationFunctionBatchPath, firstLen: 10},
} {
t.Run(tc.name, func(t *testing.T) {
capture := &ydApnCapture{}
channel, closeServer := newYdApnTestChannel(capture)
defer closeServer()
sentinel := errors.New("checkpoint unavailable")
checkpoints := 0
ctx := context.WithValue(context.Background(), model.ApnOperationCheckpointKey{}, func(output model.SimCardOperateChangeApnOutput) error {
checkpoints++
if len(output) != tc.firstLen {
t.Errorf("checkpoint contains %d results, want %d", len(output), tc.firstLen)
}
return sentinel
})
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
SimKeys: tc.keys, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", SubmitOnly: true, OperationID: "test-operation"},
})
if !errors.Is(err, sentinel) || checkpoints != 1 || len(items) != tc.firstLen {
t.Fatalf("checkpoint failure not propagated: err=%v checkpoints=%d items=%d", err, checkpoints, len(items))
}
if len(capture.paths) != 1 || !strings.HasSuffix(capture.paths[0], tc.firstPath) {
t.Fatalf("wrote another carrier request after checkpoint failure: %v", capture.paths)
}
})
}
}
func TestYdAsyncSubmitReturnsJobWithoutQuery(t *testing.T) {
capture := &ydApnCapture{}
channel, closeServer := newYdApnTestChannel(capture)
defer closeServer()
checkpoints := 0
ctx := context.WithValue(context.Background(), model.ApnOperationCheckpointKey{}, func(output model.SimCardOperateChangeApnOutput) error {
checkpoints++
if output[0].ResData.OrderNum != "JOB1" {
t.Fatal("checkpoint缺少jobId")
}
return nil
})
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
SimKeys: ydSimKeysWithMsisdn(100),
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: true},
})
if checkpoints != 1 {
t.Fatalf("拿jobId后未立即持久化:%d", checkpoints)
}
if err != nil || len(items) != 100 || len(capture.paths) != 1 {
t.Fatalf("提交应仅调用一次,items=%d paths=%v err=%v", len(items), capture.paths, err)
}
for _, item := range items {
if !item.ResData.Pending || item.ResData.OrderNum != "JOB1" || item.ResData.Uncertain {
t.Fatalf("受理状态错误:%+v", item)
}
}
}
func TestYdAsyncQueryOnlyOnceAndPreservesReason(t *testing.T) {
for _, body := range []string{
`{"status":"13012","message":"查询限流"}`,
`{"status":"0","result":[{"jobStatus":"1"}]}`,
`invalid-json`,
`{"status":"0","result":[]}`,
} {
t.Run(body, func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if !strings.Contains(r.URL.Path, "sim-batch-result") || r.URL.Query().Get("jobId") != "persisted-job" {
t.Errorf("不应重新提交:%s", r.URL.String())
}
_, _ = w.Write([]byte(body))
}))
defer server.Close()
items, err := changeSimApnFunctionYD(context.Background(), newYdApnChannelWithServer(server), &model.SimCardOperateChangeApnInput{
SimKeys: ydSimKeysWithMsisdn(10), ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: "persisted-job"},
})
if err != nil || calls != 1 || len(items) != 10 {
t.Fatalf("查询次数错误:calls=%d err=%v", calls, err)
}
for _, item := range items {
if !item.ResData.Pending || item.Message == "" || item.ResData.OrderNum != "persisted-job" {
t.Fatalf("应保留待查状态和具体原因:%+v", item)
}
}
})
}
}
func TestYdAsyncSubmitUnknownIsNotConfirmedFailure(t *testing.T) {
for _, body := range []string{`{"status":"0","result":[]}`, `not-json`, `{}`, `{"status":"bad"}`} {
t.Run(body, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}))
defer server.Close()
items, err := changeSimApnFunctionYD(context.Background(), newYdApnChannelWithServer(server), &model.SimCardOperateChangeApnInput{
SimKeys: ydSimKeysWithMsisdn(10), ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: true},
})
if err != nil || len(items) != 10 {
t.Fatalf("未知提交结果应逐卡返回:%v", err)
}
for _, item := range items {
if !item.ResData.Uncertain || item.Message == "" {
t.Fatalf("提交结果未知不得确认办理失败:%+v", item)
}
}
})
}
}
func TestYdAsyncQueryTerminalResults(t *testing.T) {
keys := ydSimKeysWithMsisdn(10)
for _, tc := range []struct{ status, want string }{{"2", "0"}, {"3", "4001"}, {"4", "failed"}} {
t.Run(tc.status, func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if !strings.Contains(r.URL.Path, "sim-batch-result") {
t.Error("查询不得提交")
}
_, _ = fmt.Fprintf(w, `{"status":"0","message":"任务完成","result":[{"jobStatus":%q,"resultList":[{"resultId":%q,"status":%q,"message":"逐卡结果"}]}]}`, tc.status, keys[0].MSISDN, tc.want)
}))
defer server.Close()
items, err := newYdApnChannelWithServer(server).QuerySimApnFunctionResult(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: keys, ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: "job"}})
if err != nil || calls != 1 || len(items) != 10 {
t.Fatalf("终态查询错误:%v", err)
}
if items[0].ResData.Pending || items[0].ResData.Uncertain {
t.Fatalf("已确认结果仍标未知:%+v", items[0])
}
if tc.status == "2" && items[0].Code != 0 {
t.Fatal("成功结果未映射")
}
if tc.status == "3" && items[0].Code != 4001 {
t.Fatal("逐卡失败原因未映射")
}
if tc.status == "4" && items[0].Code == 0 {
t.Fatal("任务失败误报成功")
}
})
}
}
func TestYdAsyncMalformedCardStatusIsUnconfirmed(t *testing.T) {
keys := ydSimKeysWithMsisdn(1)
cards := toYdBatchCardMap([]ydBatchResultItem{{ResultId: keys[0].MSISDN, Status: "invalid", Message: "bad status"}})
items := mapYdQueryResult(keys, "job", ydBatchQueryResult{finished: true, cards: cards})
if !items[0].ResData.Uncertain || items[0].Code == 0 || !strings.Contains(items[0].Message, "invalid") {
t.Fatalf("无效状态不能按成功处理:%+v", items[0])
}
}
func TestYdAsyncMissingCardRemainsUnconfirmed(t *testing.T) {
keys := ydSimKeysWithMsisdn(2)
items := mapYdQueryResult(keys, "job", ydBatchQueryResult{finished: true, cards: map[string]ydBatchCardResult{keys[0].MSISDN: {code: 0}}})
if items[0].Code != 0 || !items[1].ResData.Uncertain || items[1].ResData.OrderNum != "job" {
t.Fatalf("缺失结果应待核对:%+v", items)
}
}
func TestYdCommunicationBusinessType(t *testing.T) {
for _, tc := range []struct {
in string
want string
valid bool
}{
{"0", "0", true}, // 平台 开 -> 7.2.1 businessType 开(同向)
{"1", "1", true}, // 平台 停 -> 7.2.1 businessType 停
{"", "", false},
{"2", "", false},
} {
got, ok := convertYdCommunicationBusinessType(context.Background(), tc.in)
if got != tc.want || ok != tc.valid {
t.Errorf("businessType(%q) = (%q,%v), want (%q,%v)", tc.in, got, ok, tc.want, tc.valid)
}
}
}
func TestYdCommunicationOperType(t *testing.T) {
for _, tc := range []struct {
in string
want string
valid bool
}{
{"0", "1", true}, // 平台 开 -> 7.2.2 恢复(翻转)
{"1", "0", true}, // 平台 停 -> 7.2.2 暂停
{"", "", false},
{"9", "", false},
} {
got, ok := convertYdCommunicationOperType(context.Background(), tc.in)
if got != tc.want || ok != tc.valid {
t.Errorf("operType(%q) = (%q,%v), want (%q,%v)", tc.in, got, ok, tc.want, tc.valid)
}
}
}
type ydApnCapture struct {
paths []string
query []url.Values
msisdns string
}
// shortenYdBatchPoll 把批量结果轮询周期压缩到毫秒级,返回恢复函数。
func shortenYdBatchPoll(timeout time.Duration) func() {
oldInterval, oldTimeout := ydBatchResultPollInterval, ydBatchResultPollTimeout
ydBatchResultPollInterval, ydBatchResultPollTimeout = time.Millisecond, timeout
return func() { ydBatchResultPollInterval, ydBatchResultPollTimeout = oldInterval, oldTimeout }
}
func newYdApnChannelWithServer(server *httptest.Server) *sSimYD {
return &sSimYD{
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "test", AppKey: "test"}},
tokenProvider: diagnosisTestToken{},
apiConfig: &oneLinkAPIConfig{BaseURL: server.URL + "/v5"},
}
}
func newYdApnTestChannel(capture *ydApnCapture) (*sSimYD, func()) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capture.paths = append(capture.paths, r.URL.Path)
capture.query = append(capture.query, r.URL.Query())
switch {
case strings.Contains(r.URL.Path, "sim-communication-function/batch"):
capture.msisdns = r.URL.Query().Get("msisdns")
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobId":"JOB1"}]}`))
case strings.Contains(r.URL.Path, "sim-batch-result"):
_, _ = w.Write([]byte(ydBatchResultAllSuccessBody(capture.msisdns)))
default:
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"orderNum":"ORD1"}]}`))
}
}))
return newYdApnChannelWithServer(server), server.Close
}
// ydBatchResultAllSuccessBody 依据批量请求的 msisdns 拼一个"全部成功"的结果查询响应。
func ydBatchResultAllSuccessBody(msisdns string) string {
items := make([]string, 0)
for _, id := range strings.Split(msisdns, "_") {
id = strings.TrimSpace(id)
if id == "" {
continue
}
items = append(items, fmt.Sprintf(`{"status":"0","message":"正确","resultType":"1","resultId":%q}`, id))
}
return fmt.Sprintf(`{"status":"0","message":"正确","result":[{"jobStatus":"2","resultList":[%s]}]}`, strings.Join(items, ","))
}
func ydSimKeysWithMsisdn(n int) []*model.SimKey {
keys := make([]*model.SimKey, 0, n)
for i := 0; i < n; i++ {
keys = append(keys, &model.SimKey{
ICCID: fmt.Sprintf("898604F41523911%05d", i),
MSISDN: fmt.Sprintf("14415436%05d", i),
IMSI: fmt.Sprintf("46008743601%05d", i),
})
}
return keys
}
func ydApnCounts(paths []string) (batch, single, query int) {
for _, p := range paths {
switch {
case strings.Contains(p, "sim-communication-function/batch"):
batch++
case strings.Contains(p, "sim-batch-result"):
query++
default:
single++
}
}
return
}
// 少于 10 张:逐张走 7.2.1 单卡。
func TestYdApnChangeSmallBatchUsesSingle(t *testing.T) {
defer shortenYdBatchPoll(2 * time.Second)()
capture := &ydApnCapture{}
sim, closeFn := newYdApnTestChannel(capture)
defer closeFn()
out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: ydSimKeysWithMsisdn(5),
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
})
if err != nil {
t.Fatal(err)
}
if len(capture.paths) != 5 {
t.Fatalf("expected 5 single calls, got %d (%v)", len(capture.paths), capture.paths)
}
for i, p := range capture.paths {
if p != "/v5/ec/manage/sim-communication-service" {
t.Fatalf("call %d unexpected path %s", i, p)
}
q := capture.query[i]
if q.Get("functionType") != "01" || q.Get("businessType") != "0" || q.Get("apn") != "CMIOT" {
t.Fatalf("call %d unexpected query %v", i, q)
}
if q.Get("iccid") == "" || q.Get("transid") == "" {
t.Fatalf("call %d missing iccid/transid: %v", i, q)
}
}
if len(out) != 5 || out[0].Code != 0 || out[0].ResData.OrderNum != "ORD1" {
t.Fatalf("unexpected output %+v", out)
}
}
// 10~100 张:一次走 7.2.2 批量,open 翻成 operType=1;受理后必须回查结果。
func TestYdApnChangeTenToHundredUsesBatch(t *testing.T) {
defer shortenYdBatchPoll(2 * time.Second)()
for _, n := range []int{10, 100} {
capture := &ydApnCapture{}
sim, closeFn := newYdApnTestChannel(capture)
out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: ydSimKeysWithMsisdn(n),
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
})
if err != nil {
t.Fatal(err)
}
batch, single, query := ydApnCounts(capture.paths)
if batch != 1 || single != 0 || query == 0 {
t.Fatalf("n=%d expected 1 batch + result query, got batch=%d single=%d query=%d (%v)", n, batch, single, query, capture.paths)
}
q := capture.query[0]
if q.Get("serviceType") != "11" || q.Get("operType") != "1" || q.Get("apnName") != "CMIOT" {
t.Fatalf("n=%d unexpected batch query %v", n, q)
}
if got := len(strings.Split(q.Get("msisdns"), "_")); got != n {
t.Fatalf("n=%d msisdns count = %d", n, got)
}
if len(out) != n || out[0].Code != 0 || out[0].ResData.OrderNum != "JOB1" {
t.Fatalf("n=%d unexpected output %+v", n, out)
}
closeFn()
}
}
// 批量任务部分失败:结果查询返回的失败项必须如实回填,不能整批记成功。
func TestYdApnChangeBatchReflectsPartialFailure(t *testing.T) {
defer shortenYdBatchPoll(2 * time.Second)()
keys := ydSimKeysWithMsisdn(10)
failId := keys[3].MSISDN
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "sim-communication-function/batch"):
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobId":"JOB9"}]}`))
case strings.Contains(r.URL.Path, "sim-batch-result"):
items := make([]string, 0, len(keys))
for _, k := range keys {
if k.MSISDN == failId {
items = append(items, fmt.Sprintf(`{"status":"12047","message":"用户该服务已经是暂停状态,不能重复暂停","resultType":"1","resultId":%q}`, k.MSISDN))
continue
}
items = append(items, fmt.Sprintf(`{"status":"0","message":"正确","resultType":"1","resultId":%q}`, k.MSISDN))
}
_, _ = w.Write([]byte(fmt.Sprintf(`{"status":"0","message":"正确","result":[{"jobStatus":"3","resultList":[%s]}]}`, strings.Join(items, ","))))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
out, err := newYdApnChannelWithServer(server).ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: keys,
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
})
if err != nil {
t.Fatal(err)
}
if len(out) != 10 {
t.Fatalf("output len = %d, want 10", len(out))
}
failed := 0
for _, item := range out {
if item.Code != 0 {
failed++
if !strings.Contains(item.Message, "不能重复暂停") {
t.Fatalf("unexpected failure message %q", item.Message)
}
}
}
if failed != 1 {
t.Fatalf("failed count = %d, want 1 (%+v)", failed, out)
}
}
// 轮询超时:未取得终态时按失败记录,并注明"未确认",避免误报成功。
func TestYdApnChangeBatchResultTimeout(t *testing.T) {
defer shortenYdBatchPoll(20 * time.Millisecond)()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "sim-communication-function/batch"):
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobId":"JOB5"}]}`))
case strings.Contains(r.URL.Path, "sim-batch-result"):
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobStatus":"1","resultList":[]}]}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
out, err := newYdApnChannelWithServer(server).ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: ydSimKeysWithMsisdn(10),
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
})
if err != nil {
t.Fatal(err)
}
if len(out) != 10 {
t.Fatalf("output len = %d, want 10", len(out))
}
for _, item := range out {
if item.Code == 0 {
t.Fatalf("timeout must not be reported as success: %+v", item)
}
if !strings.Contains(item.Message, "查询上游通信结果失败") {
t.Fatalf("unexpected timeout message %q", item.Message)
}
}
}
// 停用数据:批量 operType 翻成 0,单卡 businessType=1。
func TestYdApnChangeStopDirection(t *testing.T) {
defer shortenYdBatchPoll(2 * time.Second)()
capture := &ydApnCapture{}
sim, closeFn := newYdApnTestChannel(capture)
defer closeFn()
if _, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: ydSimKeysWithMsisdn(10),
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", ApnName: "CMIOT"},
}); err != nil {
t.Fatal(err)
}
capture2 := &ydApnCapture{}
sim2, closeFn2 := newYdApnTestChannel(capture2)
defer closeFn2()
if _, err := sim2.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: ydSimKeysWithMsisdn(3),
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", ApnName: "CMIOT"},
}); err != nil {
t.Fatal(err)
}
if capture.query[0].Get("operType") != "0" {
t.Fatalf("batch stop operType = %q, want 0", capture.query[0].Get("operType"))
}
if capture2.query[0].Get("businessType") != "1" {
t.Fatalf("single stop businessType = %q, want 1", capture2.query[0].Get("businessType"))
}
}
// 超过 100 张:按 100 拆块,不足 10 的尾块退回单卡。
func TestYdApnChangeOverHundredSplits(t *testing.T) {
defer shortenYdBatchPoll(2 * time.Second)()
capture := &ydApnCapture{}
sim, closeFn := newYdApnTestChannel(capture)
defer closeFn()
out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: ydSimKeysWithMsisdn(105),
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
})
if err != nil {
t.Fatal(err)
}
batch, single, _ := ydApnCounts(capture.paths)
if batch != 1 || single != 5 {
t.Fatalf("expected 1 batch + 5 singles, got batch=%d single=%d (%v)", batch, single, capture.paths)
}
if len(out) != 105 {
t.Fatalf("output len = %d, want 105", len(out))
}
}
// 批量里有卡缺 msisdn:缺号降级走 7.2.1,其余凑够 10 张仍走 7.2.2。
func TestYdApnChangeMissingMsisdnFallsBackToSingle(t *testing.T) {
defer shortenYdBatchPoll(2 * time.Second)()
capture := &ydApnCapture{}
sim, closeFn := newYdApnTestChannel(capture)
defer closeFn()
keys := ydSimKeysWithMsisdn(15)
keys[0].MSISDN = ""
keys[5].MSISDN = ""
keys[14].MSISDN = ""
out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: keys,
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
})
if err != nil {
t.Fatal(err)
}
batch, single, _ := ydApnCounts(capture.paths)
if batch != 1 || single != 3 {
t.Fatalf("expected 1 batch + 3 singles, got batch=%d single=%d (%v)", batch, single, capture.paths)
}
if len(out) != 15 {
t.Fatalf("output len = %d, want 15", len(out))
}
for _, item := range out {
if item.Code != 0 {
t.Fatalf("unexpected failure item %+v", item)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
package upstreamimpl
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
)
const ydTokenInvalidCode = "12021"
var errYdTokenUnchanged = errors.New("mobile token recovery returned the rejected token")
type ydResponseMeta struct {
Status string `json:"status"`
Message string `json:"message"`
}
type ydHTTPResponse struct{ body string }
func (r *ydHTTPResponse) ReadAllString() string { return r.body }
type ydRequestFunc func(params g.Map) (string, error)
// ydTokenProvider is the construction seam for channels whose credential is
// shared outside t_sim_channels. Implementations own storage and refresh;
// RecoverToken must use compare-and-swap semantics for rejectedToken.
type ydTokenProvider interface {
GetToken(ctx context.Context) (string, error)
RecoverToken(ctx context.Context, rejectedToken string) (string, error)
}
const (
// expired_only reuses the current token until it actually expires.
ydTokenRefreshModeExpiredOnly = "expired_only"
// proactive enters the configured advance window before requesting a token.
ydTokenRefreshModeProactive = "proactive"
ydTokenDefaultSkewSeconds = int64(600)
)
type ydTokenRefreshPolicy struct {
mode string
skewSecond int64
}
func loadYdTokenRefreshPolicy(ctx context.Context) ydTokenRefreshPolicy {
mode := g.Cfg().MustGet(ctx, "dependencies.token_refresh.mode", ydTokenRefreshModeExpiredOnly).String()
if mode != ydTokenRefreshModeExpiredOnly && mode != ydTokenRefreshModeProactive {
g.Log().Warningf(ctx, "unknown token refresh mode=%s, fallback to %s", mode, ydTokenRefreshModeExpiredOnly)
mode = ydTokenRefreshModeExpiredOnly
}
skew := g.Cfg().MustGet(ctx, "dependencies.token_refresh.skew_seconds", ydTokenDefaultSkewSeconds).Int64()
if skew < 0 {
g.Log().Warningf(ctx, "invalid token refresh skew_seconds=%d, fallback to 0", skew)
skew = 0
}
return ydTokenRefreshPolicy{mode: mode, skewSecond: skew}
}
func (p ydTokenRefreshPolicy) shouldRefresh(channel *entity.TSimChannels, now time.Time) bool {
if channel == nil || channel.AccessToken == "" || channel.AccessTokenExpire == nil {
return true
}
expire := channel.AccessTokenExpire.Time
if p.mode == ydTokenRefreshModeProactive {
return !now.Before(expire.Add(-time.Duration(p.skewSecond) * time.Second))
}
return !now.Before(expire)
}
var (
ydTokenGuards sync.Map
ydTokenStates sync.Map // map[string]*ydTokenState
)
type ydTokenState struct {
mu sync.RWMutex
token string
createdAt time.Time
expiresAt time.Time
}
func ydTokenStateFor(chanKey string) *ydTokenState {
state, _ := ydTokenStates.LoadOrStore(chanKey, &ydTokenState{})
return state.(*ydTokenState)
}
func seedYdTokenState(channel *entity.TSimChannels) {
if channel == nil || channel.ChanKey == "" || channel.AccessToken == "" || channel.AccessTokenExpire == nil {
return
}
state := ydTokenStateFor(channel.ChanKey)
state.mu.Lock()
createdAt := time.Time{}
if channel.AccessTokenCreated != nil {
createdAt = channel.AccessTokenCreated.Time
}
if state.token == "" || createdAt.After(state.createdAt) {
state.token = channel.AccessToken
state.createdAt = createdAt
state.expiresAt = channel.AccessTokenExpire.Time
}
state.mu.Unlock()
}
func currentYdToken(channel *entity.TSimChannels) (string, time.Time) {
if channel == nil {
return "", time.Time{}
}
seedYdTokenState(channel)
state := ydTokenStateFor(channel.ChanKey)
state.mu.RLock()
token, expiresAt := state.token, state.expiresAt
state.mu.RUnlock()
if token == "" || expiresAt.IsZero() || !time.Now().Before(expiresAt) {
return "", expiresAt
}
return token, expiresAt
}
func updateYdTokenState(chanKey, token string, createdAt, expiresAt time.Time) {
state := ydTokenStateFor(chanKey)
state.mu.Lock()
state.token = token
state.createdAt = createdAt
state.expiresAt = expiresAt
state.mu.Unlock()
}
func clearYdTokenStateIfCurrent(chanKey, rejected string) {
state := ydTokenStateFor(chanKey)
state.mu.Lock()
if state.token == rejected {
state.token = ""
state.createdAt = time.Time{}
state.expiresAt = time.Time{}
}
state.mu.Unlock()
}
func withYdTokenGuard(chanKey string, fn func() string) string {
guard, _ := ydTokenGuards.LoadOrStore(chanKey, &sync.Mutex{})
mu := guard.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
return fn()
}
// validYdToken only checks actual expiry. Proactive refresh decisions belong
// to ydTokenRefreshPolicy so refresh=0 integrations can reuse the token until
// it really expires.
func validYdToken(channel *entity.TSimChannels, now time.Time) string {
if channel == nil || channel.AccessToken == "" || channel.AccessTokenExpire == nil {
return ""
}
if !now.Before(channel.AccessTokenExpire.Time) {
return ""
}
return channel.AccessToken
}
func recoverYdToken(ctx context.Context, chanKey string, channel *entity.TSimChannels, rejectedToken string, refresh func(context.Context) string) (token string, err error) {
withYdTokenGuard(chanKey, func() string {
if channel == nil {
err = errors.New("mobile token recovery has no channel")
return ""
}
seedYdTokenState(channel)
if current, _ := currentYdToken(channel); current != "" && current != rejectedToken {
channel.AccessToken = current
token = current
return ""
}
clearYdTokenStateIfCurrent(chanKey, rejectedToken)
channel.AccessToken = ""
channel.AccessTokenExpire = nil
token = refresh(ctx)
if token == "" {
err = errors.New("mobile token recovery failed to acquire a token")
} else if token == rejectedToken {
err = errYdTokenUnchanged
}
return ""
})
return token, err
}
func doYdRequest(ctx context.Context, operation, chanKey string, getToken func(context.Context) string, recoverToken func(context.Context, string) (string, error), params g.Map, request ydRequestFunc) (*ydHTTPResponse, error) {
var lastBody string
for attempt := 0; attempt < 2; attempt++ {
token := getToken(ctx)
requestParams := make(g.Map, len(params)+1)
for key, value := range params {
requestParams[key] = value
}
requestParams["token"] = token
body, err := request(requestParams)
if err != nil {
return nil, err
}
lastBody = body
var meta ydResponseMeta
if err = json.Unmarshal([]byte(body), &meta); err != nil {
return nil, err
}
if meta.Status != ydTokenInvalidCode || attempt == 1 {
return &ydHTTPResponse{body: body}, nil
}
g.Log().Warningf(ctx, "%s token rejected, invalidate and retry once, chanKey:%s", operation, chanKey)
if _, err = recoverToken(ctx, token); err != nil {
if errors.Is(err, errYdTokenUnchanged) {
g.Log().Warningf(ctx, "%s token recovery returned the rejected token, stop retrying, chanKey:%s", operation, chanKey)
return &ydHTTPResponse{body: body}, nil
}
return nil, fmt.Errorf("%s token recovery failed: %w", operation, err)
}
}
return &ydHTTPResponse{body: lastBody}, nil
}
@@ -0,0 +1,495 @@
package upstreamimpl
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/yunda"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
const yundaTemplateKey = "sim_yunda_v1"
type sSimYunda struct {
channelConfig *model.SimChannelConfigItemCache
client *yunda.Client
}
type yundaFloat float64
func (f *yundaFloat) UnmarshalJSON(data []byte) error {
var number float64
if err := json.Unmarshal(data, &number); err == nil {
*f = yundaFloat(number)
return nil
}
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
if text == "" {
*f = 0
return nil
}
value, err := strconv.ParseFloat(text, 64)
if err != nil {
return err
}
*f = yundaFloat(value)
return nil
}
type yundaCards []yundaCard
func (cards *yundaCards) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
*cards = nil
return nil
}
if data[0] == '[' {
return json.Unmarshal(data, (*[]yundaCard)(cards))
}
var card yundaCard
if err := json.Unmarshal(data, &card); err != nil {
return err
}
*cards = []yundaCard{card}
return nil
}
type yundaCard struct {
MSISDN string `json:"msisdn"`
ICCID string `json:"iccid"`
IMEI string `json:"imei"`
IMSI string `json:"imsi"`
Code string `json:"code"`
Error string `json:"error"`
Status string `json:"status"`
GPRSStatus string `json:"gprsStatus"`
GPRSStatusLower string `json:"gprsstatus"`
IP string `json:"ip"`
RAT string `json:"rat"`
StatTimestamp string `json:"statustimestamp"`
OpenTime string `json:"opentime"`
PackageID string `json:"packageid"`
PackageName string `json:"packagename"`
LifecycleStatus string `json:"lifecycleStatus"`
LifecycleStatusLower string `json:"lifecyclestatus"`
APNList []yundaAPN `json:"apnlist"`
APN string `json:"apn"`
}
type yundaAPN struct {
APNName string `json:"apnname"`
UsedFlow yundaFloat `json:"usedflow"`
DailyList []yundaDailyUse `json:"dailylist"`
}
type yundaDailyUse struct {
UsedFlow yundaFloat `json:"usedflow"`
Date string `json:"date"`
}
type yundaConfig struct {
BaseURL string `json:"baseUrl"`
Timeout string `json:"timeout"`
}
type yundaConfigEnvelope struct {
API yundaConfig `json:"api"`
BaseURL string `json:"baseUrl"`
Timeout string `json:"timeout"`
}
func NewSimYunda() *sSimYunda { return &sSimYunda{} }
func init() {
service.UpstreamAPI().RegisterTemp(yundaTemplateKey, NewSimYunda())
}
func (s *sSimYunda) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI {
cfg := yundaConfigEnvelope{}
if channelConfig != nil && channelConfig.Template != nil {
_ = json.Unmarshal([]byte(channelConfig.Template.TempConfig), &cfg)
}
if channelConfig != nil && channelConfig.Channel != nil && strings.TrimSpace(channelConfig.Channel.ChanConfig) != "" {
var channelCfg yundaConfigEnvelope
if json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &channelCfg) == nil {
if channelCfg.API.BaseURL != "" {
cfg.API.BaseURL = channelCfg.API.BaseURL
}
if channelCfg.BaseURL != "" {
cfg.BaseURL = channelCfg.BaseURL
}
if channelCfg.API.Timeout != "" {
cfg.API.Timeout = channelCfg.API.Timeout
}
if channelCfg.Timeout != "" {
cfg.Timeout = channelCfg.Timeout
}
}
}
baseURL := cfg.API.BaseURL
if baseURL == "" {
baseURL = cfg.BaseURL
}
if baseURL == "" {
baseURL = "http://api.yundaiot.cn/v1"
}
timeout := 10 * time.Second
if cfg.API.Timeout != "" {
if parsed, err := time.ParseDuration(cfg.API.Timeout); err == nil {
timeout = parsed
}
}
if cfg.Timeout != "" {
if parsed, err := time.ParseDuration(cfg.Timeout); err == nil {
timeout = parsed
}
}
appKey, appSecret := "", ""
if channelConfig != nil && channelConfig.Channel != nil {
appKey = channelConfig.Channel.AppKey
appSecret = channelConfig.Channel.AppSecret
}
return &sSimYunda{channelConfig: channelConfig, client: yunda.NewClient(yunda.Config{BaseURL: baseURL, AppKey: appKey, AppSecret: appSecret, Timeout: timeout})}
}
func yundaCardValue(key *model.SimKey, field string) string {
if key == nil {
return ""
}
switch field {
case "iccid":
return key.ICCID
case "msisdn":
return key.MSISDN
case "imsi":
return key.IMSI
}
return ""
}
func (s *sSimYunda) clientPost(ctx context.Context, path string, params map[string]string, out *yundaCards) error {
_, err := s.client.Post(ctx, path, params, out)
return err
}
func (s *sSimYunda) requestCard(ctx context.Context, path string, key *model.SimKey, fields map[string]string, out *yundaCards) error {
if key == nil {
return errors.New("sim key is required")
}
params := map[string]string{}
for name, field := range fields {
if value := yundaCardValue(key, field); value != "" {
params[name] = value
}
}
if len(params) == 0 {
return errors.New("iccid, msisdn or imsi is required")
}
return s.clientPost(ctx, path, params, out)
}
func (s *sSimYunda) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
items := make(model.SimBasicInfoOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
var result yundaCards
err := s.requestCard(ctx, "BatchGetSimInfo", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn", "imsis": "imsi"}, &result)
card := firstYundaCard(result)
if err != nil || card == nil {
items = append(items, yundaBasicError(key, errOr(err, "yunda returned empty card info")))
continue
}
items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimBasicInfoItem{ICCID: card.ICCID, IMSI: card.IMSI, MSISDN: card.MSISDN, OpenDate: card.OpenTime}})
}
return items, nil
}
func (s *sSimYunda) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) {
items := make(model.SimImeiOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
var result yundaCards
err := s.requestCard(ctx, "BatchGetSimInfo", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn", "imsis": "imsi"}, &result)
card := firstYundaCard(result)
if err != nil || card == nil {
items = append(items, &model.SimBaseRes[model.SimImeiItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty card info")})
continue
}
items = append(items, &model.SimBaseRes[model.SimImeiItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimImeiItem{Imei: card.IMEI}})
}
return items, nil
}
func (s *sSimYunda) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
items := make(model.SimCardStatusOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
var result yundaCards
err := s.requestCard(ctx, "BatchGetSimStatus", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn"}, &result)
card := firstYundaCard(result)
if err != nil || card == nil {
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty card status")})
continue
}
status, err := mapYundaCardStatus(card.Status)
if err != nil {
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: err.Error()})
continue
}
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimCardStatusItem{CardStatus: status}})
}
return items, nil
}
// Yunda lifecycle codes: 0 testing, 1 dormant, 2 inventory, 3 in use, 4 stopped, 5 cancelled.
func mapYundaCardStatus(value string) (int, error) {
switch strings.TrimSpace(value) {
case "0", "测试期":
return 6, nil
case "1", "沉默期":
return 1, nil
case "2", "库存期":
return 7, nil
case "3", "正使用":
return 2, nil
case "4", "已停机":
return 4, nil
case "5", "已销户":
return 9, nil
default:
return 0, fmt.Errorf("yunda: unsupported card status %q", value)
}
}
func (s *sSimYunda) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) {
items := make(model.SimDataUsageOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
var result yundaCards
err := s.requestCard(ctx, "BatchGetCurrentMonthGprs", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn"}, &result)
card := firstYundaCard(result)
if err != nil || card == nil {
items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty usage")})
continue
}
items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDataUsageItem{DataUsage: firstYundaUsage(card)}})
}
return items, nil
}
func (s *sSimYunda) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
var result yundaCards
params := map[string]string{"month": input.ReqData.QueryDate}
if key != nil {
params["iccids"] = key.ICCID
params["msisdns"] = key.MSISDN
}
err := s.clientPost(ctx, "BatchGetSimMonthGPRS", params, &result)
card := firstYundaCard(result)
if err != nil || card == nil {
items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty monthly usage"), ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: input.ReqData.QueryDate}})
continue
}
items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: input.ReqData.QueryDate, DataAmount: firstYundaUsage(card)}})
}
return items, nil
}
func (s *sSimYunda) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
var result yundaCards
month, date, dateErr := yundaMonthAndDate(input.ReqData.QueryDate)
if dateErr != nil {
items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{Code: consts.ApiStatusError, SimKey: key, Message: dateErr.Error()})
continue
}
params := map[string]string{"month": month, "date": date}
if key != nil {
params["iccids"] = key.ICCID
params["msisdns"] = key.MSISDN
}
err := s.clientPost(ctx, "BatchGetSimDayGPRS", params, &result)
card := firstYundaCard(result)
amount, date := firstYundaDailyUsage(card, date)
if err != nil || card == nil {
items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty daily usage"), ResData: model.SimDataUsageDailyOutputItem{QueryDate: date}})
continue
}
items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDataUsageDailyOutputItem{QueryDate: date, DataAmount: amount}})
}
return items, nil
}
func (s *sSimYunda) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) {
items := make(model.SimSessionOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
keyword := yundaCardValue(key, "iccid")
if keyword == "" {
keyword = yundaCardValue(key, "msisdn")
}
if keyword == "" {
keyword = yundaCardValue(key, "imsi")
}
var result yundaCards
err := s.clientPost(ctx, "SimOnlineQuery", map[string]string{"keyword": keyword}, &result)
card := firstYundaCard(result)
if err != nil || card == nil {
items = append(items, &model.SimBaseRes[model.SimSessionItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty online status")})
continue
}
status := "-1"
if card.GPRSStatus == "01" || card.GPRSStatusLower == "01" {
status = "0"
}
items = append(items, &model.SimBaseRes[model.SimSessionItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimSessionItem{ApnId: card.APN, Imei: card.IMEI, Rat: card.RAT, Ipv4: card.IP, Status: status, CreateTime: card.StatTimestamp}})
}
return items, nil
}
func (s *sSimYunda) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
items := make(model.SimDeviceStatusOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
var result yundaCards
err := s.requestCard(ctx, "BatchGetSimGPRSStatus", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn"}, &result)
card := firstYundaCard(result)
if err != nil || card == nil {
items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty GPRS status")})
continue
}
status := card.GPRSStatus
if status == "" {
status = card.GPRSStatusLower
}
items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDeviceStatusItem{DeviceStatus: status}})
}
return items, nil
}
func yundaUnsupported[T any](items []*model.SimBaseRes[T], keys []*model.SimKey, operation string) ([]*model.SimBaseRes[T], error) {
for _, key := range keys {
items = append(items, &model.SimBaseRes[T]{Code: consts.ApiStatusError, SimKey: key, Message: fmt.Sprintf("yunda does not support %s yet", operation)})
}
return items, nil
}
func (s *sSimYunda) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
return yundaUnsupported[model.SimStopReasonItem](nil, input.SimKeys, "stop reason query")
}
func (s *sSimYunda) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
return yundaUnsupported[model.SimFrozenStateItem](nil, input.SimKeys, "frozen state query")
}
func (s *sSimYunda) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
return yundaUnsupported[any](nil, input.SimKeys, "data diagnosis")
}
func (s *sSimYunda) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
items := make(model.SimApnInfoOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
keyword := yundaCardValue(key, "iccid")
if keyword == "" {
keyword = yundaCardValue(key, "msisdn")
}
if keyword == "" {
keyword = yundaCardValue(key, "imsi")
}
var result yundaCards
err := s.clientPost(ctx, "SimOnlineQuery", map[string]string{"keyword": keyword}, &result)
card := firstYundaCard(result)
if err != nil || card == nil {
items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty APN info")})
continue
}
status := "0"
if strings.TrimSpace(card.APN) != "" {
status = "1"
}
items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimApnInfoItemList{ApnList: []model.SimApnInfoItem{{ApnName: card.APN, Status: status}}}})
}
return items, nil
}
func (s *sSimYunda) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) {
return yundaUnsupported[model.SimCardStatusChangeItem](nil, input.SimKeys, "status change")
}
func (s *sSimYunda) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
return yundaUnsupported[model.SimCardOperateChangeApnItem](nil, input.SimKeys, "APN change")
}
func (s *sSimYunda) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
items := make(model.SimCardBindStatusOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
var result yundaCards
err := s.requestCard(ctx, "BatchGetSimInfo", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn", "imsis": "imsi"}, &result)
card := firstYundaCard(result)
if err != nil || card == nil {
items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty card info")})
continue
}
bindStatus, bindingType := "0", "sim"
if strings.TrimSpace(card.IMEI) != "" {
bindStatus, bindingType = "1", "imei"
}
items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimCardBindStatusItem{BindStatus: bindStatus, CardBindingType: bindingType}})
}
return items, nil
}
func (s *sSimYunda) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
return yundaUnsupported[model.SimRegionLimitStatusItem](nil, input.SimKeys, "region limit query")
}
func firstYundaCard(cards yundaCards) *yundaCard {
if len(cards) == 0 {
return nil
}
return &cards[0]
}
func firstYundaUsage(card *yundaCard) float64 {
if card == nil || len(card.APNList) == 0 {
return 0
}
return float64(card.APNList[0].UsedFlow)
}
func firstYundaDailyUsage(card *yundaCard, fallback string) (float64, string) {
if card == nil || len(card.APNList) == 0 || len(card.APNList[0].DailyList) == 0 {
return 0, fallback
}
item := card.APNList[0].DailyList[0]
if item.Date == "" {
item.Date = fallback
}
return float64(item.UsedFlow), item.Date
}
func yundaBasicError(key *model.SimKey, err error) *model.SimBaseRes[model.SimBasicInfoItem] {
return &model.SimBaseRes[model.SimBasicInfoItem]{Code: consts.ApiStatusError, SimKey: key, Message: err.Error()}
}
func errOr(err error, fallback string) error {
if err != nil {
return err
}
return errors.New(fallback)
}
func errMessage(err error, fallback string) string {
if err != nil {
return err.Error()
}
return fallback
}
func yundaMonthAndDate(value string) (string, string, error) {
value = strings.TrimSpace(value)
for _, layout := range []string{"20060102", "2006-01-02"} {
parsed, err := time.Parse(layout, value)
if err == nil {
return parsed.Format("2006-01"), parsed.Format("2006-01-02"), nil
}
}
return "", "", fmt.Errorf("invalid daily queryDate: %s", value)
}
@@ -0,0 +1,64 @@
package upstreamimpl
import (
"context"
"testing"
)
func TestYundaMonthAndDate(t *testing.T) {
tests := []struct {
input string
month string
date string
}{
{input: "20260921", month: "2026-09", date: "2026-09-21"},
{input: "2026-09-21", month: "2026-09", date: "2026-09-21"},
}
for _, test := range tests {
month, date, err := yundaMonthAndDate(test.input)
if err != nil || month != test.month || date != test.date {
t.Fatalf("input=%s month=%s date=%s err=%v", test.input, month, date, err)
}
}
}
func TestYundaMonthAndDateRejectsInvalidValue(t *testing.T) {
if _, _, err := yundaMonthAndDate("202609"); err == nil {
t.Fatal("expected invalid daily date")
}
}
func TestMapYundaCardStatus(t *testing.T) {
for _, tc := range []struct {
input string
want int
}{
{"0", 6}, {"1", 1}, {"2", 7}, {"3", 2}, {"正使用", 2}, {"4", 4}, {"已停机", 4}, {"5", 9}, {"已销户", 9},
} {
got, err := mapYundaCardStatus(tc.input)
if err != nil || got != tc.want {
t.Fatalf("mapYundaCardStatus(%q) = %d, %v; want %d", tc.input, got, err, tc.want)
}
}
if _, err := mapYundaCardStatus("unknown"); err == nil {
t.Fatal("expected unsupported Yunda status to return an error")
}
}
func TestTelecomCardStatusSourcesDiffer(t *testing.T) {
if got := convertDxCardStatus(context.Background(), "3"); got != 7 {
t.Fatalf("single-card product state 3 = %d, want inventory 7", got)
}
if got := convertDXBatchCardStatus(context.Background(), "3"); got != 6 {
t.Fatalf("BQ018 test deactivation 3 = %d, want testable 6", got)
}
if got := mapDXBatchInfoToBasicInfo(dxBatchInfoItem{SimStatus: "4"}).SimStatus; got != "2" {
t.Fatalf("BQ018 in-use state 4 = %q, want activated 2", got)
}
}
func TestMapDXLegacyBasicStatusKeepsMissingValueEmpty(t *testing.T) {
if got := mapDXLegacyBasicStatus(context.Background(), ""); got != "" {
t.Fatalf("missing telecom status = %q, want empty", got)
}
}
@@ -0,0 +1,26 @@
package upstreamimpl
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
const (
templateModelDirect = "direct"
templateModelSharedToken = "shared_token"
)
// Model registrations are deliberately separate from concrete temp_key
// registrations. This lets multiple source-specific templates share one
// implementation while keeping existing exact-key behavior unchanged.
func init() {
api := service.UpstreamAPI()
// direct is a classification value, not a generic implementation. Direct
// protocols must register their concrete temp_key (for example sim_yd_v5).
// shared_token is the only model-backed implementation because multiple
// source-specific templates share the OneLink business API.
api.RegisterModel(templateModelSharedToken, func(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
return NewSimOneLinkShared().NewTemp(ctx, cfg)
})
}
@@ -0,0 +1,164 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
tengyuOneLinkTokenPath = "/prod-api/open/api/oneLinkToken"
tengyuTokenTTL = time.Hour
)
// OneLinkToken is the value returned by a shared-token fetcher.
type OneLinkToken struct {
Value string
CreatedAt time.Time
ExpiresAt time.Time
}
// TokenFetcher mirrors the token core fetch boundary. chanKey is the
// protocol's c_no; it is never inferred from unrelated channel fields.
type TokenFetcher interface {
FetchToken(context.Context, string) (OneLinkToken, error)
}
// TokenProvider is the consumer-facing token core boundary.
type TokenProvider interface {
GetToken(context.Context, string) (string, error)
}
// TengyuTokenConfig contains all values required by the Tengyu protocol.
// The provider intentionally does not infer these values from channel config.
type TengyuTokenConfig struct {
BaseURL string
Path string
PwdKey string
ChannelNo string
Timeout time.Duration
}
// TengyuTokenProvider fetches a Tengyu shared OneLink token.
type TengyuTokenProvider struct {
baseURL string
path string
pwdKey string
channelNo string
client *http.Client
}
type tengyuTokenResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
func NewTengyuTokenProvider(config TengyuTokenConfig) *TengyuTokenProvider {
return &TengyuTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, tengyuOneLinkTokenPath),
pwdKey: config.PwdKey,
channelNo: config.ChannelNo,
client: newTengyuHTTPClient(config.Timeout),
}
}
// Historical in-package names are retained as a small adapter for callers
// that constructed the provider before the token-core interfaces were named.
type tengyuTokenConfig = TengyuTokenConfig
func newTengyuTokenProvider(config tengyuTokenConfig) *TengyuTokenProvider {
return NewTengyuTokenProvider(config)
}
func newTengyuHTTPClient(timeout time.Duration) *http.Client {
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &http.Client{Timeout: timeout}
}
func (p *TengyuTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) {
return p.fetch(ctx, p.channelNo)
}
func (p *TengyuTokenProvider) FetchToken(ctx context.Context, channelNo string) (OneLinkToken, error) {
if strings.TrimSpace(channelNo) == "" {
channelNo = p.channelNo
}
return p.fetch(ctx, channelNo)
}
func (p *TengyuTokenProvider) GetToken(ctx context.Context, channelNo string) (string, error) {
token, err := p.FetchToken(ctx, channelNo)
if err != nil {
return "", err
}
return token.Value, nil
}
func (p *TengyuTokenProvider) fetch(ctx context.Context, channelNo string) (OneLinkToken, error) {
if p == nil || p.client == nil {
return OneLinkToken{}, fmt.Errorf("tengyu token provider is not initialized")
}
if p.baseURL == "" {
return OneLinkToken{}, fmt.Errorf("tengyu token base URL is required")
}
if strings.TrimSpace(p.pwdKey) == "" {
return OneLinkToken{}, fmt.Errorf("tengyu pwdkey is required")
}
if strings.TrimSpace(channelNo) == "" {
return OneLinkToken{}, fmt.Errorf("tengyu channel number is required")
}
endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path))
if err != nil {
return OneLinkToken{}, fmt.Errorf("parse Tengyu token URL: %w", err)
}
query := endpoint.Query()
query.Set("pwdkey", p.pwdKey)
query.Set("c_no", channelNo)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return OneLinkToken{}, fmt.Errorf("create Tengyu token request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return OneLinkToken{}, fmt.Errorf("request Tengyu token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return OneLinkToken{}, fmt.Errorf("read Tengyu token response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return OneLinkToken{}, fmt.Errorf("Tengyu token HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var output tengyuTokenResponse
if err := json.Unmarshal(body, &output); err != nil {
return OneLinkToken{}, fmt.Errorf("decode Tengyu token response: %w", err)
}
if output.Code != http.StatusOK {
return OneLinkToken{}, fmt.Errorf("Tengyu token failed: code=%d, message=%s", output.Code, output.Msg)
}
var value string
if err := json.Unmarshal(output.Data, &value); err != nil {
return OneLinkToken{}, fmt.Errorf("decode Tengyu shared token: %w", err)
}
if strings.TrimSpace(value) == "" {
return OneLinkToken{}, fmt.Errorf("Tengyu shared token is empty")
}
now := time.Now()
return OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: now.Add(tengyuTokenTTL)}, nil
}
@@ -0,0 +1,68 @@
package upstreamimpl
import (
"context"
"sync"
"time"
)
const tibiotSimDataMarginInterval = 650 * time.Millisecond
// tibiotSimDataMarginLimiter spaces requests globally because Tibiot applies
// the simDataMargin quota to the gateway's public IP, not to a channel.
var tibiotSimDataMarginLimiter = newTibiotRateLimiter(tibiotSimDataMarginInterval)
type tibiotRateLimiter struct {
mu sync.Mutex
interval time.Duration
next time.Time
}
func newTibiotRateLimiter(interval time.Duration) *tibiotRateLimiter {
if interval <= 0 {
interval = tibiotSimDataMarginInterval
}
return &tibiotRateLimiter{interval: interval}
}
// wait blocks until a slot is available and only consumes the slot when the
// caller's context is still active. Waiting callers do not reserve future
// slots, so canceled requests cannot delay later retries.
func (l *tibiotRateLimiter) wait(ctx context.Context) error {
if l == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
for {
if err := ctx.Err(); err != nil {
return err
}
l.mu.Lock()
now := time.Now()
if !l.next.After(now) {
// Check again while holding the mutex so a cancellation racing with
// the slot boundary cannot consume a slot unnecessarily.
if err := ctx.Err(); err != nil {
l.mu.Unlock()
return err
}
l.next = now.Add(l.interval)
l.mu.Unlock()
return nil
}
wakeAt := l.next
l.mu.Unlock()
timer := time.NewTimer(time.Until(wakeAt))
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return ctx.Err()
}
}
}
@@ -0,0 +1,153 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const xuzhouMobileTokenPath = "/admin/api/get/token"
// XuzhouMobileTokenConfig contains the public provider endpoint and the
// carrier type configured by the template. AppID is the per-channel
// credential and is deliberately not read from the template.
type XuzhouMobileTokenConfig struct {
BaseURL string
Path string
AppID string
CarrierType int
TTL time.Duration
Timeout time.Duration
}
// XuzhouMobileTokenProvider fetches the shared OneLink token issued by the
// Xuzhou Mobile provider.
type XuzhouMobileTokenProvider struct {
baseURL string
path string
appID string
carrierType int
ttl time.Duration
client *http.Client
}
type xuzhouMobileTokenResponse struct {
Success bool `json:"success"`
ErrorCode json.RawMessage `json:"errorCode"`
Body struct {
Token string `json:"token"`
} `json:"body"`
}
func parseXuzhouErrorCode(raw json.RawMessage) (int, error) {
var code int
if err := json.Unmarshal(raw, &code); err == nil {
return code, nil
}
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(value))
}
func NewXuzhouMobileTokenProvider(config XuzhouMobileTokenConfig) *XuzhouMobileTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &XuzhouMobileTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, xuzhouMobileTokenPath),
appID: strings.TrimSpace(config.AppID),
carrierType: config.CarrierType,
ttl: config.TTL,
client: &http.Client{Timeout: timeout},
}
}
func (p *XuzhouMobileTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) {
if p == nil || p.client == nil {
return OneLinkToken{}, fmt.Errorf("xuzhou mobile token provider is not initialized")
}
if p.baseURL == "" {
return OneLinkToken{}, fmt.Errorf("xuzhou mobile token base URL is required")
}
if p.appID == "" {
return OneLinkToken{}, fmt.Errorf("xuzhou mobile appId is required")
}
endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path))
if err != nil {
return OneLinkToken{}, fmt.Errorf("parse Xuzhou Mobile token URL: %w", err)
}
query := endpoint.Query()
query.Set("appid", p.appID)
query.Set("carrierType", strconv.Itoa(p.carrierType))
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return OneLinkToken{}, fmt.Errorf("create Xuzhou Mobile token request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return OneLinkToken{}, fmt.Errorf("request Xuzhou Mobile token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return OneLinkToken{}, fmt.Errorf("read Xuzhou Mobile token response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return OneLinkToken{}, fmt.Errorf("Xuzhou Mobile token HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var output xuzhouMobileTokenResponse
if err := json.Unmarshal(body, &output); err != nil {
return OneLinkToken{}, fmt.Errorf("decode Xuzhou Mobile token response: %w", err)
}
errorCode, err := parseXuzhouErrorCode(output.ErrorCode)
if err != nil {
return OneLinkToken{}, fmt.Errorf("decode Xuzhou Mobile errorCode: %w", err)
}
if !output.Success || errorCode != 0 {
return OneLinkToken{}, fmt.Errorf("Xuzhou Mobile token failed: success=%t, errorCode=%d", output.Success, errorCode)
}
value := strings.TrimSpace(output.Body.Token)
if value == "" {
return OneLinkToken{}, fmt.Errorf("Xuzhou Mobile shared token is empty")
}
now := time.Now()
expiresAt := time.Time{}
if p.ttl > 0 {
expiresAt = now.Add(p.ttl)
}
return OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
}
func (p *XuzhouMobileTokenProvider) FetchToken(ctx context.Context, _ string) (OneLinkToken, error) {
return p.Fetch(ctx)
}
func normalizeProviderPath(path, fallback string) string {
path = strings.TrimSpace(path)
if path == "" {
path = fallback
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func joinProviderURL(baseURL, path string) string {
return strings.TrimRight(strings.TrimSpace(baseURL), "/") + normalizeProviderPath(path, "/")
}
@@ -0,0 +1,104 @@
package yunda
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type Config struct {
BaseURL string
AppKey string
AppSecret string
Timeout time.Duration
}
type Client struct {
baseURL string
appKey string
appSecret string
httpClient *http.Client
}
type Response struct {
Status json.RawMessage `json:"status"`
Message string `json:"message"`
Result json.RawMessage `json:"result"`
}
func NewClient(cfg Config) *Client {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
return &Client{baseURL: strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"), appKey: strings.TrimSpace(cfg.AppKey), appSecret: cfg.AppSecret, httpClient: &http.Client{Timeout: timeout}}
}
func (c *Client) Post(ctx context.Context, path string, params map[string]string, out any) (Response, error) {
if c.baseURL == "" {
return Response{}, errors.New("yunda base URL is required")
}
if c.appKey == "" || c.appSecret == "" {
return Response{}, errors.New("yunda appKey and appSecret are required")
}
signed := make(map[string]string, len(params)+3)
for key, value := range params {
if strings.TrimSpace(value) != "" {
signed[key] = value
}
}
signed["appKey"] = c.appKey
signed["timestamp"] = strconv.FormatInt(time.Now().Unix(), 10)
signed["sign"] = BuildSign(c.appKey, c.appSecret, signed)
form := url.Values{}
for key, value := range signed {
form.Set(key, value)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/"+strings.TrimLeft(path, "/"), strings.NewReader(form.Encode()))
if err != nil {
return Response{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return Response{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return Response{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Response{}, fmt.Errorf("yunda HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var response Response
if err = json.Unmarshal(body, &response); err != nil {
return Response{}, fmt.Errorf("decode yunda response: %w", err)
}
if status := response.StatusString(); status != "0" && status != "" {
return response, fmt.Errorf("yunda business status %s: %s", status, response.Message)
}
if out != nil && len(response.Result) > 0 && string(response.Result) != "null" {
if err = json.Unmarshal(response.Result, out); err != nil {
return response, fmt.Errorf("decode yunda result: %w", err)
}
}
return response, nil
}
func (r Response) StatusString() string {
var value string
if json.Unmarshal(r.Status, &value) == nil {
return value
}
var number json.Number
if json.Unmarshal(r.Status, &number) == nil {
return number.String()
}
return ""
}
@@ -0,0 +1,29 @@
package yunda
import (
"crypto/md5"
"encoding/hex"
"sort"
"strings"
)
func BuildSign(appKey, appSecret string, params map[string]string) string {
values := make(map[string]string, len(params)+2)
for key, value := range params {
values[key] = value
}
values["appKey"] = appKey
values["appSecret"] = appSecret
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
source := appKey
for _, key := range keys {
source += key + values[key]
}
source += appKey
sum := md5.Sum([]byte(source))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
@@ -0,0 +1,199 @@
package upstreamimpl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
const (
yunzeTokenPath = "/openapi/v3/query/operator-token"
yunzeTokenExpiryLayout = "2006-01-02 15:04:05"
)
// YunzeTokenConfig contains the platform endpoint and the per-channel app id.
// The Yunze operator-token API does not require app_secret.
type YunzeTokenConfig struct {
BaseURL string
Path string
AppID string
Timeout time.Duration
}
// YunzeTokenProvider fetches the shared token from the Yunze platform.
type YunzeTokenProvider struct {
baseURL string
path string
appID string
client *http.Client
}
type yunzeTokenResponse struct {
Code json.RawMessage `json:"code"`
Message string `json:"message"`
Data struct {
ChanAlias string `json:"chanAlias"`
ChanKey string `json:"chanKey"`
AccessToken string `json:"accessToken"`
// The live endpoint returns the expiry as tokenExpire. The published
// documentation incorrectly lists accessTokenExpire; it is being
// corrected to match the implementation.
TokenExpire string `json:"tokenExpire"`
} `json:"data"`
Error json.RawMessage `json:"error"`
}
// yunzeTokenRequest is the operator-token request body.
type yunzeTokenRequest struct {
AppID string `json:"appId"`
ChanKey string `json:"chanKey"`
}
// yunzeFetcher adapts YunzeTokenProvider to the shared token TokenFetcher
// contract, mirroring ziboFetcher and apikaFetcher.
type yunzeFetcher struct{ provider *YunzeTokenProvider }
func (f yunzeFetcher) FetchToken(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx, chanKey)
}
func NewYunzeTokenProvider(config YunzeTokenConfig) *YunzeTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &YunzeTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, yunzeTokenPath),
appID: strings.TrimSpace(config.AppID),
client: &http.Client{Timeout: timeout},
}
}
func (p *YunzeTokenProvider) Fetch(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) {
if p == nil || p.client == nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token provider is not initialized")
}
if p.baseURL == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token base URL is required")
}
if p.appID == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze appId is required")
}
chanKey = strings.TrimSpace(chanKey)
if chanKey == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze chanKey is required")
}
payload, err := json.Marshal(yunzeTokenRequest{AppID: p.appID, ChanKey: chanKey})
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode yunze token request: %w", err)
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
joinProviderURL(p.baseURL, p.path),
bytes.NewReader(payload),
)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("create yunze token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("request yunze token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("read yunze token response: %w", err)
}
if len(body) > 4*1024*1024 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token response is too large")
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token HTTP status %d", resp.StatusCode)
}
var output yunzeTokenResponse
if err := json.Unmarshal(body, &output); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode yunze token response: %w", err)
}
code, err := decodeYunzeCode(output.Code)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode yunze token code: %w", err)
}
if code != 0 {
return sharedtoken.OneLinkToken{}, fmt.Errorf(
"yunze token failed: code=%d message=%s",
code,
strings.TrimSpace(output.Message),
)
}
if len(output.Error) > 0 && string(output.Error) != "null" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token error: %s", string(output.Error))
}
value := strings.TrimSpace(output.Data.AccessToken)
if value == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze accessToken is empty")
}
expiresAt, err := parseYunzeTokenExpiry(output.Data.TokenExpire)
if err != nil {
return sharedtoken.OneLinkToken{}, err
}
return sharedtoken.OneLinkToken{
Value: value,
CreatedAt: time.Now(),
ExpiresAt: expiresAt,
}, nil
}
func decodeYunzeCode(raw json.RawMessage) (int, error) {
var code int
if err := json.Unmarshal(raw, &code); err == nil {
return code, nil
}
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return 0, err
}
var parsed int
if _, err := fmt.Sscanf(strings.TrimSpace(value), "%d", &parsed); err != nil {
return 0, err
}
return parsed, nil
}
// yunzeTokenLocation is the timezone used by the Yunze operator-token API.
// It is resolved once so the hot refresh path does not re-read tzdata.
var yunzeTokenLocation = func() *time.Location {
if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil {
return loc
}
return time.Local
}()
func parseYunzeTokenExpiry(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, fmt.Errorf("yunze tokenExpire is empty")
}
expiresAt, err := time.ParseInLocation(yunzeTokenExpiryLayout, raw, yunzeTokenLocation)
if err != nil {
return time.Time{}, fmt.Errorf("parse yunze tokenExpire %q: %w", raw, err)
}
return expiresAt, nil
}
@@ -0,0 +1,196 @@
package upstreamimpl
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
// yunzeExpire renders a wall-clock expiry in the operator-token wire format
// (Asia/Shanghai, "2006-01-02 15:04:05").
func yunzeExpire() string {
return time.Now().In(yunzeTokenLocation).Add(10 * time.Minute).Format(yunzeTokenExpiryLayout)
}
func TestYunzeTokenProviderFetch(t *testing.T) {
expire := time.Now().In(yunzeTokenLocation).Add(10 * time.Minute)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/openapi/v3/query/operator-token" {
t.Fatalf("path = %s", r.URL.Path)
}
if got := r.Header.Get("Content-Type"); got != "application/json" {
t.Fatalf("content type = %q", got)
}
var request yunzeTokenRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
}
if request.AppID != "app-id" || request.ChanKey != "249" {
t.Fatalf("request = %+v", request)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"","data":{"chanAlias":"中国移动物联网","chanKey":"249","accessToken":"token-1","tokenExpire":"` + expire.Format(yunzeTokenExpiryLayout) + `"},"error":null}`))
}))
defer server.Close()
provider := NewYunzeTokenProvider(YunzeTokenConfig{
BaseURL: server.URL,
Path: "/openapi/v3/query/operator-token",
AppID: "app-id",
Timeout: time.Second,
})
token, err := provider.Fetch(context.Background(), "249")
if err != nil {
t.Fatalf("fetch token: %v", err)
}
if token.Value != "token-1" {
t.Fatalf("token = %q", token.Value)
}
if token.ExpiresAt.IsZero() {
t.Fatal("token expiry is empty")
}
if token.ExpiresAt.Sub(expire).Abs() > time.Second {
t.Fatalf("expiry = %s, want %s", token.ExpiresAt, expire)
}
}
// Pins the wire field name to the live endpoint. The published documentation
// lists data.accessTokenExpire, but the endpoint returns data.tokenExpire; if
// either side changes, this test fails and forces a deliberate decision.
func TestYunzeTokenProviderExpiryFieldName(t *testing.T) {
expire := yunzeExpire()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{"accessToken":"token-1","tokenExpire":"` + expire + `"},"error":null}`))
}))
defer server.Close()
provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second})
token, err := provider.Fetch(context.Background(), "249")
if err != nil {
t.Fatalf("fetch token: %v", err)
}
if token.Value != "token-1" || token.ExpiresAt.IsZero() {
t.Fatalf("token = %+v", token)
}
}
func TestYunzeTokenProviderAcceptsStringCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":"0","data":{"accessToken":"token-str","tokenExpire":"` + yunzeExpire() + `"},"error":null}`))
}))
defer server.Close()
provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second})
token, err := provider.Fetch(context.Background(), "249")
if err != nil {
t.Fatalf("fetch token: %v", err)
}
if token.Value != "token-str" {
t.Fatalf("token = %q, want token-str", token.Value)
}
}
func TestYunzeTokenProviderRejectsFailedResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":1001,"message":"通道不存在","data":null,"error":null}`))
}))
defer server.Close()
provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second})
if _, err := provider.Fetch(context.Background(), "249"); err == nil {
t.Fatal("expected failed response error")
}
}
func TestYunzeTokenProviderRejectsErrorPayload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{"accessToken":"token-1","tokenExpire":"` + yunzeExpire() + `"},"error":{"msg":"account frozen"}}`))
}))
defer server.Close()
provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second})
if _, err := provider.Fetch(context.Background(), "249"); err == nil {
t.Fatal("expected error payload rejection")
}
}
func TestParseSharedOneLinkConfigBusinessURL(t *testing.T) {
newConfig := func(tempConfig string) *model.SimChannelConfigItemCache {
return &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{TempConfig: tempConfig},
Channel: &entity.TSimChannels{
ChanKey: "249",
AppKey: "app-id",
ChanConfig: "{}",
},
}
}
t.Run("defaults to shared OneLink URL", func(t *testing.T) {
config, api, err := parseSharedOneLinkConfig(context.Background(), newConfig(
`{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/openapi/v3/query/operator-token"}}`,
))
if err != nil {
t.Fatalf("parse config: %v", err)
}
if config.Provider != "yunze" || config.AppID != "app-id" {
t.Fatalf("config = %+v", config)
}
if api.BaseURL != sharedOneLinkAPIBaseURL {
t.Fatalf("business base URL = %q, want %q", api.BaseURL, sharedOneLinkAPIBaseURL)
}
})
t.Run("honours template api.baseUrl", func(t *testing.T) {
const custom = "https://custom.example/v5/"
config, api, err := parseSharedOneLinkConfig(context.Background(), newConfig(
`{"api":{"baseUrl":"`+custom+`"},"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/openapi/v3/query/operator-token"}}`,
))
if err != nil {
t.Fatalf("parse config: %v", err)
}
if config.Provider != "yunze" || config.AppID != "app-id" {
t.Fatalf("config = %+v", config)
}
if api.BaseURL != custom {
t.Fatalf("business base URL = %q, want %q", api.BaseURL, custom)
}
})
}
// The yunze channel template is registered by its concrete temp_key, matching
// the other shared-token templates (sim_onelink_<provider>_v1).
func TestYunzeTemplateRegistration(t *testing.T) {
template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkYunzeTemplateKey)
if !ok || template == nil {
t.Fatal("yunze template not registered")
}
config := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkYunzeTemplateKey,
TempModel: templateModelSharedToken,
TempConfig: `{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/openapi/v3/query/operator-token"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "249",
AppKey: "YZTKB3FT1SJR8652C4WP9VM",
ChanConfig: "{}",
},
}
if instance := template.NewTemp(context.Background(), config); instance == nil {
t.Fatal("yunze template failed to instantiate")
}
}
@@ -0,0 +1,145 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync/atomic"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
const ziboOneLinkTokenPath = "/ec/get/token"
// ZiboTokenConfig contains the official OneLink token endpoint and the
// per-channel credentials stored in t_sim_channels.
type ZiboTokenConfig struct {
BaseURL string
Path string
AppID string
Password string
Timeout time.Duration
}
// ZiboTokenProvider fetches a token from the official OneLink endpoint.
type ZiboTokenProvider struct {
baseURL string
path string
appID string
password string
client *http.Client
sequence atomic.Uint64
}
type ziboTokenResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
Token string `json:"token"`
TTL int `json:"ttl"`
} `json:"result"`
}
type ziboFetcher struct {
provider *ZiboTokenProvider
}
func NewZiboTokenProvider(config ZiboTokenConfig) *ZiboTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &ZiboTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, ziboOneLinkTokenPath),
appID: strings.TrimSpace(config.AppID),
password: config.Password,
client: &http.Client{Timeout: timeout},
}
}
func (f ziboFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
token, err := f.provider.Fetch(ctx)
if err != nil {
return sharedtoken.OneLinkToken{}, err
}
return sharedtoken.OneLinkToken{
Value: token.Value,
CreatedAt: token.CreatedAt,
ExpiresAt: token.ExpiresAt,
}, nil
}
func (p *ZiboTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) {
if p == nil || p.client == nil {
return OneLinkToken{}, fmt.Errorf("zibo token provider is not initialized")
}
if p.baseURL == "" {
return OneLinkToken{}, fmt.Errorf("zibo token base URL is required")
}
if p.appID == "" || strings.TrimSpace(p.password) == "" {
return OneLinkToken{}, fmt.Errorf("zibo appid and password are required")
}
endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path))
if err != nil {
return OneLinkToken{}, fmt.Errorf("parse zibo token URL: %w", err)
}
query := endpoint.Query()
query.Set("appid", p.appID)
query.Set("password", p.password)
query.Set("transid", p.nextTransID())
query.Set("refresh", "0")
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return OneLinkToken{}, fmt.Errorf("create zibo token request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return OneLinkToken{}, fmt.Errorf("request zibo token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return OneLinkToken{}, fmt.Errorf("read zibo token response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return OneLinkToken{}, fmt.Errorf("zibo token HTTP status %d", resp.StatusCode)
}
var output ziboTokenResponse
if err := json.Unmarshal(body, &output); err != nil {
return OneLinkToken{}, fmt.Errorf("decode zibo token response: %w", err)
}
if strings.TrimSpace(output.Status) != "0" {
return OneLinkToken{}, fmt.Errorf("zibo token failed: status=%s, message=%s", output.Status, output.Message)
}
if len(output.Result) == 0 || strings.TrimSpace(output.Result[0].Token) == "" {
return OneLinkToken{}, fmt.Errorf("zibo token response is empty")
}
if output.Result[0].TTL <= 0 {
return OneLinkToken{}, fmt.Errorf("zibo token ttl must be positive")
}
now := time.Now()
return OneLinkToken{
Value: strings.TrimSpace(output.Result[0].Token),
CreatedAt: now,
ExpiresAt: now.Add(time.Duration(output.Result[0].TTL) * time.Second),
}, nil
}
func (p *ZiboTokenProvider) nextTransID() string {
return p.appID + time.Now().Format("20060102150405") +
fmt.Sprintf("%08d", p.sequence.Add(1)%100000000)
}