This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-05
|
||||
84
openspec/changes/refactor-export-datasource/design.md
Normal file
84
openspec/changes/refactor-export-datasource/design.md
Normal file
@@ -0,0 +1,84 @@
|
||||
## 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 列动态列)?
|
||||
32
openspec/changes/refactor-export-datasource/proposal.md
Normal file
32
openspec/changes/refactor-export-datasource/proposal.md
Normal file
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
现有导出系统(2026-04-30 上线)存在三个影响实际可用性的缺陷:筛选条件被静默丢弃(query_json 存而不用)、finalize 阶段重查全量数据库(shard 产物废弃)、新增场景需要手写 Keyset 游标逻辑(难以支持多表 JOIN)。这三个问题共同导致"想加新导出就得开发介入、开发成本高、加完也无法按条件筛选"的现状。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **引入 DataSource 接口**:替代 `SceneStrategy` 的 `NextShardBoundary` + `QueryRows` 设计,新接口仅要求实现 `Count / Headers / Fetch`,分片游标逻辑全部由框架接管,开发者只管写业务查询(支持任意 SQL / JOIN)
|
||||
- **支持动态列**:`Headers` 方法接收 `ExportParams`,可在运行时根据数据决定列数,解决"设备绑了几张卡就要几列 ICCID"问题
|
||||
- **query_json 真正生效**:`ExportParams` 将 `query_json` 解析结果和权限快照一并传入 DataSource,`Fetch` 和 `Count` 均可读取并应用筛选条件
|
||||
- **修复 finalize 重查问题**:finalize 改为按 shard_no 顺序下载分片 OSS 文件直接拼接,不再重查数据库,shard 产物得到复用
|
||||
- **迁移现有两个场景**:`device` 和 `iot_card` 迁移到新 DataSource 接口,同时补全 query_json 解析逻辑
|
||||
- **保留原 SceneStrategy 接口**:作为复杂自定义场景的逃生通道,不强制所有场景使用 DataSource
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `export-datasource`:DataSource 接口定义、ExportParams 类型、框架侧 offset/limit 分片驱动逻辑、动态列支持
|
||||
- `export-query-params`:query_json 的解析规范与 ExportParams 注入机制,让筛选条件从创建任务一路传递到数据查询
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `unified-export-task-system`:finalize 合并策略从"重查数据库"改为"合并 shard OSS 文件";shard 阶段需保证分片 CSV 可被 finalize 独立拼接(不含表头)
|
||||
|
||||
## Impact
|
||||
|
||||
- **删除**:`internal/exporter/SceneStrategy` 接口中的 `NextShardBoundary` 方法(仅框架调用,外部无直接依赖)
|
||||
- **修改**:`internal/task/export_dispatch.go`(游标分片逻辑改为 offset 驱动)、`internal/task/export_finalize.go`(改为合并分片文件)、`internal/task/export_shard.go`(写分片文件时不含表头)
|
||||
- **新增**:`internal/exporter/datasource.go`(接口 + ExportParams 定义)、`internal/exporter/query_params.go`(query_json 解析)
|
||||
- **迁移**:`device_scene.go` / `iot_card_scene.go` 实现新接口,补 query_json 过滤
|
||||
- **无 API 变更**:四个对外接口(创建/列表/详情/取消)签名不变,前端无感知
|
||||
- **无数据库迁移**:不新增表或字段
|
||||
@@ -0,0 +1,52 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: DataSource 接口替代 SceneStrategy 游标逻辑
|
||||
|
||||
系统 SHALL 提供 `DataSource` 接口作为场景数据获取的标准契约,接口仅包含 `Count`、`Headers`、`Fetch` 三个方法,框架自动将 `Count` 结果按 `ExportDefaultShardSize` 切分为 `(offset, limit)` 对,开发者不感知游标分页。
|
||||
|
||||
#### Scenario: 框架根据 Count 自动切分分片
|
||||
|
||||
- **WHEN** dispatch 阶段调用 `DataSource.Count` 返回 5000 条,`ExportDefaultShardSize` 为 2000
|
||||
- **THEN** 框架生成 3 个分片:`(0,2000)` / `(2000,2000)` / `(4000,1000)`,不要求 DataSource 实现任何游标逻辑
|
||||
|
||||
#### Scenario: Fetch 按 offset/limit 返回数据行
|
||||
|
||||
- **WHEN** shard 阶段以 `offset=2000, limit=2000` 调用 `DataSource.Fetch`
|
||||
- **THEN** DataSource 实现返回第 2001-4000 条记录,每条记录为 `[]string`,列数与 `Headers` 返回值一致
|
||||
|
||||
#### Scenario: DataSource 实现支持任意 SQL JOIN
|
||||
|
||||
- **WHEN** 场景需要 `tb_iot_card LEFT JOIN tb_package_usage` 联表
|
||||
- **THEN** DataSource 实现可在 `Fetch` 中自由编写含 JOIN 的 GORM Raw SQL,框架不干涉查询内容
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 动态列支持
|
||||
|
||||
`DataSource.Headers` SHALL 接收 `ExportParams` 参数并可在运行时返回动态列头;dispatch 阶段调用一次 `Headers` 后将结果存入任务元数据,所有分片均使用同一份固定列头。
|
||||
|
||||
#### Scenario: 设备绑多卡时动态扩展列
|
||||
|
||||
- **WHEN** 某批次设备中最多绑了 3 张卡,dispatch 阶段调用 `Headers` 时返回 `["设备号","IMEI","ICCID_1","ICCID_2","ICCID_3"]`
|
||||
- **THEN** 所有分片的 CSV 文件均使用该 5 列结构,finalize 合并后列头一致
|
||||
|
||||
#### Scenario: 同一任务内各分片列数一致
|
||||
|
||||
- **WHEN** dispatch 阶段已固定 `resolved_headers` 为 5 列
|
||||
- **THEN** 所有 shard 任务的 `Fetch` 返回每行均为 5 个元素,列数不匹配时记录警告日志并补空字符串对齐
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 旧 SceneStrategy 接口作为逃生通道保留
|
||||
|
||||
系统 SHALL 同时支持 `DataSource` 和 `SceneStrategy` 两种注册方式;Registry 在执行时 MUST 优先识别 `DataSource`,降级识别 `SceneStrategy`;两种接口可混合注册,互不影响。
|
||||
|
||||
#### Scenario: 同时注册两种策略类型
|
||||
|
||||
- **WHEN** Registry 中注册了实现 `DataSource` 的 `IotCardDataSource` 和实现 `SceneStrategy` 的 `LegacyDeviceStrategy`
|
||||
- **THEN** 框架对 `iot_card` 走 offset/limit 路径,对 `device`(若未迁移)走原 Keyset 路径,两者并存正常工作
|
||||
|
||||
#### Scenario: 未注册场景被创建任务时拒绝
|
||||
|
||||
- **WHEN** 用户创建任务时传入 `scene=unknown_scene`
|
||||
- **THEN** 系统返回参数错误,不创建任务,Registry 不需要知道该 scene 的任何信息
|
||||
@@ -0,0 +1,41 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: query_json 筛选条件真正生效
|
||||
|
||||
系统 SHALL 将创建任务时传入的 `query.filters` 解析为 `ExportParams.Filters`,并在每次调用 `DataSource.Count` 和 `DataSource.Fetch` 时一并传入;DataSource 实现 MUST 读取 `Filters` 并应用到 SQL WHERE 条件中。
|
||||
|
||||
#### Scenario: 按店铺筛选导出 IoT 卡
|
||||
|
||||
- **WHEN** 用户创建任务时传入 `query: {"filters": {"shop_id": 5}}`
|
||||
- **THEN** `Fetch` 调用收到 `ExportParams.Filters["shop_id"] = 5`,生成的 SQL 包含 `WHERE shop_id = 5`,导出结果仅含该店铺数据
|
||||
|
||||
#### Scenario: 按时间范围筛选
|
||||
|
||||
- **WHEN** 用户创建任务时传入 `query: {"filters": {"created_at_start": "2024-01-01", "created_at_end": "2024-12-31"}}`
|
||||
- **THEN** `Fetch` 生成的 SQL 包含 `WHERE created_at >= '2024-01-01' AND created_at <= '2024-12-31'`
|
||||
|
||||
#### Scenario: 不传 filters 时全量导出
|
||||
|
||||
- **WHEN** 用户创建任务时 `query` 字段为空或 `filters` 为空对象
|
||||
- **THEN** DataSource 不附加额外 WHERE 条件,仅应用数据权限过滤(`ScopeShopIDs`)
|
||||
|
||||
#### Scenario: 筛选条件在整个任务生命周期内不变
|
||||
|
||||
- **WHEN** 任务创建后进入 dispatch → shard → finalize 各阶段
|
||||
- **THEN** 各阶段均从 `task.QueryJSON` 重新解析出相同的 `ExportParams.Filters`,筛选条件不发生漂移
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ExportParams 包含权限快照
|
||||
|
||||
系统 SHALL 将任务创建时记录的 `scope_shop_ids`、`creator_user_type` 构造进 `ExportParams`,DataSource 实现通过 `ExportParams.ScopeShopIDs` 和 `ExportParams.UserType` 应用数据权限,不得通过 context 获取实时权限(避免权限变更影响已创建任务)。
|
||||
|
||||
#### Scenario: 代理账号导出时使用权限快照
|
||||
|
||||
- **WHEN** 代理账号创建任务时 `scope_shop_ids = [10, 11, 12]`,任务执行时该代理已被移除店铺 12 的权限
|
||||
- **THEN** DataSource 仍使用快照中的 `[10, 11, 12]` 过滤,不受权限变更影响
|
||||
|
||||
#### Scenario: 平台账号导出不附加店铺过滤
|
||||
|
||||
- **WHEN** `ExportParams.UserType` 为平台类型
|
||||
- **THEN** DataSource 不附加 `shop_id IN (...)` 条件,可导出全量数据
|
||||
@@ -0,0 +1,61 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 导出任务异步分片执行
|
||||
|
||||
系统 SHALL 使用 Asynq 异步任务执行导出流程,并采用 `dispatch -> shard -> finalize` 三段式处理;大数据量导出 MUST 支持分片并行执行。dispatch 阶段 MUST 通过 `DataSource.Count` 获取总行数后按 `offset/limit` 切分分片,不再使用 Keyset 游标。
|
||||
|
||||
#### Scenario: 创建任务后异步执行
|
||||
|
||||
- **WHEN** 导出任务创建成功
|
||||
- **THEN** 系统将主任务入队,任务状态从"待处理"推进到"处理中"
|
||||
|
||||
#### Scenario: dispatch 阶段通过 Count 切分分片
|
||||
|
||||
- **WHEN** dispatch 阶段调用 DataSource.Count 返回总行数
|
||||
- **THEN** 系统按 ExportDefaultShardSize 计算分片数,每个分片记录 offset 和 limit,不依赖主表 ID 游标
|
||||
|
||||
#### Scenario: 分片任务并行处理
|
||||
|
||||
- **WHEN** dispatch 阶段生成多个 offset/limit 分片
|
||||
- **THEN** 系统并行执行多个 shard 任务并按分片回写进度
|
||||
|
||||
#### Scenario: 分片失败触发重试
|
||||
|
||||
- **WHEN** 某个 shard 任务因临时错误失败
|
||||
- **THEN** 系统按 Asynq 重试策略重试该分片,且不影响其他分片状态
|
||||
|
||||
#### Scenario: finalize 合并分片文件而非重查数据库
|
||||
|
||||
- **WHEN** 所有分片任务完成且均为成功状态
|
||||
- **THEN** finalize 阶段按 shard_no 升序从 OSS 下载各分片 CSV 文件(不含表头),顺序追加写入本地临时文件,在文件头部写入表头后上传为最终产物,不重新查询数据库
|
||||
|
||||
#### Scenario: XLSX 格式 finalize 降级路径
|
||||
|
||||
- **WHEN** 导出格式为 xlsx,finalize 阶段合并分片
|
||||
- **THEN** 各分片在 shard 阶段生成不含表头的 CSV 中间文件(而非 xlsx),finalize 合并 CSV 后用 excelize 流式写出最终 xlsx 文件
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 导出结果 OSS 交付与详情下载链接
|
||||
|
||||
系统 SHALL 将导出产物上传至 OSS,并在任务详情接口返回可直接下载的 `download_url`;该下载链接默认有效期 MUST 为 24 小时。分片产物 MUST 可被 finalize 阶段独立下载拼接(每个分片文件仅含数据行,不含表头)。
|
||||
|
||||
#### Scenario: 已完成任务详情返回下载链接
|
||||
|
||||
- **WHEN** 任务状态为"已完成"且产物上传成功
|
||||
- **THEN** 任务详情接口返回 `file_key` 与可直接下载的 `download_url`
|
||||
|
||||
#### Scenario: 未完成任务不返回下载链接
|
||||
|
||||
- **WHEN** 任务状态为"待处理"或"处理中"
|
||||
- **THEN** 任务详情接口不返回可用的 `download_url`
|
||||
|
||||
#### Scenario: 下载链接过期后可重新获取
|
||||
|
||||
- **WHEN** 用户在 URL 过期后再次调用任务详情接口
|
||||
- **THEN** 系统重新生成新的 24 小时有效下载链接
|
||||
|
||||
#### Scenario: 分片文件不含表头
|
||||
|
||||
- **WHEN** shard 阶段生成分片文件并上传 OSS
|
||||
- **THEN** 分片文件仅包含数据行,不包含表头行,使得 finalize 可直接顺序追加而无需跳过首行
|
||||
52
openspec/changes/refactor-export-datasource/tasks.md
Normal file
52
openspec/changes/refactor-export-datasource/tasks.md
Normal file
@@ -0,0 +1,52 @@
|
||||
## 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=处理中`),验证取消流程正常,任务最终状态为已取消
|
||||
Reference in New Issue
Block a user