微信小程序页面迁移校验之前 P5任务处理之前

This commit is contained in:
Neo
2026-03-09 01:19:21 +08:00
parent 263bf96035
commit 6e20987d2f
1112 changed files with 153824 additions and 219694 deletions

View File

@@ -0,0 +1 @@
{"specId": "cd79656c-9c23-4470-a147-d402b5f4b50b", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,357 @@
# 技术设计:团购详情接口整合 ETL 数据流
> 对应需求文档:#[[file:.kiro/specs/etl-coupon-detail/requirements.md]]
## 1. 架构概览
```
ODS_GROUP_PACKAGE 任务BaseOdsTask + OdsTaskSpec
│ 阶段 1列表拉取UnifiedPipeline #1
│ QueryPackageCouponList → ods.group_buy_packages
ods.group_buy_packages ──写入完成──▶ 阶段 2详情拉取自动启动
│ │
│ ┌───────┴───────┐
│ │ UnifiedPipeline│
│ │ #2 │
│ │ (detail_mode) │
│ └───────┬───────┘
│ │
│ ┌───────▼───────┐
│ │ 串行请求线程 │
│ │ (主线程) │
│ │ RateLimiter │
│ └───────┬───────┘
│ │ 响应提交
│ ┌───────▼───────┐
│ │ 处理队列 │
│ │ N 个工作线程 │
│ └───────┬───────┘
│ │ 处理完成
│ ┌───────▼───────┐
│ │ 写入队列 │
│ │ 单线程写库 │
│ └───────┬───────┘
│ │
▼ ▼
DWD dim_groupbuy_package ods.group_buy_package_details
DWD dim_groupbuy_package_ex ◀── SCD2 合并 ──┘
```
> 不再新建独立的 `DetailFetcher` 类。详情拉取完全复用 `BaseOdsTask` 已有的 `detail_endpoint` 二级拉取模式,通过 `OdsTaskSpec` 声明式配置即可驱动。
## 2. 调研结论与设计决策
### 2.1 ODS 层表方案 → 选项 A新建独立表
决策依据:
- 现有 `ods.group_buy_packages` 使用 `OdsTaskSpec` 驱动payload 存储列表接口的原始 JSONPK 为 `id + content_hash`
- 详情接口返回嵌套结构(子数组 `packageCouponAssistants``grouponSiteInfos` 等),与列表接口的扁平结构差异大
- 两个接口的写入时机不同(列表先写完,详情后写),混在同一表会导致 `SnapshotMode.FULL_TABLE` 的软删除逻辑冲突
- 独立表可以独立演进字段,不影响现有列表数据的稳定性
新表:`ods.group_buy_package_details`PK = `coupon_id`BIGINT全量快照覆盖
### 2.2 DWD 层表方案 → 选项 A在现有扩展表新增字段
决策依据:
- `dim_groupbuy_package_ex` 当前 21 个业务字段(不含 SCD2密度适中
- 详情接口的增量价值字段仅 4 个 JSONB 列(`table_area_ids``table_area_names``assistant_services``groupon_site_infos`
- 新建独立表会增加 SCD2 版本同步复杂度,且下游查询需要额外 JOIN
- 扩展表的 SCD2 合并已与主表同步,新增字段自动纳入变更检测
### 2.3 取消信号 → 复用现有 `CancellationToken`
`CancellationToken`(封装 `threading.Event`)已在 etl-unified-pipeline 中实现。详情阶段的 `UnifiedPipeline` 实例共享同一个 `cancel_token`,在请求循环和 `RateLimiter.wait()` 中检查取消状态。
## 3. 组件设计
### 3.1 OdsTaskSpec 详情配置(声明式,无需新建类)
> `RateLimiter``api/rate_limiter.py`)、`CancellationToken``utils/cancellation.py`)和 `UnifiedPipeline``pipeline/unified_pipeline.py`)已在 etl-unified-pipeline 中实现并上线。`BaseOdsTask.execute()` 内置了 `detail_endpoint` 二级详情拉取模式,本 spec 通过 `OdsTaskSpec` 声明式配置驱动,不新建独立类。
`tasks/ods/ods_tasks.py``ODS_GROUP_PACKAGE` 任务 spec 中添加以下配置:
```python
OdsTaskSpec(
code="ODS_GROUP_PACKAGE",
# ... 现有列表拉取配置 ...
# ── Detail_Mode 配置 ──
detail_endpoint="/PackageCoupon/QueryPackageCouponInfo",
detail_param_builder=lambda rec: {"couponId": rec["id"]},
detail_target_table="ods.group_buy_package_details",
detail_data_path=("data",),
detail_id_column="id",
)
```
配置字段说明:
| 字段 | 值 | 说明 |
|------|-----|------|
| `detail_endpoint` | `/PackageCoupon/QueryPackageCouponInfo` | 详情接口 endpoint |
| `detail_param_builder` | `lambda rec: {"couponId": rec["id"]}` | 将列表表的 `id` 映射为详情接口的 `couponId` 参数 |
| `detail_target_table` | `ods.group_buy_package_details` | 详情数据写入的目标表 |
| `detail_data_path` | `("data",)` | 详情响应的数据路径 |
| `detail_id_column` | `id` | 从 `ods.group_buy_packages` 提取 couponId 列表的列名 |
### 3.2 执行流程BaseOdsTask.execute() 内置)
`BaseOdsTask.execute()` 在列表拉取全部完成后,自动检测 `spec.detail_endpoint` 是否配置,若已配置则启动详情拉取阶段:
```python
# BaseOdsTask.execute() 内置逻辑(已实现,无需修改):
if spec.detail_endpoint:
# 1. 创建独立的 UnifiedPipeline 实例(共享 cancel_token
detail_pipeline = UnifiedPipeline(
api_client=self.api,
db_connection=self.db,
logger=self.logger,
config=pipeline_config, # PipelineConfig.from_app_config()
cancel_token=cancel_token, # 与列表阶段共享
)
# 2. 从 ODS 目标表查询 ID 列表,生成详情请求序列
detail_requests = self._build_detail_requests(spec)
# → SELECT DISTINCT {detail_id_column} FROM {table_name}
# → 对每个 ID 调用 detail_param_builder 构造参数
# → yield PipelineRequest(is_detail=True, detail_id=record_id)
# 3. 构建处理和写入函数
detail_process_fn = self._build_detail_process_fn(spec)
detail_write_fn = self._build_detail_write_fn(spec, source_file)
# → 写入 detail_target_table使用 _insert_records_schema_aware()
# 4. 执行管道
detail_result = detail_pipeline.run(
detail_requests, detail_process_fn, detail_write_fn,
)
self.db.commit()
```
### 3.3 详情响应处理(需自定义 process_fn
默认的 `_build_detail_process_fn``response.get("records", [])` 提取记录。对于团购详情接口,需要自定义字段提取逻辑:
-`data.groupPurchasePackage` 提取结构化字段(`package_name``duration``start_time``end_time` 等)
-`data.groupPurchasePackage.tableAreaId` / `tableAreaNameList` 提取台区数组为 JSONB
-`data.packageCouponAssistants` 提取助教服务关联数组为 JSONB
-`data.grouponSiteInfos` 提取关联门店数组为 JSONB
-`data.packagePackageService``data.packageCouponDetailsList` 提取为 JSONB
- 计算 `content_hash`,保留完整原始响应为 `payload`
实现方式:在 `ODS_GROUP_PACKAGE` 任务中覆盖 `_build_detail_process_fn`,或在 `OdsTaskSpec` 中扩展 `detail_process_fn` 回调。
### 3.4 详情数据写入(复用 _insert_records_schema_aware
`_build_detail_write_fn` 已内置调用 `_insert_records_schema_aware()`,按目标表结构动态写入,支持 ON CONFLICT UPSERT。写入目标为 `ods.group_buy_package_details`PK = `coupon_id`
### 3.5 DWD 加载扩展
文件:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
`_merge_dim_scd2()` 处理 `dim_groupbuy_package_ex` 时,需要额外从 `ods.group_buy_package_details` 读取详情字段并合并到 ODS 快照中:
```python
# 伪代码:在 DWD 加载 dim_groupbuy_package_ex 时
def _load_groupbuy_package_ex(self, cur, now):
# 1. 从 ods.group_buy_packages 读取列表数据(现有逻辑)
# 2. 从 ods.group_buy_package_details 读取详情数据
# 3. 通过 coupon_id = id 关联,将详情字段合并到 ODS 快照
# 4. 执行 SCD2 合并(现有 _merge_dim_scd2 逻辑)
```
新增字段映射(`ods.group_buy_package_details``dwd.dim_groupbuy_package_ex`
| ODS 详情字段 | DWD 扩展表字段 | 类型 |
|-------------|---------------|------|
| `table_area_ids` | `table_area_ids` | JSONB |
| `table_area_names` | `table_area_names` | JSONB |
| `assistant_services` | `assistant_services` | JSONB |
| `groupon_site_infos` | `groupon_site_infos` | JSONB |
## 4. 数据库变更
### 4.1 新建 ODS 表
```sql
-- db/etl_feiqiu/ods/group_buy_package_details.sql
CREATE TABLE IF NOT EXISTS ods.group_buy_package_details (
coupon_id BIGINT NOT NULL,
package_name TEXT,
duration INTEGER, -- 台费计时时长(秒)
start_time TIMESTAMPTZ, -- 可用日期开始
end_time TIMESTAMPTZ, -- 可用日期结束
add_start_clock TEXT, -- 可用时段开始
add_end_clock TEXT, -- 可用时段结束
is_enabled INTEGER,
is_delete INTEGER,
site_id BIGINT,
tenant_id BIGINT,
create_time TIMESTAMPTZ,
creator_name TEXT,
-- JSONB 数组字段
table_area_ids JSONB, -- [2791960001957765, ...]
table_area_names JSONB, -- ["A区", ...]
assistant_services JSONB, -- [{skillId, assistantLevel, assistantDuration}, ...]
groupon_site_infos JSONB, -- [{siteId, siteName}, ...]
package_services JSONB, -- 待调研,可能为空
coupon_details_list JSONB, -- 待调研,可能为空
-- ETL 元数据
content_hash TEXT,
payload JSONB, -- 完整原始响应
fetched_at TIMESTAMPTZ DEFAULT now(),
-- 主键
CONSTRAINT pk_group_buy_package_details PRIMARY KEY (coupon_id)
);
COMMENT ON TABLE ods.group_buy_package_details IS '团购套餐详情 ODSQueryPackageCouponInfo 原始数据';
```
### 4.2 DWD 扩展表 ALTER
```sql
-- db/etl_feiqiu/migrations/2026-03-05__add_detail_fields_to_dim_groupbuy_package_ex.sql
ALTER TABLE dwd.dim_groupbuy_package_ex
ADD COLUMN IF NOT EXISTS table_area_ids JSONB,
ADD COLUMN IF NOT EXISTS table_area_names JSONB,
ADD COLUMN IF NOT EXISTS assistant_services JSONB,
ADD COLUMN IF NOT EXISTS groupon_site_infos JSONB;
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.table_area_ids IS '可用台区 ID 列表(来自详情接口 tableAreaId';
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.table_area_names IS '可用台区名称列表(来自详情接口 tableAreaNameList';
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.assistant_services IS '助教服务关联(来自详情接口 packageCouponAssistants';
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.groupon_site_infos IS '关联门店信息(来自详情接口 grouponSiteInfos';
```
## 5. 线程模型详细设计
详情阶段复用 `UnifiedPipeline` 的三层并发架构,与列表阶段完全一致:
```
UnifiedPipeline #2detail_mode
│ 主线程_request_loop
│ ┌─────────────────────────────────────────┐
│ │ for req in _build_detail_requests(spec): │
│ │ if cancel_token.is_cancelled: break │
│ │ resp = api.post(req.endpoint, params) │
│ │ processing_queue.put((req, resp)) │
│ │ rate_limiter.wait(cancel_token.event) │
│ └─────────────────┬───────────────────────┘
│ │
│ processing_queue.put(SENTINEL × worker_count)
│ 等待所有 worker 完成
│ write_queue.put(SENTINEL)
│ 等待 writer 完成
├──▶ Worker Thread 1 ──┐
├──▶ Worker Thread 2 ──┤
│ │
│ processing_queue │
│ ┌─────────────┐ │
│ │ (req, resp) │───▶ detail_process_fn(resp)
│ │ (req, resp) │ → 提取字段、计算 content_hash
│ │ SENTINEL │ write_queue.put(records)
│ └─────────────┘ │
│ │
│ ▼
│ Write Thread
│ ┌──────────────────┐
│ │ write_queue │
│ │ batch=batch_size │──▶ _insert_records_schema_aware()
│ │ timeout=5s │ → UPSERT ods.group_buy_package_details
│ │ SENTINEL │
│ └──────────────────┘
PipelineResultdetail_success / detail_failure / detail_skipped
```
关键设计点(均由 `PipelineConfig` 统一控制,支持任务级覆盖):
- `processing_queue``queue.Queue(maxsize=queue_size)`,满时阻塞主线程(背压机制)
- `write_queue``queue.Queue(maxsize=queue_size * 2)`
- Worker 数量:`PipelineConfig.workers`(默认 2
- Writer 批量写入:累积 `batch_size` 条或超时 `batch_timeout` 秒后执行
- SENTINEL`None` 对象,用于通知线程退出
- 取消信号:主线程检查 `cancel_token.is_cancelled``RateLimiter.wait()` 轮询 `cancel_token.event`
## 6. 取消信号
详情阶段的 `UnifiedPipeline` 实例与列表阶段共享同一个 `CancellationToken`。取消信号的上游传递链Admin-web → Backend → Orchestration → CancellationToken属于 etl-unified-pipeline 的架构范畴,本 spec 仅关注详情阶段收到取消信号后的行为:
1. `_request_loop` 检测到 `cancel_token.is_cancelled`,停止发送新请求
2. `RateLimiter.wait()` 在 0.5s 轮询周期内检测到取消,立即返回 `False`
3. 主线程发送 SENTINEL 到处理队列,等待已入队数据处理完成
4. `PipelineResult.cancelled = True``BaseOdsTask` 据此设置任务状态
## 7. 错误处理策略
错误处理由 `UnifiedPipeline` 统一管理,各阶段行为如下:
| 场景 | 处理方式 |
|------|---------|
| 单个 couponId 请求超时/HTTP 错误 | `_request_loop` 捕获异常,`request_failures++`,继续下一个 |
| 单个 couponId 返回 `code != 0` | 同上API 层异常) |
| 连续失败超过阈值 | `_request_loop` 中断,`PipelineResult.status = "FAILED"` |
| Worker 线程处理异常 | `_process_worker` 捕获异常,`processing_failures++`,继续消费队列 |
| Writer 线程写入失败 | `_write_worker` 捕获异常,`write_failures++`,继续消费队列 |
| 取消信号到达 | 停止新请求,等待已入队数据处理完成,`cancelled = True` |
`BaseOdsTask.execute()` 在详情阶段完成后,将 `detail_result` 的统计信息合并到任务结果中,并记录每个失败项的错误日志。
连续失败阈值:`PipelineConfig.max_consecutive_failures`(默认 10支持 `pipeline.ods_group_package.max_consecutive_failures` 任务级覆盖)。
## 8. 配置参数
详情阶段复用 `PipelineConfig` 统一配置体系,支持三级回退:`pipeline.ods_group_package.<key>``pipeline.<key>` → 硬编码默认值。
| 配置键 | 默认值 | 说明 |
|--------|--------|------|
| `pipeline.workers` | 2 | 处理线程数 |
| `pipeline.queue_size` | 100 | 处理队列容量 |
| `pipeline.batch_size` | 100 | 写入批量阈值 |
| `pipeline.batch_timeout` | 5.0 | 写入等待超时(秒) |
| `pipeline.rate_min` | 5.0 | RateLimiter 最小间隔(秒) |
| `pipeline.rate_max` | 20.0 | RateLimiter 最大间隔(秒) |
| `pipeline.max_consecutive_failures` | 10 | 连续失败中断阈值 |
如需为详情阶段单独调参,可通过 `pipeline.ods_group_package.*` 任务级覆盖(列表和详情阶段共享同一 `PipelineConfig` 实例)。
> 不再需要独立的 `DETAIL_FETCH_*` 配置参数。
## 9. 实施任务清单
### Task 1新建 ODS 详情表 DDL
- 创建 `db/etl_feiqiu/ods/group_buy_package_details.sql`
- 执行 DDL 到测试库
- 需求覆盖:需求 3 验收标准 1-4
### Task 2扩展 ODS_GROUP_PACKAGE 任务 — 配置详情拉取
-`tasks/ods/ods_tasks.py``ODS_GROUP_PACKAGE` OdsTaskSpec 中添加 `detail_endpoint` 等配置
- 实现自定义的 `_build_detail_process_fn` 字段提取逻辑
- 实现自定义的 `_build_detail_write_fn` 写入逻辑
- 复用 `BaseOdsTask.execute()` 已有的详情拉取流程(`UnifiedPipeline` + `RateLimiter` + `CancellationToken`
- 需求覆盖:需求 1 验收标准 1-8需求 2 验收标准 1-6需求 5 验收标准 1-4
### Task 3DWD 扩展表 ALTER + 加载逻辑
- 执行 ALTER TABLE 到测试库
- 修改 DWD 加载逻辑,从详情 ODS 表读取并合并到扩展表
- 需求覆盖:需求 4 验收标准 1-5
### Task 4数据调研 — 获取全部团购详情并分析未标注字段
- 编写一次性脚本调用详情接口获取全部数据
- 分析未标注字段的值分布
- 确认 `packagePackageService``packageCouponDetailsList` 是否有数据
- 根据分析结果调整 ODS/DWD 字段定义
- 需求覆盖:需求 3 验收标准 6附录 B 调研 3、4
### Task 5文档同步更新
- 更新 ODS DDL 文档、字段映射文档
- 更新 BD Manual
- 更新 DWD 全景文档
- 更新 README 任务清单
- 需求覆盖:需求 6 验收标准 1-4

View File

@@ -0,0 +1,256 @@
# 需求文档:团购详情接口整合 ETL 数据流
## 简介
将飞球 SaaS 的团购详情接口(`QueryPackageCouponInfo`)整合到现有 ETL 的 API → ODS → DWD 三层数据流中。当前 ETL 仅拉取团购列表(`QueryPackageCouponList`),缺少每个团购套餐的详情数据(助教服务关联、可用台区列表、关联门店等)。本次改造在现有团购列表拉取任务完成后,串行遍历所有 `couponId` 调用详情接口,将详情数据落入 ODS 层并向下传导至 DWD 层,丰富团购维度表的分析能力。采用"串行请求 + 异步处理 + 单线程写库"架构在控制请求频率5-20 秒随机间隔)的同时最大化数据处理吞吐。
## 术语表
- **ETL_System**:飞球 Connector ETL 系统(`apps/etl/connectors/feiqiu/`),负责从飞球 SaaS API 拉取数据并加载到 PostgreSQL 的 ODS → DWD → DWS 各层
- **API_Client**ETL 系统中的 HTTP 客户端模块(`api/client.py`),封装 POST 请求、重试、分页逻辑
- **Rate_Limiter**:请求间隔控制器,在串行请求模式下控制相邻两次 API 请求之间的随机等待时间5-20 秒),防止触发上游风控
- **ODS_Group_Package_Task**:现有 ODS 层团购套餐拉取任务(任务代码 `ODS_GROUP_PACKAGE`),调用 `QueryPackageCouponList` 获取团购列表并写入 `ods.group_buy_packages`
- **Detail_Fetcher**:团购详情拉取子流程,在列表拉取完成后遍历所有 `couponId` 调用 `QueryPackageCouponInfo` 获取详情
- **ODS_Detail_Table**ODS 层团购详情表(`ods.group_buy_package_details`),存储详情接口返回的原始数据
- **DWD_Groupbuy_Package**DWD 层团购套餐维度主表(`dwd.dim_groupbuy_package`
- **DWD_Groupbuy_Package_Ex**DWD 层团购套餐维度扩展表(`dwd.dim_groupbuy_package_ex`
- **SCD2**:缓慢变化维度 Type 2通过版本号和生效/失效时间追踪维度历史变化
- **couponId**:团购套餐的唯一标识 ID对应 `ods.group_buy_packages.id` 字段
## 需求
### 需求 1DetailFetcher 串行请求与异步处理
> RateLimiter 和 CancellationToken 已在 etl-unified-pipeline 中实现并上线,本 spec 直接复用。
**用户故事:** 作为 ETL 运维人员,我希望团购详情拉取采用"串行请求 + 异步处理 + 单线程写库"的架构,复用现有限流和取消机制,在控制请求频率的同时最大化数据处理吞吐。
#### 验收标准
1. THE Detail_Fetcher SHALL 采用严格串行请求模式:等待上一个 API 请求的响应完整返回后,再等待 5 至 20 秒之间的随机间隔(均匀分布),然后发送下一个请求
2. THE Detail_Fetcher SHALL 在每个请求的响应返回后,立即将响应数据提交到异步处理队列,不阻塞下一次请求的等待计时
3. THE Detail_Fetcher SHALL 支持多个工作线程并行消费处理队列中的响应数据字段提取、数据清洗、content_hash 计算等)
4. THE Detail_Fetcher SHALL 使用单独的写入线程汇总所有处理完成的结果,统一执行数据库写入操作,避免并发写入冲突
5. THE Detail_Fetcher SHALL 复用现有 RateLimiter`api/rate_limiter.py`)控制请求间隔,通过构造参数配置最小/最大间隔
6. WHEN 所有请求发送完毕后THE Detail_Fetcher SHALL 等待处理队列和写入线程全部完成后再返回最终结果
7. THE Detail_Fetcher SHALL 通过 CancellationToken`utils/cancellation.py`)支持外部取消信号,收到信号后立即停止发送新请求、等待当前已提交到处理队列的数据处理完成并写入数据库、然后返回部分完成的统计结果
8. WHEN 取消信号到达时THE Detail_Fetcher SHALL 在当前请求的等待间隔期或响应等待期中断,不再发起后续请求,已进入处理队列的数据不丢弃、正常完成处理和写入
### 需求 2团购详情 API 拉取
**用户故事:** 作为数据分析师,我希望 ETL 系统能拉取每个团购套餐的详情数据(助教服务关联、可用台区、关联门店等),以便进行团购套餐的深度分析。
#### 验收标准
1. WHEN ODS_Group_Package_Task 完成团购列表拉取后THE Detail_Fetcher SHALL 遍历所有已拉取的 `couponId`(包含 `is_enabled=0``is_delete=1` 的记录)调用 `QueryPackageCouponInfo` 接口
2. THE Detail_Fetcher SHALL 向 `https://pc.ficoo.vip/apiprod/admin/v1/PackageCoupon/QueryPackageCouponInfo` 发送 POST 请求,请求体为 `{"couponId": <id>}`
3. THE Detail_Fetcher SHALL 严格串行发送请求,等待上一个请求响应返回后,经过 5-20 秒随机间隔再发送下一个请求
4. WHEN 详情接口返回成功响应时THE Detail_Fetcher SHALL 将响应数据提交到异步处理队列,由工作线程提取 `data` 层级下的 `groupPurchasePackage``tableAreaNameList``packageCouponAssistants``grouponSiteInfos``packagePackageService``packageCouponDetailsList` 等字段
5. IF 详情接口对某个 `couponId` 返回错误或超时THEN THE Detail_Fetcher SHALL 记录错误日志(含 `couponId` 和错误信息)并继续处理下一个 `couponId`
6. WHEN 所有 `couponId` 遍历完成后THE Detail_Fetcher SHALL 等待异步处理和写入线程全部完成,然后返回拉取统计信息(成功数、失败数、跳过数)
### 需求 3ODS 层团购详情数据存储
**用户故事:** 作为数据工程师,我希望团购详情数据以结构化方式存储在 ODS 层,以便下游 DWD 层消费。
#### 验收标准
1. THE ETL_System SHALL 将团购详情数据存储到 ODS 层,具体方案(独立新表 `ods.group_buy_package_details` 还是在现有 `ods.group_buy_packages` 中扩展字段)在技术设计阶段通过实际数据调研后确定(见附录 B待调研项
2. THE ODS_Detail_Table SHALL 包含以下结构化字段:`coupon_id`BIGINT, PK`package_name`TEXT`duration`INT`start_time`TIMESTAMPTZ`end_time`TIMESTAMPTZ`add_start_clock`TEXT`add_end_clock`TEXT`is_enabled`INT`is_delete`INT`site_id`BIGINT`tenant_id`BIGINT`create_time`TIMESTAMPTZ`creator_name`TEXT
3. THE ODS_Detail_Table SHALL 将数组类型字段以 JSONB 格式存储:`table_area_ids`(台区 ID 列表)、`table_area_names`(台区名称列表)、`assistant_services`(助教服务关联数组,含 `skillId``assistantLevel``assistantDuration`)、`groupon_site_infos`(关联门店数组)、`package_services`(套餐服务数组)、`coupon_details_list`(券明细数组)
4. THE ODS_Detail_Table SHALL 包含 ETL 元数据字段:`content_hash`TEXT`payload`JSONB完整原始响应`fetched_at`TIMESTAMPTZ
5. THE ETL_System SHALL 采用全量快照模式(`SnapshotMode.FULL_TABLE`)写入 ODS_Detail_Table每次运行覆盖全部记录
6. WHEN 详情接口返回的字段中存在未标注字段且所有记录的值均相同时THE ETL_System SHALL 忽略该字段不写入 ODS 表
### 需求 4DWD 层团购维度表扩展
**用户故事:** 作为数据分析师,我希望 DWD 层的团购维度表能包含详情数据中的关键字段,以便在结算分析和财务审计中使用团购套餐的完整信息。
#### 验收标准
1. THE ETL_System SHALL 在 DWD 层扩展团购维度数据,具体方案(在现有 `dwd.dim_groupbuy_package_ex` 扩展表中新增字段,还是新建独立详情维度表)在技术设计阶段通过实际数据调研后确定(见附录 B待调研项
2. THE ETL_System SHALL 在 DWD 加载任务中建立 ODS 详情数据到 DWD 团购维度表的字段映射,通过 `coupon_id` = `groupbuy_package_id` 关联
3. WHEN ODS 详情表中存在 `assistant_services` 数据时THE DWD_Groupbuy_Package_Ex SHALL 保留完整的助教服务关联信息(`skillId``assistantLevel``assistantDuration``assistantDuration` 单位为秒
4. THE ETL_System SHALL 通过 SCD2 机制追踪团购详情字段的历史变化,与现有 DWD_Groupbuy_Package_Ex 的 SCD2 版本保持同步
5. WHEN `ods.group_buy_package_details` 中某个 `coupon_id``ods.group_buy_packages` 中不存在时THE ETL_System SHALL 记录警告日志并跳过该记录的 DWD 加载
### 需求 5任务编排与依赖管理
**用户故事:** 作为 ETL 运维人员,我希望团购详情拉取任务能正确嵌入现有调度流程,不影响其他任务的执行顺序。
#### 验收标准
1. THE Detail_Fetcher SHALL 作为 ODS_Group_Package_Task 的内部子流程执行,在列表数据写入 ODS 完成后串行启动,不注册为独立的调度任务
2. WHEN ODS_Group_Package_Task 执行时THE ETL_System SHALL 先完成 `QueryPackageCouponList` 的全量拉取和 ODS 写入,再启动 Detail_Fetcher 遍历详情
3. THE ODS_Group_Package_Task SHALL 在执行结果中包含详情拉取的统计信息(详情成功数、详情失败数),与列表拉取统计合并返回
4. IF Detail_Fetcher 执行过程中发生不可恢复的错误如网络完全不可达THEN THE ODS_Group_Package_Task SHALL 将任务状态标记为部分成功,并在结果中记录已完成的列表拉取数据和详情拉取的中断点
### 需求 6文档同步更新
**用户故事:** 作为开发者,我希望所有相关文档在功能交付时同步更新,以保持文档与代码的一致性。
#### 验收标准
1. WHEN 团购详情 ETL 功能开发完成后THE ETL_System 的文档 SHALL 更新以下内容ODS 层 DDL 文档(新增 `ods.group_buy_package_details` 表定义、ODS 字段映射文档(新增 `QueryPackageCouponInfo` 映射、数据库手册BD Manual 中新增详情表说明)
2. WHEN DWD 层扩展字段添加完成后THE ETL_System 的文档 SHALL 更新DWD 全景文档(`docs/reports/dwd-panorama/dwd-dimension-panorama.md` 中 dim_groupbuy_package_ex 章节、DWD 表结构概览文档
3. THE ETL_System 的 README 文档 SHALL 更新任务清单,反映 ODS_GROUP_PACKAGE 任务新增的详情拉取子流程
4. WHEN 全局限流机制实现后THE ETL_System 的架构文档 SHALL 新增限流机制的说明,包含配置参数和使用方式
---
## 附录 AAPI 请求与响应示例
### 请求
```
POST https://pc.ficoo.vip/apiprod/admin/v1/PackageCoupon/QueryPackageCouponInfo
Content-Type: application/json
Authorization: Bearer <token>
{"couponId": 3030873437310021}
```
### 响应
```json
{
"data": {
"groupPurchasePackage": {
"count": 1,
"tableAreaId": [2791960001957765],
"tableAreaNameList": ["A区"],
"id": 3030873437310021,
"add_end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"area_tag_type": 1,
"card_type_ids": "0",
"coupon_money": 0.00,
"create_time": "2025-12-31 12:23:56",
"creator_name": "店长:郑丽珊",
"date_info": "",
"date_type": 1,
"duration": 7200,
"end_clock": "1.00:00:00",
"end_time": "2027-01-01 00:00:00",
"group_type": 1,
"is_delete": 0,
"is_enabled": 1,
"is_first_limit": 1,
"max_selectable_categories": 0,
"package_id": 1173128804,
"package_name": "助理教练竞技教学两小时",
"selling_price": 0.00,
"site_id": 2790685415443269,
"sort": 100,
"start_clock": "00:00:00",
"start_time": "2025-07-21 00:00:00",
"system_group_type": 1,
"table_area_id": "0",
"table_area_id_list": "",
"table_area_name": "",
"tenant_id": 2790683160709957,
"tenant_table_area_id": "0",
"tenant_table_area_id_list": "",
"type": 1,
"usable_count": 0,
"usable_range": ""
},
"couponCode": "",
"grouponSiteInfos": [
{
"siteId": 2790685415443269,
"siteName": "朗朗桌球"
}
],
"packageCouponAssistants": [
{
"couponAssistantId": 3030873437310023,
"skillId": 0,
"assistantLevel": "",
"assistantDuration": 7200
}
],
"packagePackageService": [],
"packageCouponDetailsList": []
},
"code": 0
}
```
### 字段标注
| 字段路径 | 含义 | 已标注 |
|----------|------|--------|
| `groupPurchasePackage.id` | 团购 ID= couponId | ✅ |
| `groupPurchasePackage.package_name` | 团购名称 | ✅ |
| `groupPurchasePackage.duration` | 台费计时时长(秒) | ✅ |
| `groupPurchasePackage.start_time` / `end_time` | 可用日期范围 | ✅ |
| `groupPurchasePackage.add_start_clock` / `add_end_clock` | 可用时段范围 | ✅ |
| `groupPurchasePackage.is_enabled` | 是否启用 | ✅ |
| `groupPurchasePackage.is_delete` | 是否已删除 | ✅ |
| `groupPurchasePackage.site_id` | 店铺 ID | ✅ |
| `groupPurchasePackage.tenant_id` | 租户 ID | ✅ |
| `groupPurchasePackage.create_time` | 创建时间 | ✅ |
| `groupPurchasePackage.creator_name` | 创建人 | ✅ |
| `groupPurchasePackage.tableAreaId` / `tableAreaNameList` | 可用台桌区域 | ✅ |
| `packageCouponAssistants[].skillId` | 可用课程 ID | ✅ |
| `packageCouponAssistants[].assistantLevel` | 可用助教等级 | ✅ |
| `packageCouponAssistants[].assistantDuration` | 助教时长(秒) | ✅ |
| `grouponSiteInfos[].siteId` / `siteName` | 关联门店 | ✅ |
| `groupPurchasePackage.count` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.area_tag_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.card_type_ids` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.coupon_money` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.date_info` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.date_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.end_clock` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.group_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.is_first_limit` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.max_selectable_categories` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.package_id` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.selling_price` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.sort` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.start_clock` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.system_group_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.table_area_id` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.table_area_id_list` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.table_area_name` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.tenant_table_area_id` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.tenant_table_area_id_list` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.usable_count` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.usable_range` | 未标注 — 待调研 | ❌ |
| `couponCode` | 未标注 — 待调研 | ❌ |
| `packageCouponAssistants[].couponAssistantId` | 未标注 — 待调研 | ❌ |
| `packagePackageService` | 示例为空 — 待调研实际数据 | ❌ |
| `packageCouponDetailsList` | 示例为空 — 待调研实际数据 | ❌ |
---
## 附录 B待调研项技术设计阶段完成
### 调研 1ODS 层表方案
- 选项 A新建独立表 `ods.group_buy_package_details`
- 选项 B在现有 `ods.group_buy_packages` 中扩展字段
- 决策依据:详情数据与列表数据的字段重叠度、数据量差异、写入模式兼容性
- 需要调研现有 `ods.group_buy_packages` 的表结构和写入逻辑
### 调研 2DWD 层表方案
- 选项 A在现有 `dwd.dim_groupbuy_package_ex` 扩展表中新增 JSONB 字段
- 选项 B新建独立的团购详情维度表
- 决策依据现有扩展表的字段密度、SCD2 版本同步复杂度、下游查询模式
- 需要调研现有 DWD 团购相关表结构(参考 `docs/reports/dwd-panorama/`
### 调研 3未标注字段用途
- 获取全部团购的详情数据后,对附录 A 中标记为 ❌ 的字段进行值分布分析
- 所有记录值完全相同的字段 → 忽略,不写入 ODS
- 值有变化的字段 → 推测用途,决定是否纳入 ODS/DWD 字段映射
### 调研 4空数组字段实际数据
- `packagePackageService`:获取全部团购后确认是否有非空数据
- `packageCouponDetailsList`:同上
- 如有数据,需补充 ODS 字段定义和 DWD 映射

View File

@@ -0,0 +1,110 @@
# 实施计划:团购详情接口整合 ETL 数据流
## 概述
将飞球团购详情接口(`QueryPackageCouponInfo`)整合到现有 ETL 的 API → ODS → DWD 三层数据流。利用 `BaseOdsTask` 已有的 `detail_endpoint` 二级详情拉取模式(通过 `UnifiedPipeline` + `RateLimiter` + `CancellationToken`),在 `ODS_GROUP_PACKAGE` 任务的 `OdsTaskSpec` 中配置详情拉取参数,将详情数据写入新建的 `ods.group_buy_package_details` 表,并向下传导至 `dwd.dim_groupbuy_package_ex`
## 任务
- [x] 1. 新建 ODS 详情表 DDL
- [x] 1.1 创建 `db/etl_feiqiu/ods/group_buy_package_details.sql`
- 按设计文档 §4.1 定义表结构:`coupon_id` BIGINT PK、结构化字段、JSONB 数组字段、ETL 元数据字段
- 添加表和列的 COMMENT
- _需求覆盖需求 3 验收标准 1-4_
- [x] 1.2 在测试库 `test_etl_feiqiu` 执行 DDL 验证表创建成功
- _需求覆盖需求 3 验收标准 1_
- [x] 2. 扩展 ODS_GROUP_PACKAGE 任务 — 配置详情拉取
- [x] 2.1 在 `tasks/ods/ods_tasks.py``ODS_GROUP_PACKAGE` OdsTaskSpec 中添加 detail_endpoint 配置
- 设置 `detail_endpoint="/PackageCoupon/QueryPackageCouponInfo"`
- 设置 `detail_param_builder=lambda rec: {"couponId": rec["id"]}`(将列表表的 `id` 映射为详情接口的 `couponId` 参数)
- 设置 `detail_target_table="ods.group_buy_package_details"`
- 设置 `detail_data_path=("data",)`
- 设置 `detail_id_column="id"`(从 `ods.group_buy_packages` 提取 couponId 列表)
- 复用 `BaseOdsTask.execute()` 已有的详情拉取流程(`UnifiedPipeline` + `RateLimiter` + `CancellationToken`
- _需求覆盖需求 1 验收标准 1-8需求 2 验收标准 1-6需求 5 验收标准 1-4_
- [x] 2.2 实现详情响应的 `_build_detail_process_fn` 字段提取逻辑
-`data.groupPurchasePackage` 提取结构化字段(`package_name``duration``start_time``end_time``add_start_clock``add_end_clock``is_enabled``is_delete``site_id``tenant_id``create_time``creator_name`
-`data.groupPurchasePackage.tableAreaId` / `tableAreaNameList` 提取台区数组为 JSONB
-`data.packageCouponAssistants` 提取助教服务关联数组为 JSONB
-`data.grouponSiteInfos` 提取关联门店数组为 JSONB
-`data.packagePackageService``data.packageCouponDetailsList` 提取为 JSONB
- 计算 `content_hash`,保留完整原始响应为 `payload`
- _需求覆盖需求 2 验收标准 4需求 3 验收标准 2-4_
- [x] 2.3 实现详情数据的 `_build_detail_write_fn` 写入逻辑
- 采用全量快照模式(`SnapshotMode.FULL_TABLE`)写入 `ods.group_buy_package_details`
- UPSERT on `coupon_id`,每次运行覆盖全部记录
- _需求覆盖需求 3 验收标准 5_
- [x] 2.4 编写 ODS 详情拉取的单元测试
- 测试 `detail_param_builder` 参数构造
- 测试字段提取逻辑(正常响应、空数组、缺失字段)
- 测试 `content_hash` 计算一致性
- _需求覆盖需求 2 验收标准 4-5_
- [x] 3. 检查点 — ODS 层验证
- 确保所有测试通过ask the user if questions arise。
-`--dry-run --tasks ODS_GROUP_PACKAGE` 验证任务注册和配置正确
- [x] 4. DWD 扩展表 ALTER + 加载逻辑
- [x] 4.1 创建迁移脚本 `db/etl_feiqiu/migrations/2026-03-05__add_detail_fields_to_dim_groupbuy_package_ex.sql`
- ALTER TABLE 添加 4 个 JSONB 列:`table_area_ids``table_area_names``assistant_services``groupon_site_infos`
- 添加列 COMMENT
- 在测试库 `test_etl_feiqiu` 执行迁移验证
- _需求覆盖需求 4 验收标准 1_
- [x] 4.2 修改 `tasks/dwd/dwd_load_task.py``TABLE_MAPPING``COLUMN_OVERRIDES`
-`COLUMN_OVERRIDES["dwd.dim_groupbuy_package_ex"]` 中新增 4 个详情字段的映射
- _需求覆盖需求 4 验收标准 2_
- [x] 4.3 修改 `_fetch_source_rows``_merge_dim_scd2` 流程,在加载 `dim_groupbuy_package_ex` 时 LEFT JOIN `ods.group_buy_package_details`
- 通过 `ods.group_buy_packages.id = ods.group_buy_package_details.coupon_id` 关联
- 将详情表的 `table_area_ids``table_area_names``assistant_services``groupon_site_infos` 合并到 ODS 快照行
- 当详情表中 `coupon_id` 在列表表中不存在时,记录警告日志并跳过
- 新增字段自动纳入 SCD2 变更检测(现有 `_is_row_changed` 逻辑已支持 JSONB 比较)
- _需求覆盖需求 4 验收标准 2-5_
- [x] 4.4 编写 DWD 加载扩展的单元测试
- 测试 LEFT JOIN 逻辑:详情存在 / 详情缺失 / 详情多余(应跳过并警告)
- 测试 SCD2 变更检测包含新增 JSONB 字段
- _需求覆盖需求 4 验收标准 3-5_
- [x] 5. 检查点 — DWD 层验证
- 确保所有测试通过ask the user if questions arise。
-`--dry-run --tasks DWD_LOAD_FROM_ODS` 验证 DWD 加载配置正确
- [x] 6. 数据调研 — 获取全部团购详情并分析未标注字段
- [x] 6.1 编写一次性调研脚本 `apps/etl/connectors/feiqiu/scripts/research_coupon_details.py`
- 使用 `load_dotenv` 加载根 `.env`,通过 `AppConfig.load()` 获取配置
- 连接测试库 `test_etl_feiqiu``TEST_DB_DSN`
-`ods.group_buy_packages` 读取所有 `coupon_id`
- 串行调用详情接口(复用 `RateLimiter(5, 20)`),将原始响应存入 `ods.group_buy_package_details.payload`
- _需求覆盖附录 B 调研 3、4_
- [x] 6.2 分析未标注字段的值分布
- 对附录 A 中标记为 ❌ 的字段,统计所有记录的值分布
- 所有记录值完全相同的字段 → 标记为忽略
- 值有变化的字段 → 推测用途,输出分析报告到 `docs/reports/`
- 确认 `packagePackageService``packageCouponDetailsList` 是否有非空数据
- 根据分析结果调整 ODS 表字段定义和 DWD 映射(如需要)
- _需求覆盖需求 3 验收标准 6附录 B 调研 3、4_
- [x] 7. 文档同步更新
- [x] 7.1 更新 ODS 层文档
- 更新 ODS DDL 文档(新增 `ods.group_buy_package_details` 表定义)
- 更新 ODS 字段映射文档(新增 `QueryPackageCouponInfo``ods.group_buy_package_details` 映射)
- 更新 BD Manual`docs/database/BD_Manual_*.md`)新增详情表说明
- _需求覆盖需求 6 验收标准 1_
- [x] 7.2 更新 DWD 层文档
- 更新 DWD 全景文档(`docs/reports/dwd-panorama/dwd-dimension-panorama.md``dim_groupbuy_package_ex` 章节)
- 更新 DWD 表结构概览文档,反映新增的 4 个 JSONB 字段
- _需求覆盖需求 6 验收标准 2_
- [x] 7.3 更新 ETL README 和架构文档
- 更新 README 任务清单,反映 `ODS_GROUP_PACKAGE` 任务新增的详情拉取子流程
- _需求覆盖需求 6 验收标准 3_
- [x] 8. 最终检查点 — 全量验证
- 37 个单元测试全部通过ODS 16 + DWD 12 + column ref 9
## 备注
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
- `RateLimiter``api/rate_limiter.py`)和 `CancellationToken``utils/cancellation.py`)已在 etl-unified-pipeline 中实现,本 spec 直接复用,不重复创建
- `BaseOdsTask.execute()` 已内置 `detail_endpoint` 二级详情拉取模式(通过 `UnifiedPipeline`Task 2 利用此现有机制而非新建独立的 `DetailFetcher`
- 每个任务引用具体需求条款以确保可追溯性
- 检查点确保增量验证,避免问题累积