package imei import ( "context" "fmt" "strings" 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" imeirepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/imei" ) const ( // chanGatewayInternalErrorCode is emitted by the gateway for an internal // fault. An empty errCode distinguishes it from a carrier business code. chanGatewayInternalErrorCode = 9999 // tokenExpiredCode is the carrier code for an expired/invalid upstream // token. The gateway already invalidates and refreshes the token once on // this code; when it still reaches the worker, retrying in a later round // lets a refreshed token succeed instead of parking the card as a terminal // business failure. tokenExpiredCode = 12021 // Mobile OneLink business codes 13000-13007 mean "system busy". Per the // QPS field test this is an HTTP 200 business rejection that still occurs // on roughly 70% of requests at 1 QPS, so it must be retried across rounds // rather than in place. mobileBusyFirstCode = 13000 mobileBusyLastCode = 13007 // chanNoRecordCode is the carrier answer for a card it holds no record of // ("系统无记录,请检查输入参数是否正确"). An IMEI is reported by the device, so // a card that was never inserted into one is a permanent, expected answer // rather than a failure: it is covered by the pass, never retried and never // parked in the failure pool. Measured on 2026-10-20 across channels // 220/250/251: all 4900 recorded failures were this code. chanNoRecordCode = 14000 // chanNoDeviceInfoCode is the Telecom 5G CMP answer for a card with no bound // device: {"result":"-1","resultMsg":"查询失败,查询不到指定卡的设备信息"}. // The chan layer passes the upstream string through gconv.Int, so it arrives // here as -1. Measured on 2026-10-22 run imei-20261022000000: 11,240 such // answers across channels 142/111/187, all with that exact message. chanNoDeviceInfoCode = -1 // chanTibiotNoInfoCode is Tibiot's "查询不到信息" business rejection, which // arrives as HTTP status 500 with a JSON body status=500. It is the same // semantic as chanNoDeviceInfoCode: the card has no device record. Measured // on the same run: 1,612 answers, of which 1,494 were 查询不到信息 and 118 // were 系统无记录. Tibiot answers with the same shape for genuine upstream // faults, so the code alone is not enough — see isNoDeviceMessage. chanTibiotNoInfoCode = 500 // mobileNoRecordMessage and tibiotNoInfoMessage are the carrier texts that // mean "this card has no device, so there is no IMEI". They gate // chanTibiotNoInfoCode because 500 is also used for real faults; matching // text here is safer than treating every 500 as a definitive answer. // chanNoDeviceInfoCode does not need this gate: -1 is not used for faults by // the Telecom endpoint. // // Matching on text is normally fragile, so these strings are intentionally // narrow and come from captured production traffic (2026-10-22). mobileNoRecordMessage = "系统无记录" tibiotNoInfoMessage = "查询不到信息" mobileBusyClass = "mobile_busy" unsupportedClass = "unsupported" dataInvalidClass = "data_invalid" noRecordClass = "no_record" networkClass = "network" protocolClass = "protocol" tokenExpiredClass = "token_expired" gatewayClass = "gateway" businessClass = "business" ) // imeiUnsupportedTemplates lists the templates whose upstream implementation // has no IMEI endpoint at all. Such a template is a deterministic terminal // state: retrying it every round would flood the failure pool with tens of // thousands of meaningless records and distort the run failure rate. // // Template keys are used rather than error text because the text changes while // the template does not. Keep this list aligned with the chan registry // (iotlcm-upstream-chan/internal/logic/upstreamimpl): // // - sim_mingqian_v3 → mingqian.Adapter (per-card IMEI endpoint unavailable) // - sim_daihe_v1 → sSimDaihe.QuerySimImei returns daiheUnsupported // - sim_qianxun_v3 → sSimQianxun.QuerySimImei returns qianxunUnsupported // // Note that the shared-token templates (sim_onelink_*) are NOT listed here: // they are registered to sSimYD, which does implement QuerySimImei. Verified // against production data on 2026-10-12 — every sim_onelink_* template with // cards is served by sSimYD. var imeiUnsupportedTemplates = map[string]struct{}{ "sim_mingqian_v3": {}, "sim_daihe_v1": {}, "sim_qianxun_v3": {}, } // mobileTemplates lists the templates served by the China Mobile OneLink // implementation, so numeric codes 13000-13007 can be classified as // "system busy" for them. Derived from the chan registration table; keep in // sync with that table. Unknown templates stay generic and are never treated // as mobile. var mobileTemplates = 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_yunda_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": {}, } func isMobileTemplate(tempKey string) bool { _, ok := mobileTemplates[strings.ToLower(strings.TrimSpace(tempKey))] return ok } func isImeiUnsupportedTemplate(tempKey string) bool { _, ok := imeiUnsupportedTemplates[strings.ToLower(strings.TrimSpace(tempKey))] return ok } // isNoDeviceMessage reports whether a carrier message means "this card has no // device bound, so no IMEI exists". // // This is only consulted for codes that carriers reuse for genuine faults (the // Tibiot 500). Codes that are unambiguous on their own (14000, -1) do not need // it, and deliberately do not depend on the text: the message is carrier // wording and may be reworded at any time. func isNoDeviceMessage(message string) bool { message = strings.TrimSpace(message) if message == "" { return false } return strings.Contains(message, tibiotNoInfoMessage) || strings.Contains(message, mobileNoRecordMessage) } // ImeiClient is the boundary to the chan gateway. type ImeiClient interface { QuerySimImei(context.Context, *model.SimImeiInput) ([]model.SimBaseRes[model.SimImeiItem], error) } // ImeiRepository persists one batch of sync results. type ImeiRepository interface { ApplyBatch(context.Context, []imeirepo.Result) error } type Handler struct { client ImeiClient repo ImeiRepository networkRetries int } func NewHandler(client ImeiClient, repo ImeiRepository, networkRetries ...int) (*Handler, error) { if client == nil || repo == nil { return nil, fmt.Errorf("imei handler dependencies are incomplete") } retries := 0 if len(networkRetries) > 0 && networkRetries[0] > 0 { retries = networkRetries[0] } return &Handler{client: client, repo: repo, networkRetries: retries}, nil } // Query performs one carrier IMEI lookup and maps the outcome onto the queue's // retry semantics. // // The classification matrix is deliberately explicit, because the difference // between "retry forever" and "park as terminal" decides whether the failure // pool stays meaningful: // // network error → network retryable // empty result slice → protocol retryable // mismatched card identifier → protocol retryable // Code == 0, non-empty IMEI → success, value written // Code == 0, empty IMEI → data_invalid terminal, covered, heartbeat only // 14000 → no_record terminal, covered, heartbeat only // -1 (no device info) → no_record terminal, covered, heartbeat only // 500 + "查询不到信息" (Tibiot) → no_record terminal, covered, heartbeat only // 13000-13007 on a mobile template → mobile_busy retryable // unsupported template → unsupported terminal failure // 9999 with empty errCode → gateway retryable // 12021 → token_expired retryable // any other non-zero Code → business terminal failure // // "Covered" marks a terminal answer that says something definitive about the // card but produces no value. It is deliberately not a failure: it records // coverage (ImeiSynced) so the heartbeat is written, while the queue keeps it // out of the failure pool and out of the run's failure count. func (h *Handler) Query(ctx context.Context, task queue.Task) zsetconsumer.Result { result := zsetconsumer.Result{Task: task} // An unsupported template is known before any network call: skip the // request entirely and park the card immediately. if isImeiUnsupportedTemplate(task.TempKey) { result.ErrorClass = unsupportedClass result.Message = "template does not support imei query" return result } input := &model.SimImeiInput{SimKeys: []*model.SimKey{{ ChanKey: task.ChanKey, ICCID: task.ICCID, IMSI: task.IMSI, MSISDN: task.MSISDN, }}} var ( output []model.SimBaseRes[model.SimImeiItem] err error ) for attempt := 0; attempt <= h.networkRetries; attempt++ { output, err = h.client.QuerySimImei(ctx, input) if err == nil { break } if ctx.Err() != nil { break } } if err != nil { result.ErrorClass = networkClass result.Retryable = true result.Message = err.Error() return result } if len(output) == 0 { result.ErrorClass = protocolClass result.Retryable = true result.Message = "chan returned empty imei result" return result } item := output[0] if item.SimKey == nil { item.SimKey = &model.SimKey{ChanKey: task.ChanKey, ICCID: task.ICCID, IMSI: task.IMSI, MSISDN: task.MSISDN} } if task.ICCID != "" && item.SimKey.ICCID != task.ICCID { result.ErrorClass = protocolClass 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 { switch { case isMobileTemplate(task.TempKey) && item.Code >= mobileBusyFirstCode && item.Code <= mobileBusyLastCode: result.ErrorClass = mobileBusyClass result.Retryable = true case item.Code == chanGatewayInternalErrorCode && strings.TrimSpace(item.ErrCode) == "": result.ErrorClass = gatewayClass result.Retryable = true case item.Code == tokenExpiredCode: result.ErrorClass = tokenExpiredClass result.Retryable = true case item.Code == chanNoRecordCode: // The carrier answered definitively: this card has no IMEI on record // and never will until a device reports one. Retrying cannot change // that, and counting it as a failure reported a 21% success rate for // a run that had in fact queried every card successfully. result.ErrorClass = noRecordClass result.ImeiSynced = true result.Covered = true case item.Code == chanNoDeviceInfoCode: // Telecom 5G CMP: "查询失败,查询不到指定卡的设备信息". The card is // valid and the query succeeded; there is simply no device bound to // it, so no IMEI exists to report. Same terminal semantics as 14000. result.ErrorClass = noRecordClass result.ImeiSynced = true result.Covered = true case item.Code == chanTibiotNoInfoCode && isNoDeviceMessage(item.Message): // Tibiot uses 500 both for "查询不到信息" and for genuine faults, // so the text decides. Only the no-info wording is a terminal answer; // any other 500 keeps falling through to business below. result.ErrorClass = noRecordClass result.ImeiSynced = true result.Covered = true default: result.ErrorClass = businessClass } return result } // Code == 0: the carrier answered. Coverage is recorded either way; the // value is only written when the carrier actually returned one. result.Success = true result.ImeiSynced = true imei := strings.TrimSpace(item.ResData.Imei) if imei == "" { // A successful query with no value is a legitimate terminal outcome // (device never reported an IMEI, or a multi-IMEI answer that the // chan layer drops). It is NOT a failure to retry, and it must never // clear the stored value. Like code 14000 it only records coverage. result.Success = false result.ErrorClass = dataInvalidClass result.Covered = true result.Message = "carrier returned no imei value" return result } result.Imei = imei return result } // Apply persists one batch. Only successfully synchronized cards produce an // update; a value is written only when the carrier returned a non-empty one. // // The extra `result.Imei != ""` guard is intentional duplication of the // handler check: it makes "never clear a stored IMEI" a property of the write // path itself, so a future handler change cannot silently start writing empty // values over good data. func (h *Handler) Apply(ctx context.Context, results []zsetconsumer.Result) error { updates := make([]imeirepo.Result, 0, len(results)) for _, result := range results { if !result.ImeiSynced { continue } update := imeirepo.Result{ CardID: result.Task.CardID, Synced: true, ValueChanged: result.Success && result.Imei != "", Imei: result.Imei, } if !update.ValueChanged { // Clear the value so a heartbeat-only result cannot carry a stale // IMEI into the CASE expression. update.Imei = "" } updates = append(updates, update) } return h.repo.ApplyBatch(ctx, updates) } // ShouldRetryNextRound is the full-sync retry policy: retryable failures are // deferred into the next run round. Terminal failures go straight to the // failure pool; covered results are terminal but are not failures at all. func ShouldRetryNextRound(result zsetconsumer.Result) bool { return !result.Success && result.Retryable }