Files

361 lines
14 KiB
Go

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
}