# 需求文档：团购详情接口整合 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` 字段

## 需求

### 需求 1：DetailFetcher 串行请求与异步处理

> 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 等待异步处理和写入线程全部完成，然后返回拉取统计信息（成功数、失败数、跳过数）

### 需求 3：ODS 层团购详情数据存储

**用户故事：** 作为数据工程师，我希望团购详情数据以结构化方式存储在 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 表

### 需求 4：DWD 层团购维度表扩展

**用户故事：** 作为数据分析师，我希望 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 新增限流机制的说明，包含配置参数和使用方式

---

## 附录 A：API 请求与响应示例

### 请求

```
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：待调研项（技术设计阶段完成）

### 调研 1：ODS 层表方案

- 选项 A：新建独立表 `ods.group_buy_package_details`
- 选项 B：在现有 `ods.group_buy_packages` 中扩展字段
- 决策依据：详情数据与列表数据的字段重叠度、数据量差异、写入模式兼容性
- 需要调研现有 `ods.group_buy_packages` 的表结构和写入逻辑

### 调研 2：DWD 层表方案

- 选项 A：在现有 `dwd.dim_groupbuy_package_ex` 扩展表中新增 JSONB 字段
- 选项 B：新建独立的团购详情维度表
- 决策依据：现有扩展表的字段密度、SCD2 版本同步复杂度、下游查询模式
- 需要调研现有 DWD 团购相关表结构（参考 `docs/reports/dwd-panorama/`）

### 调研 3：未标注字段用途

- 获取全部团购的详情数据后，对附录 A 中标记为 ❌ 的字段进行值分布分析
- 所有记录值完全相同的字段 → 忽略，不写入 ODS
- 值有变化的字段 → 推测用途，决定是否纳入 ODS/DWD 字段映射

### 调研 4：空数组字段实际数据

- `packagePackageService`：获取全部团购后确认是否有非空数据
- `packageCouponDetailsList`：同上
- 如有数据，需补充 ODS 字段定义和 DWD 映射
