在准备环境前提交次全部更改。

This commit is contained in:
Neo
2026-02-19 08:35:13 +08:00
parent ded6dfb9d8
commit 4eac07da47
1387 changed files with 6107191 additions and 33002 deletions

View File

@@ -0,0 +1,356 @@
# 飞球 ETL 任务说明文档
> 本文档是飞球 ETL 系统etl-billiards任务说明的总览入口。
> 系统从上游 SaaS API 抽取台球门店运营数据,经 ODS → DWD → DWS 三层处理后,
> 输出助教业绩、财务日报、会员分析、工资计算及自定义指数等业务报表。
## 目录
- [数据流向](#数据流向)
- [文档索引](#文档索引)
- [任务清单](#任务清单)
- [ODS 层(操作数据存储)](#ods-层操作数据存储)
- [DWD 层(明细数据)](#dwd-层明细数据)
- [DWS 层(数据服务)](#dws-层数据服务)
- [INDEX 层(指数算法)](#index-层指数算法)
- [工具类 / 校验类](#工具类--校验类)
- [Flow执行流程类型](#flow执行流程类型)
- [处理模式](#处理模式)
- [数据源模式](#数据源模式)
- [CLI 参数速查表](#cli-参数速查表)
- [常见命令示例](#常见命令示例)
---
## 数据流向
```mermaid
graph LR
API["上游 SaaS API"] -->|抓取| ODS["ODS<br/>操作数据存储层"]
JSON["本地 JSON 文件"] -->|手动入库| ODS
ODS -->|清洗 / SCD2 / 增量| DWD["DWD<br/>明细数据层"]
DWD -->|聚合 / 计算| DWS["DWS<br/>数据服务层"]
DWD -->|指数算法| INDEX["INDEX<br/>指数算法层"]
DWS --> REPORT["业务报表<br/>助教业绩 · 财务日报<br/>会员分析 · 工资计算"]
INDEX --> REPORT
```
**层级说明:**
| 层 | Schema | 职责 |
|---|---|---|
| ODS | `ods` | 保留 API 原始 payload便于回溯 |
| DWD | `dwd` | 清洗后的维度表dim_*SCD2和事实表fact_* / dwd_*,增量) |
| DWS | `dws` | 按业务维度聚合的汇总统计表 |
| INDEX | `dws` | 基于 DWD/DWS 数据计算的自定义业务指数 |
---
## 文档索引
| 文档 | 说明 |
|------|------|
| [BaseTask 公共机制](base_task_mechanism.md) | 任务基类模板方法、TaskContext、时间窗口、注册表、Flow 执行 |
| [ODS 层任务](ods_tasks.md) | 23 个通用 ODS 任务的架构、配置结构、API 端点、目标表 |
| [DWD 层任务](dwd_tasks.md) | DWD_LOAD_FROM_ODS 核心装载、SCD2 处理、质量校验 |
| [DWS 层任务](dws_tasks.md) | 助教业绩、会员分析、财务统计、运维任务共 13 个 DWS 任务 |
| [INDEX 层任务](index_tasks.md) | WBI/NCI/RS 指数算法 + ML 手动台账导入 |
| [工具类任务](utility_tasks.md) | Schema 初始化、手动入库、归档、截止检查、完整性校验 |
---
## 任务清单
### ODS 层(操作数据存储)
#### 通用 ODS 任务OdsTaskSpec 动态注册)
| 任务代码 | Python 类 | 目标表 | 简要说明 | 详情 |
|----------|-----------|--------|----------|------|
| `ODS_ASSISTANT_ACCOUNT` | `OdsAssistantAccountsTask` | `ods.assistant_accounts_master` | 助教账号档案 | [查看](ods_tasks.md) |
| `ODS_ASSISTANT_LEDGER` | `OdsAssistantLedgerTask` | `ods.assistant_service_records` | 助教服务流水 | [查看](ods_tasks.md) |
| `ODS_ASSISTANT_ABOLISH` | `OdsAssistantAbolishTask` | `ods.assistant_cancellation_records` | 助教废除记录 | [查看](ods_tasks.md) |
| `ODS_INVENTORY_CHANGE` | `OdsInventoryChangeTask` | `ods.goods_stock_movements` | 库存变化记录 | [查看](ods_tasks.md) |
| `ODS_INVENTORY_STOCK` | `OdsInventoryStockTask` | `ods.goods_stock_summary` | 库存汇总 | [查看](ods_tasks.md) |
| `ODS_GROUP_PACKAGE` | `OdsPackageTask` | `ods.group_buy_packages` | 团购套餐定义 | [查看](ods_tasks.md) |
| `ODS_GROUP_BUY_REDEMPTION` | `OdsGroupBuyRedemptionTask` | `ods.group_buy_redemption_records` | 团购套餐核销 | [查看](ods_tasks.md) |
| `ODS_MEMBER` | `OdsMemberTask` | `ods.member_profiles` | 会员档案 | [查看](ods_tasks.md) |
| `ODS_MEMBER_BALANCE` | `OdsMemberBalanceTask` | `ods.member_balance_changes` | 会员余额变动 | [查看](ods_tasks.md) |
| `ODS_MEMBER_CARD` | `OdsMemberCardTask` | `ods.member_stored_value_cards` | 会员储值卡 | [查看](ods_tasks.md) |
| `ODS_PAYMENT` | `OdsPaymentTask` | `ods.payment_transactions` | 支付流水 | [查看](ods_tasks.md) |
| `ODS_REFUND` | `OdsRefundTask` | `ods.refund_transactions` | 退款流水 | [查看](ods_tasks.md) |
| `ODS_PLATFORM_COUPON` | `OdsCouponVerifyTask` | `ods.platform_coupon_redemption_records` | 平台/团购券核销 | [查看](ods_tasks.md) |
| `ODS_RECHARGE_SETTLE` | `OdsRechargeSettleTask` | `ods.recharge_settlements` | 充值结算 | [查看](ods_tasks.md) |
| `ODS_TABLE_USE` | `OdsTableUseTask` | `ods.table_fee_transactions` | 台费计费流水 | [查看](ods_tasks.md) |
| `ODS_TABLES` | `OdsTablesTask` | `ods.site_tables_master` | 台桌维表 | [查看](ods_tasks.md) |
| `ODS_GOODS_CATEGORY` | `OdsGoodsCategoryTask` | `ods.stock_goods_category_tree` | 库存商品分类 | [查看](ods_tasks.md) |
| `ODS_STORE_GOODS` | `OdsStoreGoodsTask` | `ods.store_goods_master` | 门店商品档案 | [查看](ods_tasks.md) |
| `ODS_TABLE_FEE_DISCOUNT` | `OdsTableDiscountTask` | `ods.table_fee_discount_records` | 台费折扣/调账 | [查看](ods_tasks.md) |
| `ODS_STORE_GOODS_SALES` | `OdsGoodsLedgerTask` | `ods.store_goods_sales_records` | 门店商品销售流水 | [查看](ods_tasks.md) |
| `ODS_TENANT_GOODS` | `OdsTenantGoodsTask` | `ods.tenant_goods_master` | 租户商品档案 | [查看](ods_tasks.md) |
| `ODS_SETTLEMENT_TICKET` | `OdsSettlementTicketTask` | `ods.settlement_ticket_details` | 结账小票详情 | [查看](ods_tasks.md) |
| `ODS_SETTLEMENT_RECORDS` | `OdsOrderSettleTask` | `ods.settlement_records` | 结账记录 | [查看](ods_tasks.md) |
### DWD 层(明细数据)
| 任务代码 | Python 类 | 简要说明 | 详情 |
|----------|-----------|----------|------|
| `DWD_LOAD_FROM_ODS` | `DwdLoadTask` | 核心装载:遍历 TABLE_MAP维度走 SCD2事实走增量 | [查看](dwd_tasks.md) |
| `DWD_QUALITY_CHECK` | `DwdQualityTask` | ODS 与 DWD 行数/金额核对,输出 JSON 报表 | [查看](dwd_tasks.md) |
### DWS 层(数据服务)
#### 助教业绩域
| 任务代码 | Python 类 | 目标表 | 粒度 | 详情 |
|----------|-----------|--------|------|------|
| `DWS_ASSISTANT_DAILY` | `AssistantDailyTask` | `dws_assistant_daily_detail` | 日期+助教 | [查看](dws_tasks.md) |
| `DWS_ASSISTANT_MONTHLY` | `AssistantMonthlyTask` | `dws_assistant_monthly_summary` | 月份+助教 | [查看](dws_tasks.md) |
| `DWS_ASSISTANT_CUSTOMER` | `AssistantCustomerTask` | `dws_assistant_customer_stats` | 日期+助教+会员 | [查看](dws_tasks.md) |
| `DWS_ASSISTANT_SALARY` | `AssistantSalaryTask` | `dws_assistant_salary_calc` | 月份+助教 | [查看](dws_tasks.md) |
| `DWS_ASSISTANT_FINANCE` | `AssistantFinanceTask` | `dws_assistant_finance_analysis` | 日期+助教 | [查看](dws_tasks.md) |
#### 会员分析域
| 任务代码 | Python 类 | 目标表 | 粒度 | 详情 |
|----------|-----------|--------|------|------|
| `DWS_MEMBER_CONSUMPTION` | `MemberConsumptionTask` | `dws_member_consumption_summary` | 日期+会员 | [查看](dws_tasks.md) |
| `DWS_MEMBER_VISIT` | `MemberVisitTask` | `dws_member_visit_detail` | 日期+会员+结账单 | [查看](dws_tasks.md) |
#### 财务统计域
| 任务代码 | Python 类 | 目标表 | 粒度 | 详情 |
|----------|-----------|--------|------|------|
| `DWS_FINANCE_DAILY` | `FinanceDailyTask` | `dws_finance_daily_summary` | 日期 | [查看](dws_tasks.md) |
| `DWS_FINANCE_RECHARGE` | `FinanceRechargeTask` | `dws_finance_recharge_summary` | 日期 | [查看](dws_tasks.md) |
| `DWS_FINANCE_INCOME_STRUCTURE` | `FinanceIncomeStructureTask` | `dws_finance_income_structure` | 日期+收入类型 | [查看](dws_tasks.md) |
| `DWS_FINANCE_DISCOUNT_DETAIL` | `FinanceDiscountDetailTask` | `dws_finance_discount_detail` | 日期+折扣类型 | [查看](dws_tasks.md) |
#### 运维任务
| 任务代码 | Python 类 | 简要说明 | 详情 |
|----------|-----------|----------|------|
| `DWS_BUILD_ORDER_SUMMARY` | `DwsBuildOrderSummaryTask` | 构建订单汇总中间表 | [查看](dws_tasks.md) |
| `DWS_MAINTENANCE` | `DwsMaintenanceTask` | 统一维护:物化视图刷新 + 历史数据清理 | [查看](dws_tasks.md) |
### INDEX 层(指数算法)
| 任务代码 | Python 类 | 目标表 | 指数类型 | 详情 |
|----------|-----------|--------|----------|------|
| `DWS_WINBACK_INDEX` | `WinbackIndexTask` | `dws_member_winback_index` | WBI回流指数 | [查看](index_tasks.md) |
| `DWS_NEWCONV_INDEX` | `NewconvIndexTask` | `dws_member_newconv_index` | NCI新客转化指数 | [查看](index_tasks.md) |
| `DWS_RELATION_INDEX` | `RelationIndexTask` | `dws_relation_index` | RS关系指数 | [查看](index_tasks.md) |
| `DWS_ML_MANUAL_IMPORT` | `MlManualImportTask` | `dws_ml_manual_ledger` | ML手动台账导入 | [查看](index_tasks.md) |
### 工具类 / 校验类
| 任务代码 | Python 类 | 类型 | 简要说明 | 详情 |
|----------|-----------|------|----------|------|
| `INIT_ODS_SCHEMA` | `InitOdsSchemaTask` | utility | 执行 ODS + meta DDL创建必要目录 | [查看](utility_tasks.md) |
| `INIT_DWD_SCHEMA` | `InitDwdSchemaTask` | utility | 执行 DWD DDL | [查看](utility_tasks.md) |
| `INIT_DWS_SCHEMA` | `InitDwsSchemaTask` | utility | 执行 DWS DDL | [查看](utility_tasks.md) |
| `MANUAL_INGEST` | `ManualIngestTask` | utility | 从本地 JSON 文件手动入库到 ODS | [查看](utility_tasks.md) |
| `ODS_JSON_ARCHIVE` | `OdsJsonArchiveTask` | utility | 归档 ODS JSON 文件 | [查看](utility_tasks.md) |
| `CHECK_CUTOFF` | `CheckCutoffTask` | utility | 检查数据截止时间 | [查看](utility_tasks.md) |
| `SEED_DWS_CONFIG` | `SeedDwsConfigTask` | utility | 初始化 DWS 配置种子数据 | [查看](utility_tasks.md) |
| `DATA_INTEGRITY_CHECK` | `DataIntegrityTask` | verification | 数据完整性校验 | [查看](utility_tasks.md) |
---
## Flow执行流程类型
Flow执行流程定义了多层任务的执行顺序。通过 `--flow` 参数指定 Flow ID`--pipeline` 作为已弃用别名保留),或通过 `--layers` 参数自由组合层级,系统自动解析对应层并按拓扑排序后的顺序执行该层的所有已注册任务。
> 术语说明:**Connector**(数据源连接器)指对接的上游 SaaS 平台(如飞球),对应 `apps/etl/connectors/{connector_name}/`**Flow**(执行流程)指 ETL 任务的处理链路,描述数据从哪一层流到哪一层。
| Flow 类型 | 包含层 | 说明 |
|----------|--------|------|
| `api_ods` | ODS | 仅从 API 抓取数据到 ODS |
| `api_ods_dwd` | ODS → DWD | 抓取数据并清洗装载到 DWD |
| `api_full` | ODS → DWD → DWS → INDEX | 全流程:抓取 → 清洗 → 汇总 → 指数 |
| `ods_dwd` | DWD | 仅执行 ODS → DWD 清洗装载(不抓取) |
| `dwd_dws` | DWS | 仅执行 DWD → DWS 汇总计算 |
| `dwd_dws_index` | DWS → INDEX | 汇总计算 + 指数算法 |
| `dwd_index` | INDEX | 仅执行指数算法 |
> Flow 定义位于 `orchestration/flow_runner.py` 的 `FlowRunner.FLOW_LAYERS`。
> `--layers` 参数接受 ODS、DWD、DWS、INDEX 的任意逗号分隔组合(如 `--layers ODS,DWD`),与 `--flow`/`--pipeline` 互斥。
---
## 处理模式
通过 `--processing-mode` 参数指定,控制 Flow 的执行行为。
| 模式 | 说明 | 适用场景 |
|------|------|----------|
| `increment_only` | 仅增量处理(默认) | 日常定时调度,只处理新增/变更数据 |
| `verify_only` | 仅校验并修复,跳过增量 ETL | 数据质量巡检、手动修复不一致 |
| `increment_verify` | 先增量处理,再校验并修复 | 需要确保数据一致性的关键批次 |
**补充参数:**
- `--fetch-before-verify`:仅在 `verify_only` 模式下有效,校验前先从 API 获取最新数据
- `--verify-tables`:指定仅校验的表名(逗号分隔),用于单表验证
---
## 数据源模式
通过 `--data-source` 参数指定,控制 ODS 层的数据来源。
| 模式 | 说明 | 适用场景 |
|------|------|----------|
| `online` | 仅在线抓取(从 API 获取数据) | 正常运行,网络可用 |
| `offline` | 仅本地入库(从 JSON 文件读取) | 离线环境、JSON 回放 |
| `hybrid` | 抓取 + 入库(默认) | 同时从 API 抓取并处理本地文件 |
> 旧参数 `--pipeline-flow``FULL` / `FETCH_ONLY` / `INGEST_ONLY`)已弃用,请使用 `--data-source`。
---
## CLI 参数速查表
入口命令:`python -m cli.main`
### 基本参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--store-id` | int | — | 门店 ID |
| `--tasks` | str | — | 任务列表,逗号分隔(传统模式) |
| `--dry-run` | flag | `false` | 试运行,不提交数据库事务 |
### Flow 与模式参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--flow` | choice | — | Flow 类型(见[Flow执行流程类型](#flow执行流程类型) |
| `--pipeline` | choice | — | [已弃用] `--flow` 的别名,使用时输出 DeprecationWarning |
| `--layers` | str | — | ETL 层自由组合逗号分隔ODS,DWD,DWS,INDEX`--flow`/`--pipeline` 互斥 |
| `--processing-mode` | choice | `increment_only` | 处理模式(见[处理模式](#处理模式) |
| `--data-source` | choice | `hybrid` | 数据源模式(见[数据源模式](#数据源模式) |
| `--fetch-before-verify` | flag | `false` | 校验前先从 API 获取数据(仅 `verify_only` |
| `--verify-tables` | str | — | 仅校验指定表(逗号分隔) |
### 时间窗口参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--window-start` | datetime | — | 固定时间窗口开始(优先级高于游标) |
| `--window-end` | datetime | — | 固定时间窗口结束 |
| `--force-window-override` | flag | `false` | 强制使用 window_start/window_end不走 MAX(fetched_at) 兜底 |
| `--window-split` | choice | `none` | 时间窗口切分:`none` / `day` / `week` / `month` |
| `--window-split-unit` | str | 配置值 | 窗口切分单位(`day`/`week`/`month`/`none` |
| `--window-split-days` | int | 配置值 | 按天切分的天数(`1`/`10`/`30` |
| `--window-compensation-hours` | int | 配置值 | 窗口前后补偿小时数 |
| `--lookback-hours` | int | `24` | 回溯小时数 |
| `--overlap-seconds` | int | `3600` | 冗余秒数(默认 1 小时) |
### 数据库参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--pg-dsn` | str | — | PostgreSQL DSN 连接串 |
| `--pg-host` | str | — | PostgreSQL 主机 |
| `--pg-port` | int | — | PostgreSQL 端口 |
| `--pg-name` | str | — | PostgreSQL 数据库名 |
| `--pg-user` | str | — | PostgreSQL 用户名 |
| `--pg-password` | str | — | PostgreSQL 密码 |
### API 参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--api-base` | str | — | API 基础 URL |
| `--api-token` | str | — | API 令牌Bearer Token |
| `--api-timeout` | int | — | API 超时(秒) |
| `--api-page-size` | int | — | 分页大小 |
| `--api-retry-max` | int | — | API 重试最大次数 |
### 目录与运行参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--export-root` | str | — | 导出根目录 |
| `--log-root` | str | — | 日志根目录 |
| `--fetch-root` | str | — | 抓取 JSON 输出根目录 |
| `--ingest-source` | str | — | 本地清洗入库源目录 |
| `--write-pretty-json` | flag | `false` | 抓取 JSON 美化输出 |
| `--idle-start` | str | — | 闲时窗口开始HH:MM |
| `--idle-end` | str | — | 闲时窗口结束HH:MM |
| `--allow-empty-advance` | flag | `false` | 允许空结果推进窗口 |
### 已弃用参数
| 参数 | 替代方案 | 说明 |
|------|----------|------|
| `--pipeline` | `--flow` | 功能相同,使用 `--pipeline` 时输出 DeprecationWarning |
| `--pipeline-flow` | `--data-source` | `FULL``hybrid``FETCH_ONLY``online``INGEST_ONLY``offline` |
---
## 常见命令示例
```bash
# 全流程 ETLAPI 抓取 → ODS → DWD → DWS → INDEX
python -m cli.main --flow api_full --pg-dsn "$PG_DSN" --store-id 1 --api-token "$TOKEN"
# 仅抓取 ODS 数据
python -m cli.main --flow api_ods --store-id 1
# ODS → DWD 清洗装载(不抓取 API
python -m cli.main --flow ods_dwd
# 仅执行 DWS 汇总
python -m cli.main --flow dwd_dws
# 仅执行指数算法
python -m cli.main --flow dwd_index
# 使用 --layers 自由组合
python -m cli.main --layers ODS,DWD --store-id 1
# 仅执行 DWS + INDEX 层
python -m cli.main --layers DWS,INDEX
# 指定时间窗口
python -m cli.main --flow api_ods --window-start "2026-02-01" --window-end "2026-02-02"
# 按天切分时间窗口
python -m cli.main --flow api_ods --window-start "2026-01-01" --window-end "2026-02-01" --window-split day
# 传统模式:指定任务列表
python -m cli.main --tasks ODS_PAYMENT,ODS_MEMBER,ODS_SETTLEMENT_RECORDS --store-id 1
# 校验并修复(跳过增量)
python -m cli.main --flow api_full --processing-mode verify_only
# 校验前先从 API 获取数据
python -m cli.main --flow api_full --processing-mode verify_only --fetch-before-verify
# 增量 + 校验
python -m cli.main --flow api_full --processing-mode increment_verify
# 仅校验指定表
python -m cli.main --flow api_full --processing-mode verify_only --verify-tables "dim_member,fact_payment"
# 试运行(不提交)
python -m cli.main --dry-run --tasks DWD_LOAD_FROM_ODS
# Schema 初始化
python -m cli.main --tasks INIT_ODS_SCHEMA,INIT_DWD_SCHEMA,INIT_DWS_SCHEMA
# 手动入库(离线模式)
python -m cli.main --tasks MANUAL_INGEST --data-source offline --ingest-source ./data/json
# DWS 配置种子数据初始化
python -m cli.main --tasks SEED_DWS_CONFIG
# 数据完整性校验
python -m cli.main --tasks DATA_INTEGRITY_CHECK
```
---
> 最后更新日期2026-02-18

View File

@@ -0,0 +1,492 @@
# BaseTask 公共机制与执行参数
> 本文档说明飞球 ETL 系统中所有任务共享的基类机制、运行期上下文、时间窗口计算、任务注册以及 Flow 执行流程。
> 面向开发者,便于在开发新任务时遵循统一模式。
---
## 1. BaseTask 模板方法流程
`BaseTask`(位于 `tasks/base_task.py`)采用经典的**模板方法模式**,定义了 ETL 任务的标准执行骨架。所有具体任务ODS / DWD / DWS / INDEX均继承此基类。
### 1.1 核心方法签名
```python
class BaseTask:
def __init__(self, config, db_connection, api_client, logger): ...
def get_task_code(self) -> str: ... # 子类必须实现
def extract(self, context: TaskContext): ... # 子类必须实现
def transform(self, extracted, context: TaskContext): ... # 默认直接返回 extracted
def load(self, transformed, context: TaskContext) -> dict: ... # 子类必须实现
def execute(self, cursor_data: dict | None = None) -> dict: ... # 主入口
```
### 1.2 execute() 执行流程
`execute()` 是任务的统一入口由调度器TaskExecutor调用。完整流程如下
```
execute(cursor_data)
├─ 1. _build_context(cursor_data) → 构建 TaskContext含时间窗口计算
├─ 2. build_window_segments(...) → 按配置切分时间窗口为多段
│ 若无切分配置 → 退化为单段 [(window_start, window_end)]
├─ 3. 遍历每个窗口段 (window_start, window_end)
│ │
│ ├─ _build_context_for_window(...) → 为当前段构建独立 TaskContext
│ │
│ ├─ extract(context) → 从数据源提取数据
│ │
│ ├─ transform(extracted, context) → 清洗/转换数据
│ │
│ ├─ load(transformed, context) → 写入目标表,返回统计 counts
│ │
│ ├─ db.commit() → 提交事务
│ │
│ └─ _accumulate_counts(...) → 累加各段统计
└─ 4. 构建并返回结果字典
{status, counts, window: {start, end, minutes}, segments: [...]}
```
**关键行为:**
- 每个窗口段独立执行 E/T/L 三步,段内失败会 `db.rollback()` 并抛出异常
- 多段执行时,日志会输出进度信息(已处理天数 / 总天数)
- `transform()` 默认实现为直接返回 `extracted`,子类可选择性覆盖
- 统计累加逻辑数值类型int/float求和其他类型取首次出现的值
---
## 2. TaskContext 字段含义
`TaskContext` 是一个不可变数据类(`frozen=True`),在 E/T/L 各阶段间传递运行期信息。
```python
@dataclass(frozen=True)
class TaskContext:
store_id: int # 门店 ID
window_start: datetime # 时间窗口起始(含时区)
window_end: datetime # 时间窗口结束(含时区)
window_minutes: int # 窗口时长(分钟)
cursor: dict | None = None # 游标数据(来自上次运行记录)
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `store_id` | `int` | 当前门店标识,从 `config.get("app.store_id")` 读取 |
| `window_start` | `datetime` | 本次抽取的时间窗口起点,带时区信息 |
| `window_end` | `datetime` | 本次抽取的时间窗口终点,带时区信息 |
| `window_minutes` | `int` | `window_end - window_start` 的分钟数(最小为 1 |
| `cursor` | `dict \| None` | 调度器传入的游标字典,通常包含 `last_end`(上次窗口终点)等字段;首次运行时为 `None` |
---
## 3. 时间窗口计算逻辑
时间窗口由 `_get_time_window(cursor_data)` 方法计算,决定了本次任务抽取的数据范围。优先级从高到低:
### 3.1 优先级一手动覆盖Manual Override
当配置中同时存在 `run.window_override.start``run.window_override.end` 时,直接使用用户指定的窗口。
- 对应 CLI 参数:`--window-start` / `--window-end`
- 支持字符串自动解析(通过 `dateutil.parser.parse`
- 无时区信息时自动附加 `app.timezone`(默认 `Asia/Shanghai`
- 校验:两者必须同时提供,且 `end > start`
```
window_start = run.window_override.start
window_end = run.window_override.end
window_minutes = (end - start) 的分钟数
```
### 3.2 优先级二游标续跑Cursor Resume
当存在游标数据且包含 `last_end` 字段时,从上次结束位置回退一段重叠时间开始:
```
window_start = cursor["last_end"] - overlap_seconds
window_end = now
```
- `overlap_seconds` 来自 `config.get("run.overlap_seconds", 600)`,默认 600 秒10 分钟)
- 重叠设计目的:防止因时钟偏差或事务延迟导致数据遗漏
### 3.3 优先级三闲忙时段默认值Idle/Busy Window
当既无手动覆盖也无游标时,根据当前时间是否处于闲时窗口,选择不同的默认窗口长度:
```
闲时idlewindow_minutes = run.window_minutes.default_idle (默认 180 分钟)
忙时busywindow_minutes = run.window_minutes.default_busy (默认 30 分钟)
window_start = now - window_minutes
window_end = now
```
闲时判断逻辑:比较当前时间的 `HH:MM` 字符串是否在 `[idle_start, idle_end]` 区间内。
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `run.idle_window.start` | `"04:00"` | 闲时开始 |
| `run.idle_window.end` | `"16:00"` | 闲时结束 |
| `run.window_minutes.default_idle` | `180` | 闲时默认窗口(分钟) |
| `run.window_minutes.default_busy` | `30` | 忙时默认窗口(分钟) |
| `run.overlap_seconds` | `600` | 游标重叠秒数 |
### 3.4 流程图
```
_get_time_window(cursor_data)
├─ run.window_override.start/end 都存在?
│ ├─ 是 → 解析并返回 (override_start, override_end)
│ └─ 否 ↓
├─ 判断闲忙时段 → 确定 window_minutes
├─ cursor_data 存在且含 last_end
│ ├─ 是 → window_start = last_end - overlap_seconds
│ └─ 否 → window_start = now - window_minutes
└─ window_end = now → 返回 (start, end, minutes)
```
---
## 4. 窗口分段build_window_segments
当时间窗口跨度较大时(如回溯一个月的数据),系统支持将窗口切分为多个小段逐段执行,避免单次请求数据量过大。
### 4.1 切分入口
`build_window_segments()` 位于 `utils/windowing.py`,由 `BaseTask.execute()` 调用:
```python
def build_window_segments(
cfg, # 配置对象
start: datetime, # 窗口起点
end: datetime, # 窗口终点
*,
tz: ZoneInfo | None, # 时区
override_only: bool, # 是否仅在手动覆盖时才切分
) -> List[Tuple[datetime, datetime]]:
```
### 4.2 切分策略
**`override_only=True`(默认行为):**
- 仅当 `run.window_override.start/end` 都存在时,才按配置切分
- 否则强制 `split_unit="none"`,返回单段(即不切分)
- 设计意图:自动游标模式下窗口通常较短,无需切分;手动指定大范围时才需要
**切分配置项:**
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `run.window_split.unit` | `"month"` | 切分单位:`none` / `day` / `week` / `month` |
| `run.window_split.days` | `1` | 按天切分时的步长1 / 10 / 30 |
| `run.window_split.compensation_hours` | `0` | 窗口前后补偿小时数 |
### 4.3 底层切分函数 split_window()
```python
def split_window(start, end, *, tz, split_unit, compensation_hours, split_days=None):
```
**补偿机制:**`compensation_hours > 0`,会在切分前将窗口向两端各扩展指定小时数,用于覆盖跨段边界的数据。
**各切分模式:**
| split_unit | 行为 |
|------------|------|
| `none` / `off` / `false` / `""` | 不切分,返回 `[(start, end)]` |
| `day` / `daily` | 按 `split_days` 天为步长切分(默认 1 天一段) |
| `week` / `weekly` | 按 7 天为步长切分 |
| `month` / `monthly` | 按自然月边界切分(每段从当前位置到下月 1 日 00:00 |
**示例:** 窗口 `2026-01-15 ~ 2026-03-10``split_unit=month`
```
段 1: 2026-01-15 ~ 2026-02-01
段 2: 2026-02-01 ~ 2026-03-01
段 3: 2026-03-01 ~ 2026-03-10
```
---
## 5. TaskRegistry 注册方式与 TaskMeta 元数据
### 5.1 TaskMeta 数据结构
```python
@dataclass
class TaskMeta:
task_class: type # 任务类BaseTask 的子类)
requires_db_config: bool = True # 是否需要数据库配置(游标/运行记录)
layer: str | None = None # 所属层:"ODS" / "DWD" / "DWS" / "INDEX" / None
task_type: str = "etl" # 任务类型:"etl" / "utility" / "verification"
depends_on: list[str] = field(default_factory=list) # 依赖的任务代码列表
```
| 字段 | 说明 |
|------|------|
| `task_class` | 任务的 Python 类引用,用于 `create_task()` 时实例化 |
| `requires_db_config` | `True` 表示需要游标管理和运行记录;`False` 表示工具类/校验类任务,不走游标 |
| `layer` | 标识任务所属数据层,用于 `get_tasks_by_layer()` 按层查询 |
| `task_type` | 区分 ETL 任务、工具类任务和校验类任务 |
| `depends_on` | 声明任务间的依赖关系,`_resolve_tasks()` 会据此执行拓扑排序 |
### 5.2 注册方式
`TaskRegistry` 提供 `register()` 方法,所有任务在 `orchestration/task_registry.py` 模块加载时完成注册:
```python
class TaskRegistry:
def register(self, task_code, task_class, requires_db_config=True, layer=None, task_type="etl", depends_on=None):
self._tasks[task_code.upper()] = TaskMeta(...)
```
**注册示例:**
```python
# ODS 层 ETL 任务(默认 requires_db_config=True, task_type="etl"
# 通用 ODS 任务由 ODS_TASK_CLASSES 字典动态注册
for code, task_cls in ODS_TASK_CLASSES.items():
default_registry.register(code, task_cls, layer="ODS")
# 工具类任务(不需要游标)
default_registry.register("INIT_ODS_SCHEMA", InitOdsSchemaTask, requires_db_config=False, task_type="utility")
# 校验类任务
default_registry.register("DATA_INTEGRITY_CHECK", DataIntegrityTask, requires_db_config=False, task_type="verification")
# 带依赖声明的 DWS 任务
default_registry.register("DWS_ASSISTANT_FINANCE", AssistantFinanceTask, layer="DWS",
depends_on=["DWS_ASSISTANT_SALARY"])
```
### 5.3 TaskRegistry 核心方法
| 方法 | 说明 |
|------|------|
| `register(task_code, task_class, ...)` | 注册任务类及元数据 |
| `create_task(task_code, config, db, api, logger)` | 根据任务代码创建实例 |
| `get_metadata(task_code) -> TaskMeta` | 查询任务元数据 |
| `get_tasks_by_layer(layer) -> list[str]` | 获取指定层的所有任务代码 |
| `is_utility_task(task_code) -> bool` | 判断是否为工具类任务(`requires_db_config=False` |
| `get_all_task_codes() -> list[str]` | 获取所有已注册任务代码 |
### 5.4 当前注册任务统计
| 层 | 数量 | 说明 |
|----|------|------|
| ODS | 23 | 通用 ODS 任务(由 `ODS_TASK_CLASSES` 动态生成),全部默认 `skip_unchanged=True` |
| DWD | 2 | 含核心装载任务 `DWD_LOAD_FROM_ODS` 和质量检查 |
| DWS | 13 | 助教业绩、会员分析、财务统计、统一维护任务(原 3 个 MV 刷新/清理任务已合并为 DWS_MAINTENANCE |
| INDEX | 4 | 回流指数、新客转化指数、关系指数、手动台账导入 |
| 工具类 | 7 | Schema 初始化、手动入库、归档、校验等 |
| 校验类 | 1 | 数据完整性校验 |
> 历史说明:早期版本曾有 14 个独立 ODS 任务,已于 2026-02-14 废弃删除,全部由通用 ODS 任务替代。
---
## 6. FlowRunner Flow 执行流程
`FlowRunner`(位于 `orchestration/flow_runner.py`)负责编排多层 ETL 任务的执行顺序,并可选地运行后置校验。
> 术语说明:**Connector**(数据源连接器)指对接的上游 SaaS 平台(如飞球),对应 `apps/etl/connectors/{connector_name}/`**Flow**(执行流程)指 ETL 任务的处理链路描述数据从哪一层流到哪一层。CLI 参数 `--flow` 传递 Flow ID`--pipeline` 作为已弃用别名保留。
### 6.1 Flow 定义
系统预定义了 7 种 Flow每种 Flow 包含一组数据层:
```python
FLOW_LAYERS = {
"api_ods": ["ODS"],
"api_ods_dwd": ["ODS", "DWD"],
"api_full": ["ODS", "DWD", "DWS", "INDEX"],
"ods_dwd": ["DWD"],
"dwd_dws": ["DWS"],
"dwd_dws_index": ["DWS", "INDEX"],
"dwd_index": ["INDEX"],
}
```
| Flow | 包含层 | 典型场景 |
|------|--------|----------|
| `api_ods` | ODS | 仅从 API 抓取数据到 ODS |
| `api_ods_dwd` | ODS → DWD | 抓取并清洗到 DWD |
| `api_full` | ODS → DWD → DWS → INDEX | 全流程 ETL |
| `ods_dwd` | DWD | 仅执行 ODS→DWD 清洗(假设 ODS 已有数据) |
| `dwd_dws` | DWS | 仅执行 DWD→DWS 汇总 |
| `dwd_dws_index` | DWS → INDEX | 汇总 + 指数计算 |
| `dwd_index` | INDEX | 仅执行指数计算 |
### 6.2 处理模式
| 模式 | 说明 |
|------|------|
| `increment_only` | 仅执行增量 ETL不做校验默认 |
| `verify_only` | 跳过增量 ETL仅执行后置校验并自动修复 |
| `increment_verify` | 先执行增量 ETL再执行后置校验并修复 |
### 6.3 run() 执行流程
```
FlowRunner.run(flow, processing_mode, ...)
├─ 校验 Flow 名称合法性
├─ 设置默认时间窗口(未指定时:过去 24 小时)
├─ 根据 processing_mode 分支:
│ ┌─ verify_only ─────────────────────────────────┐
│ │ ├─ fetch_before_verify
│ │ │ ├─ 是 → 先执行 ODS 任务获取 API 数据 │
│ │ │ └─ 否 → 跳过 │
│ │ └─ _run_verification(...) │
│ └────────────────────────────────────────────────┘
│ ┌─ increment_only / increment_verify ───────────┐
│ │ ├─ _resolve_tasks(layers) → 解析任务列表 │
│ │ │ └─ topological_sort() → 拓扑排序 │
│ │ ├─ task_executor.run_tasks(tasks) │
│ │ └─ increment_verify
│ │ └─ 是 → _run_verification(...) │
│ └────────────────────────────────────────────────┘
└─ 汇总计数 → 返回结果字典
```
### 6.4 任务解析_resolve_tasks
`_resolve_tasks(layers)` 根据层列表解析出具体的任务代码,并执行拓扑排序确保依赖顺序:
| 层 | 解析逻辑 |
|----|----------|
| ODS | 优先使用 `config.run.ods_tasks`,否则从 TaskRegistry 按层查询 |
| DWD | 固定返回 `["DWD_LOAD_FROM_ODS"]` |
| DWS | 优先使用 `config.run.dws_tasks`,否则从 TaskRegistry 按层查询 |
| INDEX | 优先使用 `config.run.index_tasks`,否则从 TaskRegistry 按层查询 |
> 已移除所有硬编码回退列表,统一走 `TaskRegistry.get_tasks_by_layer()` 获取任务。空 Registry + 无配置时记录警告并返回空列表。
> 解析完成后通过 `orchestration/topological_sort.py` 的 Kahn's algorithm 执行拓扑排序,确保被依赖任务先于依赖方执行。
### 6.5 数据源模式
| 模式 | 说明 |
|------|------|
| `online` | 仅从上游 API 在线抓取 |
| `offline` | 仅从本地 JSON 文件入库 |
| `hybrid` | 先抓取再入库(默认) |
---
## 7. 校验框架概述
校验框架(`tasks/verification/`)提供各层数据的批量后置校验和自动补齐功能。
### 7.1 架构
```
BaseVerifier基类
├─ OdsVerifier — 主键 + content_hash 对比,批量 UPSERT
├─ DwdVerifier — 维度 SCD2 / 事实主键对比,批量 UPSERT
├─ DwsVerifier — 聚合对比,批量重算 UPSERT
└─ IndexVerifier — 实体覆盖对比,批量重算 UPSERT
```
### 7.2 核心接口
- `get_verifier_for_layer(layer, db_connection, logger, **kwargs)` — 工厂函数,根据层名返回对应校验器实例
- `verifier.verify_and_backfill(window_start, window_end, auto_backfill, split_unit, tables)` — 执行校验并自动补齐
### 7.3 校验结果模型
| 类 | 说明 |
|----|------|
| `VerificationResult` | 单表校验结果 |
| `VerificationSummary` | 层级汇总total_tables / consistent_tables / total_backfilled / error_tables |
| `VerificationStatus` | 校验状态枚举 |
| `WindowSegment` | 校验时间段 |
### 7.4 在 Flow 中的触发方式
校验由 `FlowRunner._run_verification()` 触发,支持:
- 按层逐一校验
- 按表名过滤(`verify_tables` 参数)
- 时间窗口切分(`window_split` 参数)
- ODS 层支持 API 数据对比或本地 JSON 对比两种模式
---
## 8. 相关配置速查
| 配置路径 | 默认值 | 说明 |
|----------|--------|------|
| `app.store_id` | — | 门店 ID |
| `app.timezone` | `"Asia/Shanghai"` | 系统时区 |
| `run.window_override.start` | — | 手动窗口起点 |
| `run.window_override.end` | — | 手动窗口终点 |
| `run.idle_window.start` | `"04:00"` | 闲时开始 |
| `run.idle_window.end` | `"16:00"` | 闲时结束 |
| `run.window_minutes.default_idle` | `180` | 闲时默认窗口(分钟) |
| `run.window_minutes.default_busy` | `30` | 忙时默认窗口(分钟) |
| `run.overlap_seconds` | `600` | 游标重叠秒数 |
| `run.window_split.unit` | `"month"` | 切分单位 |
| `run.window_split.days` | `1` | 按天切分步长 |
| `run.window_split.compensation_hours` | `0` | 窗口补偿小时数 |
| `run.ods_tasks` | `[]` | ODS 层任务列表覆盖 |
| `run.dws_tasks` | `[]` | DWS 层任务列表覆盖 |
| `run.index_tasks` | `[]` | INDEX 层任务列表覆盖 |
---
## 9. ODS 层去重与软删除机制
> 详细的 ODS 任务配置和各任务参数矩阵见 [`ods_tasks.md`](./ods_tasks.md) 和 [`ods_task_params_matrix.md`](./ods_task_params_matrix.md)。
ODS 层是追加写入的版本化存储,每次内容变更(包括删除)都是一个新版本行。以下是两个核心机制的概要。
### 9.1 content_hash 去重skip_unchanged
所有 23 个 ODS 任务默认开启去重(`skip_unchanged=True`),避免无意义的版本膨胀。
- **hash 输入**:原始 API 返回的 JSON payload + `is_delete` 字段
- **hash 算法**`json.dumps(payload, sort_keys=True, separators=(',',':'), ensure_ascii=False)` + `|` + `is_delete`,然后 SHA-256
- **去重逻辑**:与数据库中同一业务主键的最新版本 `content_hash` 比对,相同则跳过写入
- **is_delete 参与 hash**当记录的删除状态变化时hash 会变化,自动产生新版本行
### 9.2 软删除INSERT 删除版本行)
软删除采用**插入删除版本行**的方式,保持 ODS 追加写入的语义一致性,历史版本行完全不变。
**两条触发路径:**
| 路径 | 触发条件 | 机制 |
|------|---------|------|
| 路径 AAPI 返回) | 上游 API 的 JSON 中自带 `is_delete` 字段 | `_normalize_is_delete_flag` 标准化后正常写入is_delete 参与 hash → 自动产生新版本行 |
| 路径 B快照空缺 | `snapshot_mode``FULL_TABLE``WINDOW`,且 `run.snapshot_missing_delete=True` | `_mark_missing_as_deleted` 对比本次抓取 ID 与数据库已有 ID为缺失 ID 插入 `is_delete=1` 的新版本行 |
**路径 B 关键特性:**
- **幂等性**:若最新版本已是 `is_delete=1`,不会重复插入
- **历史不可变**:不执行任何 UPDATE所有历史版本行保持原样
- **版本追溯**:删除操作本身也是一个版本,可通过 `fetched_at` 追溯
### 9.3 下游取数规约
```sql
-- DWD 层从 ODS 取最新有效版本的标准查询
SELECT DISTINCT ON (id) *
FROM ods.{table_name}
WHERE is_delete IS DISTINCT FROM 1
ORDER BY id, fetched_at DESC;
```
此查询利用 `(业务主键, fetched_at DESC)` 复合索引高效执行。

View File

@@ -0,0 +1,284 @@
# ODS→DWD 层确认修改清单
> 整理日期2026-02-17最后更新2026-02-17
> 来源ODS 去重标准化 Spec 执行 + ODS→DWD 流程报告讨论 + 架构分析
> 状态标记:✅ 已完成 | 📋 待实施(用户已确认)
---
## 一、已完成的修改(✅)
### 1.1 ODS 去重标准化Spec 全部 9 个任务)
| 任务 | 内容 | 涉及文件 |
|------|------|----------|
| Task 1 | 新增 `SnapshotMode` 枚举(`FULL_SET` / `TIME_WINDOW` / `NONE` | `tasks/ods/ods_tasks.py` |
| Task 2 | content_hash 算法从 MD5 改为 SHA-256 | `tasks/ods/ods_tasks.py` |
| Task 3 | ODS DDL 补齐 `content_hash` 列 + `is_delete` 列 + `(pk, fetched_at DESC)` 索引 | `db/etl_feiqiu/schemas/ods.sql` |
| Task 4 | 迁移脚本:为已有 ODS 表添加 `latest_version` 索引 | `db/etl_feiqiu/migrations/2026-02-17__add_ods_latest_version_indexes.sql` |
| Task 5 | 软删除重构:`_mark_missing_as_deleted` 改为 INSERT 删除版本行 | `tasks/ods/ods_tasks.py` |
| Task 6 | 属性测试覆盖去重和软删除逻辑 | `tests/unit/test_ods_dedup_properties.py` |
| Task 7-9 | 文档同步、集成验证 | 多文件 |
### 1.2 ODS 参数清理
| 修改 | 内容 |
|------|------|
| `run.snapshot_missing_delete` 默认值改为 `True` | 不再需要显式配置 |
| 删除 `run.ods_backfill_null_columns` 兼容代码 | 清理历史遗留 |
### 1.3 ODS→DWD 流程报告
- 生成完整报告:`docs/etl_tasks/ods_to_dwd_flow_report.md`10 章 + 重构路线图)
- 报告经多轮 Q&A 修正,已确认的事实:
- 所有 17 张维度表都有 SCD2 列 → Type1 分支不会触发
- 所有 23 张事实表都没有 SCD2 列
- 所有 ODS 表都有 `content_hash``snapshot_mode` 始终为 True
- 水位线仅在自动水位模式下起作用,手动窗口模式不查水位线
- 严格时间窗口下,回补步骤(`_insert_missing_by_pk`)基本不产生写入
- `DwdLoadTask.extract()` 是空壳 — 只返回 `{"now": datetime.now()}`
- E/T/L 模板在各层均未按设计使用ODS 重写 execute、DWS 不继承 BaseTask
- `BaseOdsTask` 重复了 ~60% 的 `BaseTask` 窗口拆分代码
---
## 二、待实施的修改(📋 用户已确认方向)
### 2.1 统一窗口模式,去掉水位线(优先级:高)
**用户原话**"自动水位模式也确定 end 时间,去掉水位线机制"
现状:事实表增量有两条路径 — 配置了 `window_override` 时用 `WHERE fetched_at >= start AND fetched_at < end`,否则走自动水位 `WHERE fetched_at > watermark`(只有下界,无上界)。`load()` 中通过 `use_window = bool(config.get("run.window_override.start") and ...)` 判断走哪条路径。
改动:
- `DwdLoadTask.load()` 中去掉 `use_window` 判断,始终传 `context.window_start / window_end`
- 删除 `_get_fact_watermark()` 方法(约 30 行)
- `_merge_fact_increment` 中删除 watermark 分支,统一用 `WHERE fetched_at >= start AND fetched_at < end`
- `_merge_fact_increment` 签名中删除 `watermark` 参数
涉及文件:`tasks/dwd/dwd_load_task.py`
### 2.2 删除回补机制(优先级:高)
**用户原话**"类型转换失败需要直接报错的!不然除非对本次处理做数据上下游校验,不然开不开回补机制都会丢数据,需要报错提醒。"
确认逻辑:主增量写入是一条 SQL`INSERT ... SELECT ... FROM ods表`),类型转换失败会导致整条 SQL 报错、整张表事务 rollback不存在"部分行静默丢失"。回补无法兜底这种场景,应该直接报错。统一窗口后回补的 LEFT JOIN 结果集几乎一定为空。
改动:
- 删除 `_insert_missing_by_pk()` 方法(约 100 行)
- 删除 `_merge_fact_increment()` 末尾的回补调用及 `missing_inserted` 相关代码
- 删除 `FACT_MISSING_FILL_TABLES` 常量
注意:`dwd.fact_upsert_batch_size` 等批量参数不受影响 — 这些参数是 `dwd_verifier.py`(验证模块)在用,不是回补在用。
前置依赖2.1(统一窗口模式)
涉及文件:`tasks/dwd/dwd_load_task.py`
### 2.3 去掉冗余范围机制(优先级:高)
**用户原话**"去掉冗余范围机制"
与 2.1 合并实施。统一窗口后,`overlap_seconds` 仍保留(用于自动模式下 start 的回退),但不再有水位线 vs 窗口两套并行的范围过滤。
### 2.4 清理死代码和未使用常量(优先级:高)
**确认删除的项目:**
| 清理项 | 操作 | 说明 |
|--------|------|------|
| `BaseDwdTask``base_dwd_task.py` | 删除整个文件 | 死代码,`DwdLoadTask` 直接继承 `BaseTask`,从未使用 `BaseDwdTask` |
| `_pick_order_column()` 方法 | 删除 | 从未被调用,`_merge_fact_increment` 硬编码 `order_col = "fetched_at"` |
| `FACT_ORDER_CANDIDATES` 常量 | 删除 | 配合 `_pick_order_column` 使用,一起删除 |
| `FACT_MISSING_FILL_TABLES` 常量 | 删除 | 回补机制的一部分,随 2.2 删除 `_insert_missing_by_pk` 时一并清理 |
| `_upsert_scd2_row()` 方法 | 删除 | 早期逐行 SCD2 处理,已被批量方法 `_close_current_dim_bulk` / `_insert_dim_rows_bulk` 替代 |
| `_close_current_dim()` 方法 | 删除 | 同上,逐行关闭旧版本 |
| `_insert_dim_row()` 方法 | 删除 | 同上,逐行插入新版本 |
| `_merge_dim_type1_upsert()` 方法 | 删除 | 所有 17 张维度表都有 SCD2 列Type1 分支永远不触发;未来新维度表也统一走 SCD2 |
| `_merge_dim()` 中 Type1 分支判断 | 删除 | 删除 `_merge_dim_type1_upsert` 后,`_merge_dim()``if scd2_cols:` 的分支判断也不再需要,直接走 SCD2 |
**确认修复的 bug**
| 修复项 | 操作 | 说明 |
|--------|------|------|
| `_build_column_mapping()` 中引用未定义变量 `ods_table` / `cur` | 将 `ods_table``cur` 加入方法参数签名 | 当前因 `if "fetched_at" not in ods_set` 永远为 False 而未触发,但未来可能导致 `NameError` |
**确认保留的项目:**
| 保留项 | 原因 |
|--------|------|
| `dwd.fact_upsert` 及相关批量参数 | 验证模块 `dwd_verifier.py` 在使用,与回补无关 |
| `FACT_MAPPINGS` 字典名称 | 虽然维度表也用它(名字有误导),但改名影响面大(多个脚本和测试引用),暂不改 |
涉及文件:`tasks/dwd/dwd_load_task.py``tasks/dwd/base_dwd_task.py`
### 2.5 DWD content_hash 优化 + 参数化设计(优先级:中)
**用户原话**"DWD 可不可以也增加一个 HASH"、"做一个参数,使用 HASH 还是使用展平的数据,以便应对 DWD 表结构改变"
方案:
- 所有 40 张 DWD 表增加 `content_hash VARCHAR(64)`
- 新增配置 `dwd.change_detection_mode``"hash"`(默认)/ `"column"`
- hash 模式:复用 ODS 的 `content_hash`(基于 payload + is_delete 的 SHA-256一列对比
- column 模式保留当前逐列对比逻辑DWD 表结构变更后的首次全量刷新用)
- 两种模式都依赖 `FACT_MAPPINGS` 做列映射column 模式额外用映射后的值做对比)
hash 等价性说明ODS 的 `content_hash` 基于原始 payload 计算DWD 列是 payload 的确定性派生。日常运行时"payload 不变 = DWD 列不变"成立。但 DWD 表结构变更(新增列、修改映射规则)时 hash 等价关系被打破,需临时切 column 模式全量刷新。
典型工作流:
```
日常运行change_detection_mode = "hash"(快速)
→ 修改了 DWD 表结构或 FACT_MAPPINGS
→ 临时切换change_detection_mode = "column" + window_override 覆盖全量
→ 全量刷新完成后切回 "hash"
```
涉及文件DDL40 张 DWD 表)、迁移脚本、`tasks/dwd/dwd_load_task.py``config/defaults.py`
### 2.6 跨层参数提取与层级控制(优先级:中)
**用户原话**"很多任务和参数都是 API-ODS-DWD 通用的,甚至 API-ODS-DWD-DWS 与未来 API-ODS-DWD-CORE 都通用,把这部分提取出来,做层级控制,是否合理?"(确认合理)
现状问题:
- 各层对配置的使用方式不统一ODS 读 `run.*`DWD 读 `dwd.*`DWS 读 `dws.*`
- 共用参数(如 `overlap_seconds``window_minutes`)散落在 `run.*`
- 如果未来 CORE 层也需要 `overlap_seconds` 但值不同,没法区分
方案:
- 配置增加 `layer.<层名>.*` 结构,支持层级覆盖
- `AppConfig` 增加 `get_for_layer(layer, key)` 方法:先查 `layer.<层>.<key>`fallback 到 `run.<key>`
- 向后兼容:旧的 `run.*` 键仍可用,`layer.*` 优先
- `BaseDwsTask` 重构为继承 `BaseTask`,复用时间窗口和窗口拆分逻辑
BaseDwsTask 重构说明:当前 `BaseDwsTask`1243 行)不继承 `BaseTask`,有完全独立的时间范围计算逻辑(`get_time_layer_range` / `get_time_window_range`)。重构后继承 `BaseTask`,复用窗口计算和段循环,但保留 DWS 特有的业务方法(配置缓存、业绩等级、奖金计算等)。这是一个大改动,需要仔细设计。
涉及文件:`config/defaults.py``config/settings.py``tasks/base_task.py``tasks/dws/base_dws_task.py`、各层基类
### 2.7 连接器与多门店配置(优先级:中)
**用户原话**"store_id 之上有个连接器 ID也就是说现在 Feiqiu 的平台可能会有若干个 store 进行接入。每个 store 都有自己的 Token其他都一样"
方案:
**数据库设计:**
```sql
-- 连接器注册表
CREATE TABLE meta.connector (
connector_id SERIAL PRIMARY KEY,
connector_code VARCHAR(50) NOT NULL UNIQUE, -- 如 'feiqiu'
display_name VARCHAR(100),
api_base_url VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 门店注册表
CREATE TABLE meta.connector_store (
store_id BIGINT PRIMARY KEY,
connector_id INT NOT NULL REFERENCES meta.connector(connector_id),
store_name VARCHAR(100),
api_token_enc TEXT NOT NULL, -- AES 加密后的 Token
token_updated_at TIMESTAMPTZ, -- Token 最后更新时间
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT now()
);
```
**Token 安全:**
- 应用层 AES 加密存储,加密密钥通过环境变量 `TOKEN_ENCRYPT_KEY` 传入
- 管理后台不显示 Token 明文,只显示:更新时间(`token_updated_at`+ 有效性状态
- 后端提供 Token 有效性检查接口(调上游 API 验证),管理后台展示检查结果
- 日志中已有脱敏机制(`security.redact_keys` 包含 `token`
**配置体系变更:**
- .env 中用 `CONNECTOR_CODE=feiqiu` 替代 `STORE_ID` + `API_TOKEN`
- 运行时从 DB 加载门店列表,遍历执行
- 向后兼容:.env 中仍有 `STORE_ID` + `API_TOKEN` 时走旧的单门店模式
- 两种模式互斥,启动时检测并提示
**维护方式:**
- 管理后台(主):连接器/门店 CRUD、Token 更新(只能设置不能查看)、有效性检查
- CLI 命令(辅):`python -m cli.main connector add/list/update`
- 共用后端 API
涉及文件DDL2 张新表)、迁移脚本、`config/``orchestration/`、后端 API、管理后台、`.env.template`、相关文档
### 2.8 架构简化 A3E/T/L 模板替换为 `process_segment()` 钩子(优先级:中)
**来源**:架构分析发现 E/T/L 模板在各层均未按设计使用
现状问题:
- `DwdLoadTask.extract()` 是空壳 — 只返回 `{"now": datetime.now()}`
- `DwdLoadTask.transform()` 直接透传(继承自 BaseTask 默认实现)
- ODS 层完全重写了 `execute()`,不走 E/T/L 模板
- DWS 层不继承 `BaseTask`,有自己的时间窗口逻辑
- E/T/L 三步拆分在实际业务中没有意义,每层都是"读 → 处理 → 写"一体化
改动:
- `BaseTask.execute()` 改为:窗口计算 → 窗口拆分 → 循环调用 `process_segment(context)`
- 删除 `extract()` / `transform()` / `load()` 三个钩子方法
- 子类只需实现 `process_segment(context) -> dict`(返回统计计数)
- `DwdLoadTask` 将当前 `load()` 逻辑移入 `process_segment()`
- `DwdLoadTask.extract()` 中的 `{"now": datetime.now()}` 移入 `process_segment()` 内部
- ODS 层不再需要重写 `execute()`
涉及文件:`tasks/base_task.py``tasks/dwd/dwd_load_task.py``tasks/ods/ods_tasks.py`
### 2.9 架构简化 A4统一 BaseOdsTask 和 BaseTask 窗口拆分逻辑(优先级:中)
**来源**:架构分析发现 BaseOdsTask 重复了 ~60% 的 BaseTask 窗口拆分代码
现状问题:
- `BaseOdsTask` 当前不继承任何基类(独立实现)
- `BaseOdsTask._resolve_window()``BaseTask._get_time_window()` 逻辑高度重复手动窗口覆盖、闲忙时判断、overlap 回退)
- `BaseOdsTask.execute()` 自己做了窗口拆分循环(调用 `build_window_segments`),与 `BaseTask.execute()` 重复
- 两套代码独立维护,容易不一致
改动:
- `BaseOdsTask` 改为继承 `BaseTask`
- 删除 `BaseOdsTask._resolve_window()`,复用 `BaseTask._get_time_window()`
- 删除 `BaseOdsTask.execute()` 中的窗口拆分循环,复用 `BaseTask.execute()` 的段循环
- ODS 层实现 `process_segment(context)` 钩子(与 2.8 配合)
- ODS 特有逻辑API 调用、快照去重、软删除等)保留在 `process_segment()`
前置依赖2.8process_segment 钩子)
涉及文件:`tasks/base_task.py``tasks/ods/ods_tasks.py`
---
## 三、实施顺序
```
第一阶段(低风险,连续做):
2.1 统一窗口 → 2.2 删除回补 → 2.3 去冗余 → 2.4 清理死代码 + bug 修复
改动集中在 dwd_load_task.py一次性完成
第二阶段(架构重构):
2.8 E/T/L → process_segment() 钩子(改 BaseTask + DwdLoadTask
2.9 统一 ODS/BaseTask 窗口拆分(改 BaseOdsTask 继承 BaseTask
第三阶段(中风险,需设计):
2.6 跨层参数提取 — 改 defaults.py + AppConfig + 各层基类
含 BaseDwsTask 继承重构(大改动)
第四阶段(可并行):
2.5 DWD content_hash — DDL + 对比逻辑改造
2.7 连接器与多门店 — meta 表 + 加载逻辑 + 管理后台页面
```
---
## 四、已决定不做的项目
| 项目 | 决定 | 原因 |
|------|------|------|
| ODS `is_delete` 列索引 | 不做 | 低选择性(绝大多数行 is_delete=0等实际出现慢查询时再 `EXPLAIN ANALYZE` 评估 |
| ODS 单列 `(fetched_at)` 索引 | 不做 | 数据库已有该索引(经查询确认),无需重复创建 |
| `FACT_MAPPINGS` 改名 | 暂不做 | 虽然维度表也用它(名字有误导),但改名影响面大(`gen_dataflow_doc.py``gen_full_dataflow_doc.py``dataflow_analyzer.py``test_dataflow_analyzer.py` 等多处引用),收益不大 |
---
## 五、保留但不改动的项目
| 项目 | 说明 |
|------|------|
| `dwd.fact_upsert` 及批量参数 | `fact_upsert_batch_size` / `min_batch_size` / `max_retries` / `retry_backoff_sec` / `lock_timeout_ms` 是验证模块 `dwd_verifier.py` 在使用,与回补机制无关,保留 |

View File

@@ -0,0 +1,554 @@
# DWD 层任务详解
> 本文档说明飞球 ETL 系统中 DWD明细数据层的所有任务。
> DWD 层负责从 ODS 读取原始数据经清洗、类型转换和列映射后写入维度表dim_*和事实表dwd_* / fact_*
> 维度表采用 SCD2 或 Type1 Upsert 策略,事实表按时间增量装载。
---
## 概述
DWD 层共有 2 个已注册任务:
| 任务代码 | Python 类 | 注册参数 | 说明 |
|----------|-----------|----------|------|
| `DWD_LOAD_FROM_ODS` | `DwdLoadTask` | `layer="DWD"` | 核心装载任务:遍历 TABLE_MAP维度走 SCD2/Type1事实走增量 |
| `DWD_QUALITY_CHECK` | `DwdQualityTask` | `layer="DWD"`, `task_type="verification"` | ODS 与 DWD 行数/金额核对 |
> 注册位置:`orchestration/task_registry.py`
>
> **历史说明**:早期版本曾有 3 个独立 DWD 任务TICKET_DWD、PAYMENTS_DWD、MEMBERS_DWD
> 通过专用 Loader 写入不存在的 `billiards.*` schema。这些任务已于 2026-02-14 废弃删除,
> 其功能由 `DWD_LOAD_FROM_ODS` 的 TABLE_MAP 映射完全覆盖。
---
## DWD_LOAD_FROM_ODS — 核心装载任务
| 属性 | 值 |
|------|-----|
| 任务代码 | `DWD_LOAD_FROM_ODS` |
| Python 类 | `tasks.dwd.dwd_load_task.DwdLoadTask` |
| 继承 | `BaseTask` |
| 数据来源 | `ods.*`ODS 层各表) |
| 数据目标 | `dwd.*`(维度表 + 事实表,共 40+ 对映射) |
| 事务模式 | 每张表一次独立事务,单表失败回滚后继续后续表 |
| 配置项 | `dwd.only_tables`(可选,限定只处理指定表)、`dwd.fact_upsert`(默认 `True` |
### 执行流程
```
extract(context) → 返回 {"now": datetime.now()}
load(extracted, context) → 遍历 TABLE_MAP
对每张 DWD 表:
├─ 获取 DWD 列信息 + ODS 列信息
├─ 判断表名前缀:
│ ├─ dim_* → _merge_dim()(维度合并)
│ └─ 其他 → _merge_fact_increment()(事实增量)
├─ commit成功或 rollback失败
└─ 记录 summary / errors
```
### TABLE_MAP 映射表
`TABLE_MAP` 定义了 DWD 表到 ODS 表的完整映射关系。每对映射中DWD 表名为 keyODS 表名为 value。
主表与扩展表(`_ex`)共享同一 ODS 源表,通过 `FACT_MAPPINGS` 中的列映射区分写入哪些字段。
#### 维度表映射
| DWD 表 | ODS 源表 | 说明 |
|--------|----------|------|
| `dwd.dim_site` | `ods.table_fee_transactions` | 门店维度(从台费流水的 siteprofile 快照提取) |
| `dwd.dim_site_ex` | `ods.table_fee_transactions` | 门店扩展灯控、WiFi、客服等 |
| `dwd.dim_table` | `ods.site_tables_master` | 台桌维度 |
| `dwd.dim_table_ex` | `ods.site_tables_master` | 台桌扩展(台布使用时间等) |
| `dwd.dim_assistant` | `ods.assistant_accounts_master` | 助教维度 |
| `dwd.dim_assistant_ex` | `ods.assistant_accounts_master` | 助教扩展(简介、分组、灯控设备等) |
| `dwd.dim_member` | `ods.member_profiles` | 会员维度 |
| `dwd.dim_member_ex` | `ods.member_profiles` | 会员扩展(注册来源、组织等) |
| `dwd.dim_member_card_account` | `ods.member_stored_value_cards` | 会员储值卡维度 |
| `dwd.dim_member_card_account_ex` | `ods.member_stored_value_cards` | 储值卡扩展(电费抵扣、冻结余额等) |
| `dwd.dim_tenant_goods` | `ods.tenant_goods_master` | 租户商品维度 |
| `dwd.dim_tenant_goods_ex` | `ods.tenant_goods_master` | 租户商品扩展(条码、备注等) |
| `dwd.dim_store_goods` | `ods.store_goods_master` | 门店商品维度 |
| `dwd.dim_store_goods_ex` | `ods.store_goods_master` | 门店商品扩展(库存、安全库存等) |
| `dwd.dim_goods_category` | `ods.stock_goods_category_tree` | 商品分类维度(含子类展开) |
| `dwd.dim_groupbuy_package` | `ods.group_buy_packages` | 团购套餐维度 |
| `dwd.dim_groupbuy_package_ex` | `ods.group_buy_packages` | 团购套餐扩展 |
#### 事实表映射
| DWD 表 | ODS 源表 | 说明 |
|--------|----------|------|
| `dwd.dwd_settlement_head` | `ods.settlement_records` | 结算头(订单结算主记录) |
| `dwd.dwd_settlement_head_ex` | `ods.settlement_records` | 结算头扩展(支付方式、撤单、促销等) |
| `dwd.dwd_table_fee_log` | `ods.table_fee_transactions` | 台费流水 |
| `dwd.dwd_table_fee_log_ex` | `ods.table_fee_transactions` | 台费流水扩展(销售员、消费类型等) |
| `dwd.dwd_table_fee_adjust` | `ods.table_fee_discount_records` | 台费调整/折扣 |
| `dwd.dwd_table_fee_adjust_ex` | `ods.table_fee_discount_records` | 台费调整扩展 |
| `dwd.dwd_store_goods_sale` | `ods.store_goods_sales_records` | 商品销售记录 |
| `dwd.dwd_store_goods_sale_ex` | `ods.store_goods_sales_records` | 商品销售扩展 |
| `dwd.dwd_assistant_service_log` | `ods.assistant_service_records` | 助教服务记录 |
| `dwd.dwd_assistant_service_log_ex` | `ods.assistant_service_records` | 助教服务扩展 |
| `dwd.dwd_assistant_trash_event` | `ods.assistant_cancellation_records` | 助教取消/废单事件 |
| `dwd.dwd_assistant_trash_event_ex` | `ods.assistant_cancellation_records` | 助教取消扩展 |
| `dwd.dwd_member_balance_change` | `ods.member_balance_changes` | 会员余额变动 |
| `dwd.dwd_member_balance_change_ex` | `ods.member_balance_changes` | 会员余额变动扩展 |
| `dwd.dwd_groupbuy_redemption` | `ods.group_buy_redemption_records` | 团购核销记录 |
| `dwd.dwd_groupbuy_redemption_ex` | `ods.group_buy_redemption_records` | 团购核销扩展 |
| `dwd.dwd_platform_coupon_redemption` | `ods.platform_coupon_redemption_records` | 平台优惠券核销 |
| `dwd.dwd_platform_coupon_redemption_ex` | `ods.platform_coupon_redemption_records` | 平台优惠券核销扩展 |
| `dwd.dwd_recharge_order` | `ods.recharge_settlements` | 充值订单 |
| `dwd.dwd_recharge_order_ex` | `ods.recharge_settlements` | 充值订单扩展 |
| `dwd.dwd_payment` | `ods.payment_transactions` | 支付记录 |
| `dwd.dwd_refund` | `ods.refund_transactions` | 退款记录 |
| `dwd.dwd_refund_ex` | `ods.refund_transactions` | 退款扩展 |
> 共计 **17 对维度映射**(含 `_ex`+ **23 对事实映射**(含 `_ex`= **40 对**映射。
---
### 维度/事实分流逻辑
`load()` 方法遍历 `TABLE_MAP` 时,根据 DWD 表名前缀自动分流:
```python
if self._table_base(dwd_table).startswith("dim_"):
# 维度表 → _merge_dim()
else:
# 事实表 → _merge_fact_increment()
```
`_merge_dim()` 内部进一步判断维度合并策略:
| 条件 | 策略 | 方法 |
|------|------|------|
| DWD 表列中包含 SCD2 列(`scd2_start_time` / `scd2_end_time` / `scd2_is_current` / `scd2_version` | **SCD2 合并**:关闭旧版 + 插入新版 | `_merge_dim_scd2()` |
| DWD 表列中不包含 SCD2 列 | **Type1 Upsert**:主键冲突则更新 | `_merge_dim_type1_upsert()` |
> SCD2 列集合定义:`SCD_COLS = {"scd2_start_time", "scd2_end_time", "scd2_is_current", "scd2_version"}`
---
### SCD2 处理流程
当维度表包含 SCD2 列时,执行 `_merge_dim_scd2()` 方法,完整流程如下:
#### 1. 最新快照选取
从 ODS 源表中按业务主键取最新快照,使用 `DISTINCT ON` + `fetched_at DESC` 去重:
```sql
SELECT DISTINCT ON ()
<>
FROM ods.<ods_table>
WHERE "fetched_at" IS NOT NULL
ORDER BY , "fetched_at" DESC NULLS LAST
```
- **业务主键**:从 DWD 表主键中剔除 SCD2 列后得到(`_strip_scd2_keys()`
- **列映射**:优先使用 `FACT_MAPPINGS` 中的显式映射(支持 JSON 路径提取如 `siteprofile->>'shop_name'`、类型转换如 `::numeric`),其次按同名列直接映射
- **特殊处理**`dim_goods_category` 表会额外读取 `categoryboxes` 列并展开子分类行(`_expand_goods_category_rows()`
#### 2. 变更检测
将 ODS 最新快照与 DWD 当前版本(`scd2_is_current=1`)逐列对比:
```python
# 预加载 DWD 当前版本(避免逐行 SELECT
SELECT * FROM dwd.<dwd_table> WHERE COALESCE(scd2_is_current, 1) = 1
# 逐行对比(跳过 SCD2 列本身)
for col in dwd_cols:
if col in SCD_COLS: continue
if not _values_equal(current[col], incoming[col]):
return True # 有变更
```
`_values_equal()` 在对比前会做类型归一化:
- **空值归一化**:空字符串 `""``None` 视为等价
- **日期时间归一化**朴素时间naive与时区感知时间aware统一比较
- **布尔值归一化**`"true"/"false"/"1"/"0"/"yes"/"no"` 等字符串与布尔值统一比较
- **数值归一化**:字符串形式的数字(如 `"3.14"`)与 `Decimal` / `float` 统一比较
#### 3. 版本关闭与新建
对于检测到变更的记录,分两步批量操作:
**步骤 A — 批量关闭旧版本**`_close_current_dim_bulk()`
```sql
-- 单主键优化:使用 ANY 数组
UPDATE dwd.<table>
SET scd2_end_time = <now>, scd2_is_current = 0
WHERE COALESCE(scd2_is_current, 1) = 1 AND "<pk>" = ANY(<ids>)
-- 复合主键:逐条 execute_batch
UPDATE dwd.<table>
SET scd2_end_time = <now>, scd2_is_current = 0
WHERE COALESCE(scd2_is_current, 1) = 1 AND "<pk1>" = %s AND "<pk2>" = %s
```
**步骤 B — 批量插入新版本**`_insert_dim_rows_bulk()`
```sql
INSERT INTO dwd.<table> (<>) VALUES %s
```
新版本行的 SCD2 列填充规则:
| SCD2 列 | 值 |
|---------|-----|
| `scd2_start_time` | 当前时间(`now` |
| `scd2_end_time` | `9999-12-31 00:00:00`(表示"当前有效" |
| `scd2_is_current` | `1` |
| `scd2_version` | 旧版本号 + 1新记录为 1 |
#### 4. 返回统计
```python
{
"processed": <ODS 去重后总行数>,
"inserted": <新增记录数首次出现的业务主键>,
"updated": <变更记录数关闭旧版+插入新版>,
"skipped": <无变更跳过数>
}
```
---
### Type1 Upsert 处理流程
当维度表不包含 SCD2 列时,执行 `_merge_dim_type1_upsert()` 方法:
1. 从 ODS 取最新快照(同 SCD2 的 `DISTINCT ON` 逻辑)
2. 按主键去重,跳过主键为 `NULL` 的行
3. 使用 PostgreSQL `INSERT ... ON CONFLICT DO UPDATE` 一次性写入:
```sql
INSERT INTO dwd.<table> (<>) VALUES %s
ON CONFLICT (<>) DO UPDATE SET <> = EXCLUDED.<>
WHERE < IS DISTINCT FROM EXCLUDED > -- 仅在有实际变更时才更新
RETURNING (xmax = 0) AS inserted -- 区分新增 vs 更新
```
> Type1 Upsert 不保留历史版本直接覆盖旧值。SCD2 列(如果存在于列定义中)会被填充默认值(`scd2_start_time=now`, `scd2_version=1`)但不参与冲突判断。
---
### 事实表增量装载
`dim_*` 前缀的表走 `_merge_fact_increment()` 方法,核心逻辑如下:
#### 1. 水位线Watermark机制
事实表统一使用 `fetched_at` 列作为增量过滤依据,水位线获取优先级:
| 优先级 | 条件 | 行为 |
|--------|------|------|
| 1 | 配置了手动时间窗口(`run.window_override.start/end` | `WHERE fetched_at >= <start> AND fetched_at < <end>` |
| 2 | 无手动窗口 | 自动计算水位线 `_get_fact_watermark()``WHERE fetched_at > <watermark>` |
`_get_fact_watermark()` 的计算逻辑:
- 若 DWD 表包含 `fetched_at` 列 → `SELECT MAX(fetched_at) FROM <dwd_table>`
- 若 DWD 表不含 `fetched_at` 但有主键 → 通过 ODS JOIN DWD 取 `MAX(ods.fetched_at)`
- 兜底 → `"1970-01-01"`(全量装载)
#### 2. 列映射与类型转换
列映射来源(按优先级):
1. `FACT_MAPPINGS` 中的显式映射:`(dwd_列名, ods_源表达式, 可选类型转换)`
2. DWD 与 ODS 同名列直接映射
3. 主键兜底:若 DWD 主键在 ODS 中不存在但 ODS 有 `id` 列,自动映射 `pk → id`
类型转换(`_build_fact_select_exprs()`
- 当 DWD 列为数值类型(`integer`/`numeric`/`bigint` 等)而 ODS 列为文本类型时,自动添加 `CAST(NULLIF(CAST("<col>" AS text), '') AS <type>)`
#### 3. 快照去重
若 ODS 表包含 `content_hash` 列(`snapshot_mode=True`),则使用 `DISTINCT ON` 按主键 + `fetched_at DESC` 取最新快照,避免重复记录。
#### 4. 写入策略
根据配置 `dwd.fact_upsert`(默认 `True`)和 `snapshot_mode` 决定冲突处理:
| 条件 | SQL 策略 |
|------|----------|
| `snapshot_mode=True``fact_upsert=True` | `ON CONFLICT (<pk>) DO UPDATE SET ... WHERE <任一列 IS DISTINCT FROM>` |
| `fact_upsert=False` 且无 `content_hash` | `ON CONFLICT (<pk>) DO NOTHING` |
写入后通过 `RETURNING (xmax = 0) AS inserted` 区分新增(`xmax=0`)和更新(`xmax≠0`)。
#### 5. 缺失主键回补
对于可能出现"回补旧记录"的事实表(如 `dwd_assistant_service_log`,定义在 `FACT_MISSING_FILL_TABLES` 中),
在主增量写入完成后,额外执行 `_insert_missing_by_pk()`
```sql
INSERT INTO <dwd_table> (<>)
SELECT <> FROM <ods_table> o
LEFT JOIN <dwd_table> d ON d.<pk> = o.<pk>
WHERE d.<pk> IS NULL
AND o.fetched_at > <watermark> -- 同样受水位线约束
ON CONFLICT (<pk>) DO NOTHING
```
> 此步骤确保因时序乱序导致的遗漏记录能被补齐。
#### 6. FACT_MAPPINGS 列映射详解
`FACT_MAPPINGS` 是一个字典key 为 DWD 表全名value 为三元组列表 `(dwd_列名, ods_源表达式, 类型转换)`
映射类型示例:
| 映射类型 | 示例 | 说明 |
|----------|------|------|
| 简单重命名 | `("table_id", "id", None)` | ODS `id` → DWD `table_id` |
| JSON 路径提取 | `("shop_name", "siteprofile->>'shop_name'", None)` | 从 JSONB 字段提取 |
| 类型转换 | `("longitude", "siteprofile->>'longitude'", "numeric")` | 提取后转 `numeric` |
| 布尔转换 | `("is_first_limit", "is_first_limit", "boolean")` | 转布尔类型 |
| 日期转换 | `("pay_date", "pay_time", "date")` | 时间戳截断为日期 |
| SQL 表达式 | `("category_level", "CASE WHEN pid = 0 THEN 1 ELSE 2 END", None)` | 计算列 |
> 维度表和事实表共用 `FACT_MAPPINGS`(名称虽含 "FACT" 但实际覆盖所有表)。
---
### 配置与环境变量
| 配置项 | 来源 | 说明 |
|--------|------|------|
| `dwd.only_tables` | AppConfig / 环境变量 `DWD_ONLY_TABLES` | 逗号分隔的表名列表,限定只处理指定表(支持全名或短名) |
| `dwd.fact_upsert` | AppConfig | 事实表是否使用 upsert默认 `True`),设为 `False` 则用 `DO NOTHING` |
| `run.window_override.start` / `end` | AppConfig | 手动指定时间窗口,覆盖自动水位线 |
---
### 错误处理
- 每张表独立事务:成功则 `commit`,失败则 `rollback` 并记录错误后继续下一张表
- 主键缺失的行会被跳过并记录警告日志
- ODS 表缺少 `fetched_at` 列时跳过该表并记录错误
- `fetched_at``NULL` 的 ODS 行会被过滤(`WHERE fetched_at IS NOT NULL`
- 最终返回 `{"tables": [<各表统计>], "errors": [<失败表及原因>]}`
---
## DWD_QUALITY_CHECK — 数据质量检查
| 属性 | 值 |
|------|-----|
| 任务代码 | `DWD_QUALITY_CHECK` |
| Python 类 | `tasks.dwd.dwd_quality_task.DwdQualityTask` |
| 继承 | `BaseTask` |
| 任务类型 | `verification`(校验类任务) |
| 数据来源 | `DwdLoadTask.TABLE_MAP` 中定义的所有 ODS / DWD 表对40 对) |
| 输出文件 | `reports/dwd_quality_report.json` |
### 用途
`DWD_LOAD_FROM_ODS` 装载完成后,对 ODS 与 DWD 两端进行**行数**和**金额**的交叉核对,
自动生成 JSON 格式的质检报表。用于发现装载过程中可能出现的数据丢失或金额偏差。
---
### 执行流程
```
extract(context) → 返回 {"now": datetime.now()}
load(extracted, context)
遍历 DwdLoadTask.TABLE_MAP 中的每对 (dwd_table, ods_table)
├─ _compare_counts() → 行数核对
├─ _compare_amounts() → 金额核对
└─ 结果追加到 report["tables"]
写入 reports/dwd_quality_report.json
```
> 注意:本任务不执行 `transform()` 阶段,直接在 `load()` 中完成查询与报表输出。
---
### 行数核对逻辑
`_compare_counts(cur, dwd_table, ods_table)` 分别对 DWD 表和 ODS 表执行 `COUNT(1)`
返回两端行数及差值:
```sql
-- DWD 端
SELECT COUNT(1) AS cnt FROM "dwd"."<dwd_table>"
-- ODS 端
SELECT COUNT(1) AS cnt FROM "ods"."<ods_table>"
```
返回结构:
```json
{
"dwd": 12345,
"ods": 12350,
"diff": -5
}
```
- `diff = dwd - ods`:正值表示 DWD 多于 ODS可能因 SCD2 产生多版本),负值表示 DWD 少于 ODS可能有数据丢失
- 维度表因 SCD2 历史版本的存在DWD 行数通常 ≥ ODS 行数,`diff > 0` 属于正常现象
- 事实表理论上 DWD 行数应 ≤ ODS 行数(去重后),`diff > 0` 需要关注
**表名解析**`_split_table_name()` 将全限定名(如 `dwd.dim_member`)拆分为 `(schema, table)`
若表名不含 `.`,则使用默认 schemaDWD 端默认 `dwd`ODS 端默认 `ods`)。
---
### 金额列自动扫描规则
`_compare_amounts(cur, dwd_table, ods_table)` 自动识别两端表中的金额相关列,
对公共列逐列汇总对比。
#### 扫描机制
通过 `_get_numeric_amount_columns(cur, schema, table)``information_schema.columns` 查询:
```sql
SELECT column_name
FROM information_schema.columns
WHERE table_schema = %s
AND table_name = %s
AND data_type IN ('numeric', 'double precision', 'integer', 'bigint', 'smallint', 'real', 'decimal')
```
在返回的数值型列中,进一步按**列名关键词**过滤,只保留列名中包含以下任一关键词的列:
| 关键词 | 匹配示例 |
|--------|----------|
| `amount` | `pay_amount`, `discount_amount`, `actual_amount` |
| `money` | `consume_money`, `member_money` |
| `fee` | `table_fee`, `service_fee`, `fee_amount` |
| `balance` | `card_balance`, `gift_balance` |
> 关键词定义:`AMOUNT_KEYWORDS = ("amount", "money", "fee", "balance")`
> 匹配规则:列名(小写)包含关键词即命中,如 `pay_amount` 包含 `amount`。
#### 公共列取交集
分别获取 DWD 端和 ODS 端的金额列后,取**交集**`set(dwd_cols) & set(ods_cols)`)并排序,
只对两端都存在的同名金额列进行汇总对比。
#### 汇总对比
对每个公共金额列执行 `SUM()`
```sql
-- DWD 端
SELECT COALESCE(SUM("<col>"), 0) AS val FROM "dwd"."<dwd_table>"
-- ODS 端
SELECT COALESCE(SUM("<col>"), 0) AS val FROM "ods"."<ods_table>"
```
返回结构(每个金额列一条记录):
```json
{
"column": "pay_amount",
"dwd_sum": 98765.50,
"ods_sum": 98770.00,
"diff": -4.50
}
```
- `diff = dwd_sum - ods_sum`:非零值表示两端金额不一致,需排查原因
- `COALESCE(..., 0)` 确保 `NULL` 值不影响汇总结果
- 若某对表没有公共金额列,`amounts` 数组为空
---
### JSON 报表输出格式
报表写入路径:`reports/dwd_quality_report.json`(目录不存在时自动创建)。
#### 完整结构
```json
{
"generated_at": "2025-01-15T14:30:00.123456",
"tables": [
{
"dwd_table": "dwd.dim_member",
"ods_table": "ods.member_profiles",
"count": {
"dwd": 1200,
"ods": 1000,
"diff": 200
},
"amounts": [
{
"column": "balance",
"dwd_sum": 50000.00,
"ods_sum": 50000.00,
"diff": 0.0
}
]
},
{
"dwd_table": "dwd.dwd_payment",
"ods_table": "ods.payment_transactions",
"count": {
"dwd": 5000,
"ods": 5000,
"diff": 0
},
"amounts": [
{
"column": "pay_amount",
"dwd_sum": 123456.78,
"ods_sum": 123456.78,
"diff": 0.0
},
{
"column": "fee_amount",
"dwd_sum": 1234.56,
"ods_sum": 1234.56,
"diff": 0.0
}
]
}
],
"note": "行数/金额核对,金额字段基于列名包含 amount/money/fee/balance 的数值列自动扫描。"
}
```
#### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `generated_at` | `string` | 报表生成时间ISO 8601 格式) |
| `tables` | `array` | 每对 DWD↔ODS 表的核对结果 |
| `tables[].dwd_table` | `string` | DWD 表全限定名 |
| `tables[].ods_table` | `string` | ODS 表全限定名 |
| `tables[].count` | `object` | 行数核对结果 |
| `tables[].count.dwd` | `integer` | DWD 端行数 |
| `tables[].count.ods` | `integer` | ODS 端行数 |
| `tables[].count.diff` | `integer` | 行数差值(`dwd - ods` |
| `tables[].amounts` | `array` | 金额列核对结果(可能为空数组) |
| `tables[].amounts[].column` | `string` | 金额列名 |
| `tables[].amounts[].dwd_sum` | `float` | DWD 端汇总值 |
| `tables[].amounts[].ods_sum` | `float` | ODS 端汇总值 |
| `tables[].amounts[].diff` | `float` | 金额差值(`dwd_sum - ods_sum` |
| `note` | `string` | 报表说明文字 |
---
### 注意事项
- **全量扫描**:本任务对 TABLE_MAP 中所有 40 对表执行全表 `COUNT``SUM`,在数据量较大时可能耗时较长
- **不区分增量**:行数和金额对比基于全表统计,不受时间窗口限制
- **SCD2 影响**:维度表因 SCD2 历史版本的存在DWD 行数通常大于 ODS 行数,这是预期行为
- **列名匹配大小写**:金额列扫描时将列名统一转为小写后匹配关键词
- **报表覆盖**:每次运行会覆盖上一次的报表文件(`reports/dwd_quality_report.json`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,755 @@
# INDEX 层任务详解
> 本文档说明飞球 ETL 系统中 INDEX指数算法层的所有任务。
> INDEX 层基于 DWD/DWS 层数据,通过自定义算法计算业务指数,
> 服务于会员运营(回流挽回、新客转化)和助教管理(关系归属、付费关联)等场景。
---
## 概述
INDEX 层共有 4 个已注册任务:
| 任务代码 | Python 类 | 目标表 | 指数类型 | 更新策略 |
|----------|-----------|--------|----------|----------|
| `DWS_WINBACK_INDEX` | `WinbackIndexTask` | `dws_member_winback_index` | WBI回流指数 | delete-before-insert按门店全量刷新 |
| `DWS_NEWCONV_INDEX` | `NewconvIndexTask` | `dws_member_newconv_index` | NCI新客转化指数 | delete-before-insert按门店全量刷新 |
| `DWS_RELATION_INDEX` | `RelationIndexTask` | `dws_member_assistant_relation_index` | RS/OS/MS/ML关系指数 | delete-before-insert按门店全量刷新 |
| `DWS_ML_MANUAL_IMPORT` | `MlManualImportTask` | `dws_ml_manual_order_source` / `dws_ml_manual_order_alloc` | ML手动台账导入 | 按 scope 先删后写 |
> 注册位置:`orchestration/task_registry.py`,所有 INDEX 任务的 `requires_db_config=False`、`layer="INDEX"`。
---
## BaseIndexTask 公共机制
`BaseIndexTask`(位于 `tasks/dws/index/base_index_task.py`)继承自 `BaseDwsTask`,为所有指数任务提供统一的算法基础设施。
### 继承层次
```
BaseTask
└── BaseDwsTask
└── BaseIndexTask
├── MemberIndexBaseTask ← WBI / NCI 共享的会员特征提取
│ ├── WinbackIndexTask
│ └── NewconvIndexTask
├── RelationIndexTask ← RS/OS/MS/ML 四合一
└── MlManualImportTask ← ML 人工台账导入
```
### 子类必须实现的抽象方法
```python
def get_index_type(self) -> str:
"""返回指数类型标识,如 'WBI''NCI''RS'"""
```
### 核心能力
#### 1. 半衰期时间衰减函数
所有指数共享的时间权重模型,核心思想是"越近越重要"
```
decay(d; h) = exp(-ln(2) × d / h)
```
| 参数 | 含义 | 示例 |
|------|------|------|
| `d` | 事件距今天数≥0 | 7 天 |
| `h` | 半衰期(>0单位天 | 7 天 |
| 返回值 | 衰减权重,范围 (0, 1] | 0.5 |
`d = h` 时权重恰好衰减到 0.5`d = 0` 时权重为 1.0。
#### 2. 分位数计算与截断Winsorize
用于消除极端值对归一化的影响:
1. 计算 P5 和 P95 分位点
2. 将所有 Raw Score 截断到 [P5, P95] 范围内
```python
calculate_percentiles(scores, lower=5, upper=95) (P5, P95)
winsorize(value, lower, upper) clipped_value
```
#### 3. 0-10 归一化映射
将 Raw Score 映射到 0-10 分的 Display Score便于业务理解和排序
```
映射流程Raw Score → [可选压缩] → Winsorize(P5, P95) → MinMax(0, 10)
```
压缩模式(由 `compression_mode` 参数控制):
| compression_mode | 方式 | 公式 | 适用场景 |
|------------------|------|------|----------|
| 0 | 无压缩 | `y = x` | 分布较均匀时 |
| 1 | log1p | `y = ln(1 + x)` | 右偏分布(默认) |
| 2 | asinh | `y = asinh(x)` | 含负值或极端右偏 |
当所有分数几乎相同(`max - min < ε`)时,返回中间值 5.0。
#### 4. 算法参数加载
`dws.cfg_index_parameters` 表按 `index_type` 加载参数:
-`effective_from` 降序取最新生效的参数值
- 支持按 `index_type` 隔离的内存缓存TTL = 300 秒)
- 子类可通过 `get_param(name, default)` 获取单个参数
```python
load_index_parameters(index_type=None) Dict[str, float]
get_param(name, default=0.0, index_type=None) float
```
#### 5. 分位点历史管理EWMA 平滑)
为避免分位点在不同批次间剧烈波动,支持 EWMA指数加权移动平均平滑
```
Q_t = (1 - α) × Q_{t-1} + α × Q_now
```
| 参数 | 含义 | 默认值 |
|------|------|--------|
| `α`ewma_alpha | 平滑系数,越大越跟随当前值 | 0.2 |
| `Q_{t-1}` | 上一次平滑后的分位点 | 从 `dws_index_percentile_history` 表读取 |
| `Q_now` | 当前批次计算的分位点 | 实时计算 |
首次计算时无历史记录,直接使用当前分位点(不平滑)。每次计算后将原始分位点和平滑分位点保存到 `dws_index_percentile_history` 表。
#### 6. 统计工具方法
| 方法 | 功能 |
|------|------|
| `calculate_median(values)` | 中位数 |
| `calculate_mad(values)` | MAD中位绝对偏差比标准差更稳健 |
| `safe_log(value)` | 安全对数value ≤ 0 时返回默认值) |
| `safe_ln1p(value)` | 安全的 `ln(1+x)` |
---
## MemberIndexBaseTask 会员指数共享基类
`MemberIndexBaseTask`(位于 `tasks/dws/index/member_index_base.py`)继承自 `BaseIndexTask`,为 WBI 和 NCI 提供共享的会员活动特征提取逻辑。
### 模板方法 execute()
`MemberIndexBaseTask` 提供 `execute()` 模板方法,子类只需实现 `_calculate_scores()``_save_results()` 两个抽象方法:
```python
class MemberIndexBaseTask(BaseIndexTask):
def execute(self, cursor_data=None) -> dict:
context = self._build_context(cursor_data)
site_id = self._get_site_id(context)
tenant_id = self._get_tenant_id()
params = self._load_params()
activities = self._build_member_activity(site_id, tenant_id, params)
raw_scores = self._calculate_scores(activities, params, site_id, tenant_id)
normalized = self.batch_normalize_to_display(raw_scores, ...)
result = self._save_results(normalized, site_id, tenant_id, context)
return result
def _calculate_scores(self, activities, params, site_id, tenant_id) -> dict:
raise NotImplementedError # 子类实现
def _save_results(self, normalized, site_id, tenant_id, context) -> dict:
raise NotImplementedError # 子类实现
```
### 会员活动特征MemberActivityData
从 DWD 层提取并计算的会员特征数据结构:
| 字段 | 类型 | 含义 |
|------|------|------|
| `member_id` | int | 会员 ID |
| `site_id` / `tenant_id` | int | 门店 / 租户 ID |
| `member_create_time` | datetime | 会员建档时间 |
| `first_visit_time` | datetime | 首次到店时间 |
| `last_visit_time` | datetime | 最近到店时间 |
| `last_recharge_time` | datetime | 最近充值时间 |
| `t_v` | float | 距最近到店天数(截断到 recency 窗口) |
| `t_r` | float | 距最近充值天数(截断到 recency 窗口) |
| `t_a` | float | `min(t_v, t_r)`,综合活跃度 |
| `visits_14d` / `visits_60d` / `visits_total` | int | 近 14 天 / 60 天 / 总到店次数 |
| `spend_30d` / `spend_180d` | float | 近 30 天 / 180 天消费金额 |
| `sv_balance` | float | 储值卡余额 |
| `recharge_60d_amt` | float | 近 60 天充值金额 |
| `intervals` | List[float] | 到店间隔天数序列 |
| `interval_ages_days` | List[int] | 每个间隔对应的"年龄"(距今天数) |
| `recharge_unconsumed` | int | 充值后是否未回访1=是) |
### 数据来源
| 数据 | 来源表 | 提取方式 |
|------|--------|----------|
| 到店记录 | `dwd.dwd_settlement_head` | 按天去重仅计入正常结账settle_type=1和激励课结账settle_type=3 且关联 BONUS 技能) |
| 充值记录 | `dwd.dwd_recharge_order` | settle_type=5近 recency_days 天 |
| 会员建档时间 | `dwd.dim_member` | scd2_is_current=1 |
| 首次到店时间 | `dwd.dwd_settlement_head` | 全量 MIN(pay_time) |
| 储值卡余额 | `dwd.dim_member_card_account` | 按 card_type_id 筛选现金卡 |
> 会员 ID 规范化:优先使用 `member_id`,若为 0 则通过 `dim_member_card_account` 关联取 `tenant_member_id`。
### 会员分群classify_segment
WBI 和 NCI 共享的三分群逻辑,决定会员进入哪个指数的计算范围:
| 分群 | 条件 | 进入指数 |
|------|------|----------|
| **STOP** | `t_a ≥ recency_days`(默认 60 天无活动) | 不参与评分(除 STOP_HIGH_BALANCE 例外) |
| **NEW** | 满足以下任一:到店 ≤ 2 次、首访 ≤ 30 天、近期充值未回访 | NCI |
| **OLD** | 不满足 STOP 和 NEW 条件 | WBI |
STOP_HIGH_BALANCE 例外:当 `enable_stop_high_balance_exception=1` 且储值余额 ≥ `high_balance_threshold`(默认 1000 元STOP 会员仍参与 WBI 评分。
---
## DWS_WINBACK_INDEX — 老客挽回指数WBI
| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_WINBACK_INDEX` |
| Python 类 | `WinbackIndexTask``tasks/dws/index/winback_index_task.py` |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → MemberIndexBaseTask → WinbackIndexTask` |
| 目标表 | `dws.dws_member_winback_index` |
| 主键 | `site_id, member_id` |
| 指数类型 | `WBI` |
| 更新策略 | 按门店全量刷新(先 DELETE WHERE site_id = %s再 INSERT |
### 业务含义
WBI 衡量老客的"挽回紧急度"——分数越高,表示该会员越需要运营人员主动触达。适用于已有多次到店记录但近期活跃度下降的老客群体。
### 计算范围
仅对 `segment = "OLD"``status = "STOP_HIGH_BALANCE"` 的会员计算。
### 算法概要
WBI Raw Score 由 4 个分项加权求和,再乘以近期抑制系数:
```
WBI_raw = suppression × (w_over × Overdue + w_drop × Drop + w_re × Recharge + w_value × Value)
```
#### 分项 1超期紧急性Overdue
基于会员个人历史到店间隔的加权经验 CDF衡量当前缺席天数的异常程度
1. 收集会员历史到店间隔序列 `{interval_i, age_i}`
2. 计算加权 CDF`P(interval ≤ t_v)`,权重按间隔年龄半衰期衰减
3. 对小样本混合等权分布与加权分布(`λ = min(1, N / blend_min_samples)`
4. `Overdue = P^α`α 默认 2.0,放大高概率区间的紧急性)
同时计算理想到店间隔(加权中位数),用于推算 `ideal_next_visit_date`
#### 分项 2降频分Drop
检测近期到店频率是否低于历史均值:
```
expected_14d = visits_60d × 14 / 60
Drop = clip((expected_14d - visits_14d) / (expected_14d + 1), 0, 1)
```
#### 分项 3充值未回访压力Recharge
若会员充值后未回访(`recharge_unconsumed = 1`),按充值距今天数衰减:
```
Recharge = decay(t_r, h_recharge) # h_recharge 默认 7 天
```
#### 分项 4价值分Value
综合消费金额和储值余额的对数压缩:
```
Value = w_spend × ln(1 + spend_180d / M0) + w_bal × ln(1 + sv_balance / B0)
```
| 参数 | 默认值 | 含义 |
|------|--------|------|
| `M0` (amount_base_M0) | 300 | 消费金额压缩基准 |
| `B0` (balance_base_B0) | 500 | 余额压缩基准 |
#### 近期抑制Suppression
防止刚到店的会员获得高分,使用 Sigmoid 门控:
```
suppression = σ((t_v - gate_days) / slope_days)
```
-`t_v < hard_floor_days`(默认 14 天)时,`suppression = 0`(完全抑制)
-`t_v` 远大于 `gate_days` 时,`suppression → 1`(不抑制)
### 默认权重
| 参数 | 默认值 | 含义 |
|------|--------|------|
| `w_over` | 2.0 | 超期紧急性权重 |
| `w_drop` | 1.0 | 降频权重 |
| `w_re` | 0.4 | 充值压力权重 |
| `w_value` | 1.2 | 价值权重 |
---
## DWS_NEWCONV_INDEX — 新客转化指数NCI
| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_NEWCONV_INDEX` |
| Python 类 | `NewconvIndexTask``tasks/dws/index/newconv_index_task.py` |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → MemberIndexBaseTask → NewconvIndexTask` |
| 目标表 | `dws.dws_member_newconv_index` |
| 主键 | `site_id, member_id` |
| 指数类型 | `NCI` |
| 更新策略 | 按门店全量刷新(先 DELETE WHERE site_id = %s再 INSERT |
### 业务含义
NCI 衡量新客的"转化紧迫度"——分数越高,表示该新客越需要及时跟进以促成二访/三访。适用于首次到店不久或到店次数较少的新客群体。
### 计算范围
仅对 `segment = "NEW"` 的会员计算。
### 算法概要
NCI 由两部分组成欢迎建联分Welcome和转化召回分Convert合并为总分
```
NCI_raw = raw_score_welcome + raw_score_convert
```
#### 欢迎建联分Welcome
针对首访后 3 天内的会员,鼓励立即触达:
```
welcome_new = clip(1 - t_v / welcome_window_days, 0, 1) # 仅 visits_total ≤ 1 时生效
raw_score_welcome = w_welcome × welcome_new
```
#### 转化召回分Convert
由 4 个分项加权组成,并受活跃度抑制:
```
raw_score_convert = active_multiplier × (
w_need × (Need × Salvage) + w_re × Recharge × touch_multiplier + w_value × Value × touch_multiplier
)
```
##### 分项 1紧迫度Need
衡量距二访目标窗口的紧迫程度:
```
Need = clip((t_v - no_touch_days) / (2 × t2_target_days - no_touch_days), 0, 1)
```
- `no_touch_days`(默认 3 天):免打扰窗口,首访后短期内不催促
- `t2_target_days`(默认 7 天):二访目标天数
##### 分项 2挽救系数Salvage
30-60 天线性衰减,超过 60 天视为流失:
```
Salvage = clip((salvage_end - t_a) / (salvage_end - salvage_start), 0, 1)
```
##### 分项 3充值未回访压力Recharge
与 WBI 相同:`Recharge = decay(t_r, h_recharge)`
##### 分项 4价值分Value
与 WBI 相同:`Value = w_spend × ln(1 + spend_180d / M0) + w_bal × ln(1 + sv_balance / B0)`
#### 活跃新客抑制
近期高频到店的新客不需要催促,降低其排名权重:
```
若 visits_14d ≥ active_new_visit_threshold_14d 且 t_v ≤ active_new_recency_days:
active_multiplier = active_new_penalty (默认 0.2)
否则:
active_multiplier = 1.0
```
#### 免打扰窗口乘数
价值分和充值分在进入免打扰窗口后才逐步生效:
```
touch_multiplier = clip(t_v / no_touch_days, 0, 1)
```
### Display Score 归一化
NCI 产出 3 个 Display Score
- `display_score`:总分归一化(使用 EWMA 平滑)
- `display_score_welcome`:欢迎分归一化(不平滑)
- `display_score_convert`:转化分归一化(不平滑)
### 默认权重
| 参数 | 默认值 | 含义 |
|------|--------|------|
| `w_welcome` | 1.0 | 欢迎建联权重 |
| `w_need` | 1.6 | 紧迫度权重 |
| `w_re` | 0.8 | 充值压力权重 |
| `w_value` | 1.0 | 价值权重 |
---
## DWS_RELATION_INDEX — 关系指数RS/OS/MS/ML
| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_RELATION_INDEX` |
| Python 类 | `RelationIndexTask``tasks/dws/index/relation_index_task.py` |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → RelationIndexTask` |
| 目标表 | `dws.dws_member_assistant_relation_index` |
| 主键 | `site_id, member_id, assistant_id` |
| 指数类型 | RS / OS / MS / ML单任务产出四个子指数 |
| 更新策略 | 按门店全量刷新(先 DELETE WHERE site_id = %s再 INSERT |
### 业务含义
关系指数以"会员-助教"关系对为粒度,一次执行产出 4 个子指数:
| 子指数 | 全称 | 含义 |
|--------|------|------|
| **RS** | Relation Strength | 关系强度——衡量会员与助教之间的服务关系紧密程度 |
| **OS** | Ownership Share | 归属份额——确定会员的"主责助教"归属关系 |
| **MS** | Momentum Score | 升温动量——衡量关系是在升温还是降温 |
| **ML** | Money Link | 付费关联——基于人工台账的付费归因强度 |
### 数据来源
| 数据 | 来源表 | 说明 |
|------|--------|------|
| 服务记录 | `dwd.dwd_assistant_service_log` | RS/MS 的核心数据源 |
| 助教维度 | `dwd.dim_assistant` | 通过 user_id 关联获取 assistant_id |
| 人工台账 | `dws.dws_ml_manual_order_alloc` | ML 的唯一数据源 |
### 会话合并
服务记录按 `(member_id, assistant_id)` 分组后,相邻服务间隔 ≤ `session_merge_hours`(默认 4 小时的记录合并为一个会话ServiceSession。合并后保留
- 会话起止时间、总时长
- 课程权重(激励课 `course_weight = incentive_weight`,默认 1.5;普通课 = 1.0
- 是否包含激励课标记
### 子指数 1RS关系强度
```
RS_raw = (w_f × F + w_d × D) × Gate(R)
```
| 分项 | 公式 | 含义 |
|------|------|------|
| F频次 | `Σ course_weight × decay(days_ago, halflife_session)` | 加权会话频次 |
| D时长 | `Σ √(duration_min / 60) × course_weight × decay(days_ago, halflife_session)` | 加权服务时长 |
| R近期性 | `decay(days_since_last_session, halflife_last)` | 最近一次服务的时间衰减 |
| Gate | `R^gate_alpha` | 近期性门控,无近期服务则整体压低 |
默认参数:`halflife_session=14`, `halflife_last=10`, `w_f=1.0`, `w_d=0.7`, `gate_alpha=0.6`
### 子指数 2OS归属份额
OS 不是独立计算的 Raw Score而是基于 RS_raw 的份额分配:
1. 筛选 `rs_raw ≥ min_rs_raw_for_ownership`(默认 0.05)的关系对
2. 计算份额:`os_share = rs_raw / Σ rs_raw`(同一会员下所有合格助教)
3.`Σ rs_raw < min_total_rs_raw`(默认 0.10),标记为 `UNASSIGNED`
归属标签判定规则:
| 标签 | 条件 |
|------|------|
| `MAIN` | 第一名份额 ≥ `ownership_main_threshold`0.60)且与第二名差距 ≥ `ownership_gap_threshold`0.15 |
| `COMANAGE` | 份额 ≥ `ownership_comanage_threshold`0.35)但不满足 MAIN 条件 |
| `POOL` | 其余合格关系对 |
| `UNASSIGNED` | 总 RS 不足,无法形成稳定归属 |
### 子指数 3MS升温动量
衡量关系是在升温MS > 0还是降温MS ≈ 0
```
f_short = Σ course_weight × decay(days_ago, halflife_short) # 短期半衰期 7 天
f_long = Σ course_weight × decay(days_ago, halflife_long) # 长期半衰期 30 天
MS_raw = max(0, ln(f_short + ε) / (f_long + ε))
```
短期频次高于长期频次时 MS 为正,表示关系在升温。
### 子指数 4ML付费关联
以人工台账窄表(`dws_ml_manual_order_alloc`)为唯一数据源:
```
ML_raw = Σ ln(1 + allocated_amount / amount_base) × decay(days_ago, halflife_recharge)
```
| 参数 | 默认值 | 含义 |
|------|--------|------|
| `amount_base` | 500 | 金额压缩基准 |
| `halflife_recharge` | 21 天 | 充值半衰期 |
若某 `(member_id, assistant_id)` 对仅在台账中出现而无服务记录,会自动创建关系对。
### Display Score 归一化
RS、MS、ML 各自独立归一化到 0-10 分,分位历史按 `index_type` 隔离(分别记录 RS/MS/ML 的分位点。OS 不做归一化,直接输出份额和标签。
---
## DWS_ML_MANUAL_IMPORT — ML 人工台账导入
| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_ML_MANUAL_IMPORT` |
| Python 类 | `MlManualImportTask``tasks/dws/index/ml_manual_import_task.py` |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → MlManualImportTask` |
| 目标表 | `dws.dws_ml_manual_order_source`(宽表)+ `dws.dws_ml_manual_order_alloc`(窄表) |
| 主键 | 宽表:`site_id, external_id, import_scope_key, row_no`;窄表:`site_id, external_id, assistant_id` |
| 指数类型 | `ML` |
| 更新策略 | 按 scope 先删后写DAY 或 P30 批次覆盖) |
### 业务含义
ML 人工台账导入是一个工具型任务,用于将运营人员手工整理的订单-助教归因数据导入系统。导入后的数据作为 `DWS_RELATION_INDEX` 任务中 ML 子指数的唯一数据源。
该任务不依赖时间窗口,由调度器以工具任务方式直接触发。
### 文件路径解析
按以下优先级查找台账文件:
1. 配置项 `run.ml_manual_ledger_file`
2. 配置项 `run.ml_manual_file`
3. 环境变量 `ML_MANUAL_LEDGER_FILE`
### Excel 模板格式
台账文件为 `.xlsx` 格式,第一行为表头,第二行起为数据。模板列定义:
| 列名 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `site_id` | int | 否(默认取配置) | 门店 ID |
| `biz_date` | date | 是 | 业务日期 |
| `external_id` | string | 是 | 外部订单 ID唯一标识 |
| `member_id` | int | 否 | 会员 ID |
| `pay_time` | datetime | 否(默认取 biz_date | 支付时间 |
| `order_amount` | decimal | 否 | 订单金额 |
| `currency` | string | 否(默认 CNY | 币种 |
| `assistant_id_1` ~ `assistant_id_5` | int | 否 | 助教 ID最多 5 个) |
| `assistant_name_1` ~ `assistant_name_5` | string | 否 | 助教姓名 |
| `remark` | string | 否 | 备注 |
### 导入逻辑
#### 1. 读取与规范化
- 使用 `openpyxl` 读取 Excel跳过空行
- 每行规范化:类型转换、缺省值填充、助教列表提取
- `external_id` 为必填,缺失则抛出 `ValueError`
#### 2. 助教分摊
同一订单支持最多 5 个助教归因,默认均分:
```
share_ratio = 1 / N
allocated_amount = order_amount × share_ratio
```
#### 3. 覆盖策略ImportScope
根据 `biz_date` 与当前日期的距离,采用不同的覆盖粒度:
| 条件 | scope_type | 覆盖范围 | 说明 |
|------|------------|----------|------|
| `today - biz_date ≤ 30 天` | `DAY` | 单日 | 按 `site_id + biz_date` 日覆盖 |
| `today - biz_date > 30 天` | `P30` | 30 天批次 | 以固定纪元2026-01-01为锚点按 30 天分桶 |
P30 分桶算法:
```
bucket_index = (biz_date - EPOCH_ANCHOR).days // 30
bucket_start = EPOCH_ANCHOR + bucket_index × 30 天
bucket_end = bucket_start + 29 天
```
#### 4. 写入流程
1. 按 scope 删除旧数据(宽表 + 窄表)
2. 插入宽表(`dws_ml_manual_order_source`
3. Upsert 窄表(`dws_ml_manual_order_alloc`),冲突键为 `(site_id, external_id, assistant_id)`
4. 提交事务
#### 5. 导入批次号
格式:`MLM_<YYYYMMDDHHmmss>_<uuid8>`,如 `MLM_20260215143022_a1b2c3d4`
导入用户按优先级取:环境变量 `ETL_OPERATOR``USERNAME``USER``"system"`
---
## cfg_index_parameters 配置表
所有指数任务的算法参数统一存储在 `dws.cfg_index_parameters` 表中,支持按时间生效和历史追溯。
### 表结构
| 字段 | 类型 | 说明 |
|------|------|------|
| `param_id` | SERIAL PK | 自增主键 |
| `index_type` | VARCHAR(50) NOT NULL | 指数类型:`RS` / `OS` / `MS` / `ML` / `NCI` / `WBI` |
| `param_name` | VARCHAR(100) NOT NULL | 参数名称 |
| `param_value` | NUMERIC(14,6) NOT NULL | 参数值 |
| `description` | TEXT | 参数说明 |
| `effective_from` | DATE NOT NULL | 生效起始日期(默认当天) |
| `effective_to` | DATE | 生效截止日期NULL = 永久有效) |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
唯一约束:`(index_type, param_name, effective_from)`
索引:`idx_cfg_index_params_type`index_type`idx_cfg_index_params_effective`effective_from, effective_to
### 参数加载逻辑
```sql
SELECT param_name, param_value
FROM dws.cfg_index_parameters
WHERE index_type = %s
AND effective_from <= CURRENT_DATE
AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
ORDER BY effective_from DESC
```
同一 `param_name` 若有多条生效记录,取 `effective_from` 最新的一条(代码中通过 `seen` 集合去重)。
### 参数调优方式
新增一条 `effective_from` 为新日期的记录即可覆盖旧参数,旧记录自动失效(无需删除)。如需回滚,将新记录的 `effective_to` 设为过去日期即可。
### WBI 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days_recency` | 60 | 近期活跃判定窗口(天) |
| `visit_lookback_days` | 180 | 到店记录回溯窗口(天) |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 0 | 压缩模式0=无/1=log1p/2=asinh |
| `use_smoothing` | 1 | 是否启用 EWMA 分位平滑 |
| `ewma_alpha` | 0.2 | EWMA 平滑系数 |
| `new_visit_threshold` | 2 | 新客到店次数阈值 |
| `new_days_threshold` | 30 | 新客首访天数阈值 |
| `recharge_recent_days` | 14 | 近期充值窗口(天) |
| `new_recharge_max_visits` | 10 | 充值新客最大到店次数 |
| `overdue_alpha` | 2.0 | 超期 CDF 幂指数 |
| `overdue_weight_halflife_days` | 30 | 超期加权 CDF 间隔半衰期(天) |
| `overdue_weight_blend_min_samples` | 8 | 加权 CDF 最小样本数 |
| `h_recharge` | 7 | 充值衰减半衰期(天) |
| `amount_base_M0` | 300 | 消费金额压缩基准 |
| `balance_base_B0` | 500 | 余额压缩基准 |
| `value_w_spend` / `value_w_bal` | 1.0 / 1.0 | 价值分中消费/余额权重 |
| `w_over` / `w_drop` / `w_re` / `w_value` | 2.0 / 1.0 / 0.4 / 1.2 | 四分项权重 |
| `recency_hard_floor_days` | 14 | 近期硬抑制天数 |
| `recency_gate_days` | 14 | Sigmoid 门控中心(天) |
| `recency_gate_slope_days` | 3 | Sigmoid 门控斜率(天) |
| `enable_stop_high_balance_exception` | 0 | 是否启用 STOP 高余额例外 |
| `high_balance_threshold` | 1000 | 高余额阈值(元) |
### NCI 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days_recency` | 60 | 近期活跃判定窗口(天) |
| `visit_lookback_days` | 180 | 到店记录回溯窗口(天) |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 0 | 压缩模式 |
| `use_smoothing` | 1 | 是否启用 EWMA 分位平滑 |
| `ewma_alpha` | 0.2 | EWMA 平滑系数 |
| `no_touch_days_new` | 3 | 免打扰窗口(天) |
| `t2_target_days` | 7 | 二访目标天数 |
| `salvage_start` / `salvage_end` | 30 / 60 | 挽救系数衰减区间(天) |
| `welcome_window_days` | 3 | 欢迎建联窗口(天) |
| `active_new_visit_threshold_14d` | 2 | 活跃新客 14 天到店阈值 |
| `active_new_recency_days` | 7 | 活跃新客近期天数 |
| `active_new_penalty` | 0.2 | 活跃新客抑制系数 |
| `h_recharge` | 7 | 充值衰减半衰期(天) |
| `amount_base_M0` / `balance_base_B0` | 300 / 500 | 价值分压缩基准 |
| `value_w_spend` / `value_w_bal` | 1.0 / 0.8 | 价值分权重 |
| `w_welcome` / `w_need` / `w_re` / `w_value` | 1.0 / 1.6 / 0.8 / 1.0 | 分项权重 |
### RS 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days` | 60 | 服务行为回溯窗口(天) |
| `session_merge_hours` | 4 | 会话合并阈值(小时) |
| `incentive_weight` | 1.5 | 激励课权重 |
| `halflife_session` | 14 | 会话半衰期(天) |
| `halflife_last` | 10 | 最近服务半衰期(天) |
| `weight_f` / `weight_d` | 1.0 / 0.7 | 频次/时长权重 |
| `gate_alpha` | 0.6 | 近期性门控指数 |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 1 | 压缩模式(默认 log1p |
| `use_smoothing` / `ewma_alpha` | 1 / 0.2 | EWMA 平滑 |
### OS 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `min_rs_raw_for_ownership` | 0.05 | 参与归属计算的最小 RS_raw |
| `min_total_rs_raw` | 0.10 | 形成稳定归属的最小 sum_rs |
| `ownership_main_threshold` | 0.60 | 主责份额阈值 |
| `ownership_comanage_threshold` | 0.35 | 共管份额阈值 |
| `ownership_gap_threshold` | 0.15 | 主责与次席差距阈值 |
| `eps` | 0.000001 | 数值稳定项 |
### MS 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days` | 60 | 服务行为回溯窗口(天) |
| `session_merge_hours` | 4 | 会话合并阈值(小时) |
| `incentive_weight` | 1.5 | 激励课权重 |
| `halflife_short` / `halflife_long` | 7 / 30 | 短期/长期半衰期(天) |
| `eps` | 0.000001 | 数值稳定项 |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 1 | 压缩模式(默认 log1p |
| `use_smoothing` / `ewma_alpha` | 1 / 0.2 | EWMA 平滑 |
### ML 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days` | 60 | 充值行为回溯窗口(天) |
| `amount_base` | 500 | 金额压缩基准 |
| `halflife_recharge` | 21 | 充值半衰期(天) |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 1 | 压缩模式(默认 log1p |
| `use_smoothing` / `ewma_alpha` | 1 / 0.2 | EWMA 平滑 |
> 种子数据脚本:`database/seed_index_parameters.sql`
> DDL 定义:`database/schema_dws.sql`(第 21 节)

View File

@@ -0,0 +1,126 @@
# ODS 任务参数矩阵
> 本文档列举所有 23 个 ODS 任务的关键配置参数,说明每个参数在 ETL 处理流程中的作用,
> 并以通俗语言概述飞球 ETL 的 API → ODS 现状。
>
> 数据来源:`tasks/ods/ods_tasks.py` 中的 `ODS_TASK_SPECS` 声明。
> 最后更新2026-02-17ODS 去重与软删除机制标准化改造后;文档与代码对齐校验于 2026-02-17
---
## 飞球 ETL 的 API → ODS 现状(通俗版)
### 从哪抓
从上游飞球 SaaS 平台的 REST API 抓取,共对接 22 个不同的 API 端点(加上小票的特殊接口共 23 个)。覆盖台球门店的核心业务:订单结账、支付退款、会员档案与余额、助教服务与废除、商品库存、台桌、团购套餐、台费折扣等。每个端点对应一个 ODS 任务。
### 怎么解析
API 返回的 JSON 响应通过两级路径定位数据:先按 `data_path`(通常是 `"data"`)找到数据容器,再按 `list_key`(如 `"settleList"``"tenantMemberInfos"`)取出记录列表。拿到列表后,每条记录被展平为单层字典,然后运行时从数据库的 `information_schema` 读取目标表结构,按列名自动匹配——不需要手写字段映射(`ODS_RECHARGE_SETTLE` 除外,它因为嵌套结构复杂,手动定义了 60+ 个列映射)。原始 JSON 整体存入 `payload` 列,保留完整源数据便于回溯。
### 写到哪
全部写入 PostgreSQL 的 `ods.*` schema对应实际的 `ods` schema23 张表一一对应 23 个任务。
### 怎么去重
所有 23 个任务默认开启 `skip_unchanged=True`:基于原始 payload + is_delete 计算 content_hashSHA-256若与数据库中该业务 ID 最新版本的 hash 相同则跳过写入,避免无意义的版本膨胀。
冲突处理模式默认是 `update`(全字段对比,有变化才更新),也支持 `backfill`(只回填 NULL 列)和 `nothing`(跳过已存在记录),通过运行时配置 `run.ods_conflict_mode` 控制。
### 怎么处理删除
软删除采用"插入删除版本行"语义——不修改历史版本,而是 INSERT 一条 `is_delete=1` 的新版本行。三种快照模式:
- 全表快照5 个任务API 返回全量数据,本次没返回但数据库里有的记录插入一条 `is_delete=1` 的删除版本行。适用于维度表/档案表,数据量小。
- 窗口快照8 个任务):只在指定时间列的窗口范围内做软删除。适用于流水表,数据量大,只能按时间段比对。
- 无快照10 个任务):纯增量写入,不做软删除。
下游 DWD 层取数规约:`DISTINCT ON (id) ORDER BY id, fetched_at DESC`,再过滤 `is_delete = 0`
---
## 参数说明
### OdsTaskSpec 参数(代码层,硬编码在 spec 中)
| 参数 | ETL 处理中的作用 |
|------|-----------------|
| `requires_window` | 控制任务是否需要时间窗口参数来限定抓取范围。为 `True` 时,`execute()` 会解析游标/手动覆盖得到 `(start, end)` 时间段,只抓取该时间段内的数据;为 `False` 时不传时间参数,由 API 自身决定返回范围(通常配合分页抓全量)。注意:此参数跟的是 API 端点的能力,不是数据模型的分类(事实表 vs 维度表)。 |
| `time_fields` | 指定 API 请求中时间窗口参数的键名对。默认 `(startTime, endTime)`,部分任务用 `(rangeStartTime, rangeEndTime)`。必须与上游 API 契约一致。 |
| `include_fetched_at` | 控制是否向目标表写入 `fetched_at` 列(记录抓取时间戳)。仅控制元数据写入,不影响去重行为(去重由 `skip_unchanged` 控制)。 |
| `include_record_index` | 控制是否写入 `record_index` 列(记录在当前批次中的序号)。当表 PK 包含 `record_index` 时,该序号成为冲突检测的一部分。 |
| `skip_unchanged` | 去重开关(默认 `True`)。为 `True` 时,若目标表有 `content_hash` 列且有业务主键则跳过内容未变的记录。content_hash 基于原始 payload + is_delete 计算 SHA-256。不再隐式依赖 `fetched_at` 列的存在。 |
| `snapshot_mode` | 快照软删除策略枚举。`NONE`(默认)= 不做快照对比;`FULL_TABLE` = 全表快照;`WINDOW` = 窗口快照。替代原 `snapshot_full_table` + `snapshot_window_columns` 两个字段。 |
| `snapshot_time_column` | WINDOW 模式的时间列名。仅当 `snapshot_mode=WINDOW` 时有效且必填,其余模式必须为 `None`。 |
### 运行时配置(通过 `.env` / CLI 控制)
| 配置项 | 默认值 | 作用 |
|--------|--------|------|
| `run.snapshot_missing_delete` | `True` | 全局开关:是否执行软删除标记 |
| `run.snapshot_allow_empty_delete` | `False` | 是否允许空结果集触发软删除 |
| `run.ods_conflict_mode` | `update` | 冲突处理模式:`update`(全字段对比更新)/ `backfill`(仅回填 NULL/ `nothing`(跳过) |
| `run.window_override.start/end` | — | 手动覆盖时间窗口 |
| `run.window_split.*` | 见 `defaults.py` | 窗口切分策略(天数、补偿小时等) |
| `run.ods_execute_values_page_size` | `200` | 批量写入的 chunk 大小 |
> OdsTaskSpec 参数是代码层硬编码,改动需要改代码;运行时配置走 `.env` / CLI可以不改代码调整行为。
---
## 任务参数矩阵
| 任务代码 | `requires_window` | `time_fields` | `include_fetched_at` | `include_record_index` | `skip_unchanged` | `snapshot_mode` | `snapshot_time_column` |
|---|:---:|:---:|:---:|:---:|:---:|:---:|---|
| `ODS_ASSISTANT_ACCOUNT` | ✅ | 默认 | ❌ | ✅ | ✅ | `FULL_TABLE` | — |
| `ODS_SETTLEMENT_RECORDS` | ✅ | `(rangeStartTime, rangeEndTime)` | ❌ | ✅ | ✅ | `NONE` | — |
| `ODS_TABLE_USE` | ❌ | 默认 | ❌ | ✅ | ✅ | `WINDOW` | `create_time` |
| `ODS_ASSISTANT_LEDGER` | ✅ | 默认 | ❌ | ✅ | ✅ | `WINDOW` | `create_time` |
| `ODS_ASSISTANT_ABOLISH` | ✅ | 默认 | ❌ | ✅ | ✅ | `NONE` | — |
| `ODS_STORE_GOODS_SALES` | ❌ | 默认 | ❌ | ✅ | ✅ | `WINDOW` | `create_time` |
| `ODS_PAYMENT` | ❌ | 默认 | ❌ | ✅ | ✅ | `NONE` | — |
| `ODS_REFUND` | ❌ | 默认 | ❌ | ✅ | ✅ | `WINDOW` | `pay_time` |
| `ODS_PLATFORM_COUPON` | ❌ | 默认 | ❌ | ✅ | ✅ | `WINDOW` | `consume_time` |
| `ODS_MEMBER` | ❌ | 默认 | ❌ | ✅ | ✅ | `NONE` | — |
| `ODS_MEMBER_CARD` | ❌ | 默认 | ❌ | ✅ | ✅ | `FULL_TABLE` | — |
| `ODS_MEMBER_BALANCE` | ❌ | 默认 | ❌ | ✅ | ✅ | `WINDOW` | `create_time` |
| `ODS_RECHARGE_SETTLE` | ✅ | `(rangeStartTime, rangeEndTime)` | ✅ | ❌ | ✅ | `NONE` | — |
| `ODS_GROUP_PACKAGE` | ❌ | 默认 | ❌ | ✅ | ✅ | `FULL_TABLE` | — |
| `ODS_GROUP_BUY_REDEMPTION` | ❌ | 默认 | ❌ | ✅ | ✅ | `WINDOW` | `create_time` |
| `ODS_INVENTORY_STOCK` | ❌ | 默认 | ❌ | ✅ | ✅ | `NONE` | — |
| `ODS_INVENTORY_CHANGE` | ✅ | 默认 | ❌ | ✅ | ✅ | `NONE` | — |
| `ODS_TABLES` | ❌ | 默认 | ❌ | ✅ | ✅ | `NONE` | — |
| `ODS_GOODS_CATEGORY` | ❌ | 默认 | ❌ | ✅ | ✅ | `NONE` | — |
| `ODS_STORE_GOODS` | ❌ | 默认 | ❌ | ✅ | ✅ | `FULL_TABLE` | — |
| `ODS_TABLE_FEE_DISCOUNT` | ❌ | 默认 | ❌ | ✅ | ✅ | `WINDOW` | `create_time` |
| `ODS_TENANT_GOODS` | ❌ | 默认 | ❌ | ✅ | ✅ | `FULL_TABLE` | — |
| `ODS_SETTLEMENT_TICKET` | ❌ | — | ✅ | ✅ | ✅ | `NONE` | — |
> - "默认" `time_fields` 表示 `(startTime, endTime)`
> - "—" 表示该参数为 `None` 或不适用
> - `skip_unchanged` 所有任务均为 `True`(默认值),基于 payload + is_delete 计算 content_hash
> - `ODS_SETTLEMENT_TICKET` 的 `time_fields` 为 `—` 是因为它不走标准分页抓取流程,而是先收集 `orderSettleId` 再逐个调用小票接口
> - `ODS_RECHARGE_SETTLE` 是唯一手动映射 60+ 列的任务(其余任务按 DB schema 自动匹配)
---
## 去重策略
所有 23 个任务统一使用 `skip_unchanged=True`(默认值):
- content_hash 基于原始 payload`json.dumps(sort_keys=True, separators=(',',':'), ensure_ascii=False)`+ `is_delete` 值计算 SHA-256
- 写入时与数据库中该业务 ID 最新版本的 content_hash 比对,相同则跳过
- 冲突处理模式由运行时配置 `run.ods_conflict_mode` 控制(默认 `update`
## 软删除策略分类
| 策略 | `snapshot_mode` | 适用任务 | 机制 |
|------|:---:|----------|------|
| 全表快照 | `FULL_TABLE` | `ODS_ASSISTANT_ACCOUNT``ODS_MEMBER_CARD``ODS_GROUP_PACKAGE``ODS_STORE_GOODS``ODS_TENANT_GOODS`5 个) | API 返回全量,不在返回集中的记录插入一条 `is_delete=1` 的删除版本行 |
| 窗口快照 | `WINDOW` | `ODS_TABLE_USE``ODS_ASSISTANT_LEDGER``ODS_STORE_GOODS_SALES``ODS_REFUND``ODS_PLATFORM_COUPON``ODS_MEMBER_BALANCE``ODS_GROUP_BUY_REDEMPTION``ODS_TABLE_FEE_DISCOUNT`8 个) | 仅在 `snapshot_time_column` 指定的时间列窗口范围内做软删除 |
| 无快照 | `NONE` | 其余 10 个 | 纯增量写入,不做软删除 |
软删除语义INSERT 一条 `is_delete=1` 的新版本行(而非 UPDATE 历史版本),保持 ODS 追加写入的一致性。若最新版本已是 `is_delete=1` 则跳过(幂等)。

View File

@@ -0,0 +1,302 @@
# ODS 层任务详解
> 本文档说明飞球 ETL 系统中 ODS操作数据存储层的所有任务。
> ODS 层负责从上游 SaaS API 抽取原始业务数据并落地到 PostgreSQL`ods` schema保留源 payload 便于回溯。
---
## 概述
ODS 层采用**声明式配置**驱动的通用任务模式:由 `BaseOdsTask` + `OdsTaskSpec` 配置驱动,通过 `ODS_TASK_CLASSES` 字典动态注册,共 23 个任务。
所有 ODS 任务写入 `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` | 结账小票详情 |
> 所有目标表均位于 `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 表全限定名,如 `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 中提取特定字段 |
#### 时间窗口字段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `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]` | `{}` | 额外的固定请求参数 |
#### 快照与软删除字段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `skip_unchanged` | `bool` | `True` | 去重开关:为 True 时跳过 content_hash 与数据库最新版本相同的记录 |
| `snapshot_mode` | `SnapshotMode` | `SnapshotMode.NONE` | 快照软删除策略枚举NONE / FULL_TABLE / WINDOW |
| `snapshot_time_column` | `str \| None` | `None` | WINDOW 模式下用于限定软删除范围的时间列名 |
> **SnapshotMode 枚举**
> - `NONE`:不做快照对比,不触发软删除
> - `FULL_TABLE`全表快照——API 返回全量数据,不在返回集中的记录插入一条 `is_delete=1` 的删除版本行
> - `WINDOW`:窗口快照——仅在 `snapshot_time_column` 指定的时间列范围内做快照对比
>
> **校验约束**`__post_init__`
> - `WINDOW` 模式必须指定 `snapshot_time_column`(非空字符串),否则抛出 `ValueError`
> - `FULL_TABLE` 或 `NONE` 模式不允许指定 `snapshot_time_column`,否则抛出 `ValueError`
>
> **快照模式说明**:当 `snapshot_mode` 为 `FULL_TABLE` 或 `WINDOW` 时,任务会在每个分段结束后调用 `_mark_missing_as_deleted()`,为 API 未返回但数据库中存在的记录**插入一条 `is_delete=1` 的新版本行**(而非 UPDATE 历史版本)。此行为还需配合运行时配置 `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` 列。仅控制元数据写入,不影响去重行为(去重由 `skip_unchanged` 控制) |
| `include_record_index` | `bool` | `False` | 是否写入 `record_index` 列(当表 PK 包含 `record_index` 时,该序号成为冲突检测的一部分) |
### 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
│ │ └── 特殊处理GetGoodsInventoryList 端点的 siteId 需传数组格式
│ ├── 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 标准化**`_normalize_is_delete_flag` 统一为 `0/1`
5. **content_hash 计算**:基于原始 payload未展平的 API 返回 JSON+ `is_delete` 计算 SHA-256 哈希
6. **content_hash 去重**`skip_unchanged=True` 时):与数据库中同一业务主键的最新 `content_hash` 比对,相同则跳过
7. **值映射**:逐列匹配,特殊列(`payload``source_file``fetched_at``content_hash`)自动填充;对 `settlement_records``recharge_settlements` 表,若记录中包含 `siteProfile` 嵌套对象,会自动回填 `tenantid``siteid``sitename` 等缺失字段
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_mode``FULL_TABLE``WINDOW`),任务在每个分段结束后执行软删除。软删除采用**插入删除版本行**的方式(而非 UPDATE 历史版本),保持 ODS 追加写入的语义一致性。
**触发路径:**
- **路径 AAPI 返回 is_delete**:上游 API 的 JSON 响应中自带 `is_delete`/`isDelete` 等字段,由 `_normalize_is_delete_flag` 标准化后随记录正常写入。因为 `is_delete` 参与 content_hash 计算is_delete 变化会产生新 hash → 自动插入新版本行。此路径无需额外改造。
- **路径 B快照空缺`_mark_missing_as_deleted`**:通过快照对比发现上游已不存在的记录,插入一条 `is_delete=1` 的删除版本行。
**路径 B 执行流程:**
1. 收集本次抓取到的所有业务 ID → `fetched_keys`
2. 查询快照范围内数据库中 `is_delete != 1` 的业务 ID
- `FULL_TABLE` 模式:全表范围
- `WINDOW` 模式:`WHERE {snapshot_time_column} >= window_start AND < window_end`
3. 差集 = 数据库中的 ID - `fetched_keys` → 缺失 ID
4. 对每个缺失 ID
- 读取最新版本行(`DISTINCT ON (id) ... ORDER BY fetched_at DESC`
- 若最新版本已是 `is_delete=1` → 跳过(幂等性保证)
- 否则:复制该行所有字段,设 `is_delete=1`,基于原始 payload + `is_delete=1` 重算 `content_hash`INSERT 新版本行
5. 历史版本行完全不变
**关键特性:**
- **幂等性**:若最新版本已是 `is_delete=1`,不会重复插入删除版本
- **历史不可变**:所有历史版本行的内容保持不变,不执行任何 UPDATE
- **版本追溯**:删除操作本身也是一个版本,可通过 `fetched_at` 追溯删除时间
---
### content_hash 去重机制
`content_hash` 是通用 ODS 任务的核心去重手段,所有 23 个任务默认开启(`skip_unchanged=True`)。
#### 计算方式
`_compute_content_hash(record, payload, is_delete)` 基于**原始 payload**(未展平的 API 返回 JSON**is_delete** 字段计算 SHA-256 哈希:
```python
payload_str = json.dumps(payload, sort_keys=True, separators=(',',':'), ensure_ascii=False)
raw = f"{payload_str}|{is_delete}"
hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
```
- 输入是原始 API 返回的 JSON 对象payload天然不含 `fetched_at``source_file` 等元数据字段,无需排除逻辑
- `is_delete` 参与 hash 计算——当记录从正常变为删除或反之hash 会变化,自动产生新版本行
- 分隔符 `|` 确保 payload 和 is_delete 不会产生歧义
> 历史说明:改造前 hash 基于展平后的 `merged_rec`(排除 7 个元数据字段),由 `_sanitize_record_for_hash` 方法处理。该方法已删除,新算法更简洁且语义更清晰。
#### 去重流程
1. **计算**:对每条记录的原始 payload + is_delete 计算 SHA-256 哈希
2. **比对**:从数据库中按业务主键取最新一条记录的 `content_hash`(利用 `(业务主键, fetched_at DESC)` 索引)
3. **跳过**:若新记录的 `content_hash` 与数据库中最新记录相同,则跳过写入(计入 `skipped` 计数)
> 仅在 `skip_unchanged=True`(默认)且目标表包含 `content_hash` 列且有业务主键时生效。不再隐式依赖 `fetched_at` 列的存在。
#### 下游取数规约
DWD 层从 ODS 取最新有效版本的标准查询模式:
```sql
SELECT DISTINCT ON (id) *
FROM ods.{table_name}
WHERE is_delete IS DISTINCT FROM 1
ORDER BY id, fetched_at DESC;
```
此查询利用新增的 `(业务主键, fetched_at DESC)` 复合索引,避免全表扫描。
---
### 各任务详细配置
| 任务代码 | 需要窗口 | 快照模式(`snapshot_mode` | `snapshot_time_column` | 特殊说明 |
|----------|----------|---------------------------|----------------------|----------|
| `ODS_ASSISTANT_ACCOUNT` | 是 | `FULL_TABLE` | — | 助教账号档案,全量抓取后标记离职/删除 |
| `ODS_SETTLEMENT_RECORDS` | 是 | `NONE` | — | 结账记录,按时间窗口增量抓取 |
| `ODS_TABLE_USE` | 否 | `WINDOW` | `create_time` | 台费计费流水 |
| `ODS_ASSISTANT_LEDGER` | 是 | `WINDOW` | `create_time` | 助教服务流水 |
| `ODS_ASSISTANT_ABOLISH` | 是 | `NONE` | — | 助教废除记录 |
| `ODS_STORE_GOODS_SALES` | 否 | `WINDOW` | `create_time` | 门店商品销售流水 |
| `ODS_PAYMENT` | 否 | `NONE` | — | 支付流水 |
| `ODS_REFUND` | 否 | `WINDOW` | `pay_time` | 退款流水 |
| `ODS_PLATFORM_COUPON` | 否 | `WINDOW` | `consume_time` | 平台/团购券核销 |
| `ODS_MEMBER` | 否 | `NONE` | — | 会员档案 |
| `ODS_MEMBER_CARD` | 否 | `FULL_TABLE` | — | 会员储值卡 |
| `ODS_MEMBER_BALANCE` | 否 | `WINDOW` | `create_time` | 会员余额变动 |
| `ODS_RECHARGE_SETTLE` | 是 | `NONE` | — | 充值结算 |
| `ODS_GROUP_PACKAGE` | 否 | `FULL_TABLE` | — | 团购套餐定义 |
| `ODS_GROUP_BUY_REDEMPTION` | 否 | `WINDOW` | `create_time` | 团购套餐核销 |
| `ODS_INVENTORY_STOCK` | 否 | `NONE` | — | 库存汇总 |
| `ODS_INVENTORY_CHANGE` | 是 | `NONE` | — | 库存变化记录 |
| `ODS_TABLES` | 否 | `NONE` | — | 台桌维表 |
| `ODS_GOODS_CATEGORY` | 否 | `NONE` | — | 库存商品分类树 |
| `ODS_STORE_GOODS` | 否 | `FULL_TABLE` | — | 门店商品档案 |
| `ODS_TABLE_FEE_DISCOUNT` | 否 | `WINDOW` | `create_time` | 台费折扣/调账 |
| `ODS_TENANT_GOODS` | 否 | `FULL_TABLE` | — | 租户商品档案 |
| `ODS_SETTLEMENT_TICKET` | 否 | `NONE` | — | 结账小票详情(专用实现,见下文) |
> 所有 23 个任务默认 `skip_unchanged=True`(去重开启)。
> **特殊任务**`ODS_SETTLEMENT_TICKET` 虽然在 `ODS_TASK_SPECS` 中声明,但其 `ODS_TASK_CLASSES` 条目被 `OdsSettlementTicketTask` 专用实现覆盖。该任务不走标准分页抓取流程,而是先从 `payment_transactions` 表或支付 API 收集 `orderSettleId`,再逐个调用小票接口获取详情。

View File

@@ -0,0 +1,986 @@
# ODS → DWD 数据处理全流程报告
> 生成日期2026-02-17
> 代码文件:`tasks/dwd/dwd_load_task.py`DwdLoadTask约 1700 行)
> 任务代码:`DWD_LOAD_FROM_ODS`
---
## 一、总体架构
### 1.1 一句话概括
DWD 层的核心任务 `DWD_LOAD_FROM_ODS` 做的事情很简单:**从 ODS 表读取最新数据,清洗后写入 DWD 表**。
维度表dim_*)用"缓慢变化维度"策略保留历史版本,事实表用"增量追加"策略只写新数据。
### 1.2 类继承关系
```
BaseTask基类时间窗口、E/T/L 模板)
└── DwdLoadTaskDWD 装载:维度合并 + 事实增量)
```
> ⚠️ 代码中还有一个 `BaseDwdTask`(继承 BaseTask但 `DwdLoadTask` 并不继承它。
> `BaseDwdTask` 是早期遗留的死代码,其 `iter_ods_rows()` 和 `parse_payload()` 方法从未被调用。
### 1.3 处理规模
| 类别 | 数量 | 说明 |
|------|------|------|
| 维度表映射 | 17 对 | 含 `_ex` 扩展表,如 dim_site / dim_site_ex |
| 事实表映射 | 23 对 | 含 `_ex` 扩展表,如 dwd_payment / dwd_refund_ex |
| 合计 | 40 对 | 每对 = 1 个 DWD 表 ← 1 个 ODS 源表 |
### 1.4 事务模型
**每张表一次独立事务**。好处是:某张表失败不影响其他表。
```
表 A → 成功 → commit ✓
表 B → 失败 → rollback记录错误继续
表 C → 成功 → commit ✓
最终返回:{tables: [A统计, C统计], errors: [B错误]}
```
---
## 二、主流程(通俗版)
### 2.1 整体流水线
```
┌─────────────────────────────────────────────────────────┐
│ BaseTask.execute() │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ extract │ → │transform │ → │ load │ │
│ │(返回now) │ │ (透传) │ │ (遍历TABLE_MAP) │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
│ │ │
│ ┌──────────────┼───────────┐ │
│ ▼ ▼ │ │
│ dim_* 表? 其他表? │ │
│ │ │ │ │
│ _merge_dim _merge_fact_ │ │
│ │ increment │ │
│ ┌─────┴─────┐ │ │ │
│ ▼ ▼ ▼ │ │
│ 有SCD2列? 无SCD2列? 增量写入 │ │
│ │ │ + │ │
│ _merge_dim _merge_dim 缺失回补 │ │
│ _scd2 _type1_ │ │
│ upsert │ │
│ │ │
└─────────────────────────────────────────────────────────┘
```
### 2.2 举个例子:一条会员记录的旅程
假设上游 SaaS 系统中,会员"张三"的手机号从 `138xxxx1234` 改成了 `139xxxx5678`
**第 1 步ODS 层已有数据**
ODS 表 `ods.member_profiles` 中可能有多条张三的记录(每次抓取一条):
| id | name | phone | fetched_at | is_delete | content_hash |
|----|------|-------|------------|-----------|--------------|
| 1001 | 张三 | 138xxxx1234 | 2026-02-15 10:00 | 0 | abc123... |
| 1001 | 张三 | 138xxxx1234 | 2026-02-16 10:00 | 0 | abc123... |
| 1001 | 张三 | 139xxxx5678 | 2026-02-17 10:00 | 0 | def456... |
**第 2 步DWD 取最新快照**
DwdLoadTask 用 `DISTINCT ON (id) ... ORDER BY fetched_at DESC` 只取最新一条:
| id | name | phone |
|----|------|-------|
| 1001 | 张三 | 139xxxx5678 |
**第 3 步:与 DWD 当前版本对比**
DWD 表 `dwd.dim_member` 当前版本(`scd2_is_current=1`
| member_id | name | phone | scd2_version | scd2_is_current |
|-----------|------|-------|-------------|-----------------|
| 1001 | 张三 | 138xxxx1234 | 1 | 1 |
对比发现 `phone` 列不同 → 有变更。
**第 4 步:关闭旧版 + 插入新版**
```sql
-- 关闭旧版本
UPDATE dwd.dim_member
SET scd2_end_time = '2026-02-17 10:30', scd2_is_current = 0
WHERE member_id = 1001 AND scd2_is_current = 1;
-- 插入新版本
INSERT INTO dwd.dim_member (member_id, name, phone, scd2_start_time, scd2_end_time, scd2_is_current, scd2_version)
VALUES (1001, '张三', '139xxxx5678', '2026-02-17 10:30', '9999-12-31', 1, 2);
```
最终 DWD 表中有两条记录,完整保留了变更历史:
| member_id | name | phone | scd2_version | scd2_is_current | scd2_start_time | scd2_end_time |
|-----------|------|-------|-------------|-----------------|-----------------|---------------|
| 1001 | 张三 | 138xxxx1234 | 1 | 0 | 2026-02-15 | 2026-02-17 10:30 |
| 1001 | 张三 | 139xxxx5678 | 2 | 1 | 2026-02-17 10:30 | 9999-12-31 |
---
## 三、维度表处理详解
### 3.1 两种策略的选择
代码根据 DWD 表是否包含 SCD2 列自动选择策略:
| DWD 表列中有 scd2_* 列? | 策略 | 方法 | 通俗解释 |
|--------------------------|------|------|----------|
| 是 | SCD2 合并 | `_merge_dim_scd2()` | 保留历史:旧版本标记"过期",新版本插入 |
| 否 | Type1 Upsert | `_merge_dim_type1_upsert()` | 直接覆盖:有变更就更新,不保留历史 |
> SCD2 列集合:`scd2_start_time`、`scd2_end_time`、`scd2_is_current`、`scd2_version`
>
> **实际情况**:经 DDL 验证,当前所有 17 张维度表都有 SCD2 列,所有 23 张事实表都没有。
> 因此 Type1 Upsert 分支在当前架构下不会被触发,属于防御性代码。
### 3.2 ODS 最新快照选取(两种策略共用)
不管哪种策略,第一步都是从 ODS 取"每个业务主键的最新一条记录"
```sql
SELECT DISTINCT ON ()
<>
FROM ods.<>
WHERE fetched_at IS NOT NULL
ORDER BY , fetched_at DESC NULLS LAST
```
**为什么要这样做?** 因为 ODS 是"追加写入"的,同一个会员/商品可能被抓取了很多次。
我们只关心最新状态,所以用 `DISTINCT ON` + `ORDER BY fetched_at DESC` 去重。
### 3.3 列映射机制FACT_MAPPINGS
ODS 表的列名和 DWD 表的列名经常不一样(上游 API 用驼峰命名DWD 用下划线命名),
所以需要一个映射表来"翻译"。这个映射表叫 `FACT_MAPPINGS`(虽然名字里有 FACT但维度表也用它
映射优先级:
1. **显式映射**FACT_MAPPINGS 中定义的)→ 最高优先级
2. **同名映射**ODS 和 DWD 列名相同)→ 自动匹配
3. **主键兜底**DWD 主键在 ODS 中找不到,但 ODS 有 `id` 列)→ 自动映射到 `id`
举例:`dwd.dwd_settlement_head` 的映射
```python
# ODS 源表 settlement_records 用的是驼峰无下划线风格
"dwd.dwd_settlement_head": [
("order_settle_id", "id", None), # 简单重命名
("site_name", "sitename", None), # 驼峰 → 下划线
("pay_time", "paytime", None), # 同上
("consume_money", "consumemoney", None), # 同上
...
]
```
支持的映射类型:
| 类型 | 示例 | 说明 |
|------|------|------|
| 简单重命名 | `("table_id", "id", None)` | ODS 的 `id` → DWD 的 `table_id` |
| JSON 提取 | `("shop_name", "siteprofile->>'shop_name'", None)` | 从 JSONB 字段中提取值 |
| 类型转换 | `("longitude", "siteprofile->>'longitude'", "numeric")` | 提取后转数值 |
| 布尔转换 | `("is_first_limit", "is_first_limit", "boolean")` | 转布尔 |
| 日期截断 | `("pay_date", "pay_time", "date")` | 时间戳 → 日期 |
| SQL 表达式 | `("is_leaf", "CASE WHEN ... THEN 1 ELSE 0 END", None)` | 计算列 |
### 3.4 变更检测_values_equal
对比 ODS 最新值和 DWD 当前值时,不能简单用 `==`,因为类型经常不一致。
代码做了 4 层归一化:
| 归一化 | 场景 | 举例 |
|--------|------|------|
| 空值归一化 | 空字符串 `""``None` 视为相同 | `"" == None → True` |
| 日期时间归一化 | 朴素时间和带时区时间统一比较 | `2026-02-17T10:00 == 2026-02-17T10:00+08:00 → True` |
| 布尔归一化 | 字符串/数字/布尔统一 | `"true" == True == 1 → True` |
| 数值归一化 | 字符串数字和 Decimal 统一 | `"3.14" == Decimal("3.14") → True` |
> 归一化顺序:空值 → 日期 → 布尔 → 数值 → 最后才用 `==`
### 3.5 SCD2 批量优化
早期代码是逐行处理(`_upsert_scd2_row`),现在改为批量处理:
1. **预加载**:一次性 `SELECT *` 读取 DWD 当前版本到内存
2. **批量对比**:在 Python 中逐行对比,收集需要关闭/插入的主键
3. **批量关闭**:单主键用 `ANY` 数组,复合主键用 `execute_batch`
4. **批量插入**:用 `execute_values` 一次性插入所有新版本
> 旧的逐行方法 `_upsert_scd2_row` / `_close_current_dim` / `_insert_dim_row` 仍保留在代码中,
> 但已不被主流程调用(属于遗留代码)。
### 3.6 特殊处理:商品分类展开
`dwd.dim_goods_category` 表比较特殊。ODS 源表 `stock_goods_category_tree` 中,
每条记录的 `categoryboxes` 字段是一个 JSON 数组,包含子分类。
代码通过 `_expand_goods_category_rows()` 将父子关系展开为平铺行:
```
原始 ODS 记录:
{ id: 1, category_name: "饮品", categoryboxes: [{id: 11, category_name: "碳酸饮料"}, {id: 12, category_name: "果汁"}] }
展开后:
{ id: 1, category_name: "饮品", category_level: 1, is_leaf: 0, pid: 0 }
{ id: 11, category_name: "碳酸饮料", category_level: 2, is_leaf: 1, pid: 1 }
{ id: 12, category_name: "果汁", category_level: 2, is_leaf: 1, pid: 1 }
```
---
## 四、事实表处理详解
### 4.1 增量机制水位线Watermark
事实表不像维度表那样需要"对比变更",而是**只写新数据**。
怎么知道哪些是"新数据"?靠水位线。
**统一使用 `fetched_at` 作为增量列**(代码中硬编码 `order_col = "fetched_at"`)。
两种模式:
| 模式 | 触发条件 | SQL 过滤 |
|------|----------|----------|
| 窗口覆盖模式 | 配置了 `run.window_override.start/end` | `WHERE fetched_at >= <start> AND fetched_at < <end>` |
| 自动水位模式 | 未配置窗口覆盖 | `WHERE fetched_at > <watermark>` |
> **通俗理解**:水位线 = "上次处理到哪了"的标记。
>
> **重要区分**:水位线仅在"自动水位模式"下起作用(即没有配置 `run.window_override`)。
> 如果你手动指定了时间窗口(比如 `start=08:00, end=10:00`),代码直接用你给的窗口做
> `WHERE fetched_at >= start AND fetched_at < end`,完全不查水位线。
>
> 在自动水位模式下,水位线之前的数据不参与处理。取出的记录通过 `ON CONFLICT DO UPDATE`
> 写入 — 新记录直接插入,已存在的记录如果有变更就更新。
水位线计算(`_get_fact_watermark`
```
1. DWD 表有 fetched_at 列? → SELECT MAX(fetched_at) FROM dwd表
2. DWD 表无 fetched_at 但有主键? → JOIN ODS 取 MAX(ods.fetched_at)
3. 都不行? → 兜底 "1970-01-01"(相当于全量)
```
### 4.2 举个例子:一笔支付记录的写入
假设 ODS 表 `ods.payment_transactions` 新增了一笔支付:
| id | pay_time | amount | site_id | fetched_at |
|----|----------|--------|---------|------------|
| 5001 | 2026-02-17 14:00 | 88.00 | 101 | 2026-02-17 14:05 |
DWD 表 `dwd.dwd_payment` 上次水位线是 `2026-02-17 12:00`
**第 1 步**:计算水位线 → `MAX(fetched_at) = 2026-02-17 12:00`
**第 2 步**:查询增量数据
```sql
SELECT "id" AS "payment_id", "pay_time"::date AS "pay_date", "tenant_id", ...
FROM ods.payment_transactions
WHERE fetched_at > '2026-02-17 12:00'
AND fetched_at IS NOT NULL
```
**第 3 步**:写入 DWD带冲突处理
```sql
INSERT INTO dwd.dwd_payment (payment_id, pay_date, tenant_id, ...)
SELECT ...
ON CONFLICT (payment_id) DO UPDATE SET pay_date = EXCLUDED.pay_date, ...
WHERE dwd.dwd_payment.pay_date IS DISTINCT FROM EXCLUDED.pay_date
OR dwd.dwd_payment.tenant_id IS DISTINCT FROM EXCLUDED.tenant_id
OR ...
RETURNING (xmax = 0) AS inserted
```
### 4.3 快照去重
代码通过 `snapshot_mode = "content_hash" in ods_set` 判断是否启用快照去重。
经过 ODS 去重标准化改造后,所有 23 张 ODS 表都有 `content_hash` 列,
因此 `snapshot_mode` 对所有表都是 True事实表始终使用 `DISTINCT ON` 取最新快照:
```sql
SELECT DISTINCT ON () ... FROM ods表 WHERE fetched_at > 线 ORDER BY , fetched_at DESC
```
### 4.4 写入策略
由于 `snapshot_mode` 始终为 True`dwd.fact_upsert` 默认为 True
实际上所有事实表都走同一条路径:
```sql
INSERT INTO dwd表 (...)
SELECT DISTINCT ON (pk) ... FROM ods表 WHERE fetched_at > 线 ORDER BY pk, fetched_at DESC
ON CONFLICT (pk) DO UPDATE SET col1 = EXCLUDED.col1, col2 = EXCLUDED.col2, ...
WHERE dwd表.col1 IS DISTINCT FROM EXCLUDED.col1
OR dwd表.col2 IS DISTINCT FROM EXCLUDED.col2
OR ...
RETURNING (xmax = 0) AS inserted
```
> `IS DISTINCT FROM` 确保只有真正有变更的行才会被更新(避免无意义的写放大)。
> `RETURNING (xmax = 0)` 用于区分新增xmax=0和更新xmax≠0统计用。
>
> 代码中保留了 `dwd.fact_upsert=False` 时走 `DO NOTHING` 的分支,
> 以及 `snapshot_mode=False` 时不做 `DISTINCT ON` 的分支,但在当前架构下都不会触发。
### 4.5 缺失主键回补_insert_missing_by_pk
在主增量写入完成后,代码会对**所有事实表**额外执行一次"回补"操作:
```sql
INSERT INTO dwd表 (...)
SELECT ... FROM ods表 o
LEFT JOIN dwd表 d ON d.pk = o.pk
WHERE d.pk IS NULL -- DWD 中不存在的记录
AND o.fetched_at > <watermark> -- 同样受水位线/窗口约束
ON CONFLICT (pk) DO NOTHING
```
#### 什么时候会发生缺失?
**场景 1主增量写入用了 DISTINCT ON丢弃了同 ID 的旧版本**
这是最常见的触发场景。假设 ODS 中同一个助教服务记录 id=5001 有两条:
| id | fetched_at | site_id | amount |
|----|------------|---------|--------|
| 5001 | 2026-02-17 10:00 | 101 | 50.00 |
| 5001 | 2026-02-17 10:30 | 101 | 60.00 |
主增量写入的 `DISTINCT ON (id) ORDER BY fetched_at DESC` 只取了 10:30 那条。
如果这条因为某种原因写入失败比如类型转换异常、NULL 主键等id=5001 就丢了。
回补的 LEFT JOIN 会发现 DWD 中没有 id=5001于是从 ODS 补入。
**场景 2ODS 层收到"迟到"的数据**
```
时间线:
10:00 DWD 装载运行,水位线 = MAX(fetched_at) = 09:00
→ 查 ODS WHERE fetched_at > 09:00
→ 写入了 id=5001, 5002, 5003
→ 新水位线 = 10:00
10:05 ODS 层收到一条迟到数据:
id=4999, fetched_at=10:02 (在新水位线之后)
但这条数据的业务时间 pay_time=08:55 (很早的订单)
10:30 DWD 装载再次运行,水位线 = 10:00
→ 主查询 WHERE fetched_at > 10:00 → 取到 id=4999
→ 正常写入,不需要回补
```
但如果迟到数据的 `fetched_at` 恰好在水位线之前(比如手动回灌历史数据时
`fetched_at` 保留了原始值),主查询就会漏掉它。此时回补的 LEFT JOIN 也受
`fetched_at > watermark` 约束,同样捞不到。
**真正能兜底的是** `overlap_seconds`(默认 600 秒):下次运行时水位线会回退 10 分钟,
覆盖到之前可能遗漏的区间。
**场景 3并发写入冲突**
如果两个 DWD 装载进程同时运行(虽然不推荐),主写入的 `ON CONFLICT DO UPDATE`
可能因为锁竞争而跳过某些行。回补的 `ON CONFLICT DO NOTHING` 可以在下次运行时补齐。
#### 严格时间窗口下回补基本不触发
如果你的任务严格规范了时间窗口(比如明确获取 8:00-10:00 的数据ODS 任务从 10:01 启动),
那么:
1. ODS 层在 10:01 抓取的数据,`fetched_at` 都是 10:01 左右
2. DWD 任务配置 `window_override.start=08:00, end=10:00`
3. 主增量写入:`WHERE fetched_at >= 08:00 AND fetched_at < 10:00`
4. 回补也受同样的窗口约束:`WHERE o.fetched_at >= 08:00 AND o.fetched_at < 10:00`
在这种模式下ODS 和 DWD 处理的数据范围完全一致,主增量写入已经覆盖了所有记录,
回补的 LEFT JOIN 查出来的结果集几乎一定是空的(`d.pk IS NULL` 不成立)。
**结论**:严格时间窗口 + ODS 任务先于 DWD 任务完成 = 回补步骤变成纯安全网,
实际不产生写入。这是最稳健的运行方式。
> ⚠️ 代码中定义了 `FACT_MISSING_FILL_TABLES = {"dwd.dwd_assistant_service_log"}`
> 注释说"对于会出现回补旧记录的事实表",但实际上 `_insert_missing_by_pk` 对所有事实表都执行,
> 并没有检查是否在 `FACT_MISSING_FILL_TABLES` 中。这是一个**定义与实际行为不一致**的问题。
### 4.6 类型自动转换
当 DWD 列是数值类型integer/numeric/bigint 等)但 ODS 列是文本类型时,
代码自动添加类型转换:
```sql
CAST(NULLIF(CAST("col" AS text), '') AS numeric)::integer
```
> `NULLIF(..., '')` 确保空字符串不会导致转换失败,而是变成 NULL。
---
## 五、完整参数清单
### 5.1 代码级常量
| 常量 | 值 | 作用 |
|------|-----|------|
| `TABLE_MAP` | 40 对映射 | DWD 表 → ODS 源表的完整映射 |
| `SCD_COLS` | `{scd2_start_time, scd2_end_time, scd2_is_current, scd2_version}` | SCD2 元数据列集合 |
| `FACT_ORDER_CANDIDATES` | `[pay_time, create_time, update_time, occur_time, settle_time, start_use_time, fetched_at]` | 增量排序候选列(⚠️ 实际未使用,见已知问题) |
| `FACT_MISSING_FILL_TABLES` | `{dwd.dwd_assistant_service_log}` | 需要回补的事实表(⚠️ 实际未检查,见已知问题) |
| `FACT_MAPPINGS` | 大字典 | 每张 DWD 表的列映射规则 |
### 5.2 运行时配置
| 配置项 | 来源 | 默认值 | 作用 |
|--------|------|--------|------|
| `dwd.only_tables` | AppConfig / 环境变量 `DWD_ONLY_TABLES` | 空(处理所有表) | 逗号分隔的表名列表,限定只处理指定表 |
| `dwd.fact_upsert` | AppConfig / 环境变量 `DWD_FACT_UPSERT` | `True` | 事实表冲突时是否更新False 则 DO NOTHING |
| `run.window_override.start` | AppConfig | 无 | 手动指定时间窗口起点 |
| `run.window_override.end` | AppConfig | 无 | 手动指定时间窗口终点 |
| `app.timezone` | AppConfig | `Asia/Shanghai` | 时区(影响日期时间归一化) |
### 5.3 BaseTask 继承的配置
| 配置项 | 默认值 | 作用 |
|--------|--------|------|
| `run.window_minutes.default_idle` | 180 | 闲时窗口分钟数 |
| `run.window_minutes.default_busy` | 30 | 忙时窗口分钟数 |
| `run.idle_window.start` / `end` | `04:00` / `16:00` | 闲时判定区间 |
| `run.overlap_seconds` | 600 | 窗口重叠秒数(防漏) |
| `app.store_id` | — | 门店 ID |
---
## 六、已知问题与改进建议
### 6.1 `BaseDwdTask` 是死代码
`tasks/dwd/base_dwd_task.py` 中的 `BaseDwdTask` 继承了 `BaseTask`
提供了 `iter_ods_rows()``parse_payload()` 方法。
`DwdLoadTask` 直接继承 `BaseTask`,从未使用 `BaseDwdTask`
**建议**:删除 `base_dwd_task.py`,或在未来重构时决定是否复用。
### 6.2 `_build_column_mapping` 引用了未定义变量
`_build_column_mapping()` 方法的参数签名是 `(self, dwd_table, pk_cols, ods_cols)`
但方法体内引用了 `ods_table`(第 711 行)和 `cur`(第 713 行),这两个变量不在参数中。
```python
# 第 711 行 — ods_table 未定义
self.logger.error("跳过 %sODS 表 %s 缺少 fetched_at 列", dwd_table, ods_table)
# 第 713 行 — cur 未定义
self._log_missing_fetched_at(cur, ods_table)
```
**为什么没出错?** 因为所有 ODS 表都有 `fetched_at` 列,`if "fetched_at" not in ods_set` 永远为 False
这段代码从未被执行。但如果未来某张 ODS 表缺少 `fetched_at`,会抛出 `NameError`
**建议**:将 `ods_table``cur` 加入参数签名,或将这段防御逻辑移到调用方。
### 6.3 `_pick_order_column` 和 `FACT_ORDER_CANDIDATES` 未使用
代码定义了 `FACT_ORDER_CANDIDATES`7 个候选增量列)和 `_pick_order_column()` 方法,
用于智能选择事实表的增量排序列。
`_merge_fact_increment()` 中硬编码了 `order_col = "fetched_at"`
完全没有调用 `_pick_order_column()`
**影响**:所有事实表都用 `fetched_at` 做增量过滤,即使表中有更合适的业务时间列(如 `pay_time`)。
这在大多数场景下是可以的(因为 `fetched_at` 是单调递增的),但可能导致:
- 回溯窗口时,`fetched_at` 不能精确反映业务时间
- 水位线可能比实际业务时间偏晚
**建议**:评估是否需要启用 `_pick_order_column`,或明确删除这段未使用代码。
### 6.4 `FACT_MISSING_FILL_TABLES` 定义与行为不一致
`FACT_MISSING_FILL_TABLES` 只包含 `dwd.dwd_assistant_service_log`
注释说"对于会出现回补旧记录的事实表"。
`_insert_missing_by_pk()``_merge_fact_increment()` 末尾对**所有事实表**无条件调用,
没有检查 `dwd_table in self.FACT_MISSING_FILL_TABLES`
**影响**
- 正面:所有事实表都能自动回补缺失记录,更安全
- 负面:对不需要回补的表执行了额外的 LEFT JOIN 查询,有性能开销
**建议**:要么删除 `FACT_MISSING_FILL_TABLES`(承认所有表都需要回补),
要么加上条件判断(只对指定表执行回补)。
### 6.5 SCD2 逐行方法仍保留
`_upsert_scd2_row()``_close_current_dim()``_insert_dim_row()` 三个方法
是早期逐行处理的实现,现在已被批量方法(`_close_current_dim_bulk` / `_insert_dim_rows_bulk`)替代。
这些方法仍保留在代码中但不被主流程调用。
**建议**:标记为 `@deprecated` 或删除,减少维护负担。
### 6.6 Type1 Upsert 中 SCD2 列的处理
`_merge_dim_type1_upsert()` 在构建 INSERT 值时,会为 SCD2 列填充默认值
`scd2_start_time=now``scd2_version=1`),但在 ON CONFLICT DO UPDATE 时,
`scd2_start_time``scd2_version` 使用 `COALESCE(现有值, EXCLUDED值)` 保留已有值。
这意味着:如果一张表的 DDL 中有 SCD2 列但 `_merge_dim()` 判定走 Type1 路径
(理论上不会发生,因为有 SCD2 列就走 SCD2 路径SCD2 列不会被错误覆盖。
这是一个防御性设计,不是 bug。
---
## 七、数据流总结图
```
┌──────────────────────────────────────────────────────────────────┐
│ ODS 层(原始数据) │
│ │
│ ods.member_profiles ods.payment_transactions │
│ ods.settlement_records ods.table_fee_transactions │
│ ... (共 23 张 ODS 表) │
└──────────────────────┬───────────────────────────────────────────┘
│ DwdLoadTask.load()
│ 遍历 TABLE_MAP (40 对)
┌────────────┴────────────┐
▼ ▼
dim_* 前缀? 其他前缀?
│ │
┌─────┴─────┐ ┌─────┴─────┐
▼ ▼ ▼ ▼
有SCD2列 无SCD2列 自动水位 窗口覆盖
│ │ │ │
SCD2合并 Type1 增量写入 范围写入
│ Upsert │ │
│ │ └─────┬─────┘
│ │ │
│ │ 缺失回补
│ │ (_insert_missing_by_pk)
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ DWD 层(明细数据) │
│ │
│ dwd.dim_member (SCD2) dwd.dwd_payment (增量) │
│ dwd.dim_site (SCD2) dwd.dwd_settlement_head (增量) │
│ dwd.dim_table (SCD2) dwd.dwd_refund (增量) │
│ ... (共 17 维度 + 23 事实) │
└──────────────────────────────────────────────────────────────────┘
```
---
## 八、与 ODS 层的对比
| 方面 | ODS 层 | DWD 层 |
|------|--------|--------|
| 数据来源 | 上游 SaaS API | ODS 表 |
| 写入方式 | 追加写入INSERT | 维度SCD2/Upsert事实增量 INSERT |
| 去重机制 | content_hash + skip_unchanged | DISTINCT ON + ON CONFLICT |
| 历史保留 | 所有版本都保留 | 维度 SCD2 保留历史;事实只保留最新 |
| 列名风格 | 与上游 API 一致(驼峰/混合) | 标准化下划线命名 |
| 类型处理 | 原样存储(多为 text/jsonb | 类型转换numeric/boolean/date 等) |
| 软删除 | INSERT 删除版本行is_delete=1 | 不处理(依赖 ODS 的 is_delete 标记) |
| 事务粒度 | 每个任务一次事务 | 每张表一次事务 |
---
## 九、优化方案分析
### 9.1 方案DWD 增加 content_hash 列,用 hash 对比代替逐列对比
#### 当前痛点
维度表变更检测在 Python 层逐列对比(`_is_row_changed``_values_equal`),涉及 4 层类型归一化:
```
当前流程(维度表):
1. SELECT * FROM dwd表 WHERE scd2_is_current=1 → 全量加载到 Python 内存
2. SELECT DISTINCT ON (pk) ... FROM ods表 → 全量加载到 Python 内存
3. 逐行逐列对比(空值/日期/布尔/数值 4 层归一化)
4. 收集变更主键 → 批量关闭旧版 + 批量插入新版
```
事实表虽然用 SQL 层 `IS DISTINCT FROM` 对比,但需要列举所有非主键列:
```sql
WHERE dwd.col1 IS DISTINCT FROM EXCLUDED.col1
OR dwd.col2 IS DISTINCT FROM EXCLUDED.col2
OR dwd.col3 IS DISTINCT FROM EXCLUDED.col3
-- ... 可能有 20+ 列
```
#### 优化方案
在 DWD 表中增加 `content_hash` 列,直接复用 ODS 的 `content_hash`(基于 payload + is_delete 的 SHA-256
```
优化后流程(维度表):
1. 一条 SQL 搞定:
SELECT ods.pk, ods.content_hash
FROM ods表 ods
JOIN dwd表 dwd ON ods.pk = dwd.pk AND dwd.scd2_is_current = 1
WHERE ods.content_hash != dwd.content_hash
2. 只对变更的主键执行关闭旧版 + 插入新版
优化后流程(事实表):
INSERT INTO dwd表 (..., content_hash)
SELECT ..., content_hash FROM ods表
ON CONFLICT (pk) DO UPDATE SET ...
WHERE dwd表.content_hash IS DISTINCT FROM EXCLUDED.content_hash -- 一列对比代替 20+ 列
```
#### 等价性风险与参数化设计
ODS 的 `content_hash` 基于原始 payload 计算DWD 列是 payload 的确定性派生。
日常运行时"payload 不变 = DWD 列不变"成立,可以直接复用 hash。
但当 DWD 表结构变更(新增列、修改 FACT_MAPPINGS 映射规则)时,
同一个 payload 会产生不同的 DWD 列值hash 等价关系被打破。
此时需要回退到逐列对比来刷新所有记录。
建议增加配置参数 `dwd.change_detection_mode`
| 模式 | 值 | 维度表对比方式 | 事实表对比方式 | 适用场景 |
|------|-----|---------------|---------------|----------|
| hash 模式 | `"hash"`(默认) | `ods.content_hash != dwd.content_hash` | `WHERE content_hash IS DISTINCT FROM` | 日常增量运行 |
| column 模式 | `"column"` | 逐列对比(当前逻辑) | 逐列 `IS DISTINCT FROM`(当前逻辑) | DWD 表结构变更后的首次全量刷新 |
典型工作流:
```
日常运行change_detection_mode = "hash"(快速)
修改了 DWD 表结构或 FACT_MAPPINGS
临时切换change_detection_mode = "column" + window_override 覆盖全量时间范围
全量刷新完成,所有 DWD 行的 content_hash 已与 ODS 对齐
切回change_detection_mode = "hash"
```
#### 可行性分析
| 方面 | 评估 |
|------|------|
| DDL 变更 | 所有 40 张 DWD 表需要 `ALTER TABLE ADD COLUMN content_hash VARCHAR(64)` |
| 索引 | 维度表建议加 `(pk, content_hash)` 复合索引;事实表主键索引已够用 |
| 代码改动 | `_merge_dim_scd2` 改为 hash 对比;`_merge_fact_increment` 简化 WHERE 条件;写入时同步存储 hash新增 `change_detection_mode` 配置读取 |
| 回滚 | `ALTER TABLE DROP COLUMN content_hash`,代码回退即可 |
#### 预期收益
| 场景 | 当前 | 优化后 |
|------|------|--------|
| 维度表变更检测 | Python 全量加载 + 逐列对比 | 数据库层 hash 对比,不需要加载到内存 |
| 事实表冲突判断 | 20+ 列 IS DISTINCT FROM | 1 列 hash IS DISTINCT FROM |
| 网络传输 | 维度表需要传输全部列值 | 只需传输 pk + hash |
### 9.2 事实表只保留最新信息
当前设计已经是这个思路 — 事实表用 `ON CONFLICT DO UPDATE` 就是"有变更就覆盖"。
ODS 保留了所有历史快照DWD 事实表只需要最新值。
加了 content_hash 后,覆盖逻辑可以更精准:只在 hash 不同时才触发 UPDATE
避免了当前逐列 `IS DISTINCT FROM` 的开销。
### 9.3 ODS 索引利用情况
ODS 层已为所有 23 张表建立了 `(业务主键, fetched_at DESC)` 复合索引:
```sql
-- 示例
CREATE INDEX idx_ods_member_profiles_latest ON ods.member_profiles (id, fetched_at DESC);
CREATE INDEX idx_ods_settlement_records_latest ON ods.settlement_records (id, fetched_at DESC);
-- ... 共 23 个
```
DWD 装载时的查询模式与索引的匹配情况:
| 查询模式 | 是否命中索引 | 说明 |
|----------|-------------|------|
| `DISTINCT ON (id) ORDER BY id, fetched_at DESC` | ✅ 完美命中 | 复合索引的排序方向完全匹配 |
| `WHERE fetched_at > watermark` | ⚠️ 部分命中 | `fetched_at` 不是索引前缀列PostgreSQL 可能选择 Seq Scan |
| `LEFT JOIN ON d.pk = o.pk`(回补) | ✅ 命中 | 主键索引 |
#### 可优化点
事实表的水位线过滤 `WHERE fetched_at > watermark` 在数据量大时,
当前的 `(id, fetched_at DESC)` 复合索引不是最优的。
可以考虑为高频/大表额外加一个 `(fetched_at)` 单列索引:
```sql
CREATE INDEX idx_ods_settlement_records_fetched ON ods.settlement_records (fetched_at);
CREATE INDEX idx_ods_payment_transactions_fetched ON ods.payment_transactions (fetched_at);
```
但需要权衡:额外索引会增加 ODS 写入时的开销(每次 INSERT 都要维护索引)。
考虑到 ODS 写入频率远高于 DWD 读取频率,这个优化需要通过 `EXPLAIN ANALYZE` 实测后再决定。
---
## 十、重构路线图
以下为经讨论确认的重构方向,按优先级排列。每项标注了改动范围和风险等级。
### 10.1 统一窗口模式,去掉水位线机制
#### 现状问题
事实表增量有两条路径:
| 模式 | 触发条件 | SQL |
|------|----------|-----|
| 窗口覆盖 | 配置了 `window_override` | `WHERE fetched_at >= start AND fetched_at < end` |
| 自动水位 | 未配置 `window_override` | `WHERE fetched_at > watermark`(只有下界,无上界) |
自动水位模式的问题:
- 需要额外查一次 DB 算水位线(`_get_fact_watermark``SELECT MAX(fetched_at)`
- 没有上界,语义不如窗口模式清晰
- 与 ODS 层的窗口模式不一致ODS 的 `_resolve_window` 始终算出 start/end
#### 方案
统一为 `WHERE fetched_at >= start AND fetched_at < end``BaseTask._get_time_window()` 已经在自动模式下计算了 `window_start`(基于 cursor + overlap`window_end`= now`DwdLoadTask.load()` 只在 `use_window=True`(配置了 window_override时才传给 `_merge_fact_increment`
改动点:
1. `load()` 中去掉 `use_window` 判断,始终传 `context.window_start / window_end`
2. 删除 `_get_fact_watermark()` 方法
3. `_merge_fact_increment` 中删除 watermark 分支
4. `_insert_missing_by_pk` 中删除 watermark 参数
| 方面 | 评估 |
|------|------|
| 改动量 | 小(约 30 行) |
| 风险 | 低 — BaseTask 已经算好了窗口 |
| 回滚 | 恢复 `use_window` 判断即可 |
### 10.2 删除回补机制_insert_missing_by_pk
#### 现状问题
回补的设计初衷是兜底"主增量写入漏掉的记录"。但分析后发现:
1. 主增量写入是一条 SQL`INSERT ... SELECT ... FROM ods表`),如果某条记录类型转换失败,**整条 SQL 报错、整张表事务 rollback**,不存在"部分行静默丢失"的情况
2. 统一窗口模式后ODS 和 DWD 处理的数据范围完全一致,回补的 LEFT JOIN 结果集几乎一定为空
3. 并发写入冲突在当前架构下不会发生(单进程顺序执行)
如果真出了类型转换错误,应该直接报错让运维介入,而不是靠回补静默兜底。
#### 方案
直接删除:
1. 删除 `_insert_missing_by_pk()` 方法
2. 删除 `_merge_fact_increment()` 末尾的回补调用
3. 删除 `FACT_MISSING_FILL_TABLES` 常量
| 方面 | 评估 |
|------|------|
| 改动量 | 小(删除约 100 行) |
| 风险 | 低 — 统一窗口后回补本身就是空操作 |
| 前置依赖 | 10.1(统一窗口模式) |
### 10.3 清理死代码和未使用常量
统一清理第六章列出的已知问题:
| 清理项 | 操作 |
|--------|------|
| `BaseDwdTask``base_dwd_task.py` | 删除文件 |
| `_build_column_mapping` 中未定义变量 | 将 `ods_table` / `cur` 加入参数签名 |
| `_pick_order_column` + `FACT_ORDER_CANDIDATES` | 删除(已确认统一用 `fetched_at` |
| `FACT_MISSING_FILL_TABLES` | 随 10.2 一起删除 |
| SCD2 逐行方法(`_upsert_scd2_row` 等 3 个) | 删除 |
### 10.4 跨层参数提取与层级控制
#### 现状问题
各层任务对配置的使用方式不统一:
| 层 | 基类 | 时间窗口 | 配置读取 |
|----|------|----------|----------|
| ODS | `BaseOdsTask`(继承 BaseTask | 自己重写 `_resolve_window`(加了 MAX(fetched_at) 兜底) | `self.config.get("run.*")` |
| DWD | `DwdLoadTask`(继承 BaseTask | 自己搞水位线 `_get_fact_watermark` | `self.config.get("dwd.*")` |
| DWS | `BaseDwsTask`(不继承 BaseTask | 完全独立的时间范围计算 | `self.config.get("dws.*")` |
共用参数散落在 `run.*` 下,层专属参数各自为政。如果未来 CORE 层也需要 `overlap_seconds` 但值不同,没法区分。
#### 方案
配置结构改为层级覆盖:
```python
# defaults.py 新增
"layer": {
"ods": {
"overlap_seconds": 600,
"window_minutes": {"default_busy": 30, "default_idle": 180},
# ODS 层专属...
},
"dwd": {
"overlap_seconds": 300,
"change_detection_mode": "hash",
"fact_upsert": True,
# DWD 层专属...
},
"dws": {
# DWS 层专属...
},
"core": {
# CORE 层专属(预留)...
},
}
```
`AppConfig.get()` 增加层级感知:
```python
def get_for_layer(self, layer: str, key: str, default=None):
"""先查 layer.<层>.<key>,没有再 fallback 到 run.<key>"""
val = self.get(f"layer.{layer}.{key}")
if val is not None:
return val
return self.get(f"run.{key}", default)
```
代码层级统一:
```
BaseTaskE/T/L 模板 + 统一时间窗口)
├── BaseOdsTaskODSAPI 抓取 + 快照去重 + 软删除)
├── DwdLoadTaskDWD维度 SCD2 + 事实增量)
├── BaseDwsTask → 重构后继承 BaseTaskDWS聚合计算
└── BaseCoreTaskCORE预留
```
| 方面 | 评估 |
|------|------|
| 改动量 | 中(`defaults.py` + `AppConfig` + 各层基类的 config 读取) |
| 风险 | 中 — 需要确保向后兼容(旧的 `run.*` 键仍可用) |
| 分步实施 | 先加 `layer.*` 结构和 `get_for_layer`,再逐层迁移 |
### 10.5 连接器Connector与多门店配置
#### 现状
当前配置是"一个 .env 对应一个门店"
```
STORE_ID=2790685415443269
API_TOKEN=eyJhbG...(该门店的 Token
```
如果飞球平台接入多个门店,需要为每个门店维护一份 .env 或手动切换 `STORE_ID` + `API_TOKEN`
#### 需求
- 飞球平台Connector下有多个门店Store
- 每个门店有自己的 `store_id``api.token`其他配置DB、路径、窗口等共用
- 日常运行以连接器为单位,自动遍历所有门店
#### 方案:连接器注册表 + 门店列表
在数据库 `meta` schema 中维护连接器和门店的注册信息:
```sql
-- 连接器注册表
CREATE TABLE meta.connector (
connector_id SERIAL PRIMARY KEY,
connector_code VARCHAR(50) NOT NULL UNIQUE, -- 如 'feiqiu'
display_name VARCHAR(100),
api_base_url VARCHAR(255), -- 平台级 API 地址
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 门店注册表
CREATE TABLE meta.connector_store (
store_id BIGINT PRIMARY KEY, -- 上游门店 ID
connector_id INT NOT NULL REFERENCES meta.connector(connector_id),
store_name VARCHAR(100),
api_token TEXT NOT NULL, -- 该门店的 Token加密存储
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT now()
);
```
配置体系变更:
```
当前:
.env → STORE_ID + API_TOKEN单门店
改为:
.env → CONNECTOR_CODE=feiqiu连接器级别
→ DB 连接信息、路径、窗口等(共用配置)
meta.connector_store → 每个门店的 store_id + api_token门店级别
```
运行时流程:
```
1. 读取 CONNECTOR_CODE → 查 meta.connector 获取连接器信息
2. 查 meta.connector_store WHERE connector_id=? AND is_active=TRUE → 获取门店列表
3. 遍历门店列表:
for store in stores:
config.override("app.store_id", store.store_id)
config.override("api.token", store.api_token)
orchestrator.run_all_tasks() # ODS → DWD → DWS
```
.env 中不再需要 `STORE_ID``API_TOKEN`,改为:
```env
# 连接器标识(替代原来的 STORE_ID + API_TOKEN
CONNECTOR_CODE=feiqiu
# 以下保持不变
PG_DSN=postgresql://...
TIMEZONE=Asia/Shanghai
# ...
```
Token 安全性:
- Token 存在数据库中而非 .env 文件,便于集中管理和轮换
- 建议对 `api_token` 列做应用层加密(如 AES密钥通过环境变量传入
- 日志中已有脱敏机制(`security.redact_keys` 包含 `token`
向后兼容:
- 如果 .env 中仍有 `STORE_ID` + `API_TOKEN`,走旧的单门店模式
- 如果 .env 中有 `CONNECTOR_CODE`,走新的多门店模式
- 两种模式互斥,启动时检测并提示
| 方面 | 评估 |
|------|------|
| DDL 变更 | 新增 2 张 meta 表 |
| 代码改动 | 新增连接器加载逻辑 + orchestrator 遍历门店 + config 动态覆盖 |
| 改动量 | 中 |
| 风险 | 低 — 向后兼容,旧模式不受影响 |
| 前置依赖 | 无(可独立实施) |
### 10.6 实施优先级
```
第一步(低风险,立即可做):
10.1 统一窗口模式 → 10.2 删除回补 → 10.3 清理死代码
三项连续做,改动集中在 dwd_load_task.py
第二步(中风险,需设计):
10.4 跨层参数提取 — 改 defaults.py + AppConfig + 各层基类
第三步(独立,可并行):
10.5 连接器与多门店 — 新增 meta 表 + 加载逻辑
9.1 DWD content_hash 优化 — DDL + 对比逻辑改造
```

View File

@@ -0,0 +1,591 @@
# 工具类任务详解
> 本文档说明飞球 ETL 系统中所有工具类Utility和校验类Verification任务。
> 这些任务不属于 ODS/DWD/DWS/INDEX 四层业务 Flow而是为系统初始化、
> 数据灌入、归档、截止时间检查和完整性校验等运维场景服务。
---
## 概述
工具类任务共 8 个(含 1 个校验类任务),注册于 `orchestration/task_registry.py`
| 任务代码 | Python 类 | 用途 | task_type | requires_db_config |
|----------|-----------|------|-----------|-------------------|
| `INIT_ODS_SCHEMA` | `InitOdsSchemaTask` | 执行 ODS + meta DDL创建必要目录 | utility | `False` |
| `INIT_DWD_SCHEMA` | `InitDwdSchemaTask` | 执行 DWD DDL | utility | `False` |
| `INIT_DWS_SCHEMA` | `InitDwsSchemaTask` | 执行 DWS DDL | utility | `False` |
| `MANUAL_INGEST` | `ManualIngestTask` | 从本地 JSON 文件手动入库到 ODS | utility | `False` |
| `ODS_JSON_ARCHIVE` | `OdsJsonArchiveTask` | 在线抓取 ODS 接口数据并落盘 JSON | utility | `False` |
| `CHECK_CUTOFF` | `CheckCutoffTask` | 检查各任务/表的数据截止时间 | utility | `False` |
| `SEED_DWS_CONFIG` | `SeedDwsConfigTask` | 初始化 DWS 配置种子数据 | utility | `True`(默认) |
| `DATA_INTEGRITY_CHECK` | `DataIntegrityTask` | API → ODS → DWD 数据完整性校验 | verification | `False` |
> 典型执行顺序(首次部署):`INIT_ODS_SCHEMA` → `INIT_DWD_SCHEMA` → `INIT_DWS_SCHEMA` → `SEED_DWS_CONFIG`
---
## 1. INIT_ODS_SCHEMA — ODS + meta Schema 初始化
| 属性 | 值 |
|------|-----|
| 任务代码 | `INIT_ODS_SCHEMA` |
| Python 类 | `tasks.utility.init_schema_task.InitOdsSchemaTask` |
| 继承 | `BaseTask` |
| 用途 | 创建 ODS 层和 meta 调度元数据的数据库结构,并准备运行时目录 |
### 执行流程
```
extract()
├── 读取 DDL 文件路径schema_ODS_doc.sql、schema_etl_admin.sql
├── 收集需创建的目录列表
└── 返回 SQL 文本 + 目录列表
load()
├── 创建必要目录log_root、export_root、fetch_root、ingest_dir
├── 执行 meta DDLschema_etl_admin.sql
└── 执行 ODS DDLschema_ODS_doc.sql清洗后
```
### 执行的 DDL 文件
| DDL 文件 | 创建的 Schema | 主要内容 |
|----------|--------------|----------|
| `database/schema_etl_admin.sql` | `meta` | `etl_task`(任务注册表)、`etl_cursor`(游标表)、`etl_run`(运行记录表) |
| `database/schema_ODS_doc.sql` | `ods` | 20+ 张 ODS 原始表member_profiles、settlement_records、payment_transactions 等) |
### ODS DDL 清洗逻辑
ODS DDL 文件可能包含头部说明文本和 `COMMENT ON` 语句CamelCase 未加引号会导致执行失败),因此 `load()` 阶段会做轻量清洗:
1. 定位第一个 `DROP SCHEMA` 语句,丢弃之前的非 SQL 文本
2. 逐行过滤掉以 `COMMENT ON` 开头的行
### 创建的目录
| 配置路径 | 说明 |
|----------|------|
| `io.log_root` | 日志输出根目录 |
| `io.export_root` | 数据导出根目录 |
| `pipeline.fetch_root` | API 抓取数据落盘目录 |
| `pipeline.ingest_source_dir` | 手动入库数据源目录(默认同 fetch_root |
### 配置参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `schema.ods_file` | `database/schema_ODS_doc.sql` | ODS DDL 文件路径 |
| `schema.etl_admin_file` | `database/schema_etl_admin.sql` | meta DDL 文件路径 |
| `io.log_root` | — | 日志目录 |
| `io.export_root` | — | 导出目录 |
| `pipeline.fetch_root` | — | 抓取数据目录 |
| `pipeline.ingest_source_dir` | 同 fetch_root | 入库数据源目录 |
### CLI 示例
```bash
python -m cli.main --tasks INIT_ODS_SCHEMA --pg-dsn "$PG_DSN"
```
---
## 2. INIT_DWD_SCHEMA — DWD Schema 初始化
| 属性 | 值 |
|------|-----|
| 任务代码 | `INIT_DWD_SCHEMA` |
| Python 类 | `tasks.utility.init_dwd_schema_task.InitDwdSchemaTask` |
| 继承 | `BaseTask` |
| 用途 | 创建 DWD 明细数据层的数据库结构 |
### 执行流程
```
extract()
├── 读取 DDL 文件路径schema_dwd_doc.sql
└── 读取 drop_first 配置
load()
├── [可选] DROP SCHEMA dwd CASCADE
└── 执行 DWD DDLschema_dwd_doc.sql
```
### 执行的 DDL 文件
| DDL 文件 | 创建的 Schema | 主要内容 |
|----------|--------------|----------|
| `database/schema_dwd_doc.sql` | `dwd` | 维度表dim_*,含 SCD2 约束、事实表dwd_*、fact_*)、扩展表(*_ex |
DWD DDL 的特殊处理:
- 自动为含 `scd2_start_time` 列的表设置 SCD2 默认值(`scd2_start_time=now()``scd2_end_time='9999-12-31'``scd2_is_current=1``scd2_version=1`
- 自动创建 SCD2 排他约束(`EXCLUDE USING gist`,防止同一业务主键的生效区间重叠)
- 自动创建当前版本唯一索引(`WHERE scd2_is_current = 1`
### 配置参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `schema.dwd_file` | `database/schema_dwd_doc.sql` | DWD DDL 文件路径 |
| `dwd.drop_schema_first` | `False` | 是否先 DROP 再重建(危险操作,会丢失所有 DWD 数据) |
### CLI 示例
```bash
# 常规初始化
python -m cli.main --tasks INIT_DWD_SCHEMA --pg-dsn "$PG_DSN"
# 重建(先删后建,慎用)
python -m cli.main --tasks INIT_DWD_SCHEMA --pg-dsn "$PG_DSN" --extra dwd.drop_schema_first=true
```
---
## 3. INIT_DWS_SCHEMA — DWS Schema 初始化
| 属性 | 值 |
|------|-----|
| 任务代码 | `INIT_DWS_SCHEMA` |
| Python 类 | `tasks.utility.init_dws_schema_task.InitDwsSchemaTask` |
| 继承 | `BaseTask` |
| 用途 | 创建 DWS 数据服务层的数据库结构 |
### 执行流程
```
extract()
├── 读取 DDL 文件路径schema_dws.sql
└── 读取 drop_first 配置
load()
├── [可选] DROP SCHEMA dws CASCADE
└── 执行 DWS DDLschema_dws.sql
```
### 执行的 DDL 文件
| DDL 文件 | 创建的 Schema | 主要内容 |
|----------|--------------|----------|
| `database/schema_dws.sql` | `dws` | 配置表5 张 cfg_*、助教域5 张、会员域2 张、财务域7 张、订单汇总1 张) |
DWS Schema 包含的配置表:
| 配置表 | 说明 |
|--------|------|
| `cfg_performance_tier` | 绩效档位配置(阈值、抽成比例、假期天数) |
| `cfg_assistant_level_price` | 助教等级定价 |
| `cfg_bonus_rules` | 奖金规则配置 |
| `cfg_area_category` | 台区分类映射 |
| `cfg_skill_type` | 技能课程类型映射 |
### 配置参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `schema.dws_file` | `database/schema_dws.sql` | DWS DDL 文件路径 |
| `dws.drop_schema_first` | `False` | 是否先 DROP 再重建(危险操作) |
### CLI 示例
```bash
python -m cli.main --tasks INIT_DWS_SCHEMA --pg-dsn "$PG_DSN"
```
---
## 4. SEED_DWS_CONFIG — DWS 配置种子数据初始化
| 属性 | 值 |
|------|-----|
| 任务代码 | `SEED_DWS_CONFIG` |
| Python 类 | `tasks.utility.seed_dws_config_task.SeedDwsConfigTask` |
| 继承 | `BaseTask` |
| 用途 | 向 DWS 配置表插入初始数据(绩效档位、等级定价、奖金规则等) |
### 前置条件
- `dws` schema 已创建(需先执行 `INIT_DWS_SCHEMA`
- 配置表(`cfg_*`)已存在
### 执行流程
```
extract()
└── 读取 seed_dws_config.sql 文件内容
load()
└── 执行 SQLTRUNCATE + INSERT 配置数据)
```
### 执行的种子文件
| 文件 | 目标表 | 说明 |
|------|--------|------|
| `database/seed_dws_config.sql` | `cfg_performance_tier` | 绩效档位(含历史口径:旧方案至 2026-02-28新方案 2026-03-01 起) |
| | `cfg_assistant_level_price` | 助教等级定价 |
| | `cfg_bonus_rules` | 奖金规则 |
| | `cfg_area_category` | 台区分类映射 |
| | `cfg_skill_type` | 技能课程类型映射 |
### 配置参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `schema.seed_dws_file` | `database/seed_dws_config.sql` | 种子数据 SQL 文件路径 |
### CLI 示例
```bash
# 通常与 INIT_DWS_SCHEMA 一起执行
python -m cli.main --tasks INIT_DWS_SCHEMA,SEED_DWS_CONFIG --pg-dsn "$PG_DSN"
```
---
## 5. MANUAL_INGEST — 手动 JSON 入库
| 属性 | 值 |
|------|-----|
| 任务代码 | `MANUAL_INGEST` |
| Python 类 | `tasks.utility.manual_ingest_task.ManualIngestTask` |
| 继承 | `BaseTask` |
| 用途 | 从本地 JSON 文件批量灌入 ODS 表,用于离线回放或示例数据导入 |
### 执行流程概览
```
execute()
├── 确定数据目录manual.data_dir / pipeline.ingest_source_dir / tests/testdata_json
├── 遍历目录下所有 .json 文件(按文件名排序)
│ ├── [可选] 按 include_files 过滤
│ ├── 读取并解析 JSON
│ ├── 提取记录列表(兼容多层 data/list 包装)
│ ├── 按文件名关键字匹配目标 ODS 表
│ └── 批量入库INSERT ON CONFLICT
└── 返回统计计数fetched/inserted/updated/skipped/errors
```
### 文件匹配规则
`MANUAL_INGEST` 通过 `FILE_MAPPING` 将文件名关键字映射到目标 ODS 表。匹配逻辑:**文件名中包含关键字即匹配**(大小写敏感)。
| 文件名关键字 | 目标 ODS 表 |
|-------------|------------|
| `member_profiles` | `ods.member_profiles` |
| `member_balance_changes` | `ods.member_balance_changes` |
| `member_stored_value_cards` | `ods.member_stored_value_cards` |
| `recharge_settlements` | `ods.recharge_settlements` |
| `settlement_records` | `ods.settlement_records` |
| `assistant_cancellation_records` | `ods.assistant_cancellation_records` |
| `assistant_accounts_master` | `ods.assistant_accounts_master` |
| `assistant_service_records` | `ods.assistant_service_records` |
| `site_tables_master` | `ods.site_tables_master` |
| `table_fee_discount_records` | `ods.table_fee_discount_records` |
| `table_fee_transactions` | `ods.table_fee_transactions` |
| `goods_stock_movements` | `ods.goods_stock_movements` |
| `stock_goods_category_tree` | `ods.stock_goods_category_tree` |
| `goods_stock_summary` | `ods.goods_stock_summary` |
| `payment_transactions` | `ods.payment_transactions` |
| `refund_transactions` | `ods.refund_transactions` |
| `platform_coupon_redemption_records` | `ods.platform_coupon_redemption_records` |
| `group_buy_redemption_records` | `ods.group_buy_redemption_records` |
| `group_buy_packages` | `ods.group_buy_packages` |
| `settlement_ticket_details` | `ods.settlement_ticket_details` |
| `store_goods_master` | `ods.store_goods_master` |
| `tenant_goods_master` | `ods.tenant_goods_master` |
| `store_goods_sales_records` | `ods.store_goods_sales_records` |
### JSON 解析逻辑
`_extract_records()` 方法兼容多种 JSON 包装格式:
1. **顶层数组**`[{...}, {...}]` → 直接作为记录列表
2. **data 包装**`{"data": [...]}``{"code": 0, "data": [...]}` → 展开 `data` 字段
3. **嵌套 list**`{"data": {"someKey": [{...}]}}` → 自动查找第一个 list 类型的值
4. **settleList 特殊处理**:充值/结算记录的 `data.settleList` 结构会被展开,内层 `settleList` 提取为独立记录,并保留外层 `siteProfile` 供字段补充
### 入库流程
对每张目标表,入库过程如下:
1. **查询表结构**:通过 `information_schema.columns` 获取目标表的列名、数据类型
2. **构建 SQL**:生成 `INSERT INTO ... VALUES %s ON CONFLICT ...` 语句
-`content_hash` 列:`ON CONFLICT (pk, content_hash) DO NOTHING`(内容去重)
-`content_hash` 列:`ON CONFLICT (pk) DO UPDATE SET ...`upsert 覆盖)
3. **值映射**:逐列匹配 JSON 字段(忽略大小写),特殊列处理:
- `payload`:存储原始 JSON 记录
- `source_file`:填入文件名
- `fetched_at`:取记录中的值或当前时间
- `content_hash`:基于记录内容计算 SHA-256排除 `fetched_at``payload` 等 ETL 元数据字段)
- JSON 类型列:自动包装为 `psycopg2.extras.Json`
- 整数/浮点/时间戳列:自动类型转换
4. **批量执行**:使用 `psycopg2.extras.execute_values` 分批提交(默认 chunk_size=50最大 500
5. **降级处理**:批量执行失败时,降级为逐行 + `SAVEPOINT` 模式,跳过异常行继续处理
6. **事务粒度**:每个文件一次 `commit`,避免长事务
### 特殊处理
- **充值/结算记录**`recharge_settlements``settlement_records`):自动从 `siteProfile` 补齐 `tenantid``siteid``sitename`
- **空值规范化**:空字符串 `""`、空 JSON `"{}"` / `"[]"` 统一转为 `None`
- **主键校验**:主键值为 `None` 或空字符串的记录直接跳过
### 配置参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `manual.data_dir` | — | JSON 数据文件目录(优先级最高) |
| `pipeline.ingest_source_dir` | — | 入库数据源目录(次优先) |
| — | `tests/testdata_json` | 兜底默认目录 |
| `manual.include_files` | `[]`(全部) | 限定处理的文件名列表(不含扩展名,小写匹配) |
| `manual.execute_values_page_size` | `50` | 批量插入每批行数1-500 |
### CLI 示例
```bash
# 从默认目录灌入所有 JSON
python -m cli.main --tasks MANUAL_INGEST --pg-dsn "$PG_DSN"
# 指定数据目录
python -m cli.main --tasks MANUAL_INGEST --pg-dsn "$PG_DSN" \
--extra manual.data_dir=/path/to/json_files
# 只灌入指定文件
python -m cli.main --tasks MANUAL_INGEST --pg-dsn "$PG_DSN" \
--extra manual.include_files=member_profiles,settlement_records
```
---
## 6. ODS_JSON_ARCHIVE — ODS 接口数据归档
| 属性 | 值 |
|------|-----|
| 任务代码 | `ODS_JSON_ARCHIVE` |
| Python 类 | `tasks.ods.ods_json_archive_task.OdsJsonArchiveTask` |
| 继承 | `BaseTask` |
| 用途 | 在线抓取所有 ODS 相关 API 接口数据,落盘为简化 JSON 文件,供后续离线回放/入库 |
> 注意:虽然注册为 `task_type="utility"`,但该任务的源文件位于 `tasks/ods/` 目录下,因为它本质上是 ODS 数据的抓取归档。
### 归档策略
- **输出格式**:每页一个 JSON 文件,格式为 `{"code": 0, "data": [...records...]}`,与 `MANUAL_INGEST` 的解析逻辑兼容
- **文件命名**`{endpoint_stem}__p{page_no:04d}.json`(如 `GetAllOrderSettleList__p0001.json`
- **小票文件**:按 `orderSettleId` 分文件写入(`GetOrderSettleTicketNew__{orderSettleId}.json`
- **清单文件**:抓取完成后生成 `manifest.json`,记录窗口、端点、记录数等元信息
### 抓取的 API 端点
任务内置 22 个端点配置(`ENDPOINTS`),按窗口参数风格分类:
| 窗口风格 | 参数 | 端点示例 |
|----------|------|----------|
| `site` | `siteId` | `/MemberProfile/GetTenantMemberList``/Table/GetSiteTables` 等 |
| `start_end` | `siteId` + `startTime` / `endTime` | `/MemberProfile/GetMemberCardBalanceChange``/TenantGoods/GetGoodsSalesList` 等 |
| `range` | `siteId` + `rangeStartTime` / `rangeEndTime` | `/Site/GetAllOrderSettleList``/Site/GetRechargeSettleList` |
| `pay` | `siteId` + `StartPayTime` / `EndPayTime` | `/PayLog/GetPayLogListPage` |
此外,还有一个特殊端点 `/Order/GetOrderSettleTicketNew`(小票详情),按支付日志中提取的 `orderSettleId` 逐单抓取。
### 执行流程
```
extract()
├── 验证 API 客户端类型(必须为 APIClient即在线模式
├── 确定输出目录api.output_dir / pipeline.fetch_root
├── 遍历 ENDPOINTS逐端点分页抓取
│ ├── 构建请求参数(按 window_style 选择参数格式)
│ ├── 调用 iter_paginated() 分页获取
│ ├── 每页落盘为独立 JSON 文件
│ └── 从支付日志中收集 orderSettleId用于小票抓取
├── 按 orderSettleId 逐单抓取小票详情
└── 生成 manifest.json 清单文件
```
### 配置参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `pipeline.fetch_root` | — | JSON 文件输出目录 |
| `api.page_size` | `200` | API 分页大小 |
| `io.write_pretty_json` | `False` | 是否格式化输出 JSON |
### CLI 示例
```bash
# 在线抓取并归档
python -m cli.main --tasks ODS_JSON_ARCHIVE --pg-dsn "$PG_DSN" \
--store-id "$STORE_ID" --api-token "$API_TOKEN"
```
---
## 7. CHECK_CUTOFF — 数据截止时间检查
| 属性 | 值 |
|------|-----|
| 任务代码 | `CHECK_CUTOFF` |
| Python 类 | `tasks.utility.check_cutoff_task.CheckCutoffTask` |
| 继承 | `BaseTask` |
| 用途 | 报告各任务的游标截止时间和各层数据表的最新时间戳,用于运维监控 |
### 执行流程
该任务不走标准的 extract → transform → load 流程,而是直接在 `execute()` 中完成所有逻辑:
```
execute()
├── 1. 查询 meta 游标截止时间
│ ├── 关联 etl_task + etl_cursor 表
│ ├── 筛选当前门店已启用的任务
│ ├── [可选] 按 task_codes 过滤
│ └── 计算总体截止时间(排除 INIT_* 任务的最小 last_end
├── 2. 探测 ODS 表抓取时间
│ ├── 遍历 DwdLoadTask.TABLE_MAP 中的 ODS 表
│ ├── 查询每张表的 MAX(fetched_at) 和 COUNT(*)
│ └── 计算 ODS 截止时间(最小 max_fetched_at
└── 3. 探测 DWD/DWS 关键时间列
├── DWD: max(pay_time) from dwd_settlement_head / dwd_payment / dwd_refund
└── DWS: max(order_date) / max(updated_at) from dws_order_summary
```
### 校验逻辑
- **游标截止时间**:从 `meta.etl_cursor.last_end` 获取每个任务的最后成功窗口结束时间,排除 `INIT_*` 任务后取最小值作为总体截止时间
- **ODS 抓取时间**:查询每张 ODS 表的 `MAX(fetched_at)`,取最小值作为 ODS 层截止时间
- **DWD/DWS 业务时间**:探测关键业务时间列(`pay_time``order_date``updated_at`),反映数据实际覆盖范围
### 输出
任务通过日志输出检查结果,同时在返回值的 `report` 字段中包含结构化数据:
```python
{
"rows": [...], # 每个任务的游标信息
"overall_cutoff": datetime, # 总体截止时间
"ods_fetched_at": {...}, # 每张 ODS 表的 max_fetched_at
"dw_max_times": {...}, # DWD/DWS 关键时间列
}
```
### 配置参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `app.store_id` | — | 门店 ID必填 |
| `run.cutoff_task_codes` | `None`(全部) | 逗号分隔的任务代码列表,限定检查范围 |
### CLI 示例
```bash
# 检查所有任务的截止时间
python -m cli.main --tasks CHECK_CUTOFF --pg-dsn "$PG_DSN" --store-id "$STORE_ID"
# 只检查指定任务
python -m cli.main --tasks CHECK_CUTOFF --pg-dsn "$PG_DSN" --store-id "$STORE_ID" \
--extra run.cutoff_task_codes=ORDERS,PAYMENTS,MEMBERS
```
---
## 8. DATA_INTEGRITY_CHECK — 数据完整性校验
| 属性 | 值 |
|------|-----|
| 任务代码 | `DATA_INTEGRITY_CHECK` |
| Python 类 | `tasks.utility.data_integrity_task.DataIntegrityTask` |
| 继承 | `BaseTask` |
| 注册 task_type | `verification`(非 utility但在本文档中一并说明 |
| 用途 | 检查 API → ODS → DWD 全链路数据完整性,支持自动回填缺失数据 |
### 两种运行模式
#### 1. 历史模式(`history`,默认)
从指定起始日期到结束日期,按月分段检查全量历史数据的完整性。
```
execute() [mode=history]
├── 解析 history_start / history_end 时间范围
├── 调用 run_history_flow()
│ ├── 按月分段执行完整性检查
│ ├── 对比 API 记录数 vs ODS 记录数
│ ├── [可选] 对比内容一致性content_hash
│ └── [可选] 自动回填缺失数据
└── 生成 JSON 报表
```
#### 2. 窗口模式(`window`
检查指定时间窗口内的数据完整性,当提供 CLI 窗口覆盖参数时自动切换到此模式。
```
execute() [mode=window]
├── 获取时间窗口(支持 CLI 覆盖)
├── 构建窗口分段build_window_segments
├── 调用 run_window_flow()
│ ├── 逐段执行完整性检查
│ ├── 汇总缺失/不一致/错误计数
│ └── [可选] 自动回填 + 复查
└── 生成 JSON 报表
```
### 校验逻辑
核心校验由 `quality/integrity_service.py``quality/integrity_checker.py` 实现:
1. **记录数对比**API 返回的记录数 vs ODS 表中的记录数
2. **内容一致性**(可选):抽样对比 API 记录与 ODS 记录的 `content_hash`
3. **缺失检测**:识别 API 中存在但 ODS 中缺失的记录
4. **不一致检测**:识别 API 与 ODS 中内容不匹配的记录
### 自动回填
`auto_backfill=True` 时,检测到缺失或不一致数据后会自动触发回填:
1. 调用 `scripts/repair/backfill_missing_data.run_backfill()` 重新抓取缺失数据
2. 回填完成后可选复查(`recheck_after_backfill`),验证回填效果
### 配置参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `integrity.mode` | `history` | 运行模式:`history`(历史全量)/ `window`(时间窗口) |
| `integrity.history_start` | `2025-07-01` | 历史模式起始日期 |
| `integrity.history_end` | —(当前时间) | 历史模式结束日期 |
| `integrity.include_dimensions` | `False` | 是否包含维度表检查 |
| `integrity.ods_task_codes` | —(全部) | 限定检查的 ODS 任务代码 |
| `integrity.auto_backfill` | `False` | 是否自动回填缺失数据 |
| `integrity.compare_content` | `True` | 是否对比内容一致性 |
| `integrity.content_sample_limit` | — | 内容对比抽样上限 |
| `integrity.backfill_mismatch` | `True` | 是否回填不一致数据(仅 auto_backfill 时生效) |
| `integrity.recheck_after_backfill` | `True` | 回填后是否复查 |
| `integrity.force_monthly_split` | `True` | 是否强制按月分段 |
| `run.window_override.start` | — | CLI 窗口覆盖起始时间(触发 window 模式) |
| `run.window_override.end` | — | CLI 窗口覆盖结束时间 |
### 输出报表
检查结果以 JSON 格式写入 `reports/` 目录:
- 历史模式:`reports/data_integrity_history_{timestamp}.json`
- 窗口模式:`reports/data_integrity_window_{timestamp}.json`
### CLI 示例
```bash
# 历史全量检查(默认从 2025-07-01 至今)
python -m cli.main --tasks DATA_INTEGRITY_CHECK --pg-dsn "$PG_DSN" \
--store-id "$STORE_ID" --api-token "$API_TOKEN"
# 指定时间范围
python -m cli.main --tasks DATA_INTEGRITY_CHECK --pg-dsn "$PG_DSN" \
--store-id "$STORE_ID" --api-token "$API_TOKEN" \
--extra integrity.history_start=2026-01-01 --extra integrity.history_end=2026-02-01
# 窗口模式 + 自动回填
python -m cli.main --tasks DATA_INTEGRITY_CHECK --pg-dsn "$PG_DSN" \
--store-id "$STORE_ID" --api-token "$API_TOKEN" \
--window-start "2026-02-01" --window-end "2026-02-15" \
--extra integrity.auto_backfill=true
```