chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:03 +08:00
422 changed files with 80993 additions and 0 deletions
@@ -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: "898604F4152391115474",
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: "898604F4152391115474"},
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)
}
}
}