## Context

现有导出系统在 2026-04-30 上线，核心架构是三段式异步任务（dispatch → shard → finalize）+ 场景策略插件（SceneStrategy 接口）。系统整体结构健全，但存在三个实现层面的缺陷：

1. `query_json` 字段在 `service.CreateTask` 中被序列化存入数据库，但 `DeviceSceneStrategy` 和 `IotCardSceneStrategy` 的 `NextShardBoundary` 与 `QueryRows` 均不读取该字段，导致用户传入的所有筛选条件被静默丢弃。
2. `export_finalize.go` 在 finalize 阶段重新调用各分片的 `QueryRows` 将数据全量加载到内存后再生成汇总文件，shard 阶段上传的 OSS 分片文件未被复用，等同于数据查了两遍。
3. `SceneStrategy` 接口要求开发者手动实现 `NextShardBoundary`（Keyset 游标分页），新增场景需写约 80 行模板代码，且当 SQL 中含有 JOIN 时游标基于主表 ID 的假设可能被破坏（一对多 JOIN 导致结果行数与主表行数不一致）。

## Goals / Non-Goals

**Goals:**
- 引入 `DataSource` 接口，将分片游标逻辑从场景实现中剥离，开发者只需实现 `Count / Headers / Fetch`
- 支持动态列：`Headers` 接收运行时参数，可在执行时决定列数（解决设备绑多卡动态列问题）
- `query_json` 中的筛选条件通过 `ExportParams` 传入每次 `Fetch` 调用，让过滤条件真正生效
- finalize 阶段改为合并 shard OSS 分片文件，消除重查数据库
- 迁移 `device` 和 `iot_card` 两个现有场景到新接口，补全筛选条件支持
- 对外 API（4 个接口）签名不变，无数据库迁移

**Non-Goals:**
- 不做用户侧自定义导出（用户在前端自由组合列/表）
- 不支持跨租户数据合并导出
- 不引入 YAML/DSL 配置驱动
- 不删除旧 `SceneStrategy` 接口（保留作复杂场景逃生通道）

## Decisions

### 决策 1：分片改为 offset/limit 驱动，而非 Keyset 游标

**选择**：框架在 dispatch 阶段通过 `Count` 获取总行数，按 `ExportDefaultShardSize` 切分出 `(offset, limit)` 对，每个分片只记录 `offset` 和 `limit`，shard 阶段调用 `Fetch(ctx, params, offset, limit)`。

**放弃**：保留 Keyset 游标（当前实现），要求 DataSource 实现 `NextShardBoundary`。

**原因**：Keyset 游标强依赖"主表有单调递增 ID 且查询不含破坏游标的 JOIN"这一假设。实际业务查询（IoT 卡+套餐 LEFT JOIN、订单多表关联）中这个假设很容易不成立。offset/limit 对任意 SQL 均有效，代价是在超大数据量下存在 offset 深翻页性能问题——但导出任务本身是后台异步作业，对延迟不敏感，这个代价可接受。

**数据库侧应对**：`Fetch` 实现时在 SQL 末尾加 `ORDER BY id ASC` 保证结果稳定，避免不同分片间数据重复或遗漏。

### 决策 2：动态列由 Headers 在运行时决定，框架在 dispatch 阶段一次性固定

**选择**：dispatch 阶段调用 `DataSource.Headers(ctx, params)` 一次，将结果序列化存入主任务的 `query_json` 扩展字段（新增 `resolved_headers` 子键），shard 和 finalize 阶段均从该字段读取，不再重新调用 `Headers`。

**原因**：动态列场景（如设备绑多卡）的列数依赖全量数据扫描，如果每个 shard 独立调用 `Headers` 可能得到不同列数，导致各分片 CSV 列不对齐无法合并。在 dispatch 阶段固定一次，保证所有分片使用同一套表头。

**代价**：`Count` 和 `Headers` 会在 dispatch 阶段各执行一次额外查询，对于需要扫全表才能确定列数的动态列场景有一定开销，但因为是异步任务可以接受。

### 决策 3：finalize 合并分片 CSV 文件而非重查数据库

**选择**：shard 阶段写 CSV 时**不写表头**（仅数据行），finalize 阶段按 `shard_no` 升序从 OSS 下载各分片文件，顺序追加到本地临时文件，最后在文件头部写入表头，上传合并后的完整文件。

**放弃**：当前 finalize 重新调用 `QueryRows` 全量重查后在内存中合并。

**原因**：消除重复数据库查询，shard 产物得到复用。内存侧也从"全量数据 in-memory"变为"流式追加写文件"，对大数据量更友好。

**注意点**：XLSX 格式的分片文件无法直接二进制拼接（每个 xlsx 是独立 zip 包），因此 XLSX 格式的 finalize 仍需重查数据库（或先把各分片作为 csv 合并后再转 xlsx）。为简化实现，**XLSX 格式的 finalize 走降级路径：各分片 csv 合并后用 excelize 流式写出 xlsx**，shard 阶段对 XLSX 任务同样生成 csv 分片（不生成 xlsx 分片）。

### 决策 4：ExportParams 结构统一传递，query_json 在 Service 层解析

**选择**：`service.GetTaskDetail`（Worker 调用路径）负责将 `task.QueryJSON` 解析为 `map[string]any`，连同权限快照一起构造 `ExportParams`，作为参数传入所有 `DataSource` 方法。

**原因**：解析逻辑集中在一处，DataSource 实现无需关心 JSON 解析，直接消费类型化字段。

### 决策 5：保留 SceneStrategy 接口作为逃生通道

对于无法用 `DataSource` 表达的场景（如多阶段聚合、需要跨库合并的报表），保留原 `SceneStrategy` 接口。Registry 同时支持两种注册方式，框架在执行时通过类型断言判断走哪条路径。

## Risks / Trade-offs

| 风险 | 缓解措施 |
|------|----------|
| offset 深翻页在千万级数据下性能退化 | 导出任务为异步后台作业，P99 无硬性要求；若实测超时可对 DataSource 实现添加索引提示 |
| XLSX 格式降级路径导致 shard 文件与 CSV 格式行为不一致 | 在文档和注释中明确说明；XLSX 的 shard 文件统一命名为 `.csv.shard` 加以区分 |
| Headers 在 dispatch 阶段执行全表扫描（动态列场景）| DataSource 实现者可在 Headers 中只做轻量采样（如 LIMIT 1）而非全量扫描来决定动态列数 |
| 现有 device / iot_card 场景迁移引入回归 | 两个场景的 Fetch 输出与原 QueryRows 输出做 diff 验证，MCP 手动对比数据样本 |

## Migration Plan

1. 新接口与旧接口并存，不删除 `SceneStrategy`
2. dispatch/shard/finalize 三段 worker 修改为优先检测 `DataSource` 接口，降级检测 `SceneStrategy`
3. `device` 和 `iot_card` 场景实现新 `DataSource`，原 `SceneStrategy` 实现暂时保留，经线上验证后在下一个 change 中删除
4. shard 写文件逻辑：新建任务走新路径（不含表头的 csv 分片）；存量任务（已在执行中）因为 finalize 会检查 shard 文件是否可下载，若下载失败降级为重查数据库（加兜底逻辑）
5. 无数据库迁移，无 API 变更，可随时回滚（回滚只需重新部署旧版 worker）

## Open Questions

- 动态列场景中，dispatch 阶段固定 `resolved_headers` 后，若同一批次中不同分片的数据导致"实际需要的列数"不同（如某分片的设备绑了 5 张卡，另一分片最多 3 张），当前方案以 dispatch 阶段的扫描结果为准。这个"扫全表确定最大列数"的代价是否可接受，还是应该约定上限（如最多 N 列动态列）？
