chore: 初始开源发布
iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。 - Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件) - 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、 库名 xiujie_admin → iotlcm、哨兵名占位 - 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异, 不破坏「校验 MSISDN 格式」等测试断言) - 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像 digest 记录 - Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23), 移除写死的 git 凭据 - README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes 已知:3 个测试包在源仓同一提交上同样失败(client/upstream、 service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
package imei
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// fakeClient returns a canned response or error and records the call count.
|
||||
type fakeClient struct {
|
||||
output []model.SimBaseRes[model.SimImeiItem]
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeClient) QuerySimImei(context.Context, *model.SimImeiInput) ([]model.SimBaseRes[model.SimImeiItem], error) {
|
||||
f.calls++
|
||||
return f.output, f.err
|
||||
}
|
||||
|
||||
// fakeRepo captures what the handler asked to persist.
|
||||
type fakeRepo struct {
|
||||
results []imeirepo.Result
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRepo) ApplyBatch(_ context.Context, results []imeirepo.Result) error {
|
||||
f.calls++
|
||||
f.results = append(f.results, results...)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func task(tempKey string) queue.Task {
|
||||
return queue.Task{
|
||||
TaskID: "run:imei_full_sync:1",
|
||||
RunID: "run",
|
||||
ChanKey: "173",
|
||||
TempKey: tempKey,
|
||||
CardID: 1,
|
||||
ICCID: "898604F4152000015474",
|
||||
MSISDN: "1064000000001",
|
||||
IMSI: "460000000000001",
|
||||
Operator: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func item(code int, errCode, imei string) model.SimBaseRes[model.SimImeiItem] {
|
||||
return model.SimBaseRes[model.SimImeiItem]{
|
||||
Code: code,
|
||||
ErrCode: errCode,
|
||||
SimKey: &model.SimKey{ChanKey: "173", ICCID: "898604F4152000015474"},
|
||||
ResData: model.SimImeiItem{Imei: imei},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T, client ImeiClient, repo ImeiRepository, retries ...int) *Handler {
|
||||
t.Helper()
|
||||
h, err := NewHandler(client, repo, retries...)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandler: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// TestQuerySuccessWithValue 覆盖 §7 的正常命中路径。
|
||||
func TestQuerySuccessWithValue(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(0, "", "865466070817587")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if !result.Success {
|
||||
t.Fatalf("应成功: %+v", result)
|
||||
}
|
||||
if !result.ImeiSynced {
|
||||
t.Fatal("ImeiSynced 应为 true")
|
||||
}
|
||||
if result.Imei != "865466070817587" {
|
||||
t.Fatalf("imei = %q", result.Imei)
|
||||
}
|
||||
if result.Retryable {
|
||||
t.Fatal("成功不应可重试")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryEmptyValueIsTerminalAndSynced 覆盖 §7 T5/T6:上游成功但无值。
|
||||
// 这是最关键的语义——必须记录覆盖(ImeiSynced)但不得视为失败重试。
|
||||
func TestQueryEmptyValueIsTerminalAndSynced(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(0, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.Success {
|
||||
t.Fatal("空值不应标记为 Success")
|
||||
}
|
||||
if result.Retryable {
|
||||
t.Fatal("空值是终态,不应重试")
|
||||
}
|
||||
if !result.ImeiSynced {
|
||||
t.Fatal("空值仍应记录为已同步(心跳)")
|
||||
}
|
||||
if !result.Covered {
|
||||
t.Fatal("空值与 14000 同族,不应进失败池")
|
||||
}
|
||||
if result.ErrorClass != dataInvalidClass {
|
||||
t.Fatalf("errorClass = %q, want %q", result.ErrorClass, dataInvalidClass)
|
||||
}
|
||||
if result.Imei != "" {
|
||||
t.Fatalf("imei 应为空: %q", result.Imei)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryNoRecordIsCoveredNotFailed 覆盖生产实测的主路径(2026-10-20 chan
|
||||
// 220/250/251 的 4900 条“失败”全部是 code=14000)。运营商持有卡但没有 IMEI
|
||||
// 记录,这是终态正常结果:记录覆盖、绝不重试、不进失败池。
|
||||
func TestQueryNoRecordIsCoveredNotFailed(t *testing.T) {
|
||||
for _, tempKey := range []string{"sim_yd_v5", "sim_dx_v1"} {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{
|
||||
item(chanNoRecordCode, "", ""),
|
||||
}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
// 14000 的 message 是运营商原文,处理逻辑不得依赖它。
|
||||
client.output[0].Message = "系统无记录,请检查输入参数是否正确"
|
||||
|
||||
result := handler.Query(context.Background(), task(tempKey))
|
||||
if result.Success {
|
||||
t.Fatalf("%s: 无记录不得标记为 Success", tempKey)
|
||||
}
|
||||
if result.Retryable {
|
||||
t.Fatalf("%s: 无记录是终态,不应重试", tempKey)
|
||||
}
|
||||
if !result.ImeiSynced {
|
||||
t.Fatalf("%s: 无记录仍应记录覆盖(心跳)", tempKey)
|
||||
}
|
||||
if !result.Covered {
|
||||
t.Fatalf("%s: 无记录不得计入失败池", tempKey)
|
||||
}
|
||||
if result.ErrorClass != noRecordClass {
|
||||
t.Fatalf("%s: errorClass = %q, want %q", tempKey, result.ErrorClass, noRecordClass)
|
||||
}
|
||||
if result.Imei != "" {
|
||||
t.Fatalf("%s: 无记录不得携带值: %q", tempKey, result.Imei)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryNoRecordBeatsMobileBusyRange 14000 不在 13000-13007 内,
|
||||
// 但仍要求移动模板走同一条“无记录”分类——不能被 busy 分支或业务错误吞掉。
|
||||
func TestQueryNoRecordDoesNotRetryOnMobileTemplate(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(chanNoRecordCode, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.Retryable || result.ErrorClass == mobileBusyClass || result.ErrorClass == businessClass {
|
||||
t.Fatalf("14000 应为 no_record 终态: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryUnsupportedTemplateSkipsUpstream 覆盖 §7 T3:
|
||||
// 不支持的模板必须在不发请求的前提下直接终态,避免失败池爆炸。
|
||||
func TestQueryUnsupportedTemplateSkipsUpstream(t *testing.T) {
|
||||
for _, tempKey := range []string{"sim_mingqian_v3", "sim_daihe_v1", "sim_qianxun_v3"} {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(0, "", "865466070817587")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task(tempKey))
|
||||
if result.Retryable {
|
||||
t.Fatalf("%s: 不支持应为终态,不应重试", tempKey)
|
||||
}
|
||||
if result.ErrorClass != unsupportedClass {
|
||||
t.Fatalf("%s: errorClass = %q, want %q", tempKey, result.ErrorClass, unsupportedClass)
|
||||
}
|
||||
if client.calls != 0 {
|
||||
t.Fatalf("%s: 不支持模板不应发起上游请求,实际调用 %d 次", tempKey, client.calls)
|
||||
}
|
||||
if result.ImeiSynced {
|
||||
t.Fatalf("%s: 未查询不应记录覆盖", tempKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryUnsupportedTemplatesAreCaseInsensitive 模板键大小写不应影响判定。
|
||||
func TestQueryUnsupportedTemplatesAreCaseInsensitive(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), task("SIM_MingQian_V3"))
|
||||
if result.ErrorClass != unsupportedClass || client.calls != 0 {
|
||||
t.Fatalf("大小写变体未被识别: %+v, calls=%d", result, client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuerySharedTokenTemplatesAreSupported 回归保护:
|
||||
// sim_onelink_* 走 shared_token 模型 → sSimYD(支持 IMEI),
|
||||
// 不得被误列入不支持白名单(这是 2026-10-12 生产基线纠正过的判断)。
|
||||
func TestQuerySharedTokenTemplatesAreSupported(t *testing.T) {
|
||||
for _, tempKey := range []string{
|
||||
"sim_onelink_tengyu_v1",
|
||||
"sim_onelink_daihe_v1",
|
||||
"sim_onelink_qianxun_v1",
|
||||
"sim_onelink_kedang_v1",
|
||||
"sim_onelink_daliuliang_v1",
|
||||
"sim_onelink_mengwang_v1",
|
||||
} {
|
||||
if isImeiUnsupportedTemplate(tempKey) {
|
||||
t.Fatalf("%s 走 sSimYD 支持 IMEI,不应在不支持白名单", tempKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryMobileBusyIsRetryable 覆盖 §7 T4:移动 13000 区间跨轮重试。
|
||||
func TestQueryMobileBusyIsRetryable(t *testing.T) {
|
||||
for _, code := range []int{13000, 13003, 13007} {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(code, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.ErrorClass != mobileBusyClass || !result.Retryable {
|
||||
t.Fatalf("code %d: errorClass=%q retryable=%v, want %q/true", code, result.ErrorClass, result.Retryable, mobileBusyClass)
|
||||
}
|
||||
if result.Covered {
|
||||
t.Fatalf("code %d: 系统忙必须计入失败", code)
|
||||
}
|
||||
if result.ImeiSynced {
|
||||
t.Fatalf("code %d: 系统忙不应记录覆盖", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryMobileBusyOnlyForMobileTemplates 13000 只对移动族判定为 busy。
|
||||
func TestQueryMobileBusyOnlyForMobileTemplates(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(13000, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
// 电信模板收到 13000 不是移动系统忙语义,应按普通业务错误处理
|
||||
result := handler.Query(context.Background(), task("sim_dx_v1"))
|
||||
if result.ErrorClass == mobileBusyClass {
|
||||
t.Fatalf("非移动模板不应判定为 mobile_busy: %+v", result)
|
||||
}
|
||||
if result.Retryable {
|
||||
t.Fatalf("非移动模板的 13000 应为终态: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryTelecomRetryableCodeIsNotTreatedAsRetryableForImei 回归保护:
|
||||
// S00004 是电信「渠道限流」业务码,属于状态/流量链路的可重试语义;
|
||||
// IMEI 链路没有对应的限流处理,必须按普通业务错误终态处理,
|
||||
// 否则会无限重试同一张卡。
|
||||
func TestQueryTelecomRetryableCodeIsNotTreatedAsRetryableForImei(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(1, "S00004", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_dx_v1"))
|
||||
if result.Retryable {
|
||||
t.Fatalf("IMEI 链路不应把 S00004 当作可重试: %+v", result)
|
||||
}
|
||||
if result.ErrorClass != businessClass {
|
||||
t.Fatalf("errorClass = %q, want %q", result.ErrorClass, businessClass)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryGatewayAndTokenErrorsAreRetryable 覆盖 §7 T8。
|
||||
func TestQueryGatewayAndTokenErrorsAreRetryable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
errCode string
|
||||
wantClass string
|
||||
}{
|
||||
{"网关内部错误", 9999, "", gatewayClass},
|
||||
{"token 过期", tokenExpiredCode, "", tokenExpiredClass},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(tc.code, tc.errCode, "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.ErrorClass != tc.wantClass || !result.Retryable {
|
||||
t.Fatalf("%s: errorClass=%q retryable=%v, want %q/true", tc.name, result.ErrorClass, result.Retryable, tc.wantClass)
|
||||
}
|
||||
if result.Covered {
|
||||
t.Fatalf("%s: 可重试错误必须计入失败", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryBusinessErrorIsTerminal 其他业务码为终态,避免无意义重试。
|
||||
func TestQueryBusinessErrorIsTerminal(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(11011, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.Retryable {
|
||||
t.Fatal("11011 码号信息错误是确定性终态,不应重试")
|
||||
}
|
||||
if result.ErrorClass != businessClass {
|
||||
t.Fatalf("errorClass = %q, want %q", result.ErrorClass, businessClass)
|
||||
}
|
||||
if result.Covered {
|
||||
t.Fatal("11011 是真失败,必须进失败池")
|
||||
}
|
||||
if result.ImeiSynced {
|
||||
t.Fatal("11011 未拿到运营商结果,不应记录覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryNetworkErrorIsRetryable 网络错误跨轮重试,且受 networkRetries 控制。
|
||||
func TestQueryNetworkErrorIsRetryable(t *testing.T) {
|
||||
client := &fakeClient{err: errors.New("connection refused")}
|
||||
handler := newTestHandler(t, client, &fakeRepo{}, 2)
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.ErrorClass != networkClass || !result.Retryable {
|
||||
t.Fatalf("errorClass=%q retryable=%v, want network/true", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
// 1 次初始 + 2 次重试
|
||||
if client.calls != 3 {
|
||||
t.Fatalf("calls = %d, want 3(1 次初始 + 2 次重试)", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryEmptyResultSliceIsProtocolError 空切片是协议异常,应重试。
|
||||
func TestQueryEmptyResultSliceIsProtocolError(t *testing.T) {
|
||||
client := &fakeClient{output: nil}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.ErrorClass != protocolClass || !result.Retryable {
|
||||
t.Fatalf("errorClass=%q retryable=%v, want protocol/true", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryMismatchedIdentifierIsProtocolError 防止上游串卡。
|
||||
func TestQueryMismatchedIdentifierIsProtocolError(t *testing.T) {
|
||||
mismatched := item(0, "", "865466070817587")
|
||||
mismatched.SimKey = &model.SimKey{ChanKey: "173", ICCID: "OTHER-CARD"}
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{mismatched}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.ErrorClass != protocolClass || !result.Retryable {
|
||||
t.Fatalf("串卡应判为协议错误: %+v", result)
|
||||
}
|
||||
if result.ImeiSynced {
|
||||
t.Fatal("串卡不应记录覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyWritesValueOnlyWhenChanged 覆盖 §7 T9/T10 与 §5.2 守卫:
|
||||
// - 有值 → ValueChanged=true,带值写入
|
||||
// - 空值 → ValueChanged=false,且 Imei 必须被清空(不得覆盖已有值)
|
||||
// - 失败 → 完全不入库
|
||||
func TestApplyWritesValueOnlyWhenChanged(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
handler := newTestHandler(t, &fakeClient{}, repo)
|
||||
|
||||
successTask := task("sim_yd_v5")
|
||||
emptyTask := task("sim_yd_v5")
|
||||
emptyTask.CardID = 2
|
||||
failedTask := task("sim_yd_v5")
|
||||
failedTask.CardID = 3
|
||||
|
||||
err := handler.Apply(context.Background(), []zsetconsumer.Result{
|
||||
{Task: successTask, Success: true, ImeiSynced: true, Imei: "865466070817587"},
|
||||
{Task: emptyTask, Success: false, ImeiSynced: true, ErrorClass: dataInvalidClass},
|
||||
{Task: failedTask, Success: false, Retryable: true, ErrorClass: networkClass, ImeiSynced: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(repo.results) != 2 {
|
||||
t.Fatalf("应只写入 2 条(成功的 + 心跳的),实际 %d 条: %+v", len(repo.results), repo.results)
|
||||
}
|
||||
|
||||
// 第 1 条:值变更
|
||||
first := repo.results[0]
|
||||
if first.CardID != 1 || !first.ValueChanged || first.Imei != "865466070817587" {
|
||||
t.Fatalf("第一条应为值变更: %+v", first)
|
||||
}
|
||||
// 第 2 条:心跳,值必须为空(防止覆盖)
|
||||
second := repo.results[1]
|
||||
if second.CardID != 2 || second.ValueChanged {
|
||||
t.Fatalf("第二条应为心跳: %+v", second)
|
||||
}
|
||||
if second.Imei != "" {
|
||||
t.Fatalf("心跳结果的 Imei 必须为空,否则会覆盖已有值: %q", second.Imei)
|
||||
}
|
||||
// 第 3 条:失败完全不入库
|
||||
for _, r := range repo.results {
|
||||
if r.CardID == 3 {
|
||||
t.Fatal("失败结果不应入库")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNeverWritesEmptyOverValue 是 §5.2 的直接回归测试:
|
||||
// 即使上游大面积返回空值,写路径也不得产生任何带值的更新。
|
||||
func TestApplyNeverWritesEmptyOverValue(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
handler := newTestHandler(t, &fakeClient{}, repo)
|
||||
|
||||
results := make([]zsetconsumer.Result, 0, 50)
|
||||
for i := int64(1); i <= 50; i++ {
|
||||
tk := task("sim_yd_v5")
|
||||
tk.CardID = i
|
||||
results = append(results, zsetconsumer.Result{
|
||||
Task: tk, Success: false, ImeiSynced: true, ErrorClass: dataInvalidClass,
|
||||
})
|
||||
}
|
||||
if err := handler.Apply(context.Background(), results); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(repo.results) != 50 {
|
||||
t.Fatalf("50 张卡都应记录心跳,实际 %d", len(repo.results))
|
||||
}
|
||||
for _, r := range repo.results {
|
||||
if r.ValueChanged || r.Imei != "" {
|
||||
t.Fatalf("空值结果不得携带值: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNoRecordWritesHeartbeatOnly 14000 只能写心跳,绝不能清掉已有 IMEI。
|
||||
func TestApplyNoRecordWritesHeartbeatOnly(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
handler := newTestHandler(t, &fakeClient{}, repo)
|
||||
|
||||
noRecord := task("sim_yd_v5")
|
||||
if err := handler.Apply(context.Background(), []zsetconsumer.Result{
|
||||
{Task: noRecord, Success: false, ImeiSynced: true, Covered: true, ErrorClass: noRecordClass, Code: chanNoRecordCode},
|
||||
}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(repo.results) != 1 {
|
||||
t.Fatalf("无记录仍应写入 1 条心跳,实际 %d: %+v", len(repo.results), repo.results)
|
||||
}
|
||||
got := repo.results[0]
|
||||
if !got.Synced || got.ValueChanged || got.Imei != "" {
|
||||
t.Fatalf("无记录应只写心跳: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldRetryNextRound 覆盖重试策略。
|
||||
func TestShouldRetryNextRound(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result zsetconsumer.Result
|
||||
want bool
|
||||
}{
|
||||
{"成功不重试", zsetconsumer.Result{Success: true}, false},
|
||||
{"可重试失败", zsetconsumer.Result{Success: false, Retryable: true}, true},
|
||||
{"终态失败不重试", zsetconsumer.Result{Success: false, Retryable: false}, false},
|
||||
{"无记录不重试", zsetconsumer.Result{Success: false, Covered: true, ErrorClass: noRecordClass}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ShouldRetryNextRound(tc.result); got != tc.want {
|
||||
t.Fatalf("%s: got %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryNoDeviceIsCoveredNotFailed 覆盖 2026-10-22 生产 run
|
||||
// imei-20261022000000 的 12,865 条终态失败:全部是「卡上没有绑定设备,
|
||||
// 所以没有 IMEI」,不是真失败。
|
||||
//
|
||||
// 证据(生产日志实采):
|
||||
// - 电信 5G CMP 11,240 条:result="-1",message=查询失败,查询不到指定卡的设备信息
|
||||
// (通道 142/111/187,template sim_dx_v1)
|
||||
// - Tibiot 1,612 条:HTTP 500 + body status=500,message=查询不到信息(1,494)
|
||||
// 或 系统无记录(118)
|
||||
//
|
||||
// 三者都是终态:记录覆盖(写心跳)、不重试、不进失败池。
|
||||
func TestQueryNoDeviceIsCoveredNotFailed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tempKey string
|
||||
code int
|
||||
message string
|
||||
}{
|
||||
{"电信无绑定设备", "sim_dx_v1", chanNoDeviceInfoCode, "查询失败,查询不到指定卡的设备信息"},
|
||||
{"Tibiot 查询不到信息", "sim_tibiot_v1", chanTibiotNoInfoCode, "查询不到信息"},
|
||||
{"Tibiot 系统无记录", "sim_tibiot_v1", chanTibiotNoInfoCode, "系统无记录,请检查输入参数是否正确"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
it := item(tc.code, "", "")
|
||||
it.Message = tc.message
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{it}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task(tc.tempKey))
|
||||
if result.Success {
|
||||
t.Fatalf("%s: 无设备不得标记为 Success", tc.name)
|
||||
}
|
||||
if result.Retryable {
|
||||
t.Fatalf("%s: 无设备是终态,不应重试", tc.name)
|
||||
}
|
||||
if !result.ImeiSynced {
|
||||
t.Fatalf("%s: 无设备仍应记录覆盖(心跳)", tc.name)
|
||||
}
|
||||
if !result.Covered {
|
||||
t.Fatalf("%s: 无设备不得计入失败池", tc.name)
|
||||
}
|
||||
if result.ErrorClass != noRecordClass {
|
||||
t.Fatalf("%s: errorClass = %q, want %q", tc.name, result.ErrorClass, noRecordClass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryTibiotNonNoInfo500StaysBusiness 锁住 500 的边界:Tibiot 也用 500
|
||||
// 表达真实故障,只有「查询不到信息/系统无记录」的文案才是终态无记录。
|
||||
// 不加这道闸门会把真故障当成功覆盖掉,那是比多算失败更严重的错误。
|
||||
func TestQueryTibiotNonNoInfo500StaysBusiness(t *testing.T) {
|
||||
for _, message := range []string{"系统繁忙,请稍后重试", "服务异常", "internal error", ""} {
|
||||
it := item(chanTibiotNoInfoCode, "", "")
|
||||
it.Message = message
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{it}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_tibiot_v1"))
|
||||
if result.Covered {
|
||||
t.Fatalf("message=%q: 非无记录文案的 500 不得计入覆盖", message)
|
||||
}
|
||||
if result.ImeiSynced {
|
||||
t.Fatalf("message=%q: 非无记录文案的 500 不应记录覆盖", message)
|
||||
}
|
||||
if result.ErrorClass != businessClass {
|
||||
t.Fatalf("message=%q: errorClass = %q, want %q", message, result.ErrorClass, businessClass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryNoDeviceCodeIgnoresMessage 14000 与 -1 语义明确,不得依赖运营商文案
|
||||
// (文案随时可能改写),即使 message 为空也要判为无记录。
|
||||
func TestQueryNoDeviceCodeIgnoresMessage(t *testing.T) {
|
||||
for _, code := range []int{chanNoRecordCode, chanNoDeviceInfoCode} {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(code, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_dx_v1"))
|
||||
if !result.Covered || result.ErrorClass != noRecordClass {
|
||||
t.Fatalf("code=%d 空 message 仍应判 no_record: %+v", code, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user