# ODS 层任务详解 > 本文档说明飞球 ETL 系统中 ODS(操作数据存储)层的所有任务。 > ODS 层负责从上游 SaaS API 抽取原始业务数据并落地到 PostgreSQL(`billiards_ods` schema),保留源 payload 便于回溯。 --- ## 概述 ODS 层采用**声明式配置**驱动的通用任务模式:由 `BaseOdsTask` + `OdsTaskSpec` 配置驱动,通过 `ODS_TASK_CLASSES` 字典动态注册,共 23 个任务。 所有 ODS 任务写入 `billiards_ods.*` 表,原始 API 响应以 JSON 格式存入 `payload` 列,元数据列(`fetched_at`、`source_file`、`content_hash` 等)自动填充。 > **历史说明**:早期版本曾有 14 个独立 ODS 任务(ORDERS、PAYMENTS、MEMBERS 等),写入不存在的 `billiards.*` schema。 > 这些任务已于 2026-02-14 废弃删除,全部由下述通用 ODS 任务替代。 ### 任务总览 | 任务代码 | 动态类名 | API 端点 | 目标 ODS 表 | 说明 | |----------|----------|----------|-------------|------| | `ODS_ASSISTANT_ACCOUNT` | `OdsAssistantAccountsTask` | `/PersonnelManagement/SearchAssistantInfo` | `assistant_accounts_master` | 助教账号档案 | | `ODS_SETTLEMENT_RECORDS` | `OdsOrderSettleTask` | `/Site/GetAllOrderSettleList` | `settlement_records` | 结账记录 | | `ODS_TABLE_USE` | `OdsTableUseTask` | `/Site/GetSiteTableOrderDetails` | `table_fee_transactions` | 台费计费流水 | | `ODS_ASSISTANT_LEDGER` | `OdsAssistantLedgerTask` | `/AssistantPerformance/GetOrderAssistantDetails` | `assistant_service_records` | 助教服务流水 | | `ODS_ASSISTANT_ABOLISH` | `OdsAssistantAbolishTask` | `/AssistantPerformance/GetAbolitionAssistant` | `assistant_cancellation_records` | 助教废除记录 | | `ODS_STORE_GOODS_SALES` | `OdsGoodsLedgerTask` | `/TenantGoods/GetGoodsSalesList` | `store_goods_sales_records` | 门店商品销售流水 | | `ODS_PAYMENT` | `OdsPaymentTask` | `/PayLog/GetPayLogListPage` | `payment_transactions` | 支付流水 | | `ODS_REFUND` | `OdsRefundTask` | `/Order/GetRefundPayLogList` | `refund_transactions` | 退款流水 | | `ODS_PLATFORM_COUPON` | `OdsCouponVerifyTask` | `/Promotion/GetOfflineCouponConsumePageList` | `platform_coupon_redemption_records` | 平台/团购券核销 | | `ODS_MEMBER` | `OdsMemberTask` | `/MemberProfile/GetTenantMemberList` | `member_profiles` | 会员档案 | | `ODS_MEMBER_CARD` | `OdsMemberCardTask` | `/MemberProfile/GetTenantMemberCardList` | `member_stored_value_cards` | 会员储值卡 | | `ODS_MEMBER_BALANCE` | `OdsMemberBalanceTask` | `/MemberProfile/GetMemberCardBalanceChange` | `member_balance_changes` | 会员余额变动 | | `ODS_RECHARGE_SETTLE` | `OdsRechargeSettleTask` | `/Site/GetRechargeSettleList` | `recharge_settlements` | 充值结算 | | `ODS_GROUP_PACKAGE` | `OdsPackageTask` | `/PackageCoupon/QueryPackageCouponList` | `group_buy_packages` | 团购套餐定义 | | `ODS_GROUP_BUY_REDEMPTION` | `OdsGroupBuyRedemptionTask` | `/Site/GetSiteTableUseDetails` | `group_buy_redemption_records` | 团购套餐核销 | | `ODS_INVENTORY_STOCK` | `OdsInventoryStockTask` | `/TenantGoods/GetGoodsStockReport` | `goods_stock_summary` | 库存汇总 | | `ODS_INVENTORY_CHANGE` | `OdsInventoryChangeTask` | `/GoodsStockManage/QueryGoodsOutboundReceipt` | `goods_stock_movements` | 库存变化记录 | | `ODS_TABLES` | `OdsTablesTask` | `/Table/GetSiteTables` | `site_tables_master` | 台桌维表 | | `ODS_GOODS_CATEGORY` | `OdsGoodsCategoryTask` | `/TenantGoodsCategory/QueryPrimarySecondaryCategory` | `stock_goods_category_tree` | 库存商品分类树 | | `ODS_STORE_GOODS` | `OdsStoreGoodsTask` | `/TenantGoods/GetGoodsInventoryList` | `store_goods_master` | 门店商品档案 | | `ODS_TABLE_FEE_DISCOUNT` | `OdsTableDiscountTask` | `/Site/GetTaiFeeAdjustList` | `table_fee_discount_records` | 台费折扣/调账 | | `ODS_TENANT_GOODS` | `OdsTenantGoodsTask` | `/TenantGoods/QueryTenantGoods` | `tenant_goods_master` | 租户商品档案 | | `ODS_SETTLEMENT_TICKET` | `OdsSettlementTicketTask` | `/Order/GetOrderSettleTicketNew` | `settlement_ticket_details` | 结账小票详情 | > 所有目标表均位于 `billiards_ods` schema 下。 --- ## 通用 ODS 任务架构(BaseOdsTask + OdsTaskSpec 模式) 通用 ODS 任务采用**声明式配置**驱动:开发者只需定义一个 `OdsTaskSpec` 数据类实例,由 `BaseOdsTask` 提供统一的 `execute()` 流程,再通过 `_build_task_class()` 工厂函数动态生成 Python 类,最终在 `ODS_TASK_CLASSES` 字典中注册。 核心优势: - **零代码新增任务**:只需添加一条 `OdsTaskSpec` 配置即可接入新的 API 端点 - **Schema-aware 写入**:运行时从 `information_schema` 读取目标表结构,自动匹配列名和类型,无需手写字段映射 - **统一去重与冲突处理**:通过 `content_hash` 和 `ON CONFLICT` 策略保证幂等性 ### OdsTaskSpec 配置结构 `OdsTaskSpec` 是一个不可变数据类(`@dataclass(frozen=True)`),定义了单个 ODS 任务的全部配置。 #### 核心字段 | 字段 | 类型 | 说明 | |------|------|------| | `code` | `str` | 任务代码,如 `ODS_PAYMENT`,用于注册和调度 | | `class_name` | `str` | 动态生成的 Python 类名,如 `OdsPaymentTask` | | `table_name` | `str` | 目标 ODS 表全限定名,如 `billiards_ods.payment_transactions` | | `endpoint` | `str` | 上游 API 端点路径,如 `/PayLog/GetPayLogListPage` | | `description` | `str` | 任务描述(中文),用于日志和文档 | #### 分页与数据提取字段 | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `data_path` | `Tuple[str, ...]` | `("data",)` | API 响应中数据的 JSON 路径,逐层深入 | | `list_key` | `str \| None` | `None` | 数据列表在 `data_path` 下的键名(如 `"settleList"`),为 `None` 时直接取 `data_path` 下的列表 | #### 主键与列定义字段 | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `pk_columns` | `Tuple[ColumnSpec, ...]` | `()` | 业务主键列定义,用于冲突检测(通常为 `id`) | | `extra_columns` | `Tuple[ColumnSpec, ...]` | `()` | 额外列定义,用于从嵌套 JSON 中提取特定字段 | | `conflict_columns_override` | `Tuple[str, ...] \| None` | `None` | 覆盖默认冲突列(默认使用表的 PRIMARY KEY) | #### 时间窗口字段 | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `requires_window` | `bool` | `True` | 是否需要时间窗口参数(事实表为 `True`,维度快照表为 `False`) | | `time_fields` | `Tuple[str, str] \| None` | `("startTime", "endTime")` | API 请求中时间窗口参数的键名对 | | `include_site_id` | `bool` | `True` | 是否在请求中传 `siteId` 参数 | | `extra_params` | `Dict[str, Any]` | `{}` | 额外的固定请求参数 | #### 快照与软删除字段 | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `snapshot_full_table` | `bool` | `False` | 全表快照模式:API 返回全量数据,不在返回集中的记录标记为已删除 | | `snapshot_window_columns` | `Tuple[str, ...] \| None` | `None` | 窗口快照模式:指定用于限定软删除范围的时间列 | > **快照模式说明**:当 `snapshot_full_table=True` 或 `snapshot_window_columns` 非空时,任务会在每个分段结束后调用 `_mark_missing_as_deleted()`,将 API 未返回但数据库中存在的记录的 `is_delete` 标记为 `1`。此行为还需配合运行时配置 `run.snapshot_missing_delete=True` 才会生效。 #### 元数据控制字段 | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `include_source_file` | `bool` | `True` | 是否写入 `source_file` 列 | | `include_source_endpoint` | `bool` | `True` | 是否写入 `source_endpoint` 列 | | `include_fetched_at` | `bool` | `True` | 是否写入 `fetched_at` 列 | | `include_record_index` | `bool` | `False` | 是否写入 `record_index` 列 | | `include_site_column` | `bool` | `True` | 是否写入 `site_id` / `store_id` 列 | ### ColumnSpec 列映射定义 `ColumnSpec` 是不可变数据类,定义单个列的映射规则: | 字段 | 类型 | 说明 | |------|------|------| | `column` | `str` | 目标数据库列名 | | `sources` | `Tuple[str, ...]` | 源 JSON 字段名列表,按优先级回退(支持点号路径) | | `required` | `bool` | 是否为必填字段(缺失时跳过整条记录) | | `default` | `Any` | 默认值(源字段全部为空时使用) | | `transform` | `Callable \| None` | 类型转换函数 | 代码中提供了三个快捷构造函数: | 函数 | 用途 | transform | |------|------|-----------| | `_int_col(name, *sources)` | 整数列 | `TypeParser.parse_int` | | `_decimal_col(name, *sources)` | 金额列(保留 2 位小数) | `TypeParser.parse_decimal(v, 2)` | | `_bool_col(name, *sources)` | 布尔列 | 自定义 `_to_bool` | --- ### BaseOdsTask 通用 execute 流程 所有通用 ODS 任务共享 `BaseOdsTask.execute()` 方法,流程如下: ``` execute(cursor_data) │ ├── 1. 解析时间窗口(_resolve_window) │ ├── 优先级:用户手动覆盖 > 游标 + MAX(fetched_at) 兜底 > 默认窗口 │ └── 若游标推进但表未实际入库,回退到 MAX(fetched_at) 作为起点 │ ├── 2. 窗口分段(build_window_segments) │ └── 按闲忙时段或配置拆分为多个子窗口 │ ├── 3. 准备运行参数 │ ├── 读取 store_id、page_size │ ├── 解析快照模式配置 │ ├── 获取表主键列(_get_table_pk_columns) │ └── 检查表是否有 is_delete 列 │ ├── 4. 逐段执行(for seg_start, seg_end in segments) │ ├── 4a. 构建 API 请求参数(_build_params) │ ├── 4b. 分页抓取(api.iter_paginated)→ _insert_records_schema_aware 写入 │ ├── 4c. 软删除标记(若快照模式启用) │ └── 4d. 提交事务(db.commit) │ ├── 5. 汇总结果 │ └── 返回 {status, counts, window, segments, request_params} │ └── 异常处理 └── db.rollback + 记录错误日志 + 重新抛出 ``` #### Schema-aware 写入(`_insert_records_schema_aware`) 核心写入方法,运行时动态适配表结构: 1. **读取表结构**:从 `information_schema.columns` 获取目标表的所有列名、数据类型 2. **读取主键**:从 `information_schema.table_constraints` 获取 PRIMARY KEY 列 3. **记录合并**:`_merge_record_layers()` 将嵌套 JSON 展平为单层字典 4. **is_delete 标准化**:统一为 `0/1` 5. **content_hash 计算**:对记录内容计算 SHA-256 哈希 6. **content_hash 去重**:与数据库中同一业务主键的最新 `content_hash` 比对,相同则跳过 7. **值映射**:逐列匹配,特殊列(`payload`、`source_file`、`fetched_at`、`content_hash`)自动填充 8. **冲突处理**:根据 `run.ods_conflict_mode` 配置: | 模式 | SQL 行为 | 说明 | |------|----------|------| | `update` | `ON CONFLICT ... DO UPDATE SET ... WHERE IS DISTINCT FROM` | 全字段对比,仅在有变化时更新 | | `backfill` | `ON CONFLICT ... DO UPDATE SET COALESCE(existing, new) WHERE ... IS NULL` | 仅回填 NULL 列 | | `nothing` | `ON CONFLICT ... DO NOTHING` | 跳过已存在记录 | 9. **批量写入**:使用 `psycopg2.extras.execute_values` 分块写入,通过 `RETURNING (xmax = 0)` 区分插入和更新 #### 软删除标记(`_mark_missing_as_deleted`) 当快照模式启用时,任务在每个分段结束后执行软删除: - **全表快照**(`snapshot_full_table=True`):将数据库中所有 `is_delete != 1` 且不在本次 API 返回集中的记录标记为 `is_delete=1` - **窗口快照**(`snapshot_window_columns` 非空):仅在指定时间列的窗口范围内执行软删除 --- ### content_hash 去重机制 `content_hash` 是通用 ODS 任务的核心去重手段: 1. **计算**:排除元数据字段后,对剩余字段按 key 排序后 JSON 序列化,计算 SHA-256 哈希 2. **比对**:从数据库中按业务主键取最新一条记录的 `content_hash` 3. **跳过**:若新记录的 `content_hash` 与数据库中最新记录相同,则跳过写入 > 仅在目标表包含 `content_hash` 列且有 `fetched_at` 列时生效。 --- ### 各任务详细配置 | 任务代码 | 需要窗口 | 快照模式 | 特殊说明 | |----------|----------|----------|----------| | `ODS_ASSISTANT_ACCOUNT` | 是 | 全表快照 | 助教账号档案,全量抓取后标记离职/删除 | | `ODS_SETTLEMENT_RECORDS` | 是 | — | 结账记录,按时间窗口增量抓取 | | `ODS_TABLE_USE` | 否 | 窗口(`create_time`) | 台费计费流水 | | `ODS_ASSISTANT_LEDGER` | 是 | 窗口(`create_time`) | 助教服务流水 | | `ODS_ASSISTANT_ABOLISH` | 是 | — | 助教废除记录 | | `ODS_STORE_GOODS_SALES` | 否 | 窗口(`create_time`) | 门店商品销售流水 | | `ODS_PAYMENT` | 否 | — | 支付流水 | | `ODS_REFUND` | 否 | 窗口(`pay_time`) | 退款流水 | | `ODS_PLATFORM_COUPON` | 否 | 窗口(`consume_time`) | 平台/团购券核销 | | `ODS_MEMBER` | 否 | — | 会员档案 | | `ODS_MEMBER_CARD` | 否 | 全表快照 | 会员储值卡 | | `ODS_MEMBER_BALANCE` | 否 | 窗口(`create_time`) | 会员余额变动 | | `ODS_RECHARGE_SETTLE` | 是 | — | 充值结算 | | `ODS_GROUP_PACKAGE` | 否 | 全表快照 | 团购套餐定义 | | `ODS_GROUP_BUY_REDEMPTION` | 否 | 窗口(`create_time`) | 团购套餐核销 | | `ODS_INVENTORY_STOCK` | 否 | — | 库存汇总 | | `ODS_INVENTORY_CHANGE` | 是 | — | 库存变化记录 | | `ODS_TABLES` | 否 | — | 台桌维表 | | `ODS_GOODS_CATEGORY` | 否 | — | 库存商品分类树 | | `ODS_STORE_GOODS` | 否 | 全表快照 | 门店商品档案 | | `ODS_TABLE_FEE_DISCOUNT` | 否 | 窗口(`create_time`) | 台费折扣/调账 | | `ODS_TENANT_GOODS` | 否 | 全表快照 | 租户商品档案 | | `ODS_SETTLEMENT_TICKET` | 否 | — | 结账小票详情(专用实现,见下文) | > **特殊任务**:`ODS_SETTLEMENT_TICKET` 虽然在 `ODS_TASK_SPECS` 中声明,但其 `ODS_TASK_CLASSES` 条目被 `OdsSettlementTicketTask` 专用实现覆盖。该任务不走标准分页抓取流程,而是先从 `payment_transactions` 表或支付 API 收集 `orderSettleId`,再逐个调用小票接口获取详情。