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

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 @@
{"generationMode": "requirements-first"}

View File

@@ -0,0 +1,449 @@
# 设计文档ODS 去重与软删除机制标准化
## 概述
本设计对 ODS 层的 `OdsTaskSpec` 配置、content_hash 去重策略、软删除语义进行标准化改造。核心原则ODS 是追加写入的版本化存储,每次内容变更(包括删除)都是一个新版本行。
改造分四个阶段:
1. **配置精简**(方案 1删除无效/冗余字段,引入 SnapshotMode 枚举
2. **去重优化**(方案 2默认开启 skip_unchangedhash 改用 payload + is_delete
3. **索引支持**(方案 3为"取最新版本"查询添加复合索引
4. **软删除语义**(方案 4从 UPDATE 改为 INSERT 删除版本行
## 改造前后对比
### 配置层对比
| 维度 | 改造前 | 改造后 |
|------|--------|--------|
| 去重开关 | `enable_content_hash_dedup=False`22/23 任务关闭) | `skip_unchanged=True`(默认开启) |
| 快照策略 | `snapshot_full_table` + `snapshot_window_columns` 两个字段组合 | `SnapshotMode` 枚举NONE/FULL_TABLE/WINDOW+ `snapshot_time_column` |
| 冲突列 | `conflict_columns_override`(运行时不生效,仅声明性标注) | 删除PK 唯一来源为 DDL |
| 冗余字段 | `include_site_column`/`include_page_no`/`include_page_size`(全部 False | 删除,硬编码移除 |
### content_hash 计算对比
| 维度 | 改造前 | 改造后 |
|------|--------|--------|
| 输入 | 展平后的 merged_rec排除 7 个元数据字段 | 原始 payload JSON + is_delete |
| 排除逻辑 | `_sanitize_record_for_hash` 递归排除 source_file/source_endpoint/fetched_at/record_index/content_hash/payload/data | 无需排除——payload 天然不含元数据字段 |
| is_delete 参与 | 不参与is_delete 变化不改变 hash | 参与is_delete 变化产生新 hash → 新版本行) |
| 默认行为 | 22/23 任务不算 hash每次抓取都插入新行 | 所有任务默认算 hash内容不变则跳过 |
### 软删除对比
| 维度 | 改造前 | 改造后 |
|------|--------|--------|
| 操作方式 | `UPDATE ... SET is_delete=1`(修改所有历史版本) | INSERT 一条 is_delete=1 的新版本行 |
| 历史版本影响 | 所有历史版本的 is_delete 被改为 1 | 历史版本完全不变 |
| 幂等性 | 重复执行无副作用UPDATE 幂等) | 重复执行无副作用(最新版本已是 is_delete=1 则跳过) |
| 下游取数 | `WHERE is_delete = 0`(但历史版本也被改了) | `DISTINCT ON (id) ORDER BY fetched_at DESC` + `WHERE is_delete = 0` |
### 新版本数据处理流程
#### 正常写入流程(每次 ETL 运行)
```
1. API 抓取 → 获得一批记录
2. 对每条记录:
a. _normalize_is_delete_flag标准化 is_delete 字段API 可能返回 isDelete/is_deleted 等变体)
b. 取原始 record 作为 payload
c. _compute_content_hash(payload, is_delete) → 计算 hash
d. 若 skip_unchanged=True
- 查询该业务 ID 在数据库中的最新 content_hash
- 若 hash 相同 → 跳过(内容未变,无需新版本)
- 若 hash 不同或无历史记录 → 继续插入
e. INSERT INTO ods.xxx (..., content_hash, payload, is_delete, fetched_at)
ON CONFLICT (id, content_hash) DO UPDATE ...
```
#### 软删除流程(快照对比,路径 B
```
前提:任务配置了 snapshot_mode != NONE且 run.snapshot_missing_delete=True
1. 收集本次抓取到的所有业务 ID → fetched_keys
2. 查询快照范围内数据库中已有的业务 IDis_delete != 1
- FULL_TABLE 模式:全表范围
- WINDOW 模式WHERE {snapshot_time_column} >= window_start AND < window_end
3. 差集 = 数据库中的 ID - fetched_keys → 缺失 ID
4. 对每个缺失 ID
a. SELECT DISTINCT ON (id) * FROM ods.xxx WHERE id = ? ORDER BY fetched_at DESC
→ 读取最新版本行
b. 若最新版本已是 is_delete=1 → 跳过(幂等)
c. 否则:
- 复制最新版本行的所有字段
- 设 is_delete = 1
- _compute_content_hash(原payload, is_delete=1) → 新 hash
- INSERT 新版本行hash 不同,不会与现有行冲突)
5. 历史版本行完全不变
```
#### 下游取数规约
```sql
-- DWD 层从 ODS 取最新有效版本的标准查询
SELECT DISTINCT ON (id) *
FROM ods.{table_name}
WHERE is_delete IS DISTINCT FROM 1 -- 排除已删除
ORDER BY id, fetched_at DESC; -- 利用 (id, fetched_at DESC) 索引
-- 若需要包含删除状态(如审计场景)
SELECT DISTINCT ON (id) *
FROM ods.{table_name}
ORDER BY id, fetched_at DESC;
-- 然后在应用层判断 is_delete 字段
```
## 架构
改造集中在 ODS 写入管线的三个核心环节:
```mermaid
flowchart TD
A[上游 API / JSON 回放] --> B[BaseOdsTask.execute]
B --> C{记录处理}
C --> D[_normalize_is_delete_flag<br/>标准化 is_delete 字段]
D --> E[_compute_content_hash<br/>基于 payload + is_delete 算 hash]
E --> F{skip_unchanged?}
F -->|hash 相同| G[跳过]
F -->|hash 不同或新记录| H[INSERT 新版本行]
B --> I{快照对比}
I -->|snapshot_mode != NONE| J[_mark_missing_as_deleted]
J --> K[读取缺失 ID 的最新版本]
K --> L[构造 is_delete=1 的新版本]
L --> M{最新版本已是 is_delete=1?}
M -->|是| N[跳过]
M -->|否| O[INSERT 删除版本行]
```
**影响范围:**
- `apps/etl/pipelines/feiqiu/tasks/ods/ods_tasks.py` — 主要改动文件
- `db/etl_feiqiu/migrations/` — 新增索引迁移脚本
- `db/etl_feiqiu/schemas/ods.sql` — DDL 注释更新(索引)
- 7 个文档文件 — 同步更新
## 组件与接口
### 1. SnapshotMode 枚举
```python
from enum import Enum
class SnapshotMode(Enum):
"""ODS 快照软删除策略。"""
NONE = "none" # 不做快照对比,不触发软删除
FULL_TABLE = "full_table" # 全表快照:对比全表所有记录
WINDOW = "window" # 窗口快照:仅对比时间窗口内的记录
```
定义在 `ods_tasks.py` 顶部,与 OdsTaskSpec 同文件。
### 2. OdsTaskSpec改造后
```python
@dataclass(frozen=False)
class OdsTaskSpec:
code: str
class_name: str
table_name: str
endpoint: str
data_path: Tuple[str, ...] = ("data",)
list_key: str | None = None
pk_columns: Tuple[ColumnSpec, ...] = ()
extra_columns: Tuple[ColumnSpec, ...] = ()
# --- 保留字段(语义不变)---
include_source_file: bool = True
include_source_endpoint: bool = True
include_record_index: bool = False
include_fetched_at: bool = True
requires_window: bool = True
time_fields: Tuple[str, str] | None = ("startTime", "endTime")
include_site_id: bool = True
description: str = ""
extra_params: Dict[str, Any] = field(default_factory=dict)
# --- 改造字段 ---
skip_unchanged: bool = True # 原 enable_content_hash_dedup默认翻转
snapshot_mode: SnapshotMode = SnapshotMode.NONE # 替代 snapshot_full_table + snapshot_window_columns
snapshot_time_column: str | None = None # WINDOW 模式的时间列
def __post_init__(self) -> None:
if self.snapshot_mode == SnapshotMode.WINDOW and not self.snapshot_time_column:
raise ValueError(
f"任务 {self.code}: snapshot_mode=WINDOW 时必须指定 snapshot_time_column"
)
if self.snapshot_mode != SnapshotMode.WINDOW and self.snapshot_time_column is not None:
raise ValueError(
f"任务 {self.code}: snapshot_mode={self.snapshot_mode.value} 时不应指定 snapshot_time_column"
)
```
**删除的字段:**
- `conflict_columns_override` — 运行时不生效
- `include_site_column` — 全部 False
- `include_page_no` — 全部 False
- `include_page_size` — 全部 False
- `snapshot_full_table` — 被 SnapshotMode 替代
- `snapshot_window_columns` — 被 SnapshotMode + snapshot_time_column 替代
- `enable_content_hash_dedup` — 被 skip_unchanged 替代
### 3. 23 个任务的 SnapshotMode 映射
当前配置到新配置的映射规则:
| 原配置 | 新配置 |
|--------|--------|
| `snapshot_full_table=True` | `snapshot_mode=SnapshotMode.FULL_TABLE` |
| `snapshot_window_columns=("col",)` | `snapshot_mode=SnapshotMode.WINDOW, snapshot_time_column="col"` |
| 两者都未设置 | `snapshot_mode=SnapshotMode.NONE`(默认值) |
具体任务映射:
| 任务 | 原配置 | 新 snapshot_mode | snapshot_time_column |
|------|--------|-----------------|---------------------|
| ODS_ASSISTANT_ACCOUNT | snapshot_full_table=True | FULL_TABLE | None |
| ODS_MEMBER_CARD | snapshot_full_table=True | FULL_TABLE | None |
| ODS_GROUP_PACKAGE | snapshot_full_table=True | FULL_TABLE | None |
| ODS_STORE_GOODS | snapshot_full_table=True | FULL_TABLE | None |
| ODS_TENANT_GOODS | snapshot_full_table=True | FULL_TABLE | None |
| ODS_TABLE_USE | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| ODS_ASSISTANT_LEDGER | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| ODS_STORE_GOODS_SALES | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| ODS_REFUND | snapshot_window_columns=("pay_time",) | WINDOW | "pay_time" |
| ODS_PLATFORM_COUPON | snapshot_window_columns=("consume_time",) | WINDOW | "consume_time" |
| ODS_MEMBER_BALANCE | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| ODS_GROUP_BUY_REDEMPTION | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| ODS_TABLE_FEE_DISCOUNT | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| 其余 10 个任务 | 无快照配置 | NONE | None |
### 4. _compute_content_hash改造后
```python
@classmethod
def _compute_content_hash(cls, record: dict, payload: Any, is_delete: int) -> str:
"""基于原始 payload 和 is_delete 计算 content_hash。
payload: 原始 API 返回的 JSON 对象(未展平)
is_delete: 0 或 1
"""
payload_str = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=cls._hash_default,
)
raw = f"{payload_str}|{is_delete}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
```
**关键变更:**
- 输入从"展平后的 merged_rec"改为"原始 payload + is_delete"
- 删除 `_sanitize_record_for_hash` 方法(不再需要字段排除逻辑)
- 删除 `include_fetched_at` 参数payload 天然不含 fetched_at
- 分隔符 `|` 确保 payload 和 is_delete 不会产生歧义
**一次性代价:** 切换后首次运行,所有记录的 hash 都会变化(因为算法不同),会插入一批新版本行。这是预期行为,后续运行恢复正常去重。
### 5. _mark_missing_as_deleted改造后
```python
def _mark_missing_as_deleted(self, *, table, business_pk_cols,
snapshot_mode, snapshot_time_column,
window_start, window_end,
key_values, allow_empty) -> int:
"""快照对比软删除INSERT 删除版本行,而非 UPDATE 历史版本。"""
# 1. 查询快照范围内、is_delete != 1 的业务 ID
# 2. 排除本次抓取到的 key_values得到缺失 ID 集合
# 3. 对每个缺失 ID
# a. 读取最新版本行DISTINCT ON ... ORDER BY fetched_at DESC
# b. 若最新版本已是 is_delete=1跳过
# c. 否则:复制该行,设 is_delete=1重算 content_hashINSERT
# 4. 返回插入的删除版本行数
```
**接口变更:**
- `window_columns` 参数改为 `snapshot_mode` + `snapshot_time_column`
- `full_table` 参数删除(由 snapshot_mode 表达)
- 内部从 UPDATE 改为 SELECT + INSERT
### 6. _insert_records_schema_aware 的适配
- `compare_latest` 判断条件中 `self.SPEC.enable_content_hash_dedup` 改为 `self.SPEC.skip_unchanged`
- `_compute_content_hash` 调用签名变更:传入原始 record作为 payload和 is_delete 值
- 删除对 `include_site_column``include_page_no``include_page_size` 的引用
### 7. BaseOdsTask.execute 的适配
- `snapshot_full_table` / `snapshot_window_columns` 的读取改为 `spec.snapshot_mode` / `spec.snapshot_time_column`
- `_mark_missing_as_deleted` 调用参数适配
- 删除对已移除字段的引用
## 数据模型
### ODS 表结构(不变)
所有 23 个 ODS 表的 DDL 结构不变PK 仍为 `(业务id, content_hash)`
### 新增索引(迁移脚本)
每张含 `fetched_at` 列的 ODS 表新增复合索引:
```sql
-- 迁移脚本db/etl_feiqiu/migrations/YYYY-MM-DD__add_ods_latest_version_indexes.sql
-- 为 DISTINCT ON (id) ORDER BY id, fetched_at DESC 查询模式提供索引支持
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ods_member_profiles_latest
ON ods.member_profiles (id, fetched_at DESC);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ods_member_balance_changes_latest
ON ods.member_balance_changes (id, fetched_at DESC);
-- ... 对每张含 fetched_at 的 ODS 表重复此模式
-- 索引命名规范idx_ods_{table_name}_latest
-- 业务主键列名因表而异(大多数是 id少数是 recharge_order_id、sitegoodsstockid 等)
```
**注意:**
- `include_fetched_at=False` 的任务(如 ODS_ASSISTANT_ACCOUNT其表中 fetched_at 列有 DEFAULT now(),实际仍有值,也需要索引。但需确认 DDL 中是否所有表都有 fetched_at 列。
- 索引定义需同步写入 `db/etl_feiqiu/schemas/ods.sql`DDL 源文件),确保新环境初始化时自动创建索引。
- 迁移脚本 `db/etl_feiqiu/migrations/YYYY-MM-DD__add_ods_latest_version_indexes.sql` 用于已有环境的增量部署。
### 下游查询规约
DWD 层从 ODS 取数的标准模式:
```sql
SELECT DISTINCT ON (id) *
FROM ods.{table_name}
WHERE is_delete = 0 -- 或 is_delete IS DISTINCT FROM 1
ORDER BY id, fetched_at DESC;
```
此查询利用新增的 `(id, fetched_at DESC)` 索引,避免全表扫描。
## 正确性属性
*正确性属性是系统在所有合法执行路径上都应保持的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
### Property 1: SnapshotMode 与 snapshot_time_column 一致性
*For any* OdsTaskSpec 实例,当 snapshot_mode 为 WINDOW 时 snapshot_time_column 必须为非空字符串,当 snapshot_mode 为 FULL_TABLE 或 NONE 时 snapshot_time_column 必须为 None违反此约束应抛出 ValueError。
**Validates: Requirements 2.3, 2.4, 2.5, 2.6**
### Property 2: content_hash 确定性
*For any* 原始 payload合法 JSON 对象)和 is_delete 值0 或 1对相同的 (payload, is_delete) 输入调用 `_compute_content_hash` 应始终产生相同的 SHA-256 哈希值。
**Validates: Requirements 5.1, 5.4**
### Property 3: content_hash 区分性
*For any* 两组不同的 (payload, is_delete) 输入payload 不同或 is_delete 不同),`_compute_content_hash` 应产生不同的哈希值。
**Validates: Requirements 5.5**
### Property 4: skip_unchanged 跳过内容未变的记录
*For any* ODS 任务skip_unchanged=True当一条记录的 content_hash 与数据库中该业务 ID 最新版本的 content_hash 相同时,该记录应被计入 skipped 而非 inserted。
**Validates: Requirements 4.3, 8.5**
### Property 5: 记录数闭合不变量
*For any* 非空记录列表被写入 ODS 时,`fetched == inserted + updated + skipped` 恒成立。
**Validates: Requirements 8.3**
### Property 6: 软删除构造正确性
*For any* 快照对比中发现的缺失业务 ID`_mark_missing_as_deleted` 应读取该 ID 的最新版本行,构造一条 is_delete=1 的新版本行,其 content_hash 基于原始 payload + is_delete=1 重算,并通过 INSERT而非 UPDATE写入。
**Validates: Requirements 7.1, 7.2, 7.4**
### Property 7: 软删除幂等性
*For any* 业务 ID若其最新版本已经是 is_delete=1再次执行 `_mark_missing_as_deleted` 不应插入新的删除版本行。
**Validates: Requirements 7.3, 8.7**
### Property 8: 软删除不修改历史版本
*For any* 软删除操作执行后,数据库中该业务 ID 的所有历史版本行(执行前已存在的行)的内容应保持不变——不应有 UPDATE 语句作用于 ODS 表。
**Validates: Requirements 7.4, 8.6**
## 错误处理
### OdsTaskSpec 校验错误
- `SnapshotMode.WINDOW` + `snapshot_time_column=None``__post_init__` 抛出 `ValueError`
- `SnapshotMode.FULL_TABLE/NONE` + `snapshot_time_column` 不为 None → `__post_init__` 抛出 `ValueError`
- 这些错误在任务注册时(模块加载时)即触发,属于 fail-fast 设计
### hash 算法切换的一次性代价
- 首次运行后所有记录的 content_hash 都会变化,导致全量插入新版本行
- 这是预期行为,不是错误
- 日志中应记录 "hash 算法已变更,本次运行将插入全量新版本" 的提示信息
- 后续运行恢复正常去重
### 软删除的边界情况
- 缺失 ID 在数据库中无任何记录(从未抓取过)→ 跳过,不插入删除版本
- 缺失 ID 的最新版本已是 is_delete=1 → 跳过(幂等性)
- 快照范围内无任何记录且 allow_empty=False → 返回 0不执行任何操作
### 迁移脚本错误
- `CREATE INDEX CONCURRENTLY` 不能在事务块内执行 → 迁移脚本需单独执行
- 索引创建失败不影响数据写入,仅影响查询性能 → 可重试
## 测试策略
### 属性测试hypothesis
使用 `pytest` + `hypothesis` 库,每个属性测试至少运行 100 次迭代。
**测试文件:** `apps/etl/pipelines/feiqiu/tests/unit/test_ods_dedup_properties.py`
| 属性 | 测试方法 | 生成策略 |
|------|---------|---------|
| Property 1 | 生成随机 SnapshotMode + snapshot_time_column 组合,验证校验逻辑 | `st.sampled_from(SnapshotMode)` × `st.one_of(st.none(), st.text(min_size=1))` |
| Property 2 | 生成随机 JSON payload + is_delete验证两次调用结果相同 | `st.dictionaries(st.text(), st.text())` × `st.sampled_from([0, 1])` |
| Property 3 | 生成两组不同的 (payload, is_delete),验证 hash 不同 | 同上,加 `assume(pair1 != pair2)` |
| Property 4 | 用 PkAwareFakeDB 预设最新 hash验证相同记录被跳过 | `_ods_record_with_id` 策略 |
| Property 5 | 生成随机记录列表,验证 fetched == inserted + updated + skipped | `st.lists(_ods_record_with_id)` |
| Property 6 | 用 FakeDB 模拟缺失 ID 场景,验证 INSERT 而非 UPDATE | `st.lists(st.integers())` |
| Property 7 | 预设最新版本 is_delete=1验证不产生新行 | 同上 |
| Property 8 | 执行软删除后检查 FakeDB 中无 UPDATE 语句 | 同上 |
每个测试用注释标注:`# Feature: ods-dedup-standardize, Property N: {title}`
### 单元测试
**测试文件:** 适配现有 `test_ods_tasks.py``test_debug_ods_properties.py`
- 适配 OdsTaskSpec 构造函数变更(删除旧字段,使用新字段)
- 适配 `_compute_content_hash` 签名变更
- 适配 `_mark_missing_as_deleted` 参数变更
- 验证 SnapshotMode 枚举的边界情况edge cases from prework 2.5, 2.6
### 现有测试适配
现有测试中需要适配的关键点:
- `test_debug_ods_properties.py` 中的 Property 4content_hash 确定性)需要适配新的 `_compute_content_hash` 签名
- `test_debug_ods_properties.py` 中的 Property 5快照删除标记需要适配新的 INSERT 语义(检查 INSERT 而非 UPDATE
- `test_ods_tasks.py` 中的所有任务测试需要确保在新的 OdsTaskSpec 下仍能正常运行
### 测试执行命令
```bash
# ETL 单元测试(包含属性测试)
cd apps/etl/pipelines/feiqiu && pytest tests/unit -v
# 仅运行本次改造的属性测试
cd apps/etl/pipelines/feiqiu && pytest tests/unit/test_ods_dedup_properties.py -v
```

View File

@@ -0,0 +1,143 @@
# 需求文档ODS 去重与软删除机制标准化
## 简介
NeoZQYY ETL 系统的 23 个 ODS 任务在去重和软删除机制上存在配置误导、无意义版本膨胀、软删除语义不清等问题。本需求旨在精简 `OdsTaskSpec` 配置、标准化 content_hash 去重策略、优化软删除语义,使 ODS 层真正实现"忠实记录上游数据版本变更"的职责。
## 术语表
- **OdsTaskSpec**`ods_tasks.py` 中定义 ODS 任务配置的 dataclass包含端点、主键、去重开关等字段
- **BaseOdsTask**ODS 任务执行基类,包含写入、去重、软删除等核心逻辑
- **content_hash**:基于记录内容计算的 SHA-256 哈希值,与业务 ID 组成复合主键
- **skip_unchanged**:重命名后的去重开关(原 `enable_content_hash_dedup`),为 True 时跳过内容未变的记录
- **SnapshotMode**新增枚举统一表达软删除快照策略NONE / FULL_TABLE / WINDOW
- **业务主键**ODS 表复合主键中除 content_hash 外的列(通常为 `id`
- **软删除**:将上游已不存在的记录标记为 `is_delete=1`
- **payload**ODS 表中存储的原始 API 响应 JSON
## 需求
### 需求 1删除运行时无效的 conflict_columns_override 字段
**用户故事:** 作为 ETL 开发者,我希望移除不生效的配置字段,以避免对 ODS 写入行为产生误解。
#### 验收标准
1. WHEN `OdsTaskSpec` dataclass 被定义时THE OdsTaskSpec SHALL 不包含 `conflict_columns_override` 字段
2. WHEN `OdsTaskSpec.__post_init__` 执行校验时THE OdsTaskSpec SHALL 不包含任何引用 `conflict_columns_override` 的校验逻辑
3. WHEN 23 个 `ODS_TASK_SPECS` 声明被更新后THE ODS_TASK_SPECS SHALL 不包含任何 `conflict_columns_override` 参数
### 需求 2用 SnapshotMode 枚举替代软删除组合字段
**用户故事:** 作为 ETL 开发者,我希望用单一枚举字段表达快照策略,以消除 `snapshot_full_table``snapshot_window_columns` 的组合歧义。
#### 验收标准
1. THE SnapshotMode 枚举 SHALL 定义三个值NONE、FULL_TABLE、WINDOW
2. WHEN `OdsTaskSpec` dataclass 被定义时THE OdsTaskSpec SHALL 使用 `snapshot_mode: SnapshotMode` 替代 `snapshot_full_table: bool``snapshot_window_columns`
3. WHEN `snapshot_mode` 为 WINDOW 时THE OdsTaskSpec SHALL 要求 `snapshot_time_column` 为非空字符串
4. WHEN `snapshot_mode` 为 FULL_TABLE 或 NONE 时THE OdsTaskSpec SHALL 要求 `snapshot_time_column` 为 None
5. IF `snapshot_mode` 为 WINDOW 且 `snapshot_time_column` 为 NoneTHEN THE OdsTaskSpec SHALL 在 `__post_init__` 中抛出 ValueError
6. IF `snapshot_mode` 为 FULL_TABLE 且 `snapshot_time_column` 不为 NoneTHEN THE OdsTaskSpec SHALL 在 `__post_init__` 中抛出 ValueError
7. WHEN 23 个 `ODS_TASK_SPECS` 声明被迁移后THE ODS_TASK_SPECS SHALL 使用 `snapshot_mode``snapshot_time_column` 替代原有的 `snapshot_full_table``snapshot_window_columns`
### 需求 3删除全局恒定的冗余布尔字段
**用户故事:** 作为 ETL 开发者,我希望移除所有任务中值恒定的配置字段,以减少 OdsTaskSpec 的认知负担。
#### 验收标准
1. WHEN `OdsTaskSpec` dataclass 被定义时THE OdsTaskSpec SHALL 不包含 `include_site_column``include_page_no``include_page_size` 字段
2. WHEN `BaseOdsTask` 执行逻辑中引用上述三个字段时THE BaseOdsTask SHALL 将对应逻辑硬编码为 False 或直接移除
3. WHEN 23 个 `ODS_TASK_SPECS` 声明被更新后THE ODS_TASK_SPECS SHALL 不包含 `include_site_column``include_page_no``include_page_size` 参数
### 需求 4重命名去重开关并默认开启
**用户故事:** 作为 ETL 开发者,我希望去重开关名称更直观且默认开启,以减少无意义的版本膨胀。
#### 验收标准
1. WHEN `OdsTaskSpec` dataclass 被定义时THE OdsTaskSpec SHALL 使用 `skip_unchanged: bool = True` 替代 `enable_content_hash_dedup: bool = False`
2. WHEN `BaseOdsTask._insert_records_schema_aware` 执行去重判断时THE BaseOdsTask SHALL 引用 `self.SPEC.skip_unchanged` 而非 `self.SPEC.enable_content_hash_dedup`
3. WHEN `skip_unchanged` 为 True 且目标表有 content_hash 列和业务主键时THE BaseOdsTask SHALL 跳过 content_hash 与数据库中最新版本相同的记录
### 需求 5改用 payload + is_delete 计算 content_hash
**用户故事:** 作为 ETL 开发者,我希望 content_hash 基于原始 payload 和 is_delete 计算,以获得最干净的语义且不受展平逻辑影响。
#### 验收标准
1. WHEN `_compute_content_hash` 计算哈希时THE BaseOdsTask SHALL 仅基于记录的 payload原始 JSON和 is_delete 字段计算 SHA-256 哈希
2. WHEN `_compute_content_hash` 计算哈希时THE BaseOdsTask SHALL 对 payload 进行 `json.dumps(sort_keys=True, separators=(',',':'), ensure_ascii=False)` 序列化后拼接 is_delete 值再计算哈希
3. WHEN `_sanitize_record_for_hash` 被调用时THE BaseOdsTask SHALL 移除该方法,因为新的 hash 计算不再需要字段排除逻辑
4. THE _compute_content_hash SHALL 对相同的 payload 和 is_delete 组合始终产生相同的哈希值
5. THE _compute_content_hash SHALL 对不同的 payload 或不同的 is_delete 值产生不同的哈希值
### 需求 6为 ODS 表添加"取最新版本"索引
**用户故事:** 作为 ETL 开发者,我希望每张 ODS 表有 `(业务id, fetched_at DESC)` 复合索引,以高效支持 `DISTINCT ON` 取最新版本的查询模式。
#### 验收标准
1. WHEN DDL 迁移脚本执行后THE 数据库 SHALL 为每张含 fetched_at 列的 ODS 表创建 `(业务主键, fetched_at DESC)` 复合索引
2. WHEN 索引创建时THE 迁移脚本 SHALL 使用 `CREATE INDEX IF NOT EXISTS` 以保证幂等性
3. WHEN 索引创建时THE 迁移脚本 SHALL 使用 `CONCURRENTLY` 选项以避免锁表
### 需求 7软删除改为"插入删除版本"
**用户故事:** 作为 ETL 开发者,我希望软删除操作插入一条 `is_delete=1` 的新版本行,而非 UPDATE 所有历史版本,以保持 ODS 追加写入的语义一致性。
**背景:** 软删除有两个触发路径:
- **路径 AAPI 返回)**:上游 API 的 JSON 响应中自带 `is_delete`/`isDelete` 等字段,由 `_normalize_is_delete_flag` 标准化后随记录正常写入。此路径在需求 5is_delete 参与 hash生效后自动产生新版本行无需额外改造。
- **路径 B快照空缺**:通过 `_mark_missing_as_deleted` 在快照范围内FULL_TABLE 模式对比全表WINDOW 模式仅对比时间窗口内的记录)对比本次抓取的业务 ID 集合与数据库已有记录,发现缺失的 ID 需标记为删除。仅当任务配置了 `snapshot_mode` 为 FULL_TABLE 或 WINDOW 时才触发。此路径是本需求的改造重点。
#### 验收标准
1. WHEN `_mark_missing_as_deleted` 检测到某业务 ID 在上游已不存在时(路径 BTHE BaseOdsTask SHALL 读取该业务 ID 的最新版本行内容
2. WHEN 构造删除版本行时THE BaseOdsTask SHALL 将 is_delete 设为 1保留其余字段不变并基于 payload + is_delete=1 重算 content_hash
3. WHEN 删除版本行的 content_hash 与该业务 ID 最新版本的 content_hash 相同时THE BaseOdsTask SHALL 跳过插入(该记录已经是删除状态)
4. WHEN 删除版本行被插入后THE BaseOdsTask SHALL 不修改该业务 ID 的任何历史版本行
5. WHEN 上游 API 返回的记录中 is_delete=1 时(路径 ATHE BaseOdsTask SHALL 通过正常写入流程插入新版本行is_delete 参与 hash 计算hash 变化即为新版本)
6. WHEN 下游查询 ODS 数据时THE 查询规约 SHALL 先按业务 ID 取 `fetched_at DESC` 最新版本,再过滤 `is_delete = 0`
### 需求 8回归验证策略
**用户故事:** 作为 ETL 开发者,我希望有完善的回归测试覆盖本次改造的所有核心逻辑变更,以确保 23 个 ODS 任务在改造后行为正确。
**挑战:** 本次改造涉及 OdsTaskSpec 字段重构、hash 算法变更、软删除语义变更,影响所有 23 个任务的写入和删除路径。需要分层验证:
- 单元级OdsTaskSpec 校验逻辑、SnapshotMode 枚举约束、hash 计算纯函数
- 行为级skip_unchanged 去重、软删除插入版本、记录数闭合
- 属性级:用 hypothesis 对核心不变量进行随机化验证
#### 验收标准
1. WHEN OdsTaskSpec 使用 SnapshotMode.WINDOW 且 snapshot_time_column 为 None 时THE 单元测试 SHALL 验证 __post_init__ 抛出 ValueError
2. WHEN OdsTaskSpec 使用 SnapshotMode.FULL_TABLE 且 snapshot_time_column 不为 None 时THE 单元测试 SHALL 验证 __post_init__ 抛出 ValueError
3. WHEN 任意非空记录列表被写入 ODS 时THE 属性测试 SHALL 验证 fetched == inserted + updated + skipped记录数闭合不变量
4. WHEN 同一条记录的 payload 和 is_delete 不变时THE 属性测试 SHALL 验证 _compute_content_hash 产生相同的哈希值
5. WHEN skip_unchanged=True 且记录内容未变时THE 属性测试 SHALL 验证该记录被跳过而非重复插入
6. WHEN 快照对比发现缺失 ID 时THE 属性测试 SHALL 验证生成的是 INSERT 语句(而非 UPDATE且历史版本行不被修改
7. WHEN 缺失 ID 的最新版本已经是 is_delete=1 时THE 属性测试 SHALL 验证不会重复插入删除版本
8. WHEN 现有的 test_ods_tasks.py 和 test_debug_ods_properties.py 中的测试用例被适配到新接口后THE 测试套件 SHALL 全部通过
### 需求 9同步更新所有相关文档
**用户故事:** 作为 ETL 开发者,我希望所有涉及 ODS 去重、软删除、OdsTaskSpec 配置的文档与代码变更保持同步,以确保文档准确反映当前实现。
**涉及文档清单:**
- `apps/etl/pipelines/feiqiu/docs/etl_tasks/ods_task_params_matrix.md` — 任务参数矩阵
- `apps/etl/pipelines/feiqiu/docs/etl_tasks/ods_tasks.md` — ODS 任务说明
- `apps/etl/pipelines/feiqiu/docs/etl_tasks/base_task_mechanism.md` — 基础任务机制
- `apps/etl/pipelines/feiqiu/docs/architecture/ods_taskspec_refactor_proposal.md` — OdsTaskSpec 重构提案
- `apps/etl/pipelines/feiqiu/docs/database/ODS/` — ODS 数据库文档目录
- `apps/etl/pipelines/feiqiu/docs/database/overview/ods_tables_dictionary.md` — ODS 表字典
- `docs/database/etl_feiqiu_schema_migration.md` — 项目级 schema 迁移文档
#### 验收标准
1. WHEN OdsTaskSpec 字段发生变更后THE ods_task_params_matrix.md SHALL 反映新的字段名称和默认值skip_unchanged、snapshot_mode、snapshot_time_column并移除已删除字段的列
2. WHEN OdsTaskSpec 字段发生变更后THE ods_task_params_matrix.md SHALL 包含所有 23 个任务的完整参数矩阵
3. WHEN 去重和软删除机制发生变更后THE ods_tasks.md 和 base_task_mechanism.md SHALL 更新对应的机制说明
4. WHEN DDL 迁移脚本添加索引后THE ODS 数据库文档和 ods_tables_dictionary.md SHALL 记录新增索引
5. WHEN DDL 迁移脚本添加索引后THE docs/database/etl_feiqiu_schema_migration.md SHALL 记录本次迁移变更
6. WHEN 所有文档更新完成后THE 文档 SHALL 逐个文件检查并确保内容与代码实现一致

View File

@@ -0,0 +1,141 @@
# 实现计划ODS 去重与软删除机制标准化
## 概述
按方案 1→2→3→4 的顺序递进实现,每个方案完成后有检查点。核心改动集中在 `ods_tasks.py`,辅以 DDL 迁移和文档同步。
## 任务
- [x] 1. 方案 1清理 OdsTaskSpec 无效/冗余配置
- [x] 1.1 添加 SnapshotMode 枚举,重构 OdsTaskSpec dataclass
-`ods_tasks.py` 顶部定义 `SnapshotMode` 枚举NONE / FULL_TABLE / WINDOW
- 从 OdsTaskSpec 中删除 `conflict_columns_override``include_site_column``include_page_no``include_page_size``snapshot_full_table``snapshot_window_columns``enable_content_hash_dedup`
- 添加 `skip_unchanged: bool = True``snapshot_mode: SnapshotMode = SnapshotMode.NONE``snapshot_time_column: str | None = None`
- 重写 `__post_init__` 校验逻辑WINDOW 必须有 snapshot_time_columnFULL_TABLE/NONE 不能有
- _Requirements: 1.1, 1.2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 3.1, 4.1_
- [x] 1.2 迁移 23 个 ODS_TASK_SPECS 声明到新字段
- 按设计文档中的映射表,将每个任务的 snapshot_full_table/snapshot_window_columns 转换为 snapshot_mode/snapshot_time_column
- 删除所有任务中的 conflict_columns_override、include_site_column、include_page_no、include_page_size
- 将 ODS_RECHARGE_SETTLE 的 enable_content_hash_dedup=True 改为 skip_unchanged=True其余任务使用默认值 True
- _Requirements: 1.3, 2.7, 3.3_
- [x] 1.3 适配 BaseOdsTask.execute 和相关方法中对旧字段的引用
- `execute` 方法中 `snapshot_full_table` / `snapshot_window_columns` 改为读取 `spec.snapshot_mode` / `spec.snapshot_time_column`
- `_mark_missing_as_deleted` 参数签名适配(暂保持 UPDATE 语义,方案 4 再改)
- 删除 BaseOdsTask 中对 `include_site_column``include_page_no``include_page_size` 的引用
- `_insert_records_schema_aware``enable_content_hash_dedup` 改为 `skip_unchanged`
- _Requirements: 1.2, 3.2, 4.2_
- [x] 1.4 编写 SnapshotMode 校验属性测试
- **Property 1: SnapshotMode 与 snapshot_time_column 一致性**
- **Validates: Requirements 2.3, 2.4, 2.5, 2.6**
- [x] 2. 检查点 - 方案 1 完成
- 确保所有测试通过ask the user if questions arise.
- 运行 `cd apps/etl/pipelines/feiqiu && pytest tests/unit -v`
- 验证现有 test_ods_tasks.py 和 test_debug_ods_properties.py 适配后通过
- [x] 3. 方案 2默认开启 skip_unchanged + hash 算法改为 payload + is_delete
- [x] 3.1 重写 _compute_content_hash删除 _sanitize_record_for_hash
- 新签名:`_compute_content_hash(cls, record: dict, payload: Any, is_delete: int) -> str`
- 基于 `json.dumps(payload, sort_keys=True, separators=(',',':'), ensure_ascii=False)` + `|` + `is_delete` 计算 SHA-256
- 删除 `_sanitize_record_for_hash` 方法
- 删除 `include_fetched_at` 参数
- _Requirements: 5.1, 5.2, 5.3_
- [x] 3.2 适配 _insert_records_schema_aware 中的 hash 计算调用
-`_compute_content_hash(merged_rec, include_fetched_at=False)` 改为 `_compute_content_hash(merged_rec, payload=rec, is_delete=merged_rec.get("is_delete", 0))`
- 其中 `rec` 是原始 API 返回的记录(未展平),`merged_rec` 中的 is_delete 已被 `_normalize_is_delete_flag` 标准化
- _Requirements: 5.1, 5.2_
- [x] 3.3 编写 content_hash 确定性和区分性属性测试
- **Property 2: content_hash 确定性**
- **Validates: Requirements 5.1, 5.4**
- **Property 3: content_hash 区分性**
- **Validates: Requirements 5.5**
- [x] 3.4 编写 skip_unchanged 和记录数闭合属性测试
- **Property 4: skip_unchanged 跳过内容未变的记录**
- **Validates: Requirements 4.3, 8.5**
- **Property 5: 记录数闭合不变量**
- **Validates: Requirements 8.3**
- [x] 4. 检查点 - 方案 2 完成
- 确保所有测试通过ask the user if questions arise.
- 适配 test_debug_ods_properties.py 中 Property 4content_hash 确定性)到新签名
- [x] 5. 方案 3DDL 迁移 - 添加"取最新版本"索引
- [x] 5.1 创建迁移脚本并更新 DDL 源文件
- 创建 `db/etl_feiqiu/migrations/YYYY-MM-DD__add_ods_latest_version_indexes.sql`
- 为每张含 fetched_at 列的 ODS 表创建 `(业务主键, fetched_at DESC)` 复合索引
- 使用 `CREATE INDEX CONCURRENTLY IF NOT EXISTS`
- 索引命名:`idx_ods_{table_name}_latest`
- 同步更新 `db/etl_feiqiu/schemas/ods.sql` 中的索引定义
- _Requirements: 6.1, 6.2, 6.3_
- [x] 6. 方案 4软删除改为"插入删除版本"
- [x] 6.1 重写 _mark_missing_as_deleted 方法
- 接口变更:`window_columns`/`full_table` 参数改为 `snapshot_mode`/`snapshot_time_column`
- 查询快照范围内 is_delete != 1 的业务 ID排除本次抓取到的 key_values
- 对每个缺失 ID读取最新版本行DISTINCT ON + ORDER BY fetched_at DESC
- 若最新版本已是 is_delete=1 → 跳过
- 否则:复制该行,设 is_delete=1重算 content_hashINSERT 新行
- _Requirements: 7.1, 7.2, 7.3, 7.4_
- [x] 6.2 适配 BaseOdsTask.execute 中的 _mark_missing_as_deleted 调用
- 传入 snapshot_mode 和 snapshot_time_column 替代旧参数
- 更新 deleted 计数逻辑(从 UPDATE rowcount 改为 INSERT count
- _Requirements: 7.1_
- [x] 6.3 编写软删除属性测试
- **Property 6: 软删除构造正确性**
- **Validates: Requirements 7.1, 7.2, 7.4**
- **Property 7: 软删除幂等性**
- **Validates: Requirements 7.3, 8.7**
- **Property 8: 软删除不修改历史版本**
- **Validates: Requirements 7.4, 8.6**
- [x] 7. 检查点 - 方案 4 完成
- 确保所有测试通过ask the user if questions arise.
- 适配 test_debug_ods_properties.py 中 Property 5快照删除标记到新的 INSERT 语义
- 运行完整测试套件:`cd apps/etl/pipelines/feiqiu && pytest tests/unit -v`
- [x] 8. 文档同步
- [x] 8.1 更新 ods_task_params_matrix.md
- 反映新字段skip_unchanged、snapshot_mode、snapshot_time_column
- 移除已删除字段列
- 确保 23 个任务的完整参数矩阵
- _Requirements: 9.1, 9.2_
- [x] 8.2 更新 ods_tasks.md 和 base_task_mechanism.md
- 更新去重机制说明skip_unchanged 默认开启、hash 基于 payload + is_delete
- 更新软删除机制说明INSERT 删除版本行、双路径覆盖)
- _Requirements: 9.3_
- [x] 8.3 更新 ODS 数据库文档和 ods_tables_dictionary.md
- 记录新增的 `(业务主键, fetched_at DESC)` 索引
- 更新下游取数规约说明
- _Requirements: 9.4_
- [x] 8.4 更新 docs/database/etl_feiqiu_schema_migration.md
- 记录本次迁移变更(索引添加)
- _Requirements: 9.5_
- [x] 8.5 更新 ods_taskspec_refactor_proposal.md
- 标记本次改造已完成的方案1-4
- 记录方案 5冷数据归档为中长期待办
- _Requirements: 9.6_
- [x] 9. 最终检查点
- 确保所有测试通过ask the user if questions arise.
- 运行 `cd apps/etl/pipelines/feiqiu && pytest tests/unit -v`
- 运行 `cd C:\NeoZQYY && pytest tests/ -v`monorepo 属性测试)
## 备注
- 标记 `*` 的任务为可选测试任务,可跳过以加速 MVP
- 每个任务引用具体需求编号以确保可追溯
- 检查点确保增量验证
- 属性测试验证通用正确性属性,单元测试验证具体示例和边界情况
- 本次改造涉及高风险路径tasks/),完成后需触发 `/audit`