## Context

当前 `cmd/worker/main.go` 的启动入口把两类职责放在同一进程中：

- 可横向扩容的被动消费职责：Asynq Worker Server 和全部任务 Handler
- 必须单例运行的主动发起职责：`PollingInitializer`、`PollingScheduler`、套餐激活/流量重置扫描、Asynq Scheduler

其中 Asynq Worker Server 天然适合多实例消费任务，但完整复制当前 worker 会同时复制主动调度模块。当前 Asynq v0.25.1 的 `Scheduler` 是进程内 cron，到点直接 `Enqueue`，不是跨进程单例；轮询初始化器也会启动后全量扫库并写 Redis 分片队列；流量重置节流状态是进程内字段。因此第一期必须先通过显式角色把部署语义收敛清楚。

本变更只处理“角色拆分和启动编排”，不改变已有 Handler、Service、Store、Model 的业务逻辑，不新增数据库表，也不改 Asynq 任务载荷。

## Goals / Non-Goals

**Goals:**

- 新增 `all`、`leader`、`consumer` 三种 Worker 角色。
- 默认 `all`，旧部署不配置新环境变量时行为完全不变。
- `consumer` 只运行任务消费相关模块，不运行轮询初始化器、轮询调度器和 Asynq Scheduler。
- `leader` 与 `all` 的运行模块一致，但部署语义明确为单例主动调度实例。
- 启动日志可直接证明当前实例的角色和模块启停情况。
- 关闭流程按实际启动模块执行，避免 `consumer` 因未创建调度器而空指针或重复关闭。

**Non-Goals:**

- 不实现 Redis Leader 锁或自动选主。
- 不为套餐激活、订单超时等任务补充唯一性去重。
- 不改 `PollingScheduler` 的调度算法和 Redis 分片队列实现。
- 不调整 `queue.concurrency`、队列权重或业务 Handler 幂等逻辑。
- 不新增 API、数据库迁移、DTO 或 OpenAPI 文档生成器改造。
- 不新增自动化测试文件；按项目根规范使用编译和人工日志验证。

## Decisions

### Decision 1：用显式配置角色，而不是自动探测或自动选主

**选择**：新增 `worker.role`，支持 `all`、`leader`、`consumer`，通过环境变量 `JUNHONG_WORKER_ROLE` 覆盖。

**备选方案**：启动时所有 worker 抢 Redis 锁，抢到锁者自动成为 leader。

**理由**：第一期目标是最小、安全、可回滚。显式角色能立即阻止完整进程被误横向复制，改动集中在配置和启动编排；自动选主需要续期、失锁处理、调度器停止/重启语义和故障切换策略，适合第二期单独设计。

### Decision 2：保留 `all` 作为默认兼容模式

**选择**：默认 `worker.role=all`。

**备选方案**：默认改为 `leader`。

**理由**：`all` 与当前运行行为完全一致，现有生产、本地开发和临时脚本不需要增加环境变量。`leader` 用于部署语义表达，等生产灰度确认后再从 `all` 切换到 `leader`。

### Decision 3：`consumer` 初始化共享依赖，但不启动单例模块

**选择**：`consumer` 仍初始化 Redis、PostgreSQL、Storage、Gateway、`BootstrapWorker`、`PollingConfigManager`、`PollingQueueManager`、`PollingBase`、`PollingLifecycleService`、TaskHandler 和 Asynq Worker Server。

**理由**：轮询任务 Handler 执行后需要读取轮询配置、执行并发控制、更新卡缓存、重入队分片队列；导入任务也可能触发轮询生命周期回调。因此 `consumer` 不是“瘦到只连 Asynq”的进程，而是“只不主动调度”的完整任务消费进程。

**边界**：`consumer` 不创建或不启动 `PollingInitializer`、`PollingScheduler`、Asynq Scheduler；如需为构造依赖创建可选对象，也必须保证不会启动其后台 goroutine。

### Decision 4：角色常量集中定义，启动逻辑用 helper 降低误用

**选择**：新增 Worker 角色常量，并提供类似 `RunsSingletonModules()` 的判断逻辑，避免在 `main.go` 多处硬编码字符串。

**位置约束**：遵循项目常量规范，角色字符串应放在 `pkg/constants/`，并添加中文注释；`pkg/config` 和 `cmd/worker` 通过常量判断角色。

**理由**：角色字符串会出现在配置校验、启动模块判断、日志输出和文档中，集中定义可以避免 `"consumer"` 这类 magic string 分散。

### Decision 5：关闭流程记录已启动模块，而不是假设全部存在

**选择**：为 Asynq Scheduler、Polling Scheduler、Polling Initializer 使用可选变量或启动状态标记；关闭时只关闭已启动模块。

**理由**：`consumer` 不启动单例模块，关闭逻辑如果无条件 `Shutdown/Stop` 会产生 nil panic 或关闭未运行对象。按启动状态关闭也让 `all/leader/consumer` 三种角色的生命周期可读。

### Decision 6：验证以编译和人工运行日志为准

**选择**：使用 `go build ./cmd/worker`、`go build ./...`、三种角色启动日志检查作为验收。

**理由**：项目根规范明确禁止自动化测试规划和 `*_test.go` 文件；本变更也主要是进程编排，人工日志能直接验证模块启停契约。

## Risks / Trade-offs

| 风险 | 缓解措施 |
|------|----------|
| 部署层误把两个实例都配置成 `leader` | 第一期通过日志暴露误配；第二期再补 Redis Leader 锁 |
| `consumer` 漏初始化轮询依赖导致轮询任务无法重入队 | `consumer` 必须保留 `PollingConfigManager`、`PollingQueueManager`、`PollingBase`、`PollingLifecycleService` |
| 关闭流程引用未启动模块 | 使用可选变量/启动状态判断，只关闭已启动模块 |
| 默认值变更影响现有部署 | 默认保持 `all`，不配置环境变量时行为不变 |
| Asynq Scheduler 多实例去重被误解 | 文档和启动日志明确只有 `all/leader` 启动 Scheduler；`consumer` 禁止启动 |
| 角色字符串散落导致后续维护出错 | 角色值集中为常量，并在配置校验中拒绝非法值 |

## Migration Plan

1. 发布代码后，不修改现有环境变量，单实例继续以默认 `all` 运行。
2. 灰度将唯一 worker 设置为 `JUNHONG_WORKER_ROLE=leader`，确认日志中初始化器、轮询调度器和 Asynq Scheduler 均启动。
3. 新增第一个 `consumer` 实例，设置 `JUNHONG_WORKER_ROLE=consumer` 和唯一 `JUNHONG_WORKER_INSTANCE_NAME`。
4. 检查 `consumer` 日志，确认未出现“轮询调度器已启动”和“Asynq Scheduler 已启动”，且 Asynq Worker Server 正常启动。
5. 如发生异常，先停止所有 `consumer`，保留单个 `leader`；若仍异常，将唯一实例切回默认 `all` 或回滚二进制。

## Open Questions

无。自动选主、Leader 分布式锁和任务唯一性去重作为第二期增强，不阻塞第一期实施。
