init
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m3s

This commit is contained in:
Break
2026-06-16 15:15:50 +08:00
parent 84ab3bad99
commit 1f634eb465
21 changed files with 2776 additions and 41 deletions

View File

@@ -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 的任何信息

View File

@@ -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 (...)` 条件,可导出全量数据

View File

@@ -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** 导出格式为 xlsxfinalize 阶段合并分片
- **THEN** 各分片在 shard 阶段生成不含表头的 CSV 中间文件(而非 xlsxfinalize 合并 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 可直接顺序追加而无需跳过首行