## Context

当前 `PollingConfigManager.MatchConfig(card)` 使用独占式单匹配：遍历所有配置（按 priority ASC），**第一个满足条件的配置直接返回**，后续配置全部忽略。

这个设计在以下场景会出问题：
- id=29 (priority=1, card_condition="", 所有 interval 全为 NULL)：匹配所有卡，什么轮询都没配，像黑洞一样废掉整个轮询系统
- id=28 (priority=25, card_condition="suspended", card_status_check_interval=600)：永远不会有机会被评估，因为被 id=29 截断

现有 `polling-config-manager/spec.md` 描述了"按优先级返回第一个匹配"，这是**需求规格说明**，而非实现细节 bug。

## Goals / Non-Goals

**Goals:**
- 支持一张卡匹配多个配置，每个配置贡献自己非 NULL 的 interval
- 相同 task type 从多个匹配的 config 中，按 priority 数值小（优先级高）选取
- 跳过所有 interval 均为 NULL 的配置（这些配置不应参与轮询匹配）
- 向后兼容：外部已有调用 `MatchConfig` 的地方不受影响

**Non-Goals:**
- 不改变配置优先级机制（priority ASC 不变）
- 不改变 DB schema
- 不引入新的外部依赖

## Decisions

### Decision 1: `MatchConfig` → `MatchConfigs`，返回全部匹配配置

**选择**：新增 `MatchConfigs(card) []*model.PollingConfig`，返回所有满足条件的配置（已过滤 NULL-interval，已按 priority ASC 排序），保留原 `MatchConfig` 作为其特例（前向兼容）。

**替代方案 A（直接改 `MatchConfig` 返回合并结果）**：改动最小，但破坏了 `MatchConfig` 的语义（原 spec 描述为"返回第一个匹配"），且外部若有调用方依赖此行为会无声失效。

**替代方案 B（新增 `MatchConfigs` + 废弃 `MatchConfig`）**：API 更干净，但需要迁移所有调用方，成本高。

**结论**：采用替代方案 C——新增 `MatchConfigs`，`MatchConfig` 保持不变（返回 `MatchConfigs()[0]`），后续调用方逐步迁移。

---

### Decision 2: 过滤所有 interval 均为 NULL 的配置

**选择**：在 `matchConfigConditions` 之后，新增 `hasAnyEnabledInterval(cfg)` 检查，跳过所有 interval 字段全为 NULL 的配置。

**理由**：id=29 这种"所有 interval 全为 NULL"的配置在业务上等效于"不参与轮询"，但当前 `matchConfigConditions` 只检查 card 条件，不检查 interval 是否有效。增加这个过滤可以在配置层面彻底堵住"黑洞"问题。

```go
// hasAnyEnabledInterval 检查配置是否有至少一个非 NULL 的轮询间隔
func hasAnyEnabledInterval(cfg *model.PollingConfig) bool {
    return (cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0) ||
           (cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0) ||
           (cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0) ||
           (cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0) ||
           (cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0)
}
```

---

### Decision 3: 多配置 interval 合并策略

**选择**：新增 `MergedTaskIntervals(card) map[string]int` 方法（在 `config_manager.go`），对所有匹配配置按 priority 遍历，对每种 task type 选取第一个非 NULL 的 interval。

**合并逻辑**：
```
所有匹配 configs（按 priority ASC）:
  for each config:
    for each taskType in allTaskTypes:
      if merged[taskType] == nil && config[taskType] != nil:
        merged[taskType] = config[taskType]
```

**理由**：`initBatch` 和 `enqueueCard` 都需要这个合并逻辑，集中在 `PollingConfigManager` 中实现可以保持两处调用的一致性。

**新增类型**：
```go
// TaskTypeInterval 某任务类型的轮询间隔信息
type TaskTypeInterval struct {
    Interval int // 秒
    Priority int  // 来源配置的 priority
}
```

---

### Decision 4: `initBatch` 中的 interval 选取更新

**当前**：`initBatch` 调用 `cfg := p.configMgr.MatchConfig(card)`，然后分别读取 `cfg.CardStatusCheckInterval`、`cfg.PackageCheckInterval` 等字段。

**修改后**：
```go
intervals := p.configMgr.MergedTaskIntervals(card)
for taskType, info := range intervals {
    nextCheck := calculateNextCheckTime(/* 对应 last 字段 */, info.Interval, now)
    // ZADD 到对应分片队列
}
```

每种 task type 对应哪个 `Last*CheckAt` 字段：
| taskType | card 字段 |
|----------|----------|
| realname | LastRealNameCheckAt |
| carddata | LastDataCheckAt |
| package | LastDataCheckAt |
| protect | LastProtectCheckAt |
| card_status | LastCardStatusCheckAt |

---

### Decision 5: `enqueueCard` 中的 interval 选取更新

**当前**：`getEnabledTaskTypes(cfg)` 返回 `[]string`，`calcInitialDelay` 分别处理每种 task type。

**修改后**：复用 `MergedTaskIntervals`，逻辑与 `initBatch` 一致。`calcInitialDelay` 签名改为 `calcInitialDelay(interval int) time.Time`，接收合并后的 interval 值，内联 jitter 计算。

`getEnabledTaskTypes` 移除——`MergedTaskIntervals` 的 key set 天然就是启用的 task type 列表。

---

### Decision 6: `requeueCard`（`internal/task/polling_base.go`）迁移

**发现**：`MatchConfig` 有第三个调用方 `PollingBase.requeueCard()`（`internal/task/polling_base.go:105`），位于 `internal/task/` 包而非 `internal/polling/`。这是**稳态路径**——每次轮询任务完成后的重入队操作，被 5 个 handler 共 26 处调用，是执行频率最高的匹配路径。

**当前**：
```go
cfg := b.configMgr.MatchConfig(card)
interval := getIntervalByTaskType(cfg, taskType)
```

**修改后**：
```go
intervals := b.configMgr.MergedTaskIntervals(card)
info, ok := intervals[taskType]
if !ok || info.Interval <= 0 {
    return nil // 该 task type 无有效配置，不入队
}
nextCheckAt := time.Now().Add(time.Duration(info.Interval) * time.Second)
return b.queueMgr.Requeue(ctx, cardID, taskType, nextCheckAt)
```

**`getIntervalByTaskType` 移除**：`MergedTaskIntervals` 已提供按 task type 查 interval 的能力。`requeueCard` 是 `getIntervalByTaskType` 的唯一调用方，迁移后该函数不再需要。

**注意**：`requeueCard` 只需要单个 taskType 的 interval，而 `MergedTaskIntervals` 返回全量 map。这在功能上正确，性能开销可忽略（配置通常 ≤10 条，task type 仅 5 种）。若后续需要优化，可新增 `MergedIntervalForTaskType(card, taskType)` 方法按需停止遍历。

---

---

### Decision 7: `scheduleLoop` 顶层 panic recovery + 心跳机制

**问题**：`scheduleLoop` 的主 for-select 循环没有 `recover()`。`processOneShard` 里的子 goroutine 有 recover，但 `processManualQueue`、`processActivationTasks` 等直接在主循环调用，panic 会导致整个调度 goroutine 终止——所有分片队列积压，无任何告警。

**修改**：

```go
func (s *Scheduler) scheduleLoop(ctx context.Context) {
    defer s.wg.Done()
    defer func() {
        if r := recover(); r != nil {
            s.logger.Error("调度主循环发生 panic，调度已停止，需重启 Worker",
                zap.Any("panic", r))
        }
    }()
    // ... 原有逻辑
}
```

**心跳机制**：每次 tick 向 Redis 写入心跳 key（`polling:scheduler:heartbeat`），TTL 设为 2 倍 ScheduleInterval（2 秒）。告警规则通过检查该 key 是否存在判断调度器存活。

```go
case <-ticker.C:
    // 写心跳，TTL=2s（2×1s tick），超过 2s 无更新说明调度器挂了
    _ = s.redis.Set(ctx, constants.RedisPollingSchedulerHeartbeatKey(),
        time.Now().Unix(), 2*s.cfg.ScheduleInterval)
    s.processShardSchedule(ctx)
```

**新增常量**：`pkg/constants/redis.go` 新增 `RedisPollingSchedulerHeartbeatKey() string`，返回 `"polling:scheduler:heartbeat"`。

**不新增 AlertRule**：心跳 key 是字符串 key（`GET` 检查 TTL），当前 AlertService 的告警指标是队列深度/成功率，告警规则的具体配置由运维通过 API 创建，不在代码中硬编码。

---

### Decision 8: Scheduler 注入 `initializer`，Init 完成前守卫

**问题**：Scheduler 和 Initializer 同时启动，Init 需要数十分钟加载全量卡。这期间 Scheduler 每秒 tick 出队到空队列，产生无意义的 Redis 读请求和日志噪音。更严重的是，Init 期间手动触发队列的卡会被正常出队，但 `MatchConfig` 此时已可用（ConfigManager 先加载），所以手动触发是正常的——**只需跳过定时出队的空分片扫描**。

**修改**：

`Scheduler` struct 新增可选 `initializer *PollingInitializer` 字段，通过 `SetInitializer(init *PollingInitializer)` 在 `Start` 前注入：

```go
func (s *Scheduler) processShardSchedule(ctx context.Context) {
    // 仍然处理手动队列（允许启动期手动触发）
    for _, taskType := range allTaskTypes {
        s.processManualQueue(ctx, taskType, s.cfg.MaxManualBatchSize)
    }

    // Init 未完成时跳过分片扫描
    if s.initializer != nil && !s.initializer.IsCompleted() {
        return
    }
    // ... 原有分片出队逻辑
}
```

**为何不在 `Start` 前 wait**：同步等待 Init 完成会延迟 Worker 启动（Init 可能需要 30 分钟），不可接受。Skip 方案既保留了手动触发能力，又消除了空扫描噪音。

---

### Decision 9: `initBatch` 失败日志级别 Warn→Error

**问题**：`initBatch` 失败时当前记录 Warn 并继续。但 initBatch 失败意味着该批次的卡**未进入任何轮询队列**，除非下次重启重新 Init，这些卡永久丢失调度，是应该触发告警的严重问题。

**修改**：

```go
if initErr := p.initBatch(ctx, cards); initErr != nil {
    // Warn → Error：initBatch 失败 = 该批卡未进入轮询队列
    p.logger.Error("批量初始化失败，该批次卡未入队，需关注",
        zap.Int("batch_start_id", int(lastID)), zap.Error(initErr))
}
```

**不做**：不重试（批次失败通常是 Redis 连接问题，重试可能级联）。重启 Worker 会触发完整 Init 重跑。

---

### Decision 10: Pipeline 逐条错误检查

**问题**：`initBatch` 中 `flushPipe()` 调用 `pipe.Exec(ctx)` 只检查整体 error。go-redis 的 Pipeline 在部分命令失败时，整体 error 可能是 `MultiError`，也可能是 `nil`（取决于 go-redis 版本和 Redis 模式）。不逐条检查 `cmd.Err()`，会导致部分 ZADD 失败静默丢失。

**修改**：

```go
flushPipe := func() {
    if cmdCount == 0 {
        return
    }
    cmds, execErr := pipe.Exec(ctx)
    if execErr != nil {
        p.logger.Error("Pipeline flush 失败", zap.Error(execErr))
    }
    // 逐条检查，记录具体失败的命令
    for _, cmd := range cmds {
        if cmd.Err() != nil && cmd.Err() != redis.Nil {
            p.logger.Warn("Pipeline 单条命令失败",
                zap.String("cmd", cmd.Name()), zap.Error(cmd.Err()))
        }
    }
    pipe = p.redis.Pipeline()
    cmdCount = 0
}
```

**注意**：逐条检查仅记录日志，不中断批次。ZADD 失败率极低（仅在 Redis 数据类型冲突等异常情况），单条失败不应阻塞整个批次。

---

## Risks / Trade-offs

**[Risk] `MatchConfig` 的前向兼容**：外部若有代码调用 `MatchConfig` 并期望"独占式匹配"，改为返回第一个后可能产生语义变化。
→ **Mitigation**：`MatchConfig` 行为不变，只是内部调用 `MatchConfigs()[0]`，前向兼容。

**[Risk] 临时修复 id=29 的副作用**：用户提到 id=29 当前 `status=1`，所有 interval 全为 NULL。修复后 `MatchConfigs` 会跳过它（因为 `hasAnyEnabledInterval` 返回 false），相当于它从轮询系统"退出"。这是预期行为，但可能用户原本希望它作为 catch-all 兜底配置。
→ **Mitigation**：已在 design 中明确说明，临时方案是用户手动 `UPDATE tb_polling_config SET status=0 WHERE id=29`。

**[Risk] 相同 task type 不同 interval 的优先级覆盖**：极端场景下，配置 A (priority=5, card_status=300) 和配置 B (priority=10, card_status=600) 同时匹配一张卡，按设计会选 A 的 300 秒。但用户可能期望更特定的配置（如 carrier_id 匹配的）优先级高于泛化配置（如 card_condition）。
→ **当前设计已满足**：priority ASC = 优先级高优先。配置 A 如果 priority 更小（更高优先级），它应该更具体才合理。如果出现"更具体的配置 priority 反而更大"的情况，那是配置管理员的问题，不是代码问题。

**[Trade-off] 临时修复 vs 根本修复**：本 design 是根本修复，但需要代码改动 + 部署。临时修复（禁用 id=29）可以立即生效，用户可自行选择。

## Open Questions（已闭环）

1. **~~是否有其他调用方依赖 `MatchConfig` 的独占语义？~~** ✅ **已确认：有。** `internal/task/polling_base.go:105` 的 `requeueCard()` 是第三个调用方，位于 `internal/task/` 包。这是稳态路径（每次轮询任务完成后执行），被 5 个 handler 共 26 处调用。已在 Decision 6 中覆盖迁移方案。
2. **~~id=29 是否应该被删除而不是禁用？~~** ✅ **决定：添加数据迁移 SQL 禁用。** id=29 作为历史配置，禁用（`status=0`）而非删除——保留审计可追溯性。在 tasks.md 中添加迁移任务。
3. **~~`calcInitialDelay` 需要改造~~** ✅ **已决定：修改签名为 `calcInitialDelay(interval int) time.Time`。** 详见 Decision 5。同时移除 `getIntervalByTaskType`（详见 Decision 6）。
