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,2 @@
schema: spec-driven
created: 2026-06-05

View 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` 两个现有场景到新接口,补全筛选条件支持
- 对外 API4 个接口)签名不变,无数据库迁移
**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 阶段各执行一次额外查询,对于需要扫全表才能确定列数的动态列场景有一定开销,但因为是异步任务可以接受。
### 决策 3finalize 合并分片 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 分片)。
### 决策 4ExportParams 结构统一传递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 列动态列)?

View 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 变更**:四个对外接口(创建/列表/详情/取消)签名不变,前端无感知
- **无数据库迁移**:不新增表或字段

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 可直接顺序追加而无需跳过首行

View 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=处理中`),验证取消流程正常,任务最终状态为已取消

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-12

View File

@@ -0,0 +1,137 @@
## Context
当前奇成迁移脚本分为 `scan_legacy.py``migrate_assets.py``migrate_runtime.py`。脚本可以生成卡、设备、绑定、分配、伪订单、套餐使用记录和系列回填 SQL但多个关键决策依赖奇成查询结果和脚本默认值
- 设备归属由 `sim_iccid_1` 对应卡的奇成 `agent_id` 推导。
- 设备套餐来源由 `current_slot` 决定,但 CSV 未提供时默认 `1`
- 套餐迁移只解析一条当前正式套餐,无法覆盖“当前生效 + 未生效待生效”的队列。
- 卡和设备共用一套套餐解析路径,导致设备绑定卡、独立卡、卡级系列和设备级系列边界不清。
- 缺失代理映射或未命中奇成归属时,脚本会把资产留在平台库存,无法体现人工指定的迁移目标。
本次变更只涉及 `scripts/migration/` 下的离线迁移脚本和文档,不改变线上 Go API、Handler/Service/Store/Model 分层和业务数据库结构。
## Goals / Non-Goals
**Goals:**
-`mapping.yaml` 成为迁移决策源:资产给哪个店铺、设备当前槽位、套餐来源槽位、套餐迁移范围都由配置决定。
- 支持万级迁移的批量规则:默认店铺、设备/独立卡分支规则、按槽位规则、少量资产级覆盖。
- 拆分单卡迁移与设备迁移分支,避免独立卡和设备绑定卡共用不清晰逻辑。
- 迁移当前生效和未生效待生效正式套餐,分别写入 `tb_package_usage.status=1``status=0`
- 为每次生成 SQL 输出可审计 CSV让业务和开发能在执行前确认归属、套餐映射和跳过原因。
- 保持奇成连接只读、SQL 幂等、人工执行和当前脚本目录内的轻量实现方式。
**Non-Goals:**
- 不新增后台 API 或前端页面。
- 不新增数据库表或修改线上业务模型。
- 不迁移历史已过期套餐、加油包、历史订单明细或历史流量日明细。
- 不逐资产要求配置店铺;逐资产配置仅用于异常覆盖。
- 不新增自动化测试或 `_test.go`
## Decisions
### 决策 1使用“批量规则 + 覆盖项”的配置模型
**选择**:在 `mapping.yaml` 中新增 `ownership_rules``package_rules``overrides` 三类配置。
示例:
```yaml
ownership_rules:
default_target_shop_code: KWTX
device:
mode: default_shop
current_slot: 2
package_source_slot: 2
standalone_card:
mode: default_shop
package_rules:
migrate_statuses:
- active
- pending
packages:
- legacy_meal_id: D6695908379395072
target_package_id: 40
overrides:
devices:
- virtual_no: "862639073940258"
target_shop_code: OTHER
current_slot: 1
package_source_slot: 1
cards:
- iccid: "89861590172420360956"
target_shop_code: OTHER
```
**理由**:单批上万资产不能逐条配置,默认规则覆盖 99% 场景,覆盖项只处理少量例外。
**替代方案**:继续使用 `mapping.agents` 从奇成代理推导店铺。否决原因:奇成代理不是本次迁移的权威归属,且无代理或代理未映射时会出现静默漏分配。
### 决策 2拆分独立卡与设备套餐分支
**选择**
- 独立卡:只为未绑定设备的卡生成 `usage_type='single_card'`
- 设备:只按设备的 `package_source_slot` 对应卡生成 `usage_type='device'`,其他槽位卡只做绑定和卡基础信息,不重复生成主套餐。
**理由**:新系统运行态按设备维度判断设备套餐和复机条件,设备内多卡不能重复生成并列主套餐。独立卡和设备套餐生命周期也需要不同的资产定位字段。
**替代方案**:继续逐卡生成套餐后再靠 `_runtime_asset_for_card()` 折叠。否决原因:折叠逻辑隐蔽,难以解释某张卡为什么迁或不迁。
### 决策 3完整查询正式套餐生命周期并映射为 active/pending 队列
**选择**:新增查询函数读取每张来源卡在奇成 `tbl_card_life` 中所有正式套餐记录,排除 `type=1` 加油包和已过期记录,按当前时间和状态识别:
- 当前生效:写入 `status=1`,保留 `activated_at``expires_at` 和已用量。
- 未生效待生效:写入 `status=0``data_usage_mb=0`,按开始/到期时间和队列顺序设置 `priority`
**理由**:新系统已支持 `PackageUsageStatusPending=0` 和队列激活能承接待生效套餐。当前脚本只取一条最大过期时间套餐解释不了“20G/100G 只迁一个”这类问题。
**替代方案**:只迁当前生效套餐。否决原因:业务已确认必须迁移当前生效 + 未生效待生效套餐。
### 决策 4生成审核 CSV 作为执行前合同
**选择**:每次生成 SQL 时输出:
- `ownership_resolution.csv`:资产类型、资产标识、来源卡、目标店铺、决策来源、是否分配、原因。
- `package_resolution.csv`资产类型、资产标识、来源卡、legacy 套餐、目标套餐、目标状态、优先级、决策、原因。
- `summary.txt`:按资产类型、状态、错误类型汇总。
**理由**:迁移脚本是一次性高风险操作,执行前必须能从产物直接解释每个资产和套餐的处理结果。
**替代方案**:只写 `errors.csv` / `warnings.csv`。否决原因:只能看到异常,无法证明正常路径是否符合业务预期。
### 决策 5关键缺失项阻断不再静默降级
**选择**:以下情况必须写入 `errors.csv` 并跳过对应资产或套餐:
- 目标店铺码不存在或为空且规则要求分配。
- 设备 `current_slot` / `package_source_slot` 不在 1-4 或对应槽位无卡。
- legacy 套餐没有 `target_package_id`
- 目标套餐不存在、已删除或不是正式套餐。
- 同一资产产生多个 active 主套餐且无法按规则裁决。
**理由**:静默进入平台库存或漏迁套餐会让后续人工排查成本远高于生成期阻断。
## Risks / Trade-offs
- **[风险] 新配置结构与旧 `mapping.yaml` 不兼容** → 提供清晰错误和示例,必要时保留旧字段读取但输出升级提示。
- **[风险] 奇成套餐状态字段与真实生效窗口不一致** → 审核 CSV 同时输出奇成原始 `status/type/start_date/expire_date`,便于人工确认。
- **[风险] 待生效套餐优先级排序错误会影响自动激活顺序** → 按 `start_date ASC, expire_date ASC, legacy_life_id ASC` 生成稳定排序,并在 `package_resolution.csv` 输出 priority。
- **[风险] 批量默认店铺误配会影响大量资产** → `ownership_resolution.csv` 必须在执行 SQL 前人工抽查summary 输出目标店铺分布。
- **[Trade-off] 不做 UI 化配置** → 先保持脚本和 YAML满足迁移批次的速度和可审查性后续如迁移频率升高再考虑管理界面。
## Migration Plan
1. 扩展 `mapping.yaml.example``resources/README.md`,明确批量规则、槽位字段和审核流程。
2. 增强配置加载器,新增结构化配置对象和校验错误。
3. 扩展 CSV 加载器,支持 `current_slot` / `package_source_slot` 列;未填时从 `ownership_rules.device` 取默认值。
4. 扩展奇成查询,读取完整正式套餐生命周期。
5. 重构 SQL 构造逻辑,拆分独立卡和设备套餐生成。
6. 新增审核 CSV 与阻断错误输出。
7. 用当前 120 台设备样例和新增小样例手动生成 SQL核对 120 台归属、4 类套餐迁移问题和待生效队列。
**回滚**:本变更只影响 SQL 生成脚本。若生成产物不符合预期,不执行输出 SQL 即可;已生成 SQL 可删除后用旧分支重新生成。
## Open Questions
- 是否所有迁移批次默认都只有一个目标店铺,还是需要支持按资源文件分组指定多个默认店铺?
- 待生效套餐 `activated_at` 是否保留奇成 `start_date`,还是写 `NULL` 等待新系统激活时回填?
- 同一来源卡存在多个奇成 `status=1` 正式套餐时,是否按时间窗口裁决一个 active其余转 pending还是直接阻断人工处理

View File

@@ -0,0 +1,43 @@
## Why
当前奇成迁移脚本把奇成 `agent_id`、默认 `sim_iccid_1`、单一当前套餐查询结果当成迁移决策来源,导致批量设备店铺归属、设备主卡、套餐系列和套餐队列迁移不可控。随着单批上万到几万张卡迁移成为常态,脚本需要改为由 `mapping.yaml` 的批量规则驱动,奇成只提供历史事实,生成可审核的中间产物后再输出 SQL。
功能 ID`feature-qicheng-migration-control`
## What Changes
- **重构迁移决策来源**`mapping.yaml` 成为资产归属、设备当前槽位、套餐来源槽位、套餐迁移范围的唯一决策入口;奇成只用于读取运营商、套餐生命周期、流量和佣金等历史事实。
- **新增批量归属规则**:支持默认店铺、按设备/独立卡分支配置、按槽位配置和少量资产级覆盖,避免万级迁移逐卡配置。
- **拆分单卡迁移与设备迁移分支**:独立卡生成 `single_card` 套餐使用记录;设备绑定卡按设备维度生成 `device` 套餐使用记录,设备内卡不重复生成主套餐。
- **支持当前生效 + 未生效待生效套餐迁移**:完整读取奇成正式套餐生命周期,当前生效套餐写为 `status=1`,后续待生效套餐写为 `status=0` 并按优先级排队。
- **新增审核产物**:生成 `package_resolution.csv``ownership_resolution.csv` 和摘要文件,明确每个资产/套餐的决策、映射、跳过原因和缺失项。
- **增强阻断规则**:关键映射缺失、目标店铺不存在、目标套餐无效、设备主卡槽位缺失等必须进入错误清单并阻断对应资产,不再静默进入平台库存或漏写系列。
- **更新脚本文档**:同步更新 `scripts/migration/README.md``resources/README.md` 和奇成迁移方案文档,说明新配置模型、执行顺序和人工审核点。
## Capabilities
### New Capabilities
- `qicheng-migration-control`:定义奇成迁移脚本的批量归属决策、设备/单卡分支、套餐队列迁移、审核产物和阻断规则。
### Modified Capabilities
无。
## Impact
**脚本与配置**
- `scripts/migration/config/mapping.yaml.example`:新增批量归属、槽位、套餐迁移范围和覆盖配置示例。
- `scripts/migration/lib/mapping_loader.py`:加载并校验新的配置结构,保留旧配置兼容或提供明确升级错误。
- `scripts/migration/lib/csv_loader.py`:支持设备 `current_slot``package_source_slot` 或由批量规则填充默认槽位。
- `scripts/migration/lib/legacy_query.py`:新增读取完整正式套餐生命周期的查询。
- `scripts/migration/lib/sql_builder.py`:拆分单卡/设备套餐生成逻辑,生成审核 CSV 和阻断错误。
- `scripts/migration/scan_legacy.py``migrate_assets.py``migrate_runtime.py`:按新规则消费配置并输出 SQL。
**数据库与业务系统**
- 不新增业务表,不修改 API不引入外部依赖。
- 继续输出人工执行 SQL保持只读访问奇成、幂等插入和线上手动执行模式。
**验证**
- 不新增自动化测试或 `_test.go`
- 通过样例 CSV、生成的 `errors.csv` / `warnings.csv` / 审核 CSV、SQL 内容核对和测试库手动 SQL 查询验证。

View File

@@ -0,0 +1,143 @@
## ADDED Requirements
### Requirement: 批量归属决策
奇成迁移脚本 SHALL 使用 `mapping.yaml` 的批量归属规则决定卡和设备的目标店铺。奇成 `agent_id` 只能作为可选参考信息输出到审核文件MUST NOT 作为默认归属决策来源。
#### Scenario: 默认店铺分配设备
- **WHEN** `ownership_rules.device.mode = default_shop``default_target_shop_code = KWTX`
- **THEN** 所有未被 `overrides.devices` 覆盖的设备迁移 SQL 使用 `KWTX` 对应的 `tb_shop.id` 作为 `shop_id`
- **AND** `ownership_resolution.csv` 记录每台设备的决策来源为 `default_shop`
#### Scenario: 默认店铺分配独立卡
- **WHEN** `ownership_rules.standalone_card.mode = default_shop``default_target_shop_code = KWTX`
- **THEN** 所有未绑定设备且未被 `overrides.cards` 覆盖的卡迁移 SQL 使用 `KWTX` 对应的 `tb_shop.id` 作为 `shop_id`
#### Scenario: 覆盖项优先于批量规则
- **WHEN** 某设备在 `overrides.devices` 中配置了 `target_shop_code = OTHER`
- **THEN** 该设备使用 `OTHER` 作为目标店铺
- **AND** 审核文件记录决策来源为 `override`
#### Scenario: 目标店铺缺失阻断
- **WHEN** 规则要求资产分配到某个 `target_shop_code`,但新库不存在该店铺
- **THEN** 脚本 MUST 在 `errors.csv` 写入该资产的错误
- **AND** 脚本 MUST NOT 为该资产生成店铺分配 SQL
### Requirement: 显式设备主卡和套餐来源槽位
奇成迁移脚本 SHALL 支持在输入数据或 `mapping.yaml` 中显式指定设备 `current_slot``package_source_slot`。未逐行指定时,脚本 SHALL 使用 `ownership_rules.device.current_slot``ownership_rules.device.package_source_slot` 作为批量默认值。
#### Scenario: 使用批量默认槽位
- **WHEN** `devices.csv` 某设备未填写 `current_slot``package_source_slot`
- **AND** `ownership_rules.device.current_slot = 2`
- **AND** `ownership_rules.device.package_source_slot = 2`
- **THEN** 该设备的当前绑定卡和套餐来源卡均使用 `sim_iccid_2`
#### Scenario: 使用设备行覆盖槽位
- **WHEN** `devices.csv` 某设备填写 `current_slot = 1``package_source_slot = 1`
- **THEN** 该设备使用第 1 槽作为当前槽位和套餐来源槽位
- **AND** 该行配置优先于批量默认槽位
#### Scenario: 槽位无卡阻断设备套餐
- **WHEN** 某设备的 `package_source_slot = 2`,但 `sim_iccid_2` 为空
- **THEN** 脚本 MUST 在 `errors.csv` 写入该设备的错误
- **AND** 脚本 MUST NOT 为该设备生成套餐使用记录
### Requirement: 单卡与设备迁移分支分离
奇成迁移脚本 SHALL 分离独立卡套餐迁移和设备套餐迁移。独立卡套餐 MUST 写入 `usage_type = single_card` 并关联 `iot_card_id`;设备套餐 MUST 写入 `usage_type = device` 并关联 `device_id`
#### Scenario: 独立卡生成单卡套餐
- **WHEN** 某卡未绑定设备且命中可迁移套餐
- **THEN** 脚本为该卡生成 `tb_order.order_type = single_card`
- **AND** 脚本为该卡生成 `tb_package_usage.usage_type = single_card`
#### Scenario: 设备绑定卡生成设备套餐
- **WHEN** 某卡位于设备 `package_source_slot`
- **THEN** 脚本为该设备生成 `tb_order.order_type = device`
- **AND** 脚本为该设备生成 `tb_package_usage.usage_type = device`
#### Scenario: 设备非来源槽位不重复生成套餐
- **WHEN** 某卡绑定在设备非 `package_source_slot` 的槽位
- **THEN** 脚本 MUST NOT 为该卡单独生成主套餐使用记录
- **AND** 该卡仍 SHALL 生成基础卡资产和设备绑定 SQL
### Requirement: 当前生效和待生效套餐迁移
奇成迁移脚本 SHALL 迁移正式套餐中的当前生效套餐和未生效待生效套餐。当前生效套餐写入 `tb_package_usage.status = 1`;待生效套餐写入 `tb_package_usage.status = 0` 并设置稳定 `priority`
#### Scenario: 当前生效套餐迁移为 active
- **WHEN** 奇成某正式套餐处于当前生效状态
- **THEN** 脚本生成的 `tb_package_usage.status = 1`
- **AND** `activated_at``expires_at` 来源于奇成套餐生命周期时间
#### Scenario: 未生效套餐迁移为 pending
- **WHEN** 奇成某正式套餐属于未生效待生效套餐
- **THEN** 脚本生成的 `tb_package_usage.status = 0`
- **AND** `data_usage_mb = 0`
- **AND** `priority` 按待生效顺序稳定递增
#### Scenario: 已过期套餐不迁移
- **WHEN** 奇成某正式套餐已过期且不属于当前生效或待生效范围
- **THEN** 脚本 MUST NOT 为该套餐生成 `tb_package_usage`
- **AND** `package_resolution.csv` 记录跳过原因
#### Scenario: 目标套餐映射缺失阻断
- **WHEN** 某可迁移 legacy 套餐没有配置 `target_package_id`
- **THEN** 脚本 MUST 在 `errors.csv``package_resolution.csv` 中记录缺失映射
- **AND** 脚本 MUST NOT 为该套餐生成套餐使用记录
### Requirement: 迁移审核产物
奇成迁移脚本 SHALL 在生成 SQL 时同步输出可审计 CSV说明每个资产和套餐的最终决策。
#### Scenario: 输出归属审核文件
- **WHEN** 执行 `migrate_assets.py`
- **THEN** 脚本输出 `ownership_resolution.csv`
- **AND** 文件至少包含资产类型、资产标识、来源卡、目标店铺、决策来源、是否生成分配、原因
#### Scenario: 输出套餐审核文件
- **WHEN** 执行 `migrate_runtime.py`
- **THEN** 脚本输出 `package_resolution.csv`
- **AND** 文件至少包含资产类型、资产标识、来源卡、legacy 套餐 ID、legacy 套餐名称、目标套餐 ID、目标状态、优先级、决策、原因
#### Scenario: 摘要显示关键数量
- **WHEN** 脚本生成完成
- **THEN** `summary.txt` MUST 展示卡数、设备数、分配资产数、active 套餐数、pending 套餐数、错误数和跳过数
### Requirement: 迁移脚本只读和幂等边界
奇成迁移脚本 SHALL 保持对奇成库只读,并生成可重复执行的 PostgreSQL SQL 文件。脚本 MUST NOT 直接写新库业务数据。
#### Scenario: 奇成连接只读
- **WHEN** 脚本连接奇成库查询元数据和套餐生命周期
- **THEN** 连接 MUST 使用只读事务
#### Scenario: 生成 SQL 幂等
- **WHEN** 同一批次 SQL 被重复执行
- **THEN** 插入类语句 SHALL 使用唯一键或条件确保不会重复创建相同资产、订单、套餐使用记录和分配记录
#### Scenario: 不直接写新库
- **WHEN** 执行 Python 迁移脚本
- **THEN** 脚本只生成 SQL 和审核文件
- **AND** 脚本 MUST NOT 直接向新库写入业务表

View File

@@ -0,0 +1,55 @@
## 1. 配置与文档
- [x] 1.1 更新 `scripts/migration/config/mapping.yaml.example`,新增 `ownership_rules``package_rules``overrides` 示例
- [x] 1.2 更新 `scripts/migration/resources/README.md`,说明 `current_slot``package_source_slot`、批量默认槽位和覆盖优先级
- [x] 1.3 更新 `scripts/migration/README.md`,说明新执行流程、审核 CSV、错误阻断规则和手动验证方法
- [x] 1.4 更新 `docs/脚本/奇成数据迁移方案.md`,将范围改为当前生效 + 未生效待生效套餐,并说明 mapping.yaml 是决策源
## 2. 配置加载与输入解析
- [x] 2.1 扩展 `lib/mapping_loader.py`,新增归属规则、套餐规则、覆盖规则的数据结构
- [x] 2.2 在 `mapping_loader` 中实现配置校验:店铺码格式、槽位范围、套餐规则、覆盖项唯一性
- [x] 2.3 扩展 `lib/csv_loader.py`,支持读取设备行级 `current_slot``package_source_slot`
- [x] 2.4 实现设备槽位解析优先级:设备行配置 > `overrides.devices` > `ownership_rules.device` 默认值
- [x] 2.5 保留或显式拒绝旧配置结构,输出中文错误提示和升级指引
## 3. 奇成套餐生命周期查询
- [x] 3.1 在 `lib/legacy_query.py` 新增正式套餐生命周期查询函数,读取当前生效和未生效待生效套餐所需字段
- [x] 3.2 查询结果保留 legacy 套餐 ID、套餐名、类型、状态、开始时间、到期时间和稳定排序键
- [x] 3.3 排除加油包和已过期套餐,并在审核产物中记录跳过原因
- [x] 3.4 处理同一资产多个 active 主套餐的冲突:无法唯一裁决时写入错误并阻断该资产套餐生成
## 4. 归属决策与资产 SQL
- [x] 4.1 新增归属解析模块或函数,统一输出资产目标店铺、决策来源和错误
- [x] 4.2 修改卡资产生成逻辑:独立卡按 `ownership_rules.standalone_card` 和覆盖项确定店铺
- [x] 4.3 修改设备资产生成逻辑:设备按 `ownership_rules.device`、槽位配置和覆盖项确定店铺
- [x] 4.4 修改设备绑定逻辑:`is_current` 使用解析后的 `current_slot`,不再固定默认 slot1
- [x] 4.5 修改分配记录生成逻辑:只按归属解析结果生成,不再依赖奇成 `agent_id`
## 5. 套餐队列 SQL
- [x] 5.1 拆分独立卡套餐生成路径,生成 `single_card` 订单和套餐使用记录
- [x] 5.2 拆分设备套餐生成路径,按 `package_source_slot` 生成 `device` 订单和套餐使用记录
- [x] 5.3 为 active 套餐写入 `status=1`、生效/到期时间和用量快照
- [x] 5.4 为 pending 套餐写入 `status=0`、稳定 `priority`、到期时间和 0 用量
- [x] 5.5 设备和卡的 `series_id` 从目标套餐 `tb_package.series_id` 回填,并覆盖已有资产漏写场景
- [x] 5.6 目标套餐不存在、已删除或不是 formal 时生成 SQL 前置校验或错误阻断
## 6. 审核产物与错误输出
- [x] 6.1 生成 `output/ownership_resolution.csv`
- [x] 6.2 生成 `output/package_resolution.csv`
- [x] 6.3 更新 `output/summary.txt`展示分配数、active/pending 套餐数、跳过数和错误数
- [x] 6.4 更新 `errors.csv` / `warnings.csv` 结构或内容,覆盖店铺缺失、槽位缺失、套餐映射缺失、目标套餐无效等关键错误
- [x] 6.5 确保关键错误阻断对应资产或套餐 SQL不再静默进入平台库存
## 7. 手动验证
- [x] 7.1 使用当前 120 台设备样例生成 SQL确认 `ownership_resolution.csv` 显示 120 台设备归属到预期店铺
- [x] 7.2 使用包含 current + pending 套餐的小样例生成 SQL确认 `package_resolution.csv` 显示 active 和 pending 队列
- [x] 7.3 检查 `step2_02_package_usages.sql`,确认 active 写 `status=1`pending 写 `status=0` 且 priority 稳定递增
- [x] 7.4 检查设备套餐 SQL确认只为 `package_source_slot` 生成 device 级套餐,非来源槽位不重复生成主套餐
- [ ] 7.5 在测试库手动执行生成 SQL查询 `tb_device.shop_id``tb_iot_card.shop_id``tb_package_usage.status``tb_package_usage.priority` 和资产 `series_id`
- [x] 7.6 记录未验证项和已知限制,不新增自动化测试或 `_test.go`