// Package monthlyusagehistory implements the zset.Handler for previous-month // usage history. // // It queries one card per task through the upstream gateway (the same path the // status sync uses), validates the returned period and identity, and writes the // usage to the TDengine monthly table. // // The failure classifier is intentionally separate from the monthly-usage one // and deliberately conservative: only errors that are known to be transient are // retried. An unknown carrier code is parked as a terminal business failure // instead of being retried, because a retry storm against a rate-limited carrier // is worse than a card that needs manual review (see the 2026-09-16 disk-full // incident). Like the monthly classifier, it never inspects the error text. package monthlyusagehistory import ( "context" "fmt" "strings" "time" zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset" ) const ( // errCodeTelecomChannelLimited is the carrier error code that means the // whole channel is throttled or disabled. errCodeTelecomChannelLimited = "S00004" // chanGatewayInternalError is the gateway code for transport, HTTP and // response-decoding failures. An empty errCode distinguishes those from a // carrier business result that happens to use the same number. chanGatewayInternalError = 9999 // tokenExpiredCode is the carrier code for an expired upstream token. The // gateway refreshes the token once; a later round can then succeed. tokenExpiredCode = 12021 mobileBusyFirstCode = 13000 mobileBusyLastCode = 13007 mobileBusyClass = "mobile_busy" ) // mobileUsageTemplates identifies the China Mobile OneLink implementations. // Carrier identity is resolved from the channel template rather than from the // channel name or the error text, both of which are unstable. // // The set mirrors the OneLink registration table in the upstream proxy and is // kept in sync with the monthly-usage classifier // (service/monthlyusage/handler.go). Unknown templates stay "generic" and are // never classified as mobile. var mobileUsageTemplates = map[string]struct{}{ "sim_yd_v5": {}, "sim_yd_mock_v5": {}, "sim_sdtywl_v1": {}, "sim_onelink_tengyu_v1": {}, "sim_onelink_xuzhou_mobile_v1": {}, "sim_onelink_lelian_v1": {}, "sim_onelink_qianxun_v1": {}, "sim_onelink_henan_xinjing_v1": {}, "sim_henan_xinjing_direct_v1": {}, "sim_onelink_zibo_v1": {}, "sim_tibiot_v1": {}, "sim_qianxun_v3": {}, "sim_yunda_v1": {}, "sim_mingqian_v3": {}, "sim_daihe_v1": {}, "sim_onelink_daihe_v1": {}, "sim_onelink_daliuliang_v1": {}, "sim__daliuliang_v1": {}, "sim_onelink_mobile_public_v1": {}, "sim_onelink_kedang_v1": {}, "sim_onelink_beixin_v1": {}, "lanshang_8000": {}, "lanshang_8003": {}, } // usageCarrierForTemplate resolves a channel template to its carrier. // // Telecom is detected through model.IsTelecomTemplate so every worker path // shares one definition (it covers both sim_dx_v1 and sim_dx_v3). Hard-coding // the v1 template here would silently classify a v3 channel as generic, which // in turn would drop its S00004 channel-blocking behaviour. func usageCarrierForTemplate(tempKey string) string { tempKey = strings.ToLower(strings.TrimSpace(tempKey)) if model.IsTelecomTemplate(tempKey) { return "telecom" } if _, ok := mobileUsageTemplates[tempKey]; ok { return "mobile" } return "generic" } // classifyMonthlyHistoryFailure maps one upstream item to the queue semantics owned // by this service. // // message is accepted only so callers can log it; it never influences the // decision, because text matching was the root cause of the 2026-09-14 channel // retry loop. The default is a non-retryable business failure: a wrong retry // multiplies carrier throttling, while a missing retry costs one manual replay. func classifyMonthlyHistoryFailure(tempKey string, code int, errCode string) (errorClass string, retryable, blockChannel bool) { carrier := usageCarrierForTemplate(tempKey) switch { case carrier == "telecom" && strings.EqualFold(strings.TrimSpace(errCode), errCodeTelecomChannelLimited): return "channel_limited", false, true case carrier == "mobile" && code >= mobileBusyFirstCode && code <= mobileBusyLastCode: return mobileBusyClass, true, false case code == tokenExpiredCode: return "token_expired", true, false case code == chanGatewayInternalError && strings.TrimSpace(errCode) == "": return "network", true, false default: return "business", false, false } } // UsageClient is the upstream surface needed by the handler. type UsageClient interface { QuerySimDatausageMonthly(context.Context, *model.SimDataUsageMonthlyInput) ([]model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error) } // UsageRepository persists the monthly usage history rows. type UsageRepository interface { UpdateMonthly(context.Context, []model.TrafficUsageMonthlyItem) error } type Handler struct { client UsageClient repo UsageRepository } func NewHandler(client UsageClient, repo UsageRepository) (*Handler, error) { if client == nil || repo == nil { return nil, fmt.Errorf("monthly history handler dependencies are incomplete") } return &Handler{client: client, repo: repo}, nil } // Query performs one upstream monthly-usage request for a single card. // // task.QueryMonth carries the run's period identifier: for a monthly history run // that is yyyyMM, and it is sent upstream verbatim as queryDate. func (h *Handler) Query(ctx context.Context, task queue.Task) zsetconsumer.Result { result := zsetconsumer.Result{Task: task} queryMonth := strings.TrimSpace(task.QueryMonth) if queryMonth == "" { result.ErrorClass = "protocol" result.Message = "monthly history task is missing its query month" return result } output, err := h.client.QuerySimDatausageMonthly(ctx, &model.SimDataUsageMonthlyInput{ SimKeys: []*model.SimKey{{ ChanKey: task.ChanKey, ICCID: task.ICCID, IMSI: task.IMSI, MSISDN: task.MSISDN, }}, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: queryMonth}, }) if err != nil { result.ErrorClass = "network" result.Retryable = true result.Message = err.Error() return result } if len(output) == 0 || output[0].SimKey == nil { result.ErrorClass = "protocol" result.Retryable = true result.Message = "chan returned empty monthly usage result" return result } item := output[0] if task.ICCID != "" && item.SimKey.ICCID != task.ICCID { result.ErrorClass = "protocol" result.Retryable = true result.Message = "chan returned mismatched card identifier" return result } result.Code = item.Code result.Message = item.Message result.ErrCode = item.ErrCode if item.Code != 0 { result.ErrorClass, result.Retryable, result.BlockChannel = classifyMonthlyHistoryFailure(task.TempKey, item.Code, item.ErrCode) return result } // A successful code with a different (or missing) period would write the // wrong month, so it is treated as a protocol failure rather than silently // accepted. This mirrors the legacy admin reconcile requirement. if period, ok := normalizeMonthlyHistoryQueryMonth(item.ResData.DeadLine); !ok || period != queryMonth { result.ErrorClass = "protocol" result.Retryable = true result.Message = "chan returned mismatched monthly usage period" return result } result.Success = true result.UsageMB = item.ResData.DataAmount return result } // Apply writes every successful result to the TDengine monthly table. // // The timestamp is derived from the run period rather than from the response, so // every card in a run lands on the same 23:59:59.000 row that the dashboard and // the diagnosis page read. func (h *Handler) Apply(ctx context.Context, results []zsetconsumer.Result) error { items := make([]model.TrafficUsageMonthlyItem, 0, len(results)) for _, result := range results { if !result.Success { continue } month, ok := parseMonthlyHistoryQueryMonth(strings.TrimSpace(result.Task.QueryMonth)) if !ok { return fmt.Errorf("monthly history task %s has an invalid period %q", result.Task.TaskID, result.Task.QueryMonth) } items = append(items, model.TrafficUsageMonthlyItem{ TrafficUsageBase: model.TrafficUsageBase{ Iccid: result.Task.ICCID, UsageMb: result.UsageMB, PackageCode: result.Task.PackageCode, }, Month: month, }) } return h.repo.UpdateMonthly(ctx, items) } // ShouldRetryNextRound is the monthly-history retry policy. It is passed to the // run-aware ZSet consumer only; it keeps the classifier's verdict authoritative. func ShouldRetryNextRound(result zsetconsumer.Result) bool { return !result.Success && result.Retryable } var monthlyHistoryLocation = time.FixedZone("UTC+8", 8*60*60) // normalizeMonthlyHistoryQueryMonth canonicalizes an upstream period to yyyyMM // and reports whether the value was interpretable at all. // // The monthly usage endpoint answers with DeadLine, which may be a month, a // month-first date, or a full date; all of those collapse to the same yyyyMM so // the run period can be compared against the response. func normalizeMonthlyHistoryQueryMonth(value string) (string, bool) { for _, layout := range []string{"200601", "2006-01", "20060102", "2006-01-02"} { parsed, err := time.ParseInLocation(layout, strings.TrimSpace(value), monthlyHistoryLocation) if err == nil { return parsed.Format("200601"), true } } return "", false } // parseMonthlyHistoryQueryMonth parses a run period. The month-first day is // used because the repository derives the end-of-month timestamp from it. func parseMonthlyHistoryQueryMonth(value string) (time.Time, bool) { parsed, err := time.ParseInLocation("200601", value, monthlyHistoryLocation) if err != nil { return time.Time{}, false } return parsed, true }