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:
MiFaZhan
2026-10-22 16:37:56 +08:00
commit 2bec257289
361 changed files with 71524 additions and 0 deletions
@@ -0,0 +1,259 @@
// 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
}
@@ -0,0 +1,298 @@
package monthlyusagehistory
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"
)
type fakeClient struct {
output []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]
err error
// gotQueryDate records the period the handler sent upstream so a test can
// prove the task period is forwarded verbatim.
gotQueryDate string
}
func (c *fakeClient) QuerySimDatausageMonthly(_ context.Context, input *model.SimDataUsageMonthlyInput) ([]model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error) {
c.gotQueryDate = input.ReqData.QueryDate
return c.output, c.err
}
type fakeRepo struct {
items []model.TrafficUsageMonthlyItem
err error
}
func (r *fakeRepo) UpdateMonthly(_ context.Context, items []model.TrafficUsageMonthlyItem) error {
r.items = append(r.items, items...)
return r.err
}
func monthlyTask() queue.Task {
return queue.Task{
TaskID: "run:op:42",
RunID: "run",
Operation: model.OperationPreviousDayUsageFullSync,
QueryMonth: "202609",
ChanKey: "217",
TempKey: "sim_yd_v5",
CardID: 42,
ICCID: "89860042",
PackageCode: "PKG-A",
}
}
func TestQueryForwardsTaskMonthUpstream(t *testing.T) {
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
Code: 0,
SimKey: &model.SimKey{ICCID: "89860042"},
ResData: model.SimDataUsageMonthlyOutputItem{
DeadLine: "202609",
DataAmount: 12.5,
},
}}}
handler, err := NewHandler(client, &fakeRepo{})
if err != nil {
t.Fatal(err)
}
result := handler.Query(context.Background(), monthlyTask())
if !result.Success {
t.Fatalf("expected success, got class=%s message=%s", result.ErrorClass, result.Message)
}
if client.gotQueryDate != "202609" {
t.Fatalf("upstream queryDate = %q, want the task period 202609", client.gotQueryDate)
}
if result.UsageMB != 12.5 {
t.Fatalf("usage = %v, want 12.5", result.UsageMB)
}
}
func TestQueryAcceptsEquivalentPeriodFormats(t *testing.T) {
for _, period := range []string{"202609", "2026-09", "20260901", "2026-09-01"} {
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
Code: 0,
SimKey: &model.SimKey{ICCID: "89860042"},
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: period, DataAmount: 1},
}}}
handler, _ := NewHandler(client, &fakeRepo{})
if result := handler.Query(context.Background(), monthlyTask()); !result.Success {
t.Fatalf("period %q was rejected: class=%s message=%s", period, result.ErrorClass, result.Message)
}
}
}
func TestQueryRejectsMismatchedMonth(t *testing.T) {
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
Code: 0,
SimKey: &model.SimKey{ICCID: "89860042"},
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: "202608", DataAmount: 1},
}}}
handler, _ := NewHandler(client, &fakeRepo{})
result := handler.Query(context.Background(), monthlyTask())
if result.Success {
t.Fatal("a mismatched period must not be accepted")
}
if result.ErrorClass != "protocol" || !result.Retryable {
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
}
}
func TestQueryRejectsMissingMonthEcho(t *testing.T) {
// A channel that omits DeadLine from a successful response must fail too:
// the task period would be unverifiable, so writing the row risks
// wrong-month data. This mirrors the admin reconcile requirement.
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
Code: 0,
SimKey: &model.SimKey{ICCID: "89860042"},
ResData: model.SimDataUsageMonthlyOutputItem{DataAmount: 1},
}}}
handler, _ := NewHandler(client, &fakeRepo{})
result := handler.Query(context.Background(), monthlyTask())
if result.Success {
t.Fatal("a missing period echo must not be accepted")
}
if result.ErrorClass != "protocol" || !result.Retryable {
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
}
}
func TestQueryRejectsMismatchedICCID(t *testing.T) {
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
Code: 0,
SimKey: &model.SimKey{ICCID: "89869999"},
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: "202609"},
}}}
handler, _ := NewHandler(client, &fakeRepo{})
result := handler.Query(context.Background(), monthlyTask())
if result.Success || result.ErrorClass != "protocol" || !result.Retryable {
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
}
}
func TestQueryRejectsEmptyResult(t *testing.T) {
handler, _ := NewHandler(&fakeClient{}, &fakeRepo{})
result := handler.Query(context.Background(), monthlyTask())
if result.Success || result.ErrorClass != "protocol" || !result.Retryable {
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
}
}
func TestQueryTreatsTransportErrorAsRetryableNetwork(t *testing.T) {
handler, _ := NewHandler(&fakeClient{err: errors.New("connection refused")}, &fakeRepo{})
result := handler.Query(context.Background(), monthlyTask())
if result.ErrorClass != "network" || !result.Retryable {
t.Fatalf("class=%s retryable=%v, want network/retryable", result.ErrorClass, result.Retryable)
}
}
func TestQueryRejectsTaskWithoutMonth(t *testing.T) {
handler, _ := NewHandler(&fakeClient{}, &fakeRepo{})
task := monthlyTask()
task.QueryMonth = ""
result := handler.Query(context.Background(), task)
if result.Success || result.Retryable {
t.Fatalf("a task without a period must fail without retrying: %+v", result)
}
}
func TestApplyWritesPackageCodeAndEndOfMonthTimestamp(t *testing.T) {
repo := &fakeRepo{}
handler, _ := NewHandler(&fakeClient{}, repo)
err := handler.Apply(context.Background(), []zsetconsumer.Result{
{Task: monthlyTask(), Success: true, UsageMB: 7.25},
{Task: monthlyTask(), Success: false},
})
if err != nil {
t.Fatal(err)
}
if len(repo.items) != 1 {
t.Fatalf("wrote %d items, want only the successful one", len(repo.items))
}
item := repo.items[0]
if item.Iccid != "89860042" || item.UsageMb != 7.25 || item.PackageCode != "PKG-A" {
t.Fatalf("item = %+v, want iccid/usage/packageCode carried from the task", item)
}
// The handler supplies the month; repository.snapshotMonth turns it into the
// end-of-month 23:59:59.000 row (covered by repository_test.go). Assert the
// month here so a period mix-up cannot slip through.
if got := item.Month.Format("2006-01"); got != "2026-09" {
t.Fatalf("month = %s, want the run period 2026-09", got)
}
}
func TestApplyRejectsInvalidMonth(t *testing.T) {
handler, _ := NewHandler(&fakeClient{}, &fakeRepo{})
task := monthlyTask()
task.QueryMonth = "2026-10-09"
if err := handler.Apply(context.Background(), []zsetconsumer.Result{{Task: task, Success: true}}); err == nil {
t.Fatal("expected an unparseable period to fail the write")
}
}
func TestClassifyMonthlyHistoryFailure(t *testing.T) {
tests := []struct {
name string
tempKey string
code int
errCode string
wantClass string
wantRetry bool
wantBlocked bool
}{
{name: "telecom channel limited", tempKey: "sim_dx_v1", code: 500, errCode: "S00004", wantClass: "channel_limited", wantBlocked: true},
{name: "telecom channel limited lowercase", tempKey: "sim_dx_v1", code: 500, errCode: "s00004", wantClass: "channel_limited", wantBlocked: true},
// sim_dx_v3 is a telecom template too. It is resolved through
// model.IsTelecomTemplate so a v3 channel keeps S00004 blocking instead
// of falling through to generic and retrying every card to exhaustion.
{name: "telecom v3 channel limited", tempKey: "sim_dx_v3", code: 500, errCode: "S00004", wantClass: "channel_limited", wantBlocked: true},
{name: "telecom v3 template is not treated as mobile", tempKey: "sim_dx_v3", code: 13000, wantClass: "business"},
{name: "mobile busy first code", tempKey: "sim_yd_v5", code: 13000, wantClass: "mobile_busy", wantRetry: true},
{name: "mobile busy last code", tempKey: "sim_yd_v5", code: 13007, wantClass: "mobile_busy", wantRetry: true},
{name: "mobile one past the busy range", tempKey: "sim_yd_v5", code: 13008, wantClass: "business"},
{name: "mobile one below the busy range", tempKey: "sim_yd_v5", code: 12999, wantClass: "business"},
{name: "busy code on a non-mobile template", tempKey: "sim_qianxun_v3_bogus", code: 13000, wantClass: "business"},
{name: "token expired", tempKey: "sim_yd_v5", code: 12021, wantClass: "token_expired", wantRetry: true},
{name: "gateway internal without errCode", tempKey: "sim_yd_v5", code: 9999, errCode: "", wantClass: "network", wantRetry: true},
{name: "gateway code with a carrier errCode stays business", tempKey: "sim_yd_v5", code: 9999, errCode: "E42", wantClass: "business"},
{name: "telecom S00004 without the telecom template is not channel limited", tempKey: "sim_yd_v5", code: 500, errCode: "S00004", wantClass: "business"},
{name: "unknown code is not retried", tempKey: "sim_yd_v5", code: 4242, wantClass: "business"},
{name: "unknown code on an unknown template is not retried", tempKey: "some_new_channel_v9", code: 13000, wantClass: "business"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
class, retry, blocked := classifyMonthlyHistoryFailure(test.tempKey, test.code, test.errCode)
if class != test.wantClass || retry != test.wantRetry || blocked != test.wantBlocked {
t.Fatalf("classify(%q, %d, %q) = (%s, %v, %v), want (%s, %v, %v)",
test.tempKey, test.code, test.errCode, class, retry, blocked, test.wantClass, test.wantRetry, test.wantBlocked)
}
})
}
}
// TestClassifyIgnoresMessageText pins the 2026-09-14 discipline: classification
// must never depend on carrier text, because text matching caused the channel
// retry loop that the rule was introduced to prevent.
func TestClassifyIgnoresMessageText(t *testing.T) {
// A "busy"-looking message on an unknown code must stay non-retryable.
if _, retry, _ := classifyMonthlyHistoryFailure("sim_yd_v5", 4242, ""); retry {
t.Fatal("an unknown code must not become retryable because its message looked transient")
}
// And a busy code must stay retryable regardless of what the text says.
if _, retry, _ := classifyMonthlyHistoryFailure("sim_yd_v5", 13000, ""); !retry {
t.Fatal("a mobile busy code must stay retryable")
}
}
func TestShouldRetryNextRoundFollowsClassifier(t *testing.T) {
if ShouldRetryNextRound(zsetconsumer.Result{Success: true}) {
t.Fatal("a successful result must not be retried")
}
if !ShouldRetryNextRound(zsetconsumer.Result{Retryable: true}) {
t.Fatal("a retryable failure must be deferred to the next round")
}
if ShouldRetryNextRound(zsetconsumer.Result{Retryable: false}) {
t.Fatal("a terminal failure must not be retried")
}
}
func TestUsageCarrierForTemplateMatchesSharedDefinition(t *testing.T) {
// The monthly-history classifier must agree with model.IsTelecomTemplate for every
// telecom template the rest of the worker recognises. A drift here means a
// channel silently loses its S00004 blocking behaviour.
for _, tempKey := range []string{"sim_dx_v1", "sim_dx_v3", "SIM_DX_V1", " sim_dx_v3 "} {
if got := usageCarrierForTemplate(tempKey); got != "telecom" {
t.Errorf("usageCarrierForTemplate(%q) = %q, want telecom", tempKey, got)
}
}
for _, tempKey := range []string{"sim_yd_v5", "sim_onelink_daihe_v1"} {
if got := usageCarrierForTemplate(tempKey); got != "mobile" {
t.Errorf("usageCarrierForTemplate(%q) = %q, want mobile", tempKey, got)
}
}
for _, tempKey := range []string{"", "some_new_channel_v9", "sim_dx_v2"} {
if got := usageCarrierForTemplate(tempKey); got != "generic" {
t.Errorf("usageCarrierForTemplate(%q) = %q, want generic", tempKey, got)
}
}
}
func TestQueryBlockChannelPropagatesForTelecomS00004(t *testing.T) {
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
Code: 500,
ErrCode: "S00004",
Message: "channel disabled",
SimKey: &model.SimKey{ICCID: "89860042"},
}}}
handler, _ := NewHandler(client, &fakeRepo{})
task := monthlyTask()
task.TempKey = "sim_dx_v1"
result := handler.Query(context.Background(), task)
if result.ErrorClass != "channel_limited" || !result.BlockChannel || result.Retryable {
t.Fatalf("telecom S00004 must block the channel without retrying: %+v", result)
}
}