在前后端开发联调前 的提交20260223
This commit is contained in:
1
.kiro/specs/etl-aggregation-fix/.config.kiro
Normal file
1
.kiro/specs/etl-aggregation-fix/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
464
.kiro/specs/etl-aggregation-fix/design.md
Normal file
464
.kiro/specs/etl-aggregation-fix/design.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# 设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
本设计覆盖 v8 联调中 4 个"临时止血"修复的深度方案,按优先级排列:
|
||||
1. **需求 D(P0)**:`DwdLoadTask.load()` 返回值格式规范化
|
||||
2. **需求 C1(P1)**:会员生日字段 ETL 链路补齐
|
||||
3. **需求 B(P1)**:多门店会员查询支持
|
||||
4. **需求 A(P2)**:助教月度聚合按档位分段统计
|
||||
5. **需求 C2(P2)**:助教手动补录会员生日
|
||||
|
||||
设计原则:
|
||||
- 每个需求独立可部署,按优先级逐步实施
|
||||
- DDL 变更通过迁移脚本执行,支持回滚
|
||||
- 保持现有 ETL 架构(BaseTask E/T/L 模板)不变
|
||||
|
||||
## 架构
|
||||
|
||||
整体架构不变,变更集中在以下层面:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "需求 D: 返回值规范化"
|
||||
D1[DwdLoadTask.load] -->|errors: int| D2[BaseTask._accumulate_counts]
|
||||
D2 -->|sum| D3[FlowRunner._safe_int]
|
||||
end
|
||||
|
||||
subgraph "需求 A: 档位分段统计"
|
||||
A1[dws_assistant_daily_detail] -->|GROUP BY level_code| A2[AssistantMonthlyTask]
|
||||
A2 -->|多行/档位| A3[dws_assistant_monthly_summary]
|
||||
A3 --> A4[AssistantSalaryTask 分段计算]
|
||||
end
|
||||
|
||||
subgraph "需求 B: 多门店会员查询"
|
||||
B1[dwd.事实表] -->|member_id IN| B2[dim_member]
|
||||
B2 --> B3[DWS 任务]
|
||||
end
|
||||
|
||||
subgraph "需求 C: 生日字段"
|
||||
C1[ODS payload] -->|birthday 提取| C2[dim_member.birthday]
|
||||
C3[后端 API] -->|UPSERT| C4[zqyy_app.member_birthday_manual]
|
||||
C2 --> C5[DWS 任务: COALESCE]
|
||||
C4 -->|FDW 只读| C5
|
||||
end
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 需求 D:返回值格式规范化
|
||||
|
||||
**变更文件:**
|
||||
- `apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
- `apps/etl/connectors/feiqiu/tasks/base_task.py`
|
||||
|
||||
**DwdLoadTask.load() 返回值变更:**
|
||||
|
||||
```python
|
||||
# 变更前
|
||||
return {"tables": summary, "errors": errors}
|
||||
# errors: list[dict],如 [{"table": "dim_assistant_ex", "error": "..."}]
|
||||
|
||||
# 变更后
|
||||
return {
|
||||
"tables": summary,
|
||||
"errors": len(errors), # int — 与其他任务一致
|
||||
"error_details": errors, # list[dict] — 保留详情供日志使用
|
||||
}
|
||||
```
|
||||
|
||||
**BaseTask._accumulate_counts() 防御层增强:**
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
def _accumulate_counts(total: dict, current: dict) -> dict:
|
||||
for key, value in (current or {}).items():
|
||||
if isinstance(value, (int, float)):
|
||||
total[key] = (total.get(key) or 0) + value
|
||||
elif isinstance(value, list):
|
||||
# 防御层:list 类型转为 len() 累加
|
||||
total[key] = (total.get(key) or 0) + len(value)
|
||||
else:
|
||||
total.setdefault(key, value)
|
||||
return total
|
||||
```
|
||||
|
||||
**FlowRunner._safe_int() 保留不变**,作为最终防御层。
|
||||
|
||||
### 需求 A:助教月度聚合按档位分段统计
|
||||
|
||||
**DDL 变更:**
|
||||
|
||||
```sql
|
||||
-- 迁移脚本:删除旧唯一约束,创建新约束
|
||||
ALTER TABLE dws.dws_assistant_monthly_summary
|
||||
DROP CONSTRAINT IF EXISTS uk_dws_assistant_monthly;
|
||||
|
||||
ALTER TABLE dws.dws_assistant_monthly_summary
|
||||
ADD CONSTRAINT uk_dws_assistant_monthly
|
||||
UNIQUE (site_id, assistant_id, stat_month, assistant_level_code);
|
||||
```
|
||||
|
||||
**AssistantMonthlyTask._extract_daily_aggregates() 变更:**
|
||||
|
||||
```sql
|
||||
-- 变更前:GROUP BY assistant_id, DATE_TRUNC('month', stat_date)
|
||||
-- 变更后:加入 assistant_level_code 分组
|
||||
SELECT
|
||||
assistant_id,
|
||||
assistant_level_code,
|
||||
assistant_level_name,
|
||||
-- nickname 取时间最后一条
|
||||
(ARRAY_AGG(assistant_nickname ORDER BY stat_date DESC))[1] AS assistant_nickname,
|
||||
DATE_TRUNC('month', stat_date)::DATE AS stat_month,
|
||||
COUNT(DISTINCT stat_date) AS work_days,
|
||||
SUM(total_service_count) AS total_service_count,
|
||||
-- ... 其余聚合字段不变
|
||||
FROM dws.dws_assistant_daily_detail
|
||||
WHERE site_id = %s AND ({month_where})
|
||||
GROUP BY assistant_id, assistant_level_code, assistant_level_name,
|
||||
DATE_TRUNC('month', stat_date)
|
||||
```
|
||||
|
||||
**AssistantSalaryTask 适配:**
|
||||
- `_extract_monthly_summary()` 返回多行(同一助教不同档位)
|
||||
- `transform()` 遍历每行分别计算工资,按档位使用对应的 `level_price` 和 `tier`
|
||||
- 最终每个 `(assistant_id, stat_month, assistant_level_code)` 生成一条工资记录
|
||||
|
||||
**AssistantFinanceTask._extract_daily_revenue() nickname 修复:**
|
||||
|
||||
```sql
|
||||
-- 变更前:MAX(s.nickname) AS assistant_nickname
|
||||
-- 变更后:
|
||||
(ARRAY_AGG(s.nickname ORDER BY s.start_use_time DESC))[1] AS assistant_nickname
|
||||
```
|
||||
|
||||
**AssistantCustomerTask._extract_service_pairs() nickname 修复:**
|
||||
|
||||
```sql
|
||||
-- 变更前:MAX(assistant_nickname) AS assistant_nickname
|
||||
-- 变更后:
|
||||
(ARRAY_AGG(assistant_nickname ORDER BY service_date DESC))[1] AS assistant_nickname
|
||||
```
|
||||
|
||||
### 需求 B:多门店会员查询支持
|
||||
|
||||
**变更模式:** 所有 `_extract_member_info(site_id)` 方法的 SQL 从:
|
||||
|
||||
```sql
|
||||
WHERE register_site_id = %s AND scd2_is_current = 1
|
||||
```
|
||||
|
||||
改为通过事实表反查:
|
||||
|
||||
```sql
|
||||
WHERE member_id IN (
|
||||
SELECT DISTINCT tenant_member_id
|
||||
FROM dwd.{事实表}
|
||||
WHERE site_id = %s AND tenant_member_id IS NOT NULL AND tenant_member_id != 0
|
||||
) AND scd2_is_current = 1
|
||||
```
|
||||
|
||||
**受影响的任务和对应事实表:**
|
||||
|
||||
| 任务 | 方法 | 事实表 |
|
||||
|------|------|--------|
|
||||
| `member_visit_task.py` | `_extract_member_info` | `dwd_settlement_head` |
|
||||
| `member_consumption_task.py` | `_extract_member_info` | `dwd_settlement_head` |
|
||||
| `assistant_customer_task.py` | `_extract_member_info` | `dwd_assistant_service_log` |
|
||||
|
||||
**`dim_member_card_account` 的处理:**
|
||||
- `member_consumption_task.py` 和 `finance_recharge_task.py` 中对 `dim_member_card_account` 的查询也使用 `register_site_id`
|
||||
- 同样改为通过事实表的 `tenant_member_id` 反查:
|
||||
|
||||
```sql
|
||||
WHERE tenant_member_id IN (
|
||||
SELECT DISTINCT tenant_member_id
|
||||
FROM dwd.{事实表}
|
||||
WHERE site_id = %s AND tenant_member_id IS NOT NULL AND tenant_member_id != 0
|
||||
) AND scd2_is_current = 1
|
||||
```
|
||||
|
||||
### 需求 C1:会员生日字段 ETL 链路补齐
|
||||
|
||||
**DDL 变更:**
|
||||
|
||||
```sql
|
||||
-- dim_member 加列
|
||||
ALTER TABLE dwd.dim_member ADD COLUMN IF NOT EXISTS birthday DATE;
|
||||
COMMENT ON COLUMN dwd.dim_member.birthday IS '会员生日,来源:ODS member_profiles payload 中的 birthday 字段';
|
||||
```
|
||||
|
||||
**ODS → DWD 装载:**
|
||||
- `DwdLoadTask` 的列映射是自动的(通过 `_get_columns()` 读取 DWD 表列名,与 ODS 列名匹配)
|
||||
- ODS `member_profiles` 表没有 `birthday` 列,但 `payload` JSONB 中可能包含
|
||||
- 需要在 `_build_column_mapping()` 或 `_fetch_source_rows()` 中增加从 `payload` 提取 `birthday` 的逻辑
|
||||
- 方案:在 ODS 表也加 `birthday` 列(保持 ODS 与 API 字段对齐),ODS 入库时从 JSON 提取
|
||||
|
||||
```sql
|
||||
-- ODS member_profiles 加列
|
||||
ALTER TABLE ods.member_profiles ADD COLUMN IF NOT EXISTS birthday DATE;
|
||||
```
|
||||
|
||||
ODS 入库逻辑(`ods_tasks.py`)已有从 JSON 提取字段的机制,新增 `birthday` 字段映射即可。DwdLoadTask 的自动列匹配会自动将 `ods.member_profiles.birthday` 映射到 `dwd.dim_member.birthday`。
|
||||
|
||||
**SCD2 处理:**
|
||||
- `birthday` 作为 `dim_member` 的普通维度列,SCD2 变化检测会自动包含
|
||||
- 当 API 返回的 birthday 值变化时,会触发 SCD2 版本更新
|
||||
|
||||
**DWS 任务恢复 birthday 引用:**
|
||||
- `member_visit_task.py` 的 `_extract_member_info()` SQL 中加入 `birthday`
|
||||
- `member_consumption_task.py` 同理
|
||||
|
||||
### 需求 C2:助教手动补录会员生日
|
||||
|
||||
**DDL(`zqyy_app` / `test_zqyy_app` 业务库):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS member_birthday_manual (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
member_id BIGINT NOT NULL,
|
||||
birthday_value DATE NOT NULL,
|
||||
recorded_by_assistant_id BIGINT,
|
||||
recorded_by_name VARCHAR(50),
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source VARCHAR(20) DEFAULT 'assistant',
|
||||
CONSTRAINT uk_member_birthday_manual
|
||||
UNIQUE (member_id, recorded_by_assistant_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE member_birthday_manual IS '助教手动补录的会员生日信息';
|
||||
CREATE INDEX idx_mbd_member ON member_birthday_manual (member_id);
|
||||
```
|
||||
|
||||
**FDW 映射(ETL 库读取业务库数据):**
|
||||
|
||||
当前 FDW 方向是 `zqyy_app` → `etl_feiqiu`(业务库读 ETL 数据)。需求 C2 需要反向:ETL DWS 任务读取业务库的手动补录表。
|
||||
|
||||
方案:在 `etl_feiqiu` 库中创建指向 `zqyy_app` 的 FDW 外部表:
|
||||
|
||||
```sql
|
||||
-- 在 etl_feiqiu 中执行
|
||||
CREATE SERVER IF NOT EXISTS zqyy_app_server
|
||||
FOREIGN DATA WRAPPER postgres_fdw
|
||||
OPTIONS (host 'localhost', dbname 'zqyy_app', port '5432');
|
||||
|
||||
CREATE USER MAPPING IF NOT EXISTS FOR etl_user
|
||||
SERVER zqyy_app_server
|
||||
OPTIONS (user 'app_reader', password '***');
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS fdw_app;
|
||||
|
||||
CREATE FOREIGN TABLE fdw_app.member_birthday_manual (
|
||||
id BIGINT,
|
||||
member_id BIGINT,
|
||||
birthday_value DATE,
|
||||
recorded_by_assistant_id BIGINT,
|
||||
recorded_by_name VARCHAR(50),
|
||||
recorded_at TIMESTAMPTZ,
|
||||
source VARCHAR(20)
|
||||
) SERVER zqyy_app_server
|
||||
OPTIONS (schema_name 'public', table_name 'member_birthday_manual');
|
||||
```
|
||||
|
||||
**DWS 任务生日读取逻辑:**
|
||||
|
||||
```sql
|
||||
-- 优先手动补录值,其次 API 值
|
||||
COALESCE(
|
||||
(SELECT birthday_value
|
||||
FROM fdw_app.member_birthday_manual
|
||||
WHERE member_id = m.member_id
|
||||
ORDER BY recorded_at ASC -- 最早提交优先
|
||||
LIMIT 1),
|
||||
m.birthday
|
||||
) AS member_birthday
|
||||
```
|
||||
|
||||
**后端 API:**
|
||||
|
||||
```python
|
||||
# apps/backend/app/routers/member_birthday.py
|
||||
@router.post("/member-birthday")
|
||||
async def submit_member_birthday(
|
||||
member_id: int,
|
||||
birthday_value: date,
|
||||
assistant_id: int,
|
||||
assistant_name: str,
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""助教提交会员生日(UPSERT)"""
|
||||
sql = """
|
||||
INSERT INTO member_birthday_manual
|
||||
(member_id, birthday_value, recorded_by_assistant_id, recorded_by_name)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (member_id, recorded_by_assistant_id)
|
||||
DO UPDATE SET
|
||||
birthday_value = EXCLUDED.birthday_value,
|
||||
recorded_at = NOW()
|
||||
"""
|
||||
db.execute(sql, (member_id, birthday_value, assistant_id, assistant_name))
|
||||
return {"status": "ok"}
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 变更汇总
|
||||
|
||||
| 库 | 表 | 变更类型 | 说明 |
|
||||
|----|-----|---------|------|
|
||||
| `etl_feiqiu` | `ods.member_profiles` | 加列 | `birthday DATE` |
|
||||
| `etl_feiqiu` | `dwd.dim_member` | 加列 | `birthday DATE` |
|
||||
| `etl_feiqiu` | `dws.dws_assistant_monthly_summary` | 改约束 | UK 加入 `assistant_level_code` |
|
||||
| `zqyy_app` | `member_birthday_manual` | 新建表 | 手动补录生日 |
|
||||
| `etl_feiqiu` | `fdw_app.member_birthday_manual` | 新建外部表 | FDW 映射 |
|
||||
|
||||
### 迁移脚本清单
|
||||
|
||||
按优先级排序,每个迁移脚本独立可执行:
|
||||
|
||||
1. `2026-02-22__D_dwd_load_return_format.sql` — 无 DDL(纯代码变更)
|
||||
2. `2026-02-22__C1_dim_member_add_birthday.sql` — ODS/DWD 加列
|
||||
3. `2026-02-22__B_no_ddl_code_only.sql` — 无 DDL(纯代码变更)
|
||||
4. `2026-02-22__A_monthly_summary_uk_change.sql` — 唯一约束变更
|
||||
5. `2026-02-22__C2_member_birthday_manual.sql` — 新建表 + FDW
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是系统在所有合法执行中都应保持为真的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是连接人类可读规格说明与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: DwdLoadTask 返回值格式一致性
|
||||
|
||||
*对于任意* DwdLoadTask.load() 的执行结果,返回字典中 `errors` 键的值应为 `int` 类型,且等于 `error_details` 列表的长度。
|
||||
|
||||
**验证: 需求 1.1**
|
||||
|
||||
### Property 2: _accumulate_counts 类型安全累加
|
||||
|
||||
*对于任意* 包含 `int`、`float`、`list` 类型值的计数字典,`_accumulate_counts()` 应将 `int`/`float` 直接累加,将 `list` 转为 `len()` 后累加,且不抛出异常。
|
||||
|
||||
**验证: 需求 1.2**
|
||||
|
||||
### Property 3: 档位分段聚合正确性
|
||||
|
||||
*对于任意* 助教在同一月内存在 N 个不同 `assistant_level_code` 的日度数据,`_extract_daily_aggregates()` 应返回恰好 N 行记录,每行的业绩指标之和应等于该助教该月的总业绩。
|
||||
|
||||
**验证: 需求 2.1**
|
||||
|
||||
### Property 4: nickname 按时间倒序取值
|
||||
|
||||
*对于任意* 助教在聚合周期内有多条不同 nickname 的记录,聚合结果中的 nickname 应等于时间最晚的那条记录的 nickname。此属性适用于 AssistantMonthlyTask、AssistantFinanceTask、AssistantCustomerTask 三个任务。
|
||||
|
||||
**验证: 需求 2.3, 2.5, 2.6**
|
||||
|
||||
### Property 5: 工资按档位分段计算
|
||||
|
||||
*对于任意* 助教在同一月有多个档位的月度汇总记录,AssistantSalaryTask 应为每个档位分别计算工资,每个档位使用对应的 `level_price` 和 `tier` 配置,且所有档位的工资记录数等于月度汇总的行数。
|
||||
|
||||
**验证: 需求 2.4**
|
||||
|
||||
### Property 6: 跨店会员可查
|
||||
|
||||
*对于任意* 在 A 店注册但在 B 店有消费记录的会员,B 店的 DWS 任务通过事实表反查 `dim_member` 时,应能获取到该会员的维度信息(nickname、mobile 等)。
|
||||
|
||||
**验证: 需求 3.1, 3.2**
|
||||
|
||||
### Property 7: birthday ODS→DWD 装载正确性
|
||||
|
||||
*对于任意* ODS `member_profiles` 中包含 `birthday` 值的记录,DwdLoadTask 装载后 `dwd.dim_member` 中对应记录的 `birthday` 应与 ODS 源值一致。
|
||||
|
||||
**验证: 需求 4.2**
|
||||
|
||||
### Property 8: birthday SCD2 变化检测
|
||||
|
||||
*对于任意* `dim_member` 现有记录,当 ODS 中同一会员的 `birthday` 值发生变化时,SCD2 应关闭旧版本并创建新版本,新版本的 `birthday` 等于新值。
|
||||
|
||||
**验证: 需求 4.3**
|
||||
|
||||
### Property 9: 生日 UPSERT 幂等性
|
||||
|
||||
*对于任意* `(member_id, assistant_id)` 组合,连续两次提交不同的 `birthday_value`,`member_birthday_manual` 表中该组合应只有一条记录,且 `birthday_value` 等于最后一次提交的值。
|
||||
|
||||
**验证: 需求 5.2, 5.5**
|
||||
|
||||
### Property 10: 手动补录优先于 API 来源
|
||||
|
||||
*对于任意* 同时在 `dim_member.birthday` 和 `member_birthday_manual` 中有值的会员,DWS 任务输出的 `member_birthday` 应等于手动补录表中的值。
|
||||
|
||||
**验证: 需求 5.4**
|
||||
|
||||
### Property 11: SCD2 更新不影响手动补录表
|
||||
|
||||
*对于任意* 在 `member_birthday_manual` 中有记录的会员,执行 DwdLoadTask SCD2 更新 `dim_member.birthday` 后,`member_birthday_manual` 中的记录应保持不变。
|
||||
|
||||
**验证: 需求 5.6**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 需求 D:返回值格式
|
||||
|
||||
- `DwdLoadTask.load()` 中单表装载失败时,错误信息追加到 `error_details` 列表,`errors` 计数递增
|
||||
- `_accumulate_counts()` 遇到未知类型时使用 `setdefault` 保留原值(现有行为不变)
|
||||
- `_safe_int()` 遇到非 int/list 类型时返回 0(现有行为不变)
|
||||
|
||||
### 需求 A:档位分段
|
||||
|
||||
- 助教在某月无任何服务记录时,不生成月度汇总行(现有行为不变)
|
||||
- `assistant_level_code` 为 NULL 时,作为独立分组处理(NULL 视为一个档位)
|
||||
- 唯一约束变更后,需要清理旧数据(DELETE + 重新计算当月数据)
|
||||
|
||||
### 需求 B:多门店查询
|
||||
|
||||
- 事实表中无该门店消费记录时,`_extract_member_info()` 返回空字典(现有行为不变)
|
||||
- 子查询返回空集时,`WHERE member_id IN (空集)` 等价于 `WHERE FALSE`,不会报错
|
||||
|
||||
### 需求 C1:生日字段
|
||||
|
||||
- ODS 中 `birthday` 为 NULL 或空字符串时,DWD 中存为 NULL
|
||||
- 无效日期格式时,DwdLoadTask 的现有类型转换逻辑会将其置为 NULL
|
||||
|
||||
### 需求 C2:手动补录
|
||||
|
||||
- `member_id` 不存在于 `dim_member` 时,仍允许提交(助教可能先于 ETL 发现新客户)
|
||||
- `birthday_value` 格式校验由后端 API 的 Pydantic schema 处理
|
||||
- FDW 连接失败时,DWS 任务应 catch 异常并降级为仅使用 `dim_member.birthday`
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试框架
|
||||
|
||||
- 属性测试:`hypothesis`(Python),每个属性测试最少 100 次迭代
|
||||
- 单元测试:`pytest`
|
||||
- 测试工具:`apps/etl/connectors/feiqiu/tests/unit/task_test_utils.py` 提供 FakeDB/FakeAPI
|
||||
|
||||
### 属性测试
|
||||
|
||||
每个正确性属性对应一个 hypothesis 属性测试,标注格式:
|
||||
|
||||
```python
|
||||
# Feature: etl-aggregation-fix, Property N: {property_text}
|
||||
@given(...)
|
||||
def test_property_N_xxx(data):
|
||||
...
|
||||
```
|
||||
|
||||
属性测试放置位置:
|
||||
- 需求 D 相关(Property 1-2):`apps/etl/connectors/feiqiu/tests/unit/test_return_format_properties.py`
|
||||
- 需求 A 相关(Property 3-5):`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
|
||||
- 需求 B 相关(Property 6):`apps/etl/connectors/feiqiu/tests/unit/test_multi_store_properties.py`
|
||||
- 需求 C 相关(Property 7-11):`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
|
||||
|
||||
### 单元测试
|
||||
|
||||
单元测试覆盖具体示例和边界情况:
|
||||
- DDL 结构验证(唯一约束、列存在性)
|
||||
- 空数据 / NULL 值边界
|
||||
- 迁移脚本的回滚验证
|
||||
|
||||
### 测试环境
|
||||
|
||||
- 数据库:`test_etl_feiqiu` / `test_zqyy_app`(通过 `TEST_DB_DSN` 环境变量)
|
||||
- 纯单元测试使用 FakeDB/FakeAPI,不涉及真实数据库连接
|
||||
- ETL 测试 cwd:`apps/etl/connectors/feiqiu/`
|
||||
- 后端测试 cwd:`apps/backend/`
|
||||
84
.kiro/specs/etl-aggregation-fix/requirements.md
Normal file
84
.kiro/specs/etl-aggregation-fix/requirements.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 需求文档
|
||||
|
||||
## 简介
|
||||
|
||||
v8 联调修复了 11 个 BUG,其中 4 个的当前修复方式是"临时止血",需要更完整的方案。本 Spec 覆盖以下四个需求:
|
||||
- **需求 A**:助教月度聚合按档位分段统计,替代 `MAX()` 聚合
|
||||
- **需求 B**:多门店会员查询 `register_site_id` 已知限制标记 + 预留扩展方案
|
||||
- **需求 C**:会员生日字段全链路补齐(C1: ETL 链路 / C2: 手动补录)
|
||||
- **需求 D**:`DwdLoadTask.load()` 返回值格式规范化
|
||||
|
||||
## 术语表
|
||||
|
||||
- **DwdLoadTask**:DWD 层装载任务,负责将 ODS 原始数据清洗装载至 DWD 明细层
|
||||
- **DWS 任务**:数据汇总层任务,从 DWD 层聚合生成业务报表数据
|
||||
- **SCD2**:缓慢变化维度类型 2,通过版本化记录维度属性的历史变化
|
||||
- **BaseTask**:ETL 任务基类,提供 Extract/Transform/Load 模板方法和计数累加逻辑
|
||||
- **FlowRunner**:ETL 流程编排器,按层级顺序执行任务并汇总计数
|
||||
- **dim_member**:DWD 层会员维度表,使用 SCD2 管理历史版本
|
||||
- **dws_assistant_monthly_summary**:DWS 层助教月度汇总表
|
||||
- **dws_assistant_salary_calc**:DWS 层助教工资计算表
|
||||
- **register_site_id**:会员注册门店 ID,当前 `dim_member` 中唯一的门店标识
|
||||
- **assistant_level_code**:助教档位代码,助教月内可能因升级/降级而变化
|
||||
- **dim_member_birthday_manual**:手动补录生日表(位于 `zqyy_app` 业务库),存储助教提交的会员生日信息,通过 FDW 只读映射供 ETL DWS 任务读取
|
||||
- **_safe_int()**:`flow_runner.py` 中的类型安全辅助函数,将 `int`/`list`/`None` 统一转为 `int`
|
||||
- **_accumulate_counts()**:`BaseTask` 中的计数累加方法,合并多段执行的统计结果
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:DwdLoadTask 返回值格式规范化(需求 D)
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望 DwdLoadTask.load() 的返回值格式与其他任务保持一致,以便 FlowRunner 能安全地汇总所有任务的计数。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN DwdLoadTask.load() 执行完成, THE DwdLoadTask SHALL 返回包含 `errors` 键(值为 `int` 类型,等于错误列表长度)和 `error_details` 键(值为 `list[dict]` 类型,包含错误详情)的字典
|
||||
2. WHEN BaseTask._accumulate_counts() 遇到值为 `list` 类型的计数项, THE BaseTask SHALL 将该值转换为 `len(list)` 后再累加(防御层)
|
||||
3. WHEN FlowRunner 汇总所有任务计数, THE FlowRunner SHALL 保留 `_safe_int()` 作为最终防御层,确保 `sum()` 不会因类型不一致而崩溃
|
||||
|
||||
### 需求 2:助教月度聚合按档位分段统计(需求 A)
|
||||
|
||||
**用户故事:** 作为运营管理者,我希望助教月度汇总按档位分段统计业绩,以便准确反映助教在不同档位期间的表现和工资计算。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 助教在同一月内存在多个 assistant_level_code, THE AssistantMonthlyTask SHALL 按 `(assistant_id, stat_month, assistant_level_code)` 分组生成多行记录,分别统计各档位的业绩指标
|
||||
2. THE dws_assistant_monthly_summary 表 SHALL 使用 `(site_id, assistant_id, stat_month, assistant_level_code)` 作为唯一约束
|
||||
3. WHEN AssistantMonthlyTask 需要取 nickname 值, THE AssistantMonthlyTask SHALL 按时间倒序取最后一条记录的 nickname,而非使用 `MAX()` 聚合
|
||||
4. WHEN AssistantSalaryTask 计算工资, THE AssistantSalaryTask SHALL 按档位分段计算抽成,适配新的多行月度汇总结构
|
||||
5. WHEN AssistantFinanceTask 提取日度收入需要 nickname, THE AssistantFinanceTask SHALL 按时间倒序取最后一条记录的 nickname,而非使用 `MAX()` 聚合
|
||||
6. WHEN AssistantCustomerTask 提取服务对需要 nickname, THE AssistantCustomerTask SHALL 按时间倒序取最后一条记录的 nickname,而非使用 `MAX()` 聚合
|
||||
|
||||
### 需求 3:多门店会员查询支持(需求 B)
|
||||
|
||||
**用户故事:** 作为运营管理者,我希望 DWS 任务能正确查询跨店消费的会员信息,以便 B 店能看到在 A 店注册但在 B 店消费的会员维度数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN DWS 任务需要查询会员信息, THE DWS 任务 SHALL 通过事实表中的 `member_id` 反查 `dim_member`,而非使用 `WHERE register_site_id = %s` 预筛选
|
||||
2. WHEN 会员在 A 店注册并在 B 店消费, THE B 店的 DWS 任务 SHALL 能查询到该会员的昵称、手机号等维度信息
|
||||
3. WHEN DWS 任务执行会员信息提取, THE DWS 任务 SHALL 使用 `WHERE member_id IN (SELECT DISTINCT member_id FROM dwd.事实表 WHERE site_id = %s)` 模式获取会员维度数据
|
||||
|
||||
### 需求 4:会员生日字段 ETL 链路补齐(需求 C1)
|
||||
|
||||
**用户故事:** 作为运营管理者,我希望会员生日信息能从上游 API 完整传递到 DWS 层,以便用于会员分析和销售线索。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE dim_member 表 SHALL 包含 `birthday DATE` 列
|
||||
2. WHEN DwdLoadTask 执行 ODS → DWD 装载, THE DwdLoadTask SHALL 在列映射中包含 `birthday` 字段,将 ODS 中的生日数据装载到 `dim_member.birthday`
|
||||
3. WHEN SCD2 更新 dim_member 记录, THE DwdLoadTask SHALL 将 `birthday` 作为变化检测字段之一,正常处理生日值的变化
|
||||
4. WHEN MemberVisitTask 等 DWS 任务提取会员信息, THE DWS 任务 SHALL 从 `dim_member.birthday` 读取生日字段并写入 DWS 目标表
|
||||
|
||||
### 需求 5:助教手动补录会员生日(需求 C2)
|
||||
|
||||
**用户故事:** 作为助教,我希望能手动提交客户的生日信息,以便在上游 API 未提供生日数据时补充这一重要销售线索。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE `zqyy_app` 业务库(开发/测试环境使用 `test_zqyy_app`)SHALL 包含 `member_birthday_manual` 表,结构包含 `member_id`、`birthday_value`、`recorded_by_assistant_id`、`recorded_by_name`、`recorded_at`、`source` 字段,唯一约束为 `(member_id, recorded_by_assistant_id)`
|
||||
2. WHEN 同一助教对同一会员重复提交生日, THE 系统 SHALL 更新该助教的已有记录(UPSERT),保留所有其他助教的提交记录
|
||||
3. WHEN DWS 任务需要读取手动补录生日, THE DWS 任务 SHALL 通过 FDW 只读映射从 `zqyy_app.member_birthday_manual` 读取数据
|
||||
4. WHEN dim_member.birthday(API 来源)和 member_birthday_manual(手动来源)同时存在, THE DWS 任务 SHALL 优先使用手动补录值
|
||||
5. WHEN 助教通过后端 API 提交生日, THE 后端 SHALL 提供 POST 接口接收 `member_id`、`birthday_value`、`assistant_id`、`assistant_name` 参数,执行 UPSERT 写入 `zqyy_app.member_birthday_manual`
|
||||
6. WHEN SCD2 更新 dim_member.birthday, THE DwdLoadTask SHALL 正常更新 API 来源的生日值,不影响业务库中手动补录表的数据
|
||||
228
.kiro/specs/etl-aggregation-fix/tasks.md
Normal file
228
.kiro/specs/etl-aggregation-fix/tasks.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# 实现计划:ETL 聚合修复与生日字段补齐
|
||||
|
||||
## 概述
|
||||
|
||||
按优先级(D → C1 → B → A → C2)逐步实施,每个需求独立可部署。代码变更集中在 ETL Connector 的 tasks 层和后端 API,DDL 变更通过迁移脚本执行。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 需求 D:DwdLoadTask 返回值格式规范化
|
||||
- [x] 1.1 修改 DwdLoadTask.load() 返回值格式
|
||||
- 在 `apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py` 的 `load()` 方法中,将 `return {"tables": summary, "errors": errors}` 改为 `return {"tables": summary, "errors": len(errors), "error_details": errors}`
|
||||
- _需求: 1.1_
|
||||
|
||||
- [x] 1.2 增强 BaseTask._accumulate_counts() 防御层
|
||||
- 在 `apps/etl/connectors/feiqiu/tasks/base_task.py` 的 `_accumulate_counts()` 方法中,增加 `isinstance(value, list)` 分支,将 list 转为 `len()` 后累加
|
||||
- _需求: 1.2_
|
||||
|
||||
- [x] 1.3 编写属性测试:DwdLoadTask 返回值格式一致性
|
||||
- **Property 1: DwdLoadTask 返回值格式一致性**
|
||||
- **验证: 需求 1.1**
|
||||
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_return_format_properties.py`
|
||||
|
||||
- [x] 1.4 编写属性测试:_accumulate_counts 类型安全累加
|
||||
- **Property 2: _accumulate_counts 类型安全累加**
|
||||
- **验证: 需求 1.2**
|
||||
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_return_format_properties.py`
|
||||
|
||||
- [x] 2. 检查点 — 需求 D 完成
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 3. 需求 C1:会员生日字段 ETL 链路补齐
|
||||
- [x] 3.1 编写迁移脚本:ODS/DWD 加 birthday 列
|
||||
- 创建 `db/etl_feiqiu/migrations/2026-02-22__C1_dim_member_add_birthday.sql`
|
||||
- ODS: `ALTER TABLE ods.member_profiles ADD COLUMN IF NOT EXISTS birthday DATE;`
|
||||
- DWD: `ALTER TABLE dwd.dim_member ADD COLUMN IF NOT EXISTS birthday DATE;`
|
||||
- 包含回滚语句和验证 SQL
|
||||
- _需求: 4.1_
|
||||
|
||||
- [-] 3.1a 在测试库执行迁移脚本 C1
|
||||
- 在 `test_etl_feiqiu` 上执行 `2026-02-22__C1_dim_member_add_birthday.sql`
|
||||
- 执行验证 SQL 确认列已添加
|
||||
- _需求: 4.1_
|
||||
|
||||
- [x] 3.2 更新 ODS 入库逻辑:提取 birthday 字段
|
||||
- 在 ODS 入库任务中增加 `birthday` 字段的 JSON 提取映射
|
||||
- 确认 `ods_tasks.py` 中 `member_profiles` 的字段列表包含 `birthday`
|
||||
- _需求: 4.2_
|
||||
|
||||
- [x] 3.3 验证 DwdLoadTask 自动列映射包含 birthday
|
||||
- DwdLoadTask 通过 `_get_columns()` 自动读取 DWD 表列名,确认 `birthday` 被自动包含在列映射中
|
||||
- SCD2 变化检测自动包含所有非 SCD2 元数据列,确认 `birthday` 参与变化检测
|
||||
- _需求: 4.2, 4.3_
|
||||
|
||||
- [x] 3.4 恢复 DWS 任务中的 birthday 引用
|
||||
- 修改 `member_visit_task.py` 的 `_extract_member_info()` SQL,加入 `birthday` 字段
|
||||
- 修改 `member_consumption_task.py` 的 `_extract_member_info()` SQL,加入 `birthday` 字段
|
||||
- 修改 DWS 任务的 `transform()` 方法,将 `member_birthday` 写入输出记录
|
||||
- _需求: 4.4_
|
||||
|
||||
- [x] 3.5 编写属性测试:birthday ODS→DWD 装载正确性
|
||||
- **Property 7: birthday ODS→DWD 装载正确性**
|
||||
- **验证: 需求 4.2**
|
||||
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
|
||||
|
||||
- [x] 3.6 编写属性测试:birthday SCD2 变化检测
|
||||
- **Property 8: birthday SCD2 变化检测**
|
||||
- **验证: 需求 4.3**
|
||||
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
|
||||
|
||||
- [x] 4. 检查点 — 需求 C1 完成
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 5. 需求 B:多门店会员查询支持
|
||||
- [x] 5.1 修改 member_visit_task.py 的 _extract_member_info()
|
||||
- 将 `WHERE register_site_id = %s` 改为通过 `dwd_settlement_head` 事实表的 `tenant_member_id` 反查
|
||||
- _需求: 3.1, 3.2_
|
||||
|
||||
- [x] 5.2 修改 member_consumption_task.py 的 _extract_member_info()
|
||||
- 将 `WHERE register_site_id = %s` 改为通过 `dwd_settlement_head` 事实表的 `tenant_member_id` 反查
|
||||
- 同时修改 `dim_member_card_account` 查询,改为通过事实表反查
|
||||
- _需求: 3.1, 3.2_
|
||||
|
||||
- [x] 5.3 修改 assistant_customer_task.py 的 _extract_member_info()
|
||||
- 将 `WHERE register_site_id = %s` 改为通过 `dwd_assistant_service_log` 事实表的 `tenant_member_id` 反查
|
||||
- _需求: 3.1, 3.2_
|
||||
|
||||
- [x] 5.4 修改 finance_recharge_task.py 的 dim_member_card_account 查询
|
||||
- 将 `WHERE register_site_id = %s` 改为通过事实表反查
|
||||
- _需求: 3.1_
|
||||
|
||||
- [x] 5.5 编写属性测试:跨店会员可查
|
||||
- **Property 6: 跨店会员可查**
|
||||
- **验证: 需求 3.1, 3.2**
|
||||
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_multi_store_properties.py`
|
||||
|
||||
- [x] 6. 检查点 — 需求 B 完成
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 7. 需求 A:助教月度聚合按档位分段统计
|
||||
- [x] 7.1 编写迁移脚本:唯一约束变更
|
||||
- 创建 `db/etl_feiqiu/migrations/2026-02-22__A_monthly_summary_uk_change.sql`
|
||||
- DROP 旧约束 `uk_dws_assistant_monthly`,ADD 新约束 `(site_id, assistant_id, stat_month, assistant_level_code)`
|
||||
- 包含回滚语句和验证 SQL
|
||||
- _需求: 2.2_
|
||||
|
||||
- [x] 7.1a 在测试库执行迁移脚本 A
|
||||
- 在 `test_etl_feiqiu` 上执行 `2026-02-22__A_monthly_summary_uk_change.sql`
|
||||
- 执行验证 SQL 确认约束已变更(`SELECT conname FROM pg_constraint ...`)
|
||||
- _需求: 2.2_
|
||||
|
||||
- [x] 7.2 修改 AssistantMonthlyTask._extract_daily_aggregates()
|
||||
- GROUP BY 加入 `assistant_level_code, assistant_level_name`
|
||||
- nickname 改为 `(ARRAY_AGG(assistant_nickname ORDER BY stat_date DESC))[1]`
|
||||
- _需求: 2.1, 2.3_
|
||||
|
||||
- [x] 7.3 修改 AssistantMonthlyTask._process_month() 适配多行
|
||||
- 确认 `_process_month()` 能正确处理同一助教多个档位的聚合数据
|
||||
- 每行使用自己的 `assistant_level_code` 进行档位匹配和排名计算
|
||||
- _需求: 2.1_
|
||||
|
||||
- [x] 7.4 修改 AssistantSalaryTask 适配档位分段工资计算
|
||||
- `_extract_monthly_summary()` 已能返回多行(同一助教不同档位)
|
||||
- `transform()` 遍历每行分别计算工资,按档位使用对应的 `level_price` 和 `tier`
|
||||
- _需求: 2.4_
|
||||
|
||||
- [x] 7.5 修改 AssistantFinanceTask._extract_daily_revenue() nickname 取值
|
||||
- 将 `MAX(s.nickname)` 改为 `(ARRAY_AGG(s.nickname ORDER BY s.start_use_time DESC))[1]`
|
||||
- _需求: 2.5_
|
||||
|
||||
- [x] 7.6 修改 AssistantCustomerTask._extract_service_pairs() nickname 取值
|
||||
- 将 `MAX(assistant_nickname)` 改为 `(ARRAY_AGG(assistant_nickname ORDER BY service_date DESC))[1]`
|
||||
- _需求: 2.6_
|
||||
|
||||
- [x] 7.7 编写属性测试:档位分段聚合正确性
|
||||
- **Property 3: 档位分段聚合正确性**
|
||||
- **验证: 需求 2.1**
|
||||
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
|
||||
|
||||
- [x] 7.8 编写属性测试:nickname 按时间倒序取值
|
||||
- **Property 4: nickname 按时间倒序取值**
|
||||
- **验证: 需求 2.3, 2.5, 2.6**
|
||||
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
|
||||
|
||||
- [x] 7.9 编写属性测试:工资按档位分段计算
|
||||
- **Property 5: 工资按档位分段计算**
|
||||
- **验证: 需求 2.4**
|
||||
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
|
||||
|
||||
- [x] 8. 检查点 — 需求 A 完成
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 9. 需求 C2:助教手动补录会员生日
|
||||
- [x] 9.1 编写迁移脚本:创建 member_birthday_manual 表
|
||||
- 创建 `db/zqyy_app/migrations/2026-02-22__C2_member_birthday_manual.sql`
|
||||
- 在 `zqyy_app` / `test_zqyy_app` 中创建 `member_birthday_manual` 表
|
||||
- 包含回滚语句和验证 SQL
|
||||
- _需求: 5.1_
|
||||
|
||||
- [x] 9.1a 在测试库执行迁移脚本 C2
|
||||
- 在 `test_zqyy_app` 上执行 `2026-02-22__C2_member_birthday_manual.sql`
|
||||
- 执行验证 SQL 确认表和约束已创建
|
||||
- _需求: 5.1_
|
||||
|
||||
- [x] 9.2 编写 FDW 映射脚本:ETL 库读取业务库
|
||||
- 创建 `db/fdw/setup_fdw_reverse.sql`(etl_feiqiu → zqyy_app 方向)
|
||||
- 创建 `db/fdw/setup_fdw_reverse_test.sql`(test 环境版本)
|
||||
- 在 ETL 库中创建 `fdw_app.member_birthday_manual` 外部表
|
||||
- _需求: 5.3_
|
||||
|
||||
- [x] 9.2a 在测试库执行 FDW 映射脚本
|
||||
- 在 `test_etl_feiqiu` 上执行 `setup_fdw_reverse_test.sql`
|
||||
- 验证 `fdw_app.member_birthday_manual` 外部表可读取
|
||||
- _需求: 5.3_
|
||||
|
||||
- [x] 9.3 修改 DWS 任务:生日读取优先级逻辑
|
||||
- 在 `member_visit_task.py` 和 `member_consumption_task.py` 的 `_extract_member_info()` 中,使用 `COALESCE(fdw_app.member_birthday_manual, dim_member.birthday)` 逻辑
|
||||
- 增加 FDW 连接失败的降级处理
|
||||
- _需求: 5.4_
|
||||
|
||||
- [x] 9.4 实现后端 API:生日提交接口
|
||||
- 创建 `apps/backend/app/routers/member_birthday.py`
|
||||
- 实现 `POST /member-birthday` 接口,执行 UPSERT
|
||||
- 创建 Pydantic schema `apps/backend/app/schemas/member_birthday.py`
|
||||
- 在 `apps/backend/app/main.py` 中注册路由
|
||||
- _需求: 5.5_
|
||||
|
||||
- [x] 9.5 编写属性测试:生日 UPSERT 幂等性
|
||||
- **Property 9: 生日 UPSERT 幂等性**
|
||||
- **验证: 需求 5.2, 5.5**
|
||||
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
|
||||
|
||||
- [x] 9.6 编写属性测试:手动补录优先于 API 来源
|
||||
- **Property 10: 手动补录优先于 API 来源**
|
||||
- **验证: 需求 5.4**
|
||||
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
|
||||
|
||||
- [x] 9.7 编写属性测试:SCD2 更新不影响手动补录表
|
||||
- **Property 11: SCD2 更新不影响手动补录表**
|
||||
- **验证: 需求 5.6**
|
||||
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
|
||||
|
||||
- [x] 10. 收尾:主 DDL 合并与文档更新
|
||||
- [x] 10.1 合并迁移变更到主 DDL 文件
|
||||
- 将 `birthday` 列定义合并到 `db/etl_feiqiu/schemas/ods.sql`(`member_profiles` 表)
|
||||
- 将 `birthday` 列定义合并到 `db/etl_feiqiu/schemas/dwd.sql`(`dim_member` 表)
|
||||
- 将新唯一约束合并到 `db/etl_feiqiu/schemas/dws.sql`(`dws_assistant_monthly_summary` 表)
|
||||
- 将 `member_birthday_manual` 表定义合并到 `db/zqyy_app/schemas/init.sql`
|
||||
- 将 FDW 反向映射合并到 `db/fdw/setup_fdw.sql` 和 `db/fdw/setup_fdw_test.sql`
|
||||
|
||||
- [x] 10.2 更新数据库文档
|
||||
- 在 `docs/database/` 中新建或更新受影响表的文档:
|
||||
- `dim_member`:新增 `birthday` 列说明
|
||||
- `dws_assistant_monthly_summary`:唯一约束变更说明
|
||||
- `member_birthday_manual`:新建表文档(含 FDW 映射说明)
|
||||
- 遵循现有 `BD_Manual_*.md` 命名规范
|
||||
|
||||
- [x] 10.3 最终验证
|
||||
- 确保所有测试通过
|
||||
- 确认主 DDL 文件与测试库实际结构一致
|
||||
- ask the user if questions arise
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的任务为可选测试任务,可跳过以加速 MVP
|
||||
- 每个需求独立可部署,检查点确保增量验证
|
||||
- 迁移脚本需在 `test_etl_feiqiu` / `test_zqyy_app` 上先行验证
|
||||
- 属性测试使用 hypothesis,每个属性最少 100 次迭代
|
||||
- 单元测试使用 FakeDB/FakeAPI,不依赖真实数据库
|
||||
Reference in New Issue
Block a user