260 lines
9.9 KiB
Go
260 lines
9.9 KiB
Go
// 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
|
|
}
|