package upstreamimpl import ( "bytes" "context" "crypto/md5" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "sync" "time" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" "golang.org/x/sync/singleflight" ) const ( tibiotDefaultBaseURL = "https://api.tibiot.com" tibiotTimeLayout = "20060102150405" tibiotUsageCacheTTL = 30 * time.Second ) var tibiotEast8 = time.FixedZone("UTC+8", 8*60*60) type sSimTibiot struct { channelConfig *model.SimChannelConfigItemCache baseURL string userName string password string http *http.Client usageCache sync.Map usageFlight singleflight.Group } type tibiotUsageCacheEntry struct { Usage float64 UpdatedAt string ExpiresAt time.Time } type tibiotUsageResult struct { Usage float64 Cached bool UpdatedAt string BusinessError *tibiotResponse } type tibiotResponse struct { Status any `json:"status"` Message string `json:"message"` Data json.RawMessage `json:"data"` } type tibiotCardInfo struct { ICCID string `json:"iccid"` IMSI string `json:"imsi"` MSISDN string `json:"msisdn"` IMEI string `json:"imei"` CardStatus any `json:"cardStatus"` PackageName string `json:"packageName"` TotalFlow string `json:"totalFlow"` CardFlow string `json:"cardFlow"` LeftFlow string `json:"leftFlow"` OpenCardTime string `json:"openCardTime"` LastActivateTime string `json:"lastActivateTime"` ActivateTime string `json:"activateTime"` PackageTime string `json:"packageTime"` Remark string `json:"remark"` ChannelID string `json:"channelId"` PackageID string `json:"packageId"` CardType any `json:"cardType"` PoolID string `json:"poolId"` } type tibiotOnlineInfo struct { ICCID string `json:"iccid"` MSISDN string `json:"msisdn"` CardStatus string `json:"cardStatus"` NetworkStatus string `json:"netWorkStatus"` EquipmentStatus string `json:"equipmentStatus"` OnlineStatus string `json:"onlineStatus"` SeparationStatus string `json:"separationStatus"` NetworkType string `json:"networkType"` RegionLimitStatus string `json:"regionLimitStatus"` APN string `json:"apn"` IP string `json:"ip"` IMEI string `json:"imei"` } type tibiotMonthUsage struct { Years string `json:"years"` CardFlow string `json:"cardFlow"` ICCID string `json:"iccid"` MSISDN string `json:"msisdn"` SyncTime string `json:"syncTime"` } type tibiotDailyUsage struct { SyncStatus string `json:"syncStatus"` CardFlow string `json:"cardFlow"` CreateTime string `json:"createTime"` SyncTime string `json:"syncTime"` ICCID string `json:"iccid"` IMSI string `json:"imsi"` MSISDN string `json:"msisdn"` QueryDate string `json:"queryTime"` } type tibiotBatchCardData struct { PageNum int `json:"pageNum"` PageSize int `json:"pageSize"` TotalCount int `json:"totalCount"` PageCount int `json:"pageCount"` Records []tibiotCardInfo `json:"records"` } func NewSimTibiot() *sSimTibiot { return &sSimTibiot{} } func init() { service.UpstreamAPI().RegisterTemp("sim_tibiot_v1", NewSimTibiot()) } func (s *sSimTibiot) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI { baseURL := tibiotDefaultBaseURL if channelConfig != nil && channelConfig.Template != nil { var cfg struct { API struct { BaseURL string `json:"baseUrl"` } `json:"api"` } if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &cfg); err != nil { g.Log().Warningf(ctx, "parse Tibiot template config failed: %v", err) } else if strings.TrimSpace(cfg.API.BaseURL) != "" { baseURL = strings.TrimRight(strings.TrimSpace(cfg.API.BaseURL), "/") } } var credentials struct { UserName string `json:"userName"` Password string `json:"password"` } if channelConfig != nil && channelConfig.Channel != nil { if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &credentials); err != nil { g.Log().Warningf(ctx, "parse Tibiot channel config failed, chanKey=%s err=%v", channelConfig.Channel.ChanKey, err) } } return &sSimTibiot{ channelConfig: channelConfig, baseURL: baseURL, userName: strings.TrimSpace(credentials.UserName), password: credentials.Password, http: &http.Client{Timeout: resolveUpstreamHTTPTimeout(ctx, templateConfigJSON(channelConfig))}, } } func tibiotSignature(password, tKey string) string { first := md5.Sum([]byte(password)) firstText := hex.EncodeToString(first[:]) second := md5.Sum([]byte(firstText + tKey)) return hex.EncodeToString(second[:]) } func tibiotAuthFields(userName, password string, now time.Time) map[string]any { tKey := now.In(tibiotEast8).Format(tibiotTimeLayout) return map[string]any{ "userName": userName, "passWord": tibiotSignature(password, tKey), "tKey": tKey, } } func (s *sSimTibiot) post(ctx context.Context, endpoint string, params map[string]any) (tibiotResponse, error) { if s == nil { return tibiotResponse{}, fmt.Errorf("Tibiot client is nil") } if strings.TrimSpace(s.userName) == "" || s.password == "" { return tibiotResponse{}, fmt.Errorf("Tibiot credentials are not configured") } payload := tibiotAuthFields(s.userName, s.password, time.Now()) for key, value := range params { payload[key] = value } body, err := json.Marshal(payload) if err != nil { return tibiotResponse{}, fmt.Errorf("marshal Tibiot request: %w", err) } url := strings.TrimRight(s.baseURL, "/") + "/" + strings.TrimLeft(endpoint, "/") req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return tibiotResponse{}, fmt.Errorf("create Tibiot request: %w", err) } req.Header.Set("Content-Type", "application/json") client := s.http if client == nil { client = &http.Client{Timeout: defaultUpstreamHTTPTimeout} } resp, err := client.Do(req) if err != nil { return tibiotResponse{}, fmt.Errorf("Tibiot request %s: %w", endpoint, err) } defer resp.Body.Close() responseBody, err := io.ReadAll(resp.Body) if err != nil { return tibiotResponse{}, fmt.Errorf("read Tibiot response %s: %w", endpoint, err) } rawResponse := strings.TrimSpace(string(responseBody)) if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { g.Log().Warningf(ctx, "Tibiot HTTP error endpoint=%s status=%d response=%s", endpoint, resp.StatusCode, rawResponse) return tibiotResponse{}, fmt.Errorf("Tibiot HTTP status %d", resp.StatusCode) } var output tibiotResponse if err := json.Unmarshal(responseBody, &output); err != nil { g.Log().Warningf(ctx, "decode Tibiot response failed endpoint=%s response=%s err=%v", endpoint, rawResponse, err) return tibiotResponse{}, fmt.Errorf("decode Tibiot response %s: %w", endpoint, err) } if !tibiotSuccess(output) { g.Log().Warningf(ctx, "Tibiot business error endpoint=%s status=%d message=%s response=%s", endpoint, tibiotCode(output), output.Message, rawResponse) } else { g.Log().Infof(ctx, "Tibiot response endpoint=%s status=%d response=%s", endpoint, tibiotCode(output), rawResponse) } return output, nil } func tibiotCode(resp tibiotResponse) int { return gconv.Int(resp.Status) } func tibiotSuccess(resp tibiotResponse) bool { return tibiotCode(resp) == 0 } func tibiotCardIdentifier(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 tibiotParams(simKey *model.SimKey) (map[string]any, error) { name, value := tibiotCardIdentifier(simKey) if name == "" { return nil, fmt.Errorf("missing iccid/msisdn/imsi") } return map[string]any{name: value}, nil } func tibiotUnsupported[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 tibiotError[T any](simKey *model.SimKey, err error) *model.SimBaseRes[T] { return &model.SimBaseRes[T]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()} } func tibiotBusinessError[T any](simKey *model.SimKey, resp tibiotResponse) *model.SimBaseRes[T] { return &model.SimBaseRes[T]{ SimKey: simKey, Code: tibiotCode(resp), ErrCode: strconv.Itoa(tibiotCode(resp)), Message: resp.Message, } } func decodeTibiotData[T any](resp tibiotResponse, target *T) error { if len(resp.Data) == 0 || string(resp.Data) == "null" { return fmt.Errorf("Tibiot response data is empty") } if err := json.Unmarshal(resp.Data, target); err != nil { return fmt.Errorf("decode Tibiot response data: %w", err) } return nil } func decodeTibiotBatchCards(resp tibiotResponse) (records []tibiotCardInfo, pageCount int, err error) { var pageData tibiotBatchCardData if err = decodeTibiotData(resp, &pageData); err == nil && pageData.Records != nil { return pageData.Records, pageData.PageCount, nil } var list []tibiotCardInfo if listErr := decodeTibiotData(resp, &list); listErr != nil { if err != nil { return nil, 0, err } return nil, 0, listErr } return list, 1, nil } func parseTibiotFlow(value string) float64 { value = strings.ToUpper(strings.TrimSpace(value)) if value == "" { return 0 } for _, unit := range []struct { suffix string factor float64 }{ {suffix: "EB", factor: 1024 * 1024 * 1024 * 1024}, {suffix: "PB", factor: 1024 * 1024 * 1024}, {suffix: "TB", factor: 1024 * 1024}, {suffix: "GB", factor: 1024}, {suffix: "MB", factor: 1}, {suffix: "KB", factor: 1.0 / 1024}, } { if strings.HasSuffix(value, unit.suffix) { return gconv.Float64(strings.TrimSpace(strings.TrimSuffix(value, unit.suffix))) * unit.factor } } return gconv.Float64(value) } func tibiotSessionStatus(value string) string { switch strings.ToUpper(strings.TrimSpace(value)) { case "1", "ONLINE": return consts.CardSessionStatusOnline case "2", "OFFLINE": return consts.CardSessionStatusOffline default: return consts.CardSessionStatusUnknown } } func tibiotDeviceStatus(value string) string { switch strings.ToUpper(strings.TrimSpace(value)) { case "1", "ON", "ONLINE": return "1" case "2", "0", "OFF", "OFFLINE": return "0" default: return consts.CardSessionStatusUnknown } } func (s *sSimTibiot) 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 { params, err := tibiotParams(simKey) if err != nil { items = append(items, tibiotError[model.SimBasicInfoItem](simKey, err)) continue } resp, err := s.post(ctx, "/api/v1/card/queryCardInfo", params) if err != nil { items = append(items, tibiotError[model.SimBasicInfoItem](simKey, err)) continue } if !tibiotSuccess(resp) { items = append(items, tibiotBusinessError[model.SimBasicInfoItem](simKey, resp)) continue } var data tibiotCardInfo if err := decodeTibiotData(resp, &data); err != nil { items = append(items, tibiotError[model.SimBasicInfoItem](simKey, err)) continue } items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ SimKey: simKey, Code: consts.ApiStatusSuccess, Message: resp.Message, ResData: model.SimBasicInfoItem{ ICCID: data.ICCID, IMSI: data.IMSI, MSISDN: data.MSISDN, ActiveDate: firstNonEmpty(data.ActivateTime, data.LastActivateTime), OpenDate: firstNonEmpty(data.OpenCardTime, data.ActivateTime), Remark: data.Remark, SimStatus: strconv.Itoa(convertTibiotCardStatus(context.Background(), gconv.Int(data.CardStatus))), MainOfferName: data.PackageName, }, }) } return items, nil } func (s *sSimTibiot) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { if input == nil { return make(model.SimSessionOutput, 0), nil } items := make(model.SimSessionOutput, 0, len(input.SimKeys)) for _, simKey := range input.SimKeys { params, err := tibiotParams(simKey) if err != nil { items = append(items, tibiotError[model.SimSessionItem](simKey, err)) continue } resp, err := s.post(ctx, "/api/v1/card/cardOnlineInformation", params) if err != nil { items = append(items, tibiotError[model.SimSessionItem](simKey, err)) continue } if !tibiotSuccess(resp) { items = append(items, tibiotBusinessError[model.SimSessionItem](simKey, resp)) continue } var data tibiotOnlineInfo if err := decodeTibiotData(resp, &data); err != nil { items = append(items, tibiotError[model.SimSessionItem](simKey, err)) continue } items = append(items, &model.SimBaseRes[model.SimSessionItem]{ SimKey: simKey, Code: consts.ApiStatusSuccess, Message: resp.Message, ResData: model.SimSessionItem{ Imei: data.IMEI, Rat: data.NetworkType, Ipv4: data.IP, ApnId: data.APN, Status: tibiotSessionStatus(data.OnlineStatus), }, }) } return items, nil } func (s *sSimTibiot) 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 { params, err := tibiotParams(simKey) if err != nil { items = append(items, tibiotError[model.SimCardStatusItem](simKey, err)) continue } resp, err := s.post(ctx, "/api/v1/card/queryCardInfo", params) if err != nil { items = append(items, tibiotError[model.SimCardStatusItem](simKey, err)) continue } if !tibiotSuccess(resp) { items = append(items, tibiotBusinessError[model.SimCardStatusItem](simKey, resp)) continue } var data tibiotCardInfo if err := decodeTibiotData(resp, &data); err != nil { items = append(items, tibiotError[model.SimCardStatusItem](simKey, err)) continue } items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ SimKey: simKey, Code: consts.ApiStatusSuccess, Message: resp.Message, ResData: model.SimCardStatusItem{CardStatus: convertTibiotCardStatus(ctx, gconv.Int(data.CardStatus)), LastChangeDate: data.LastActivateTime}, }) } return items, nil } func (s *sSimTibiot) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { if input == nil { return make(model.SimStopReasonOutput, 0), nil } items := make(model.SimStopReasonOutput, 0, len(input.SimKeys)) for _, simKey := range input.SimKeys { params, err := tibiotParams(simKey) if err != nil { items = append(items, tibiotError[model.SimStopReasonItem](simKey, err)) continue } resp, err := s.post(ctx, "/api/v1/card/getCardStopReason", params) if err != nil { items = append(items, tibiotError[model.SimStopReasonItem](simKey, err)) continue } if !tibiotSuccess(resp) { items = append(items, tibiotBusinessError[model.SimStopReasonItem](simKey, resp)) continue } var reasons []string if err := decodeTibiotData(resp, &reasons); err != nil { items = append(items, tibiotError[model.SimStopReasonItem](simKey, err)) continue } items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimStopReasonItem{StopReason: strings.Join(reasons, ";")}}) } return items, nil } func (s *sSimTibiot) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { if input == nil { return make(model.SimFrozenStateOutput, 0), nil } return tibiotUnsupported[model.SimFrozenStateItem](input.SimKeys, "Tibiot does not support frozen state query"), nil } func (s *sSimTibiot) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { if input == nil { return make(model.SimCardBindStatusOutput, 0), nil } items := make(model.SimCardBindStatusOutput, 0, len(input.SimKeys)) for _, simKey := range input.SimKeys { params, err := tibiotParams(simKey) if err != nil { items = append(items, tibiotError[model.SimCardBindStatusItem](simKey, err)) continue } resp, err := s.post(ctx, "/api/v1/card/queryCardBindStatus", params) if err != nil { items = append(items, tibiotError[model.SimCardBindStatusItem](simKey, err)) continue } if !tibiotSuccess(resp) { items = append(items, tibiotBusinessError[model.SimCardBindStatusItem](simKey, resp)) continue } var value string if err := decodeTibiotData(resp, &value); err != nil { items = append(items, tibiotError[model.SimCardBindStatusItem](simKey, err)) continue } items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimCardBindStatusItem{BindStatus: value}}) } return items, nil } func (s *sSimTibiot) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { if input == nil { return make(model.SimDeviceStatusOutput, 0), nil } items := make(model.SimDeviceStatusOutput, 0, len(input.SimKeys)) for _, simKey := range input.SimKeys { params, err := tibiotParams(simKey) if err != nil { items = append(items, tibiotError[model.SimDeviceStatusItem](simKey, err)) continue } resp, err := s.post(ctx, "/api/v1/card/cardOnlineInformation", params) if err != nil { items = append(items, tibiotError[model.SimDeviceStatusItem](simKey, err)) continue } if !tibiotSuccess(resp) { items = append(items, tibiotBusinessError[model.SimDeviceStatusItem](simKey, resp)) continue } var data tibiotOnlineInfo if err := decodeTibiotData(resp, &data); err != nil { items = append(items, tibiotError[model.SimDeviceStatusItem](simKey, err)) continue } items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimDeviceStatusItem{DeviceStatus: tibiotDeviceStatus(data.EquipmentStatus)}}) } return items, nil } func (s *sSimTibiot) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { if input == nil { return make(model.SimRegionLimitStatusOutput, 0), nil } items := make(model.SimRegionLimitStatusOutput, 0, len(input.SimKeys)) for _, simKey := range input.SimKeys { params, err := tibiotParams(simKey) if err != nil { items = append(items, tibiotError[model.SimRegionLimitStatusItem](simKey, err)) continue } resp, err := s.post(ctx, "/api/v1/card/cardOnlineInformation", params) if err != nil { items = append(items, tibiotError[model.SimRegionLimitStatusItem](simKey, err)) continue } if !tibiotSuccess(resp) { items = append(items, tibiotBusinessError[model.SimRegionLimitStatusItem](simKey, resp)) continue } var data tibiotOnlineInfo if err := decodeTibiotData(resp, &data); err != nil { items = append(items, tibiotError[model.SimRegionLimitStatusItem](simKey, err)) continue } items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimRegionLimitStatusItem{RegionLimitStatus: data.RegionLimitStatus}}) } return items, nil } func (s *sSimTibiot) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) { if input == nil { return make(model.SimImeiOutput, 0), nil } items := make(model.SimImeiOutput, 0, len(input.SimKeys)) for _, simKey := range input.SimKeys { params, err := tibiotParams(simKey) if err != nil { items = append(items, tibiotError[model.SimImeiItem](simKey, err)) continue } resp, err := s.post(ctx, "/api/v1/card/querySimIMEI", params) if err != nil { items = append(items, tibiotError[model.SimImeiItem](simKey, err)) continue } if !tibiotSuccess(resp) { items = append(items, tibiotBusinessError[model.SimImeiItem](simKey, resp)) continue } var data struct { IMEI string `json:"imei"` } if err := decodeTibiotData(resp, &data); err != nil { items = append(items, tibiotError[model.SimImeiItem](simKey, err)) continue } items = append(items, &model.SimBaseRes[model.SimImeiItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimImeiItem{Imei: data.IMEI}}) } return items, nil } func (s *sSimTibiot) 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 { result, err := s.querySimDataUsage(ctx, simKey) if err != nil { items = append(items, tibiotError[model.SimDataUsageItem](simKey, err)) continue } if result.BusinessError != nil { items = append(items, tibiotBusinessError[model.SimDataUsageItem](simKey, *result.BusinessError)) continue } items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ SimKey: simKey, Code: 0, Cached: result.Cached, UpdatedAt: result.UpdatedAt, ResData: model.SimDataUsageItem{DataUsage: result.Usage}, }) } return items, nil } func (s *sSimTibiot) querySimDataUsage(ctx context.Context, simKey *model.SimKey) (*tibiotUsageResult, error) { cacheKey := tibiotUsageCacheKey(simKey) if cached, ok := s.cachedUsage(cacheKey); ok { return cached, nil } value, err, _ := s.usageFlight.Do(cacheKey, func() (any, error) { if cached, ok := s.cachedUsage(cacheKey); ok { return cached, nil } params, paramsErr := tibiotParams(simKey) if paramsErr != nil { return nil, paramsErr } params["flowUnit"] = "MB" var ( lastResp tibiotResponse lastErr error usage float64 ) for attempt := 0; attempt < 2; attempt++ { if waitErr := tibiotSimDataMarginLimiter.wait(ctx); waitErr != nil { lastErr = waitErr break } lastResp, lastErr = s.post(ctx, "/api/v1/card/simDataMargin", params) if lastErr != nil { if errors.Is(lastErr, context.Canceled) || errors.Is(lastErr, context.DeadlineExceeded) { break } continue } if !tibiotSuccess(lastResp) { continue } var value string value, lastErr = decodeTibiotUsage(lastResp) if lastErr == nil { usage = parseTibiotFlow(value) break } } if lastErr != nil { return nil, lastErr } if !tibiotSuccess(lastResp) { return &tibiotUsageResult{BusinessError: &lastResp}, nil } updatedAt := time.Now().Format("2006-01-02 15:04:05") s.usageCache.Store(cacheKey, tibiotUsageCacheEntry{Usage: usage, UpdatedAt: updatedAt, ExpiresAt: time.Now().Add(tibiotUsageCacheTTL)}) return &tibiotUsageResult{Usage: usage, UpdatedAt: updatedAt}, nil }) if err != nil { return nil, err } result, ok := value.(*tibiotUsageResult) if !ok || result == nil { return nil, fmt.Errorf("Tibiot usage query returned invalid result") } return result, nil } func (s *sSimTibiot) cachedUsage(cacheKey string) (*tibiotUsageResult, bool) { value, ok := s.usageCache.Load(cacheKey) if !ok { return nil, false } entry, ok := value.(tibiotUsageCacheEntry) if !ok || time.Now().After(entry.ExpiresAt) { s.usageCache.Delete(cacheKey) return nil, false } return &tibiotUsageResult{Usage: entry.Usage, Cached: true, UpdatedAt: entry.UpdatedAt}, true } func tibiotUsageCacheKey(simKey *model.SimKey) string { if simKey == nil { return "unknown" } for _, value := range []string{simKey.ICCID, simKey.IMSI, simKey.MSISDN} { if value = strings.TrimSpace(value); value != "" { return value } } return "unknown" } func decodeTibiotUsage(resp tibiotResponse) (string, error) { var value string if err := decodeTibiotData(resp, &value); err == nil { if strings.TrimSpace(value) == "" { return "", fmt.Errorf("Tibiot usage data is empty") } return value, nil } else { var number float64 if numberErr := decodeTibiotData(resp, &number); numberErr != nil { return "", err } return strconv.FormatFloat(number, 'f', -1, 64), nil } } func (s *sSimTibiot) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { if input == nil { return make(model.SimDataDiagnosisOutput, 0), nil } return tibiotUnsupported[any](input.SimKeys, "Tibiot does not support data diagnosis"), nil } func (s *sSimTibiot) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { if input == nil { return make(model.SimDataUsageMonthlyOutput, 0), nil } queryMonth, err := tibiotMonth(input.ReqData.QueryDate) if err != nil { return nil, err } items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys)) for _, simKey := range input.SimKeys { params, err := tibiotParams(simKey) if err != nil { items = append(items, tibiotError[model.SimDataUsageMonthlyOutputItem](simKey, err)) continue } params["flowUnit"] = "MB" resp, err := s.post(ctx, "/api/v1/card/queryCardMonthData", params) if err != nil { items = append(items, tibiotError[model.SimDataUsageMonthlyOutputItem](simKey, err)) continue } if !tibiotSuccess(resp) { items = append(items, tibiotBusinessError[model.SimDataUsageMonthlyOutputItem](simKey, resp)) continue } var data []tibiotMonthUsage if err := decodeTibiotData(resp, &data); err != nil { items = append(items, tibiotError[model.SimDataUsageMonthlyOutputItem](simKey, err)) continue } var selected *tibiotMonthUsage for i := range data { dataMonth, parseErr := tibiotMonth(data[i].Years) if queryMonth == "" || (parseErr == nil && dataMonth == queryMonth) { selected = &data[i] break } } if selected == nil { items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: "monthly usage data not found"}) continue } if strings.TrimSpace(selected.CardFlow) == "" { items = append(items, tibiotError[model.SimDataUsageMonthlyOutputItem](simKey, fmt.Errorf("Tibiot monthly usage data is empty"))) continue } items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: selected.Years, DataAmount: parseTibiotFlow(selected.CardFlow)}}) } return items, nil } func tibiotDate(queryDate string) (string, error) { queryDate = strings.TrimSpace(queryDate) if queryDate == "" { return "", fmt.Errorf("queryDate is required") } if parsed, err := time.Parse("20060102", queryDate); err == nil { return parsed.Format("2006-01-02"), nil } if parsed, err := time.Parse("2006-01-02", queryDate); err == nil { return parsed.Format("2006-01-02"), nil } return "", fmt.Errorf("invalid queryDate: %s", queryDate) } func tibiotMonth(queryDate string) (string, error) { queryDate = strings.TrimSpace(queryDate) if queryDate == "" { return "", nil } 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 monthly queryDate: %s", queryDate) } func (s *sSimTibiot) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { if input == nil { return make(model.SimDataUsageDailyOutput, 0), nil } items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys)) queryDate, err := tibiotDate(input.ReqData.QueryDate) if err != nil { return nil, err } for _, simKey := range input.SimKeys { params, err := tibiotParams(simKey) if err != nil { items = append(items, tibiotError[model.SimDataUsageDailyOutputItem](simKey, err)) continue } params["queryTime"] = queryDate params["flowUnit"] = "MB" resp, err := s.post(ctx, "/api/v1/card/newIotData", params) if err != nil { items = append(items, tibiotError[model.SimDataUsageDailyOutputItem](simKey, err)) continue } if !tibiotSuccess(resp) { items = append(items, tibiotBusinessError[model.SimDataUsageDailyOutputItem](simKey, resp)) continue } var data tibiotDailyUsage if err := decodeTibiotData(resp, &data); err != nil { items = append(items, tibiotError[model.SimDataUsageDailyOutputItem](simKey, err)) continue } if strings.TrimSpace(data.CardFlow) == "" { items = append(items, tibiotError[model.SimDataUsageDailyOutputItem](simKey, fmt.Errorf("Tibiot daily usage data is empty"))) continue } items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate, DataAmount: parseTibiotFlow(data.CardFlow)}}) } return items, nil } func (s *sSimTibiot) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { if input == nil { return make(model.SimApnInfoOutput, 0), nil } return tibiotUnsupported[model.SimApnInfoItemList](input.SimKeys, "Tibiot does not support APN information query"), nil } func (s *sSimTibiot) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { if input == nil { return make(model.SimCardStatusChangeOutput, 0), nil } return tibiotUnsupported[model.SimCardStatusChangeItem](input.SimKeys, "Tibiot does not support SIM card status changes"), nil } func (s *sSimTibiot) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { if input == nil { return make(model.SimCardOperateChangeApnOutput, 0), nil } operateType := 0 switch strings.TrimSpace(input.ReqData.OperType) { case consts.CardApnOperTypeOpen: operateType = 1 case consts.CardApnOperTypeStop: operateType = 2 default: return tibiotUnsupported[model.SimCardOperateChangeApnItem](input.SimKeys, "unsupported operType, expected 0(open) or 1(stop)"), nil } items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys)) for _, simKey := range input.SimKeys { params, err := tibiotParams(simKey) if err != nil { items = append(items, tibiotError[model.SimCardOperateChangeApnItem](simKey, err)) continue } params["operateType"] = operateType resp, err := s.post(ctx, "/api/v1/card/cardNetWork", params) if err != nil { items = append(items, tibiotError[model.SimCardOperateChangeApnItem](simKey, err)) continue } if !tibiotSuccess(resp) { items = append(items, tibiotBusinessError[model.SimCardOperateChangeApnItem](simKey, resp)) continue } items = append(items, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimCardOperateChangeApnItem{OrderNum: ""}}) } return items, nil } func (s *sSimTibiot) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { // Tibiot's batch endpoint returns paged card records. Query pages until all // requested identifiers are found, preserving the caller's input order. if input == nil || len(input.SimKeys) == 0 { return make(model.SimCardStatusOutput, 0), nil } requested := make(map[string]*model.SimKey, len(input.SimKeys)*2) for _, key := range input.SimKeys { if key == nil { continue } if key.ICCID != "" { requested["iccid:"+strings.ToUpper(strings.TrimSpace(key.ICCID))] = key } if key.MSISDN != "" { requested["msisdn:"+strings.TrimSpace(key.MSISDN)] = key } if key.IMSI != "" { requested["imsi:"+strings.ToUpper(strings.TrimSpace(key.IMSI))] = key } } found := make(map[*model.SimKey]tibiotCardInfo, len(input.SimKeys)) for page := 1; page <= 100 && len(found) < len(input.SimKeys); page++ { params := map[string]any{"cardType": 1, "pageNum": page, "pageSize": 100, "flowUnit": "MB"} if len(input.SimKeys) == 1 { if name, value := tibiotCardIdentifier(input.SimKeys[0]); name != "" { params[name] = value } } resp, err := s.post(ctx, "/api/v1/card/batchQueryCardInfo", params) if err != nil { return nil, err } if !tibiotSuccess(resp) { return nil, fmt.Errorf("Tibiot batch status failed: code=%d message=%s", tibiotCode(resp), resp.Message) } records, pageCount, err := decodeTibiotBatchCards(resp) if err != nil { return nil, err } for _, item := range records { if key := requested["iccid:"+strings.ToUpper(strings.TrimSpace(item.ICCID))]; key != nil { found[key] = item } if key := requested["msisdn:"+strings.TrimSpace(item.MSISDN)]; key != nil { found[key] = item } if key := requested["imsi:"+strings.ToUpper(strings.TrimSpace(item.IMSI))]; key != nil { found[key] = item } } if pageCount > 0 && page >= pageCount { break } if len(records) < 100 { break } } items := make(model.SimCardStatusOutput, 0, len(input.SimKeys)) for _, simKey := range input.SimKeys { data, ok := found[simKey] 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: 0, ResData: model.SimCardStatusItem{CardStatus: convertTibiotCardStatus(ctx, gconv.Int(data.CardStatus)), LastChangeDate: data.LastActivateTime}}) } return items, nil } func (s *sSimTibiot) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { return s.QuerySimDataUsageMonthly(ctx, input) } func (s *sSimTibiot) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { if input == nil || len(input.SimKeys) == 0 { return make(model.SimDataUsageDailyOutput, 0), nil } queryDate, err := tibiotDate(input.ReqData.QueryDate) if err != nil { return nil, err } params := map[string]any{"queryTime": queryDate, "flowUnit": "MB"} useICCID := true for _, key := range input.SimKeys { if key == nil || strings.TrimSpace(key.ICCID) == "" { useICCID = false break } } if useICCID { values := make([]string, 0, len(input.SimKeys)) for _, key := range input.SimKeys { values = append(values, strings.TrimSpace(key.ICCID)) } params["iccids"] = strings.Join(values, ",") } else { values := make([]string, 0, len(input.SimKeys)) for _, key := range input.SimKeys { if key == nil || strings.TrimSpace(key.MSISDN) == "" { return nil, fmt.Errorf("batch daily query requires all ICCIDs or MSISDNs") } values = append(values, strings.TrimSpace(key.MSISDN)) } params["msisdns"] = strings.Join(values, ",") } resp, err := s.post(ctx, "/api/v1/card/batchNewIotData", params) if err != nil { return nil, err } if !tibiotSuccess(resp) { return nil, fmt.Errorf("Tibiot batch daily failed: code=%d message=%s", tibiotCode(resp), resp.Message) } var data []tibiotDailyUsage if err := decodeTibiotData(resp, &data); err != nil { return nil, err } byID := make(map[string]tibiotDailyUsage, len(data)*2) for _, item := range data { if item.ICCID != "" { byID["iccid:"+strings.ToUpper(strings.TrimSpace(item.ICCID))] = item } if item.IMSI != "" { byID["imsi:"+strings.ToUpper(strings.TrimSpace(item.IMSI))] = item } if item.MSISDN != "" { byID["msisdn:"+strings.TrimSpace(item.MSISDN)] = item } } items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys)) for _, key := range input.SimKeys { var item tibiotDailyUsage var ok bool if key != nil { for _, identifier := range []struct { name string value string }{ {name: "iccid", value: key.ICCID}, {name: "msisdn", value: key.MSISDN}, {name: "imsi", value: key.IMSI}, } { value := strings.TrimSpace(identifier.value) if value == "" { continue } lookup := identifier.name + ":" + value if identifier.name != "msisdn" { lookup = identifier.name + ":" + strings.ToUpper(value) } if item, ok = byID[lookup]; ok { break } } } if !ok { items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: key, Code: consts.ApiStatusError, Message: "batch response missing card", ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate}}) continue } if strings.TrimSpace(item.CardFlow) == "" { items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: key, Code: consts.ApiStatusError, Message: "batch response card flow is empty", ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate}}) continue } items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: key, Code: 0, Message: resp.Message, ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate, DataAmount: parseTibiotFlow(item.CardFlow)}}) } return items, nil }