## 1. 新增 DataSource 接口与 ExportParams 类型，删除旧 SceneStrategy 实现

- [x] 1.1 在 `internal/exporter/datasource.go` 新建文件，定义 `DataSource` 接口（`Scene / Count / Headers / Fetch`）和 `ExportParams` 结构体（`Filters map[string]any / ScopeShopIDs []uint / UserType int`）
- [x] 1.2 在 `internal/exporter/query_params.go` 新建文件，实现 `ParseExportParams(task *model.ExportTask) ExportParams`：从 `task.QueryJSON` 解析 `filters`，从 `task.ScopeShopIDs` 和 `task.CreatorUserType` 填充权限字段
- [x] 1.3 删除 `internal/exporter/device_scene.go` 和 `internal/exporter/iot_card_scene.go` 中的全部旧 `SceneStrategy` 实现代码（文件内容清空，后续第 5 组重写为 `DataSource` 实现）
- [x] 1.4 重写 `internal/exporter/registry.go`：`Registry` 只存储 `DataSource`，删除 `SceneStrategy` 相关类型和方法；`Get` 直接返回 `DataSource`；`NewDefaultRegistry` 暂时为空（第 5 组补充）
- [x] 1.5 删除 `internal/exporter/scope.go` 中 `applyShopScopeForTask` 函数（该逻辑后续由各 DataSource 实现内部通过 `ExportParams` 处理，不再作为公共函数）
- [x] 1.6 用 `go build ./internal/exporter/...` 确认编译无错误

## 2. 修改 dispatch：改为 offset/limit 切分分片

- [x] 2.1 修改 `internal/model/export_task.go` 中 `ExportShardTask`：将 `CursorStart / CursorEnd uint64` 字段语义改为 `Offset / Limit int`（字段名改为 `ShardOffset / ShardLimit`），同步更新 `gorm` 标签注释
- [x] 2.2 新增数据库迁移文件 `migrations/000XXX_alter_export_shard_task_offset.up.sql`：在 `tb_export_shard_task` 中添加 `shard_offset bigint not null default 0` 和 `shard_limit int not null default 0` 列（保留原 `cursor_start / cursor_end` 列兼容旧任务）
- [x] 2.3 修改 `internal/task/export_dispatch.go`：`buildShards` 方法改为调用 `DataSource.Count` 获取总行数，按 `ExportDefaultShardSize` 计算 offset/limit 对，生成 `ExportShardTask` 列表（填 `ShardOffset / ShardLimit`）；删除原 Keyset 游标逻辑
- [x] 2.4 修改 `internal/task/export_dispatch.go`：dispatch 阶段调用 `DataSource.Headers` 一次，将结果序列化后存入 `task.QueryJSON` 的 `resolved_headers` 子键（通过新增 `ExportTaskStore.UpdateResolvedHeaders` 方法写入）
- [x] 2.5 修改 `internal/store/postgres/export_task_store.go`：新增 `UpdateResolvedHeaders(ctx, taskID, headers []string, updater uint) error` 方法
- [x] 2.6 用 `go build ./internal/task/...` 确认编译无错误

## 3. 修改 shard：调用 DataSource.Fetch，分片文件不含表头

- [x] 3.1 修改 `internal/task/export_shard.go`：shard 阶段从 `task.QueryJSON` 的 `resolved_headers` 读取表头；调用 `DataSource.Fetch(ctx, params, shard.ShardOffset, shard.ShardLimit)` 获取数据行，删除原 `strategy.QueryRows` 调用
- [x] 3.2 修改 `internal/task/export_common.go` 的 `writeExportFile`：新增 `withoutHeader bool` 参数，当为 `true` 时跳过写表头行；shard 阶段调用时传 `true`，finalize 写入表头
- [x] 3.3 用 `go build ./internal/task/...` 确认编译无错误

## 4. 修改 finalize：合并分片 OSS 文件，不重查数据库

- [x] 4.1 在 `pkg/storage/types.go` 确认（或新增）`Provider` 接口有 `Download(ctx, key string) (io.ReadCloser, error)` 方法；若无则添加并在对应实现中实现
- [x] 4.2 修改 `internal/task/export_finalize.go`：所有分片成功后，按 `shard_no` 升序循环从 OSS 下载各分片文件，流式追加写入本地临时 CSV 文件；写完后在文件头部插入 `resolved_headers` 行
- [x] 4.3 修改 `internal/task/export_finalize.go`：XLSX 格式走降级路径——合并 CSV 后用 `excelize` 流式写出 xlsx，而非直接合并 xlsx 分片
- [x] 4.4 用 `go build ./internal/task/...` 确认编译无错误

## 5. 迁移 device 和 iot_card 场景到 DataSource 接口

- [x] 5.1 重写 `internal/exporter/device_scene.go`：实现 `DataSource` 接口，`Count` 按权限快照计总数，`Headers` 返回固定列头（非动态），`Fetch` 用 GORM 加 `ORDER BY id ASC LIMIT ? OFFSET ?` 查询并应用 `ExportParams.Filters`（支持 `status / shop_id / created_at_start / created_at_end` 过滤）
- [x] 5.2 重写 `internal/exporter/iot_card_scene.go`：同上，支持 `status / shop_id / carrier_name / created_at_start / created_at_end` 过滤条件；`Fetch` 中用 `LEFT JOIN tb_package_usage` 补充套餐信息列（可选列，通过 `Filters["with_package"]` 开关控制）
- [x] 5.3 修改 `internal/exporter/registry.go` 的 `NewDefaultRegistry`：改为注册新的 `DataSource` 实现，删除旧 `SceneStrategy` 注册
- [x] 5.4 用 MCP PostgreSQL 工具分别对 device 和 iot_card 场景各抽取一批数据，对比新 `Fetch` 输出与原 `QueryRows` 输出一致（重点验证权限过滤和筛选条件）
- [x] 5.5 用 `go build ./...` 确认全项目编译无错误

## 6. 执行数据库迁移并验证

- [x] 6.1 执行 `migrate -path migrations -database "..." up` 应用 shard 表字段迁移
- [x] 6.2 用 MCP PostgreSQL 工具确认 `tb_export_shard_task` 表已新增 `shard_offset / shard_limit` 列
- [x] 6.3 确认存量 shard 记录的 `cursor_start / cursor_end` 列数据未被破坏（迁移为纯新增列，无数据变更）

## 7. 端到端验证

- [x] 7.1 通过 API 创建一个 `scene=iot_card, format=csv` 的导出任务，传入筛选条件 `{"filters": {"shop_id": 5}}`，用 MCP 查询 `tb_export_task` 确认 `query_json` 已记录筛选参数
- [x] 7.2 等待任务完成，用 MCP 查询 `tb_export_shard_task` 确认各分片的 `shard_offset / shard_limit` 已按预期填写，`status = 3`（已成功）
- [x] 7.3 调用任务详情接口获取 `download_url`，下载文件验证：列头与 `Headers` 一致、数据行仅含 `shop_id=5` 的记录、无重复行、无缺失行
- [x] 7.4 创建一个 `scene=device, format=xlsx` 的导出任务（不传筛选条件），验证全量导出正常，xlsx 文件可正常打开
- [x] 7.5 创建导出任务后立即取消（`status=处理中`），验证取消流程正常，任务最终状态为已取消
