chore: 文档与 IDE 配置整理
- .kiro/specs/ → docs/specs/(41 个历史需求 spec 迁移,移除 .config.kiro) - CLAUDE.md 三层拆分:根文件精简 + apps/backend/CLAUDE.md + .claude/commands/ - 新增 /spec-close、/pre-change 两个工作流命令 - DDL 基线刷新(从测试库重新导出 11 个文件,dws 35→38 表,biz 18→21 表) - BD_Manual → BD_manual 命名统一(48 个文件) - 修复 3 处文档与数据库不一致(auth.users.status 默认值、scheduled_tasks 字段、RLS 视图数) - 新增 BD_manual_public_rbac_tables.md(public schema 8 张 RBAC/工作流表) - 合并 biz.trigger_jobs 文档(10→12 字段,归档独立文档) - docs/database/README.md 索引更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,430 +0,0 @@
|
||||
# 设计文档:小程序数据库基础设施层(miniapp-db-foundation)
|
||||
|
||||
## 概述
|
||||
|
||||
本设计实现 P1 基础设施层的三大核心能力:
|
||||
|
||||
1. **业务库 Schema 划分**:在 `test_zqyy_app` 中创建 `auth`(认证)和 `biz`(业务)两个 Schema,配合权限管理
|
||||
2. **ETL 库 RLS 视图层**:在 `test_etl_feiqiu.app` Schema 中为 35 张 DWD/DWS 表创建行级安全视图,通过 `site_id` 隔离多门店数据
|
||||
3. **FDW 跨库映射**:通过 `postgres_fdw` 将 ETL 库的 RLS 视图映射为业务库的只读外部表
|
||||
|
||||
**环境变量驱动**:所有数据库名称通过 `.env` 环境变量引用,不硬编码。迁移脚本中使用占位符,验证脚本从 `PG_DSN` / `APP_DB_DSN` 解析连接信息。
|
||||
|
||||
| 环境变量 | 用途 | 示例值 |
|
||||
|---------|------|--------|
|
||||
| `PG_DSN` | ETL 库连接字符串 | `postgresql://user:pass@host:5432/test_etl_feiqiu` |
|
||||
| `APP_DB_DSN` | 业务库连接字符串 | `postgresql://user:pass@host:5432/test_zqyy_app` |
|
||||
|
||||
整体数据流向:
|
||||
|
||||
```
|
||||
ETL 库(PG_DSN) 业务库(APP_DB_DSN)
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ dwd.dim_member │ │ auth (用户认证) │
|
||||
│ dwd.dim_assistant │ │ biz (业务数据) │
|
||||
│ dws.dws_* │ │ public (系统管理) │
|
||||
│ dws.cfg_* │ │ │
|
||||
│ │ │ │ fdw_etl │
|
||||
│ ▼ │ postgres_fdw │ ├ v_dim_member │
|
||||
│ app.v_dim_member │ ◄──────────────► │ ├ v_dim_assistant │
|
||||
│ app.v_dws_* │ IMPORT SCHEMA │ └ v_dws_* │
|
||||
│ (RLS: site_id 过滤) │ │ (外部表,只读) │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
### 分层架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "业务库(APP_DB_DSN)"
|
||||
AUTH["auth Schema<br/>用户认证、权限、映射"]
|
||||
BIZ["biz Schema<br/>业务数据"]
|
||||
PUBLIC["public Schema<br/>系统管理表(保留)"]
|
||||
FDW["fdw_etl Schema<br/>FDW 外部表(只读)"]
|
||||
end
|
||||
|
||||
subgraph "ETL 库(PG_DSN)"
|
||||
APP["app Schema<br/>RLS 视图层"]
|
||||
DWD["dwd Schema<br/>明细层(11 张表)"]
|
||||
DWS["dws Schema<br/>汇总层(24 张表)"]
|
||||
end
|
||||
|
||||
FDW -->|"postgres_fdw<br/>IMPORT FOREIGN SCHEMA"| APP
|
||||
APP -->|"WHERE site_id = current_setting(...)"| DWD
|
||||
APP -->|"WHERE site_id = current_setting(...)"| DWS
|
||||
```
|
||||
|
||||
### 执行顺序
|
||||
|
||||
迁移脚本必须按以下顺序执行:
|
||||
|
||||
1. **ETL 库**(通过 `PG_DSN` 连接):创建 `app` Schema → 创建 `app_reader` 角色 → 创建 RLS 视图 → 授权
|
||||
2. **业务库**(通过 `APP_DB_DSN` 连接):创建 `auth`/`biz` Schema → 安装 `postgres_fdw` → 创建外部服务器 → 用户映射 → 导入外部表 → 授权
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 组件 1:Schema 管理(业务库)
|
||||
|
||||
**职责**:在业务库(`APP_DB_DSN`)中创建 `auth` 和 `biz` Schema,配置权限。
|
||||
|
||||
**SQL 接口**:
|
||||
```sql
|
||||
-- 创建 Schema
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
CREATE SCHEMA IF NOT EXISTS biz;
|
||||
|
||||
-- 授权 app_user
|
||||
GRANT USAGE ON SCHEMA auth TO app_user;
|
||||
GRANT USAGE ON SCHEMA biz TO app_user;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth TO app_user;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA biz TO app_user;
|
||||
|
||||
-- 未来新表自动授权
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA auth
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA biz
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
|
||||
```
|
||||
|
||||
### 组件 2:RLS 视图层(ETL 库)
|
||||
|
||||
**职责**:在 ETL 库(`PG_DSN`)的 `app` Schema 中为每张源表创建带 `site_id` 过滤的视图。
|
||||
|
||||
**视图命名规则**:`app.v_<源表名>`,例如 `app.v_dim_member`、`app.v_dws_member_consumption_summary`。
|
||||
|
||||
**视图模板**:
|
||||
```sql
|
||||
CREATE OR REPLACE VIEW app.v_<源表名> AS
|
||||
SELECT * FROM <schema>.<源表名>
|
||||
WHERE site_id = current_setting('app.current_site_id')::bigint;
|
||||
```
|
||||
|
||||
**DWD 层视图清单(11 张)**:
|
||||
|
||||
| 视图名 | 源表 |
|
||||
|--------|------|
|
||||
| `app.v_dim_member` | `dwd.dim_member` |
|
||||
| `app.v_dim_assistant` | `dwd.dim_assistant` |
|
||||
| `app.v_dim_member_card_account` | `dwd.dim_member_card_account` |
|
||||
| `app.v_dim_table` | `dwd.dim_table` |
|
||||
| `app.v_dwd_settlement_head` | `dwd.dwd_settlement_head` |
|
||||
| `app.v_dwd_table_fee_log` | `dwd.dwd_table_fee_log` |
|
||||
| `app.v_dwd_assistant_service_log` | `dwd.dwd_assistant_service_log` |
|
||||
| `app.v_dwd_recharge_order` | `dwd.dwd_recharge_order` |
|
||||
| `app.v_dwd_store_goods_sale` | `dwd.dwd_store_goods_sale` |
|
||||
| `app.v_dim_staff` | `dwd.dim_staff` |
|
||||
| `app.v_dim_staff_ex` | `dwd.dim_staff_ex` |
|
||||
|
||||
**DWS 层视图清单(24 张)**:
|
||||
|
||||
| 视图名 | 源表 |
|
||||
|--------|------|
|
||||
| `app.v_dws_member_consumption_summary` | `dws.dws_member_consumption_summary` |
|
||||
| `app.v_dws_member_visit_detail` | `dws.dws_member_visit_detail` |
|
||||
| `app.v_dws_member_winback_index` | `dws.dws_member_winback_index` |
|
||||
| `app.v_dws_member_newconv_index` | `dws.dws_member_newconv_index` |
|
||||
| `app.v_dws_member_assistant_relation_index` | `dws.dws_member_assistant_relation_index` |
|
||||
| `app.v_dws_member_assistant_intimacy` | `dws.dws_member_assistant_intimacy` |
|
||||
| `app.v_dws_assistant_daily_detail` | `dws.dws_assistant_daily_detail` |
|
||||
| `app.v_dws_assistant_monthly_summary` | `dws.dws_assistant_monthly_summary` |
|
||||
| `app.v_dws_assistant_salary_calc` | `dws.dws_assistant_salary_calc` |
|
||||
| `app.v_dws_assistant_customer_stats` | `dws.dws_assistant_customer_stats` |
|
||||
| `app.v_dws_assistant_finance_analysis` | `dws.dws_assistant_finance_analysis` |
|
||||
| `app.v_dws_finance_daily_summary` | `dws.dws_finance_daily_summary` |
|
||||
| `app.v_dws_finance_income_structure` | `dws.dws_finance_income_structure` |
|
||||
| `app.v_dws_finance_recharge_summary` | `dws.dws_finance_recharge_summary` |
|
||||
| `app.v_dws_finance_discount_detail` | `dws.dws_finance_discount_detail` |
|
||||
| `app.v_dws_finance_expense_summary` | `dws.dws_finance_expense_summary` |
|
||||
| `app.v_dws_platform_settlement` | `dws.dws_platform_settlement` |
|
||||
| `app.v_dws_assistant_recharge_commission` | `dws.dws_assistant_recharge_commission` |
|
||||
| `app.v_cfg_performance_tier` | `dws.cfg_performance_tier` |
|
||||
| `app.v_cfg_assistant_level_price` | `dws.cfg_assistant_level_price` |
|
||||
| `app.v_cfg_bonus_rules` | `dws.cfg_bonus_rules` |
|
||||
| `app.v_cfg_index_parameters` | `dws.cfg_index_parameters` |
|
||||
| `app.v_dws_order_summary` | `dws.dws_order_summary` |
|
||||
|
||||
**P2 预留(注释标记,暂不创建)**:
|
||||
- `dws.dws_member_spending_power_index` → 待 P2 完成后补充
|
||||
- `dws.dws_assistant_order_contribution` → 待 P2 完成后补充
|
||||
|
||||
**`cfg_*` 表特殊处理**:配置表(`cfg_performance_tier`、`cfg_assistant_level_price`、`cfg_bonus_rules`、`cfg_index_parameters`)可能不含 `site_id` 列。对于不含 `site_id` 的配置表,视图直接 `SELECT *` 不加过滤条件。
|
||||
|
||||
**权限配置**:
|
||||
```sql
|
||||
-- 创建只读角色(如不存在)
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_reader') THEN
|
||||
CREATE ROLE app_reader LOGIN;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
GRANT USAGE ON SCHEMA app TO app_reader;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_reader;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO app_reader;
|
||||
```
|
||||
|
||||
### 组件 3:FDW 跨库映射(业务库)
|
||||
|
||||
**职责**:通过 `postgres_fdw` 将 ETL 库 `app` Schema 的视图映射为业务库 `fdw_etl` Schema 的外部表。
|
||||
|
||||
**实现方式**:使用 `IMPORT FOREIGN SCHEMA` 批量导入,而非逐表定义外部表。这与现有 `db/fdw/setup_fdw_test.sql` 的模式一致。
|
||||
|
||||
**环境感知**:迁移脚本中的 `host`、`dbname`、`port`、`password` 等连接参数使用占位符 `'***'`,部署时根据环境替换。项目已有 `db/fdw/setup_fdw_test.sql`(测试环境)和 `db/fdw/setup_fdw.sql`(生产环境)的分环境模式,本次迁移脚本遵循相同模式——提供测试环境和生产环境两个版本。
|
||||
|
||||
```sql
|
||||
-- 安装扩展
|
||||
CREATE EXTENSION IF NOT EXISTS postgres_fdw;
|
||||
|
||||
-- 创建外部服务器
|
||||
-- host / dbname / port 按环境替换,从 PG_DSN 解析 ETL 库连接信息
|
||||
CREATE SERVER IF NOT EXISTS etl_feiqiu_server
|
||||
FOREIGN DATA WRAPPER postgres_fdw
|
||||
OPTIONS (host '***', dbname '***', port '***');
|
||||
|
||||
-- 用户映射(密码按环境替换)
|
||||
CREATE USER MAPPING IF NOT EXISTS FOR app_user
|
||||
SERVER etl_feiqiu_server
|
||||
OPTIONS (user 'app_reader', password '***');
|
||||
|
||||
-- 创建目标 Schema
|
||||
CREATE SCHEMA IF NOT EXISTS fdw_etl;
|
||||
|
||||
-- 批量导入
|
||||
IMPORT FOREIGN SCHEMA app
|
||||
FROM SERVER etl_feiqiu_server
|
||||
INTO fdw_etl;
|
||||
|
||||
-- 授权
|
||||
GRANT USAGE ON SCHEMA fdw_etl TO app_user;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA fdw_etl TO app_user;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA fdw_etl GRANT SELECT ON TABLES TO app_user;
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
1. 使用 `IMPORT FOREIGN SCHEMA` 而非逐表 `CREATE FOREIGN TABLE`——自动匹配列定义,避免手动维护列类型不一致的风险
|
||||
2. 新增 RLS 视图后只需重新执行 `IMPORT` 即可同步
|
||||
3. 与现有 `db/fdw/setup_fdw_test.sql` 保持一致
|
||||
4. 服务器名使用通用名 `etl_feiqiu_server`(不含环境前缀),通过连接参数区分环境
|
||||
|
||||
### 组件 4:验证脚本
|
||||
|
||||
**职责**:自动化检查所有数据库对象是否正确创建。
|
||||
|
||||
**文件位置**:`scripts/ops/validate_p1_db_foundation.py`
|
||||
|
||||
**接口**:
|
||||
```python
|
||||
def validate_p1_db_foundation() -> dict:
|
||||
"""
|
||||
返回验证结果字典:
|
||||
{
|
||||
"schemas": {"auth": bool, "biz": bool, "app": bool, "fdw_etl": bool},
|
||||
"rls_views": {"app.v_dim_member": bool, ...},
|
||||
"fdw_tables": {"fdw_etl.v_dim_member": bool, ...},
|
||||
"rls_filtering": bool,
|
||||
"permissions": {"app_user": bool, "app_reader": bool},
|
||||
"errors": [str, ...]
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
**环境变量依赖**(强制从 `.env` 加载,缺失时 `RuntimeError` 终止):
|
||||
- `PG_DSN`:ETL 库连接字符串(从中解析 host、port、dbname)
|
||||
- `APP_DB_DSN`:业务库连接字符串(从中解析 host、port、dbname)
|
||||
- 脚本通过 `load_dotenv()` 加载根 `.env`,禁止硬编码任何数据库名称或连接参数
|
||||
|
||||
## 数据模型
|
||||
|
||||
### Schema 拓扑
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
test_zqyy_app_auth {
|
||||
string schema_name "auth"
|
||||
string purpose "用户认证、权限、映射"
|
||||
}
|
||||
test_zqyy_app_biz {
|
||||
string schema_name "biz"
|
||||
string purpose "业务数据(任务、备注、AI、Excel)"
|
||||
}
|
||||
test_zqyy_app_fdw_etl {
|
||||
string schema_name "fdw_etl"
|
||||
string purpose "FDW 外部表(只读)"
|
||||
}
|
||||
test_zqyy_app_public {
|
||||
string schema_name "public"
|
||||
string purpose "系统管理表(保留)"
|
||||
}
|
||||
test_etl_feiqiu_app {
|
||||
string schema_name "app"
|
||||
string purpose "RLS 视图层"
|
||||
}
|
||||
test_etl_feiqiu_dwd {
|
||||
string schema_name "dwd"
|
||||
string purpose "明细层(11 张表)"
|
||||
}
|
||||
test_etl_feiqiu_dws {
|
||||
string schema_name "dws"
|
||||
string purpose "汇总层(24 张表)"
|
||||
}
|
||||
|
||||
test_etl_feiqiu_app ||--o{ test_etl_feiqiu_dwd : "视图引用"
|
||||
test_etl_feiqiu_app ||--o{ test_etl_feiqiu_dws : "视图引用"
|
||||
test_zqyy_app_fdw_etl ||--|| test_etl_feiqiu_app : "postgres_fdw 映射"
|
||||
```
|
||||
|
||||
### RLS 视图数据流
|
||||
|
||||
对于含 `site_id` 的表:
|
||||
```
|
||||
源表数据(全量)→ RLS 视图(site_id 过滤)→ FDW 外部表(只读访问)
|
||||
```
|
||||
|
||||
对于不含 `site_id` 的配置表(`cfg_*`):
|
||||
```
|
||||
源表数据(全量)→ 直通视图(无过滤)→ FDW 外部表(只读访问)
|
||||
```
|
||||
|
||||
### 迁移脚本清单
|
||||
|
||||
| 序号 | 目标库 | 文件名 | 内容 |
|
||||
|------|--------|--------|------|
|
||||
| 1 | ETL 库(`PG_DSN`) | `YYYY-MM-DD__p1_create_app_schema_rls_views.sql` | 创建 app Schema + 全部 RLS 视图 + app_reader 权限 |
|
||||
| 2 | 业务库(`APP_DB_DSN`) | `YYYY-MM-DD__p1_create_auth_biz_schemas.sql` | 创建 auth/biz Schema + app_user 权限 |
|
||||
| 3 | 业务库(`APP_DB_DSN`) | `YYYY-MM-DD__p1_setup_fdw_etl.sql` | FDW 扩展 + 外部服务器 + 用户映射 + 导入外部表 |
|
||||
|
||||
|
||||
## 正确性属性(Correctness Properties)
|
||||
|
||||
*属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1:默认权限自动授予
|
||||
|
||||
*For any* 在 `auth` 或 `biz` Schema 中新创建的表,`app_user` 角色都应自动获得 SELECT、INSERT、UPDATE、DELETE 权限,无需额外手动授权。
|
||||
|
||||
**Validates: Requirements 1.5**
|
||||
|
||||
### Property 2:public Schema 不变量
|
||||
|
||||
*For any* 迁移脚本执行前后,`test_zqyy_app.public` Schema 中的表集合应保持不变——迁移不应删除、重命名或修改 `public` 中的现有表。
|
||||
|
||||
**Validates: Requirements 1.6**
|
||||
|
||||
### Property 3:RLS 视图定义包含 site_id 过滤
|
||||
|
||||
*For any* `test_etl_feiqiu.app` Schema 中的 RLS 视图(含 `site_id` 列的源表对应的视图),其视图定义 SQL 中都应包含 `current_setting('app.current_site_id')` 过滤条件。
|
||||
|
||||
**Validates: Requirements 2.4**
|
||||
|
||||
### Property 4:RLS 过滤正确性
|
||||
|
||||
*For any* 含 `site_id` 列的 RLS 视图和任意有效的 `site_id` 值,设置 `app.current_site_id` 后查询该视图,返回结果中所有行的 `site_id` 都应等于设置的值。
|
||||
|
||||
**Validates: Requirements 2.5**
|
||||
|
||||
### Property 5:未设置 site_id 时 RLS 视图拒绝访问
|
||||
|
||||
*For any* 含 `site_id` 过滤的 RLS 视图,在未设置 `app.current_site_id` 的会话中执行查询,应抛出错误而非返回数据。
|
||||
|
||||
**Validates: Requirements 2.6**
|
||||
|
||||
### Property 6:FDW 外部表完整性与数据一致性
|
||||
|
||||
*For any* `test_etl_feiqiu.app` Schema 中的视图,`test_zqyy_app.fdw_etl` Schema 中都应存在对应的可查询外部表,且在相同 `site_id` 条件下,外部表返回的数据与 RLS 视图返回的数据一致。
|
||||
|
||||
**Validates: Requirements 3.4, 3.5, 3.6**
|
||||
|
||||
### Property 7:迁移脚本结构合规性
|
||||
|
||||
*For any* `db/etl_feiqiu/migrations/` 或 `db/zqyy_app/migrations/` 中本次新增的迁移脚本文件,文件名应匹配 `YYYY-MM-DD__*.sql` 模式,且文件内容中应包含回滚语句(以注释形式)。
|
||||
|
||||
**Validates: Requirements 4.3, 4.4**
|
||||
|
||||
### Property 8:迁移脚本幂等性
|
||||
|
||||
*For any* 本次新增的迁移脚本,连续执行两次的结果应与执行一次相同——第二次执行不应产生错误。
|
||||
|
||||
**Validates: Requirements 4.5**
|
||||
|
||||
### Property 9:环境变量缺失时验证脚本报错
|
||||
|
||||
*For any* 必需环境变量(`PG_DSN`、`APP_DB_DSN`)的缺失组合,验证脚本应立即抛出错误终止,而非静默使用默认值或空字符串。
|
||||
|
||||
**Validates: Requirements 5.8**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 迁移脚本错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| Schema 已存在 | `CREATE SCHEMA IF NOT EXISTS` 幂等跳过 |
|
||||
| 视图已存在 | `CREATE OR REPLACE VIEW` 覆盖更新 |
|
||||
| 角色不存在 | `DO $$ ... IF NOT EXISTS ... END $$` 条件创建 |
|
||||
| 源表不存在(P2 待建表) | 以注释形式预留,不创建视图 |
|
||||
| FDW 服务器已存在 | `CREATE SERVER IF NOT EXISTS` 幂等跳过 |
|
||||
| 用户映射已存在 | `CREATE USER MAPPING IF NOT EXISTS` 幂等跳过 |
|
||||
| `IMPORT FOREIGN SCHEMA` 表已存在 | 先 `DROP SCHEMA fdw_etl CASCADE` 再重新导入(脚本中提供选项) |
|
||||
|
||||
### 验证脚本错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| 环境变量缺失 | `RuntimeError` 立即终止,输出缺失变量名 |
|
||||
| 数据库连接失败 | 捕获 `psycopg2.OperationalError`,输出连接参数(脱敏)和错误信息 |
|
||||
| Schema/视图/外部表不存在 | 记录为失败项,继续检查其余项目 |
|
||||
| RLS 过滤验证无数据 | 标记为 SKIP(无法验证),不标记为失败 |
|
||||
| 权限查询失败 | 记录具体错误,继续检查 |
|
||||
|
||||
### `current_setting` 未设置时的行为
|
||||
|
||||
PostgreSQL 中 `current_setting('app.current_site_id')` 在未设置时会抛出 `ERROR: unrecognized configuration parameter "app.current_site_id"`。这是期望行为(需求 2.6),确保不会意外返回全量数据。
|
||||
|
||||
如果需要更友好的错误信息,可以使用 `current_setting('app.current_site_id', true)` 返回 NULL,然后在视图中用 `CASE` 处理。但当前设计选择让 PostgreSQL 原生报错,因为:
|
||||
1. 更安全——不可能绕过
|
||||
2. 后端代码必须显式设置 `SET app.current_site_id = ...`,这是一个强制约束
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
使用 Python `hypothesis` 框架,测试目录:`tests/`(Monorepo 级属性测试目录)。
|
||||
|
||||
每个属性测试至少运行 100 次迭代。每个测试用注释标注对应的设计属性编号。
|
||||
|
||||
标注格式:`# Feature: miniapp-db-foundation, Property N: <属性标题>`
|
||||
|
||||
**属性测试清单**:
|
||||
|
||||
| 属性 | 测试方法 | 生成器 |
|
||||
|------|---------|--------|
|
||||
| P1 默认权限 | 生成随机表名,在 auth/biz 中创建表,验证 app_user 权限 | `hypothesis.strategies.text` 生成合法 SQL 标识符 |
|
||||
| P3 视图定义过滤 | 遍历所有 app schema 视图,检查定义 SQL | 无需生成器,遍历所有视图 |
|
||||
| P4 RLS 过滤正确性 | 生成随机 site_id,设置后查询视图,验证结果 | `hypothesis.strategies.integers` 生成 site_id |
|
||||
| P5 未设置 site_id 报错 | 遍历所有 RLS 视图,在新会话中查询 | 无需生成器,遍历所有视图 |
|
||||
| P7 脚本结构合规 | 遍历所有新增迁移脚本,验证命名和内容 | 无需生成器,遍历文件 |
|
||||
| P8 幂等性 | 对每个迁移脚本执行两次,验证无错误 | 无需生成器 |
|
||||
| P9 环境变量缺失 | 生成环境变量缺失组合,验证报错 | `hypothesis.strategies.sampled_from` 生成缺失组合 |
|
||||
|
||||
**注意**:P2(public schema 不变量)和 P6(FDW 数据一致性)需要真实数据库环境,作为集成测试在验证脚本中实现,而非 hypothesis 属性测试。
|
||||
|
||||
### 单元测试
|
||||
|
||||
单元测试聚焦于验证脚本的逻辑正确性:
|
||||
|
||||
- 验证脚本在 Schema 缺失时正确报告失败
|
||||
- 验证脚本在权限不足时正确报告
|
||||
- 验证脚本的输出格式正确(JSON 结构)
|
||||
- 环境变量缺失时的错误消息包含变量名
|
||||
|
||||
### 集成测试
|
||||
|
||||
集成测试通过验证脚本 `scripts/ops/validate_p1_db_foundation.py` 实现,覆盖:
|
||||
|
||||
- 全部 Schema 存在性检查
|
||||
- 全部 RLS 视图存在性和过滤正确性
|
||||
- 全部 FDW 外部表存在性和可查询性
|
||||
- 权限配置完整性
|
||||
- FDW 数据与 RLS 视图数据一致性
|
||||
@@ -1,88 +0,0 @@
|
||||
# 需求文档:小程序数据库基础设施层(miniapp-db-foundation)
|
||||
|
||||
## 简介
|
||||
|
||||
P1 基础设施层是整个小程序系统的第一个 SPEC,无前置依赖,是所有后续 SPEC 的硬依赖。本 SPEC 负责在业务库 `test_zqyy_app` 中建立清晰的 Schema 划分(`auth` + `biz`),在 ETL 库 `test_etl_feiqiu` 中为数据依赖矩阵列出的所有 DWD/DWS 表创建 RLS 视图(按 `site_id` 隔离),并通过 `postgres_fdw` 将 RLS 视图映射为业务库的外部表,使后端无需直连 ETL 库即可读取汇总/维度数据。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Schema_Manager**:负责在 PostgreSQL 数据库中创建和管理 Schema、权限配置的迁移脚本系统
|
||||
- **RLS_View_Layer**:在 `test_etl_feiqiu.app` Schema 中创建的一组视图,通过 `current_setting('app.current_site_id')` 按 `site_id` 过滤数据,实现行级安全隔离
|
||||
- **FDW_Bridge**:通过 `postgres_fdw` 扩展在 `test_zqyy_app.fdw_etl` Schema 中创建的外部表集合,只读映射 ETL 库 `app` Schema 的 RLS 视图
|
||||
- **Migration_Script**:存放在 `db/zqyy_app/migrations/` 或 `db/etl_feiqiu/migrations/` 中的纯 SQL 迁移脚本,以日期前缀命名
|
||||
- **Validation_Script**:用于验证数据库对象是否正确创建、权限是否配置正确、数据是否可查询的 Python 脚本
|
||||
- **site_id**:门店标识符,类型为 `BIGINT`,用于多门店数据隔离
|
||||
- **app_reader**:ETL 库侧的只读角色,供 FDW 用户映射使用
|
||||
- **app_user**:业务库侧的应用连接角色,通过 FDW 读取 ETL 数据
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:业务库 Schema 划分与权限配置
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要 `test_zqyy_app` 中有清晰的 Schema 划分(`auth` + `biz`),以便按功能组织业务表。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Migration_Script 执行完成, THE Schema_Manager SHALL 在 `test_zqyy_app` 中创建 `auth` Schema
|
||||
2. WHEN Migration_Script 执行完成, THE Schema_Manager SHALL 在 `test_zqyy_app` 中创建 `biz` Schema
|
||||
3. WHEN `auth` Schema 创建完成, THE Schema_Manager SHALL 授予 `app_user` 角色对 `auth` Schema 的 USAGE 权限和对其中所有表的 SELECT、INSERT、UPDATE、DELETE 权限
|
||||
4. WHEN `biz` Schema 创建完成, THE Schema_Manager SHALL 授予 `app_user` 角色对 `biz` Schema 的 USAGE 权限和对其中所有表的 SELECT、INSERT、UPDATE、DELETE 权限
|
||||
5. WHEN 新表在 `auth` 或 `biz` Schema 中创建, THE Schema_Manager SHALL 通过 ALTER DEFAULT PRIVILEGES 自动授予 `app_user` 角色相应权限
|
||||
6. THE Migration_Script SHALL 保留 `public` Schema 中现有的系统管理表(`admin_users`、`roles`、`permissions` 等)不受影响
|
||||
|
||||
### 需求 2:ETL 库 RLS 视图层创建
|
||||
|
||||
**用户故事:** 作为系统管理员,我需要 RLS 视图按 `site_id` 隔离数据,以便多门店数据安全。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Migration_Script 执行完成, THE Schema_Manager SHALL 在 `test_etl_feiqiu` 中创建 `app` Schema(如不存在)
|
||||
2. WHEN `app` Schema 创建完成, THE RLS_View_Layer SHALL 为数据依赖矩阵中列出的每张 DWD 表创建对应的 RLS 视图(共 11 张:`dim_member`、`dim_assistant`、`dim_member_card_account`、`dim_table`、`dwd_settlement_head`、`dwd_table_fee_log`、`dwd_assistant_service_log`、`dwd_recharge_order`、`dwd_store_goods_sale`、`dim_staff`、`dim_staff_ex`)
|
||||
3. WHEN `app` Schema 创建完成, THE RLS_View_Layer SHALL 为数据依赖矩阵中列出的每张 DWS 表创建对应的 RLS 视图(共 24 张,包含 `dws_*` 和 `cfg_*` 表)
|
||||
4. THE RLS_View_Layer 中每个视图 SHALL 包含 `WHERE site_id = current_setting('app.current_site_id')::bigint` 过滤条件
|
||||
5. WHEN 设置 `app.current_site_id` 为某门店 ID 后查询 RLS 视图, THE RLS_View_Layer SHALL 仅返回该门店的数据
|
||||
6. WHEN 未设置 `app.current_site_id` 时查询 RLS 视图, THE RLS_View_Layer SHALL 抛出错误而非返回全部数据
|
||||
7. THE Schema_Manager SHALL 授予 `app_reader` 角色对 `app` Schema 的 USAGE 权限和对其中所有视图的 SELECT 权限
|
||||
8. THE RLS_View_Layer SHALL 为 P2 待建表(`dws_member_spending_power_index`、`dws_assistant_order_contribution`)在迁移脚本中以注释形式预留位置
|
||||
|
||||
### 需求 3:FDW 外部表映射
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要通过 FDW 从 `test_zqyy_app` 读取 ETL 库的 DWS/DWD 数据,以便小程序页面展示 ETL 计算结果。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Migration_Script 执行完成, THE FDW_Bridge SHALL 在 `test_zqyy_app` 中安装 `postgres_fdw` 扩展
|
||||
2. WHEN `postgres_fdw` 扩展安装完成, THE FDW_Bridge SHALL 创建指向 `test_etl_feiqiu` 的外部服务器 `test_etl_feiqiu_server`
|
||||
3. WHEN 外部服务器创建完成, THE FDW_Bridge SHALL 创建 `app_user` 到 `app_reader` 的用户映射
|
||||
4. WHEN 用户映射创建完成, THE FDW_Bridge SHALL 在 `fdw_etl` Schema 中通过 `IMPORT FOREIGN SCHEMA app` 导入所有外部表
|
||||
5. WHEN 外部表导入完成, THE FDW_Bridge SHALL 对 `fdw_etl` Schema 中的每张外部表执行 `SELECT` 查询验证可读性
|
||||
6. WHEN 外部表查询成功, THE FDW_Bridge 返回的数据 SHALL 与 ETL 库 `app` Schema 中对应 RLS 视图的数据一致
|
||||
7. THE FDW_Bridge SHALL 授予 `app_user` 角色对 `fdw_etl` Schema 的 USAGE 权限和对其中所有外部表的 SELECT 权限
|
||||
|
||||
### 需求 4:迁移脚本管理
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要所有数据库变更都有对应的迁移脚本,以便变更可追溯、可重放、可回滚。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Migration_Script SHALL 将 ETL 库变更(RLS 视图创建、`app` Schema 权限)存放在 `db/etl_feiqiu/migrations/` 目录中
|
||||
2. THE Migration_Script SHALL 将业务库变更(Schema 创建、FDW 配置)存放在 `db/zqyy_app/migrations/` 目录中
|
||||
3. THE Migration_Script SHALL 使用日期前缀命名(格式:`YYYY-MM-DD__<描述>.sql`)
|
||||
4. THE Migration_Script SHALL 在每个脚本中包含回滚语句(以注释形式)
|
||||
5. THE Migration_Script SHALL 使用 `IF NOT EXISTS` / `OR REPLACE` 等幂等语法,确保重复执行不会报错
|
||||
6. THE Migration_Script SHALL 使用 UTF-8 编码,纯 SQL(非 ORM)
|
||||
|
||||
### 需求 5:端到端验证
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要一个自动化验证脚本,确认所有数据库对象正确创建且数据可访问。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Validation_Script 执行时, THE Validation_Script SHALL 检查 `test_zqyy_app` 中 `auth` 和 `biz` Schema 是否存在
|
||||
2. WHEN Validation_Script 执行时, THE Validation_Script SHALL 检查 `test_etl_feiqiu.app` Schema 中所有 RLS 视图是否存在
|
||||
3. WHEN Validation_Script 执行时, THE Validation_Script SHALL 检查 `test_zqyy_app.fdw_etl` 中所有外部表是否存在
|
||||
4. WHEN Validation_Script 执行时, THE Validation_Script SHALL 对每张外部表执行 `SELECT count(*)` 验证可查询性
|
||||
5. WHEN Validation_Script 执行时, THE Validation_Script SHALL 设置 `app.current_site_id` 后验证 RLS 视图正确过滤数据
|
||||
6. WHEN Validation_Script 执行时, THE Validation_Script SHALL 验证 `app_user` 和 `app_reader` 角色的权限配置正确
|
||||
7. WHEN 验证发现异常, THE Validation_Script SHALL 输出具体的失败项和错误信息
|
||||
8. THE Validation_Script SHALL 从 `.env` 加载数据库连接参数(`PG_DSN`、`APP_DB_DSN`),缺失时立即报错终止
|
||||
@@ -1,108 +0,0 @@
|
||||
# 实现计划:小程序数据库基础设施层(miniapp-db-foundation)
|
||||
|
||||
## 概述
|
||||
|
||||
按照"ETL 库先行 → 业务库跟进 → FDW 桥接 → 验证收尾"的顺序,将设计拆分为可增量执行的编码任务。每个迁移脚本使用幂等语法,所有连接参数通过环境变量驱动。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. ETL 库:创建 app Schema 与 RLS 视图
|
||||
- [x] 1.1 编写迁移脚本 `db/etl_feiqiu/migrations/YYYY-MM-DD__p1_create_app_schema_rls_views.sql`
|
||||
- 创建 `app` Schema(`CREATE SCHEMA IF NOT EXISTS app`)
|
||||
- 创建 `app_reader` 角色(条件创建,`DO $$ ... IF NOT EXISTS ... END $$`)
|
||||
- 为 11 张 DWD 表创建 RLS 视图(`CREATE OR REPLACE VIEW app.v_<表名> AS SELECT * FROM dwd.<表名> WHERE site_id = current_setting('app.current_site_id')::bigint`)
|
||||
- 为 24 张 DWS 表创建 RLS 视图(同上模式;`cfg_*` 配置表若无 `site_id` 列则直接 `SELECT *` 不加过滤)
|
||||
- 在脚本末尾以注释形式预留 P2 待建表位置(`dws_member_spending_power_index`、`dws_assistant_order_contribution`)
|
||||
- 授予 `app_reader` 对 `app` Schema 的 USAGE + SELECT 权限 + ALTER DEFAULT PRIVILEGES
|
||||
- 包含回滚语句(注释形式)
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.7, 2.8, 4.1, 4.3, 4.4, 4.5, 4.6_
|
||||
|
||||
- [x] 1.2 编写属性测试:RLS 视图定义包含 site_id 过滤
|
||||
- **Property 3: RLS 视图定义包含 site_id 过滤**
|
||||
- 遍历 `app` Schema 所有视图,查询 `pg_views.definition`,验证含 `site_id` 列的源表对应视图包含 `current_setting` 过滤条件
|
||||
- **Validates: Requirements 2.4**
|
||||
|
||||
- [x] 1.3 编写属性测试:未设置 site_id 时 RLS 视图拒绝访问
|
||||
- **Property 5: 未设置 site_id 时 RLS 视图拒绝访问**
|
||||
- 遍历所有含 `site_id` 过滤的 RLS 视图,在新会话(未设置 `app.current_site_id`)中执行 `SELECT`,验证抛出错误
|
||||
- **Validates: Requirements 2.6**
|
||||
|
||||
- [x] 2. 业务库:创建 auth/biz Schema 与权限
|
||||
- [x] 2.1 编写迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p1_create_auth_biz_schemas.sql`
|
||||
- 创建 `auth` Schema(`CREATE SCHEMA IF NOT EXISTS auth`)
|
||||
- 创建 `biz` Schema(`CREATE SCHEMA IF NOT EXISTS biz`)
|
||||
- 授予 `app_user` 对 `auth`/`biz` 的 USAGE + CRUD 权限
|
||||
- 设置 ALTER DEFAULT PRIVILEGES 自动授权未来新表
|
||||
- 包含回滚语句(注释形式)
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 4.2, 4.3, 4.4, 4.5, 4.6_
|
||||
|
||||
- [x] 2.2 编写属性测试:默认权限自动授予
|
||||
- **Property 1: 默认权限自动授予**
|
||||
- 使用 hypothesis 生成随机合法表名,在 `auth`/`biz` 中创建临时表,验证 `app_user` 自动获得 SELECT/INSERT/UPDATE/DELETE 权限,测试后清理
|
||||
- **Validates: Requirements 1.5**
|
||||
|
||||
- [x] 3. 业务库:配置 FDW 跨库映射
|
||||
- [x] 3.1 编写迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p1_setup_fdw_etl.sql`
|
||||
- 安装 `postgres_fdw` 扩展
|
||||
- 创建外部服务器 `etl_feiqiu_server`(host/dbname/port 使用占位符 `'***'`,按环境替换)
|
||||
- 创建 `app_user` → `app_reader` 用户映射
|
||||
- 创建 `fdw_etl` Schema
|
||||
- 执行 `IMPORT FOREIGN SCHEMA app FROM SERVER etl_feiqiu_server INTO fdw_etl`
|
||||
- 授予 `app_user` 对 `fdw_etl` 的 USAGE + SELECT 权限 + ALTER DEFAULT PRIVILEGES
|
||||
- 包含回滚语句(注释形式)
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.7, 4.2, 4.3, 4.4, 4.5, 4.6_
|
||||
|
||||
- [x] 3.2 编写属性测试:FDW 外部表完整性与数据一致性
|
||||
- **Property 6: FDW 外部表完整性与数据一致性**
|
||||
- 遍历 ETL 库 `app` Schema 所有视图,验证 `fdw_etl` 中存在对应外部表且可查询;在相同 `site_id` 下对比数据一致性
|
||||
- **Validates: Requirements 3.4, 3.5, 3.6**
|
||||
|
||||
- [x] 4. Checkpoint — 迁移脚本验证
|
||||
- 确保三个迁移脚本均可在测试环境中成功执行(按顺序:ETL 库 → 业务库 Schema → 业务库 FDW)
|
||||
- 验证重复执行不报错(幂等性)
|
||||
- 如有问题请告知用户
|
||||
|
||||
- [x] 5. 编写端到端验证脚本
|
||||
- [x] 5.1 创建 `scripts/ops/validate_p1_db_foundation.py`
|
||||
- 通过 `load_dotenv()` 加载根 `.env`,读取 `PG_DSN` 和 `APP_DB_DSN`,缺失时 `RuntimeError` 终止
|
||||
- 检查业务库中 `auth`、`biz` Schema 存在性
|
||||
- 检查 ETL 库中 `app` Schema 及所有 RLS 视图存在性(对照设计文档中的 35 张视图清单)
|
||||
- 检查业务库中 `fdw_etl` Schema 及所有外部表存在性
|
||||
- 对每张外部表执行 `SELECT count(*)` 验证可查询性
|
||||
- 设置 `app.current_site_id` 后验证 RLS 视图过滤正确性
|
||||
- 验证 `app_user` 和 `app_reader` 角色权限配置
|
||||
- 输出结构化验证结果(通过/失败/跳过),失败项附带错误信息
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8_
|
||||
|
||||
- [x] 5.2 编写属性测试:RLS 过滤正确性
|
||||
- **Property 4: RLS 过滤正确性**
|
||||
- 使用 hypothesis 生成随机 `site_id`,设置 `app.current_site_id` 后查询含 `site_id` 的 RLS 视图,验证返回结果中所有行的 `site_id` 等于设置值
|
||||
- **Validates: Requirements 2.5**
|
||||
|
||||
- [x] 5.3 编写属性测试:环境变量缺失报错
|
||||
- **Property 9: 环境变量缺失时验证脚本报错**
|
||||
- 使用 hypothesis 生成 `PG_DSN`/`APP_DB_DSN` 的缺失组合,验证脚本抛出 `RuntimeError`
|
||||
- **Validates: Requirements 5.8**
|
||||
|
||||
- [x] 5.4 编写属性测试:迁移脚本结构合规性
|
||||
- **Property 7: 迁移脚本结构合规性**
|
||||
- 遍历本次新增的迁移脚本文件,验证文件名匹配 `YYYY-MM-DD__*.sql` 模式,且内容包含回滚语句注释
|
||||
- **Validates: Requirements 4.3, 4.4**
|
||||
|
||||
- [x] 5.5 编写属性测试:迁移脚本幂等性
|
||||
- **Property 8: 迁移脚本幂等性**
|
||||
- 对每个迁移脚本连续执行两次,验证第二次执行无错误
|
||||
- **Validates: Requirements 4.5**
|
||||
|
||||
- [x] 6. Final checkpoint — 全量验证
|
||||
- 运行验证脚本 `python scripts/ops/validate_p1_db_foundation.py`,确认所有检查项通过
|
||||
- 运行属性测试 `cd C:\NeoZQYY && pytest tests/ -v -k p1`,确认所有属性测试通过
|
||||
- 如有问题请告知用户
|
||||
|
||||
## 说明
|
||||
|
||||
- 标记 `*` 的子任务为可选,可跳过以加速 MVP
|
||||
- 每个任务引用具体的需求编号,确保可追溯
|
||||
- Checkpoint 确保增量验证
|
||||
- 属性测试使用 Python hypothesis 框架,测试文件放在根目录 `tests/` 下
|
||||
- 迁移脚本中的数据库连接参数(host/dbname/port/password)均使用占位符,按环境替换
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,562 +0,0 @@
|
||||
# 设计文档:ETL DWS 层扩展 — 小程序数据支撑
|
||||
|
||||
## 概述
|
||||
|
||||
本设计覆盖三个独立但相关的 DWS 层扩展模块:
|
||||
|
||||
1. **助教订单流水四项统计**:新建 `AssistantOrderContributionTask`,计算每名助教每日的订单总流水、订单净流水、时效贡献流水、时效净贡献。算法核心在于"时效贡献流水"的台费分摊和酒水食品均分逻辑。
|
||||
2. **会员消费汇总扩展**:修改现有 `MemberConsumptionTask`,新增 30/60/90 天充值窗口统计和次均消费字段。
|
||||
3. **定档折算惩罚**:修改现有 `AssistantDailyTask`,新增时间重叠检测和惩罚计算逻辑。
|
||||
|
||||
三个模块共享同一套 RLS 视图 + FDW 映射基础设施。
|
||||
|
||||
### 设计决策
|
||||
|
||||
1. **助教订单流水独立建表**:四项统计粒度为 `(site_id, assistant_id, stat_date)`,与现有 `dws_assistant_daily_detail` 粒度相同但语义不同(daily_detail 聚焦服务时长/金额,contribution 聚焦订单级流水分摊),独立建表避免字段膨胀。`stat_date` 为营业日(以 `BUSINESS_DAY_START_HOUR` 08:00 为日切点)。
|
||||
2. **时效贡献流水计算为纯函数**:核心分摊算法(`compute_time_weighted_revenue`)设计为静态方法,输入为结构化的订单数据,输出为每名助教的贡献值。不依赖数据库,便于属性测试。
|
||||
3. **惩罚检测在 transform 阶段完成**:定档折算惩罚的时间重叠检测和计算在 `AssistantDailyTask.transform` 中完成,不新建独立任务,因为惩罚字段与日度明细同粒度。
|
||||
4. **充值统计复用现有 extract 模式**:在 `MemberConsumptionTask` 中新增一个 `_extract_recharge_stats` 方法,与现有的 `_extract_consumption_stats` 并行提取,在 transform 阶段合并。
|
||||
|
||||
## 架构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph 数据来源(DWD)
|
||||
SH[dwd_settlement_head<br/>结算主表]
|
||||
TF[dwd_table_fee_log<br/>台费明细]
|
||||
ASL[dwd_assistant_service_log<br/>助教服务记录]
|
||||
RO[dwd_recharge_order<br/>充值订单]
|
||||
end
|
||||
|
||||
subgraph 新建任务
|
||||
AOC[AssistantOrderContributionTask<br/>助教订单流水统计]
|
||||
end
|
||||
|
||||
subgraph 修改任务
|
||||
MCT[MemberConsumptionTask<br/>+充值窗口 +次均消费]
|
||||
ADT[AssistantDailyTask<br/>+惩罚检测 +惩罚计算]
|
||||
end
|
||||
|
||||
subgraph 输出(DWS)
|
||||
T1[dws_assistant_order_contribution<br/>新建]
|
||||
T2[dws_member_consumption_summary<br/>扩展字段]
|
||||
T3[dws_assistant_daily_detail<br/>扩展字段]
|
||||
end
|
||||
|
||||
subgraph 基础设施
|
||||
RLS[app schema RLS 视图]
|
||||
FDW[fdw_etl 外部表映射]
|
||||
end
|
||||
|
||||
SH --> AOC
|
||||
TF --> AOC
|
||||
ASL --> AOC
|
||||
AOC --> T1
|
||||
|
||||
RO --> MCT
|
||||
SH --> MCT
|
||||
MCT --> T2
|
||||
|
||||
ASL --> ADT
|
||||
TF --> ADT
|
||||
ADT --> T3
|
||||
|
||||
T1 --> RLS
|
||||
T2 --> RLS
|
||||
T3 --> RLS
|
||||
RLS --> FDW
|
||||
```
|
||||
|
||||
### 任务依赖关系
|
||||
|
||||
```
|
||||
DWD_LOAD_FROM_ODS
|
||||
├── DWS_ASSISTANT_DAILY (扩展:+惩罚检测计算)
|
||||
├── DWS_MEMBER_CONSUMPTION (扩展:+充值窗口+次均消费)
|
||||
└── DWS_ASSISTANT_ORDER_CONTRIBUTION (新建:四项统计)
|
||||
```
|
||||
|
||||
`DWS_ASSISTANT_ORDER_CONTRIBUTION` 依赖 `DWD_LOAD_FROM_ODS`(需要最新的结算、台费、服务记录数据)。
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### AssistantOrderContributionTask(新建)
|
||||
|
||||
继承 `BaseDwsTask`,实现四项统计计算:
|
||||
|
||||
```python
|
||||
class AssistantOrderContributionTask(BaseDwsTask):
|
||||
DATE_COL = "stat_date"
|
||||
|
||||
def get_task_code(self) -> str:
|
||||
return "DWS_ASSISTANT_ORDER_CONTRIBUTION"
|
||||
|
||||
def get_target_table(self) -> str:
|
||||
return "dws_assistant_order_contribution"
|
||||
|
||||
def get_primary_keys(self) -> List[str]:
|
||||
return ["site_id", "assistant_id", "stat_date"]
|
||||
|
||||
# --- ETL 主流程 ---
|
||||
def extract(self, context: TaskContext) -> Dict[str, Any]: ...
|
||||
def transform(self, extracted, context) -> List[Dict[str, Any]]: ...
|
||||
# load() 使用 BaseDwsTask 默认实现
|
||||
|
||||
# --- 数据提取 ---
|
||||
def _extract_order_data(self, site_id, start_date, end_date) -> List[Dict]: ...
|
||||
|
||||
# --- 核心计算(纯函数,可独立测试) ---
|
||||
@staticmethod
|
||||
def compute_order_gross_revenue(order: OrderData) -> Decimal:
|
||||
"""订单总流水 = 台费 + 酒水食品 + 所有助教服务费"""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def compute_order_net_revenue(order: OrderData) -> Decimal:
|
||||
"""订单净流水 = 订单总流水 - 所有助教服务分成"""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def compute_time_weighted_revenue(
|
||||
order: OrderData, assistant_id: int
|
||||
) -> Decimal:
|
||||
"""时效贡献流水 = 台费按时长分摊 + 个人服务费 + 酒水食品按时长比例"""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def compute_time_weighted_net_revenue(
|
||||
time_weighted_revenue: Decimal, assistant_commission: Decimal
|
||||
) -> Decimal:
|
||||
"""时效净贡献 = 时效贡献流水 - 个人服务分成"""
|
||||
...
|
||||
```
|
||||
|
||||
### 核心数据结构
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TableUsage:
|
||||
"""台桌使用信息"""
|
||||
table_id: int
|
||||
table_area: str # 区域名称(A/B/C/S/TV/M1-M7 等)
|
||||
usage_seconds: int # 台桌使用时长(秒)
|
||||
table_fee: Decimal # 台费/房费
|
||||
|
||||
@dataclass
|
||||
class AssistantService:
|
||||
"""助教服务记录"""
|
||||
assistant_id: int
|
||||
table_id: int
|
||||
service_seconds: int # 服务时长(秒)
|
||||
ledger_amount: Decimal # 服务流水(助教收费)
|
||||
commission: Decimal # 助教分成
|
||||
skill_id: int
|
||||
course_type: str # BASE / BONUS / ROOM
|
||||
|
||||
@dataclass
|
||||
class OrderData:
|
||||
"""订单聚合数据(一个结算单的完整信息)"""
|
||||
order_settle_id: int
|
||||
site_id: int
|
||||
total_table_fee: Decimal # 台费总额
|
||||
total_goods_amount: Decimal # 酒水食品总额
|
||||
tables: List[TableUsage] # 台桌列表
|
||||
assistants: List[AssistantService] # 助教服务列表
|
||||
```
|
||||
|
||||
### 四项统计算法详解
|
||||
|
||||
#### 1. 订单总流水(order_gross_revenue)
|
||||
|
||||
```
|
||||
order_gross_revenue = total_table_fee + total_goods_amount + SUM(所有助教的 ledger_amount)
|
||||
```
|
||||
|
||||
每个参与助教获得相同的 order_gross_revenue 值。
|
||||
|
||||
#### 2. 订单净流水(order_net_revenue)
|
||||
|
||||
```
|
||||
order_net_revenue = order_gross_revenue - SUM(所有助教的 commission)
|
||||
```
|
||||
|
||||
每个参与助教获得相同的 order_net_revenue 值。
|
||||
|
||||
#### 3. 时效贡献流水(time_weighted_revenue)— 核心算法
|
||||
|
||||
这是最复杂的计算,按以下步骤进行:
|
||||
|
||||
**步骤 1:确定每张台桌的有效计费时长**
|
||||
```
|
||||
对于每张台桌 t:
|
||||
助教总服务时长 = SUM(该台桌所有助教的 service_seconds)
|
||||
有效计费时长 = MAX(助教总服务时长, 台桌使用时长)
|
||||
每小时单价 = table_fee / (有效计费时长 / 3600)
|
||||
```
|
||||
|
||||
**步骤 2:按助教在台桌的服务时长分摊台费**
|
||||
```
|
||||
对于每个助教 a 在台桌 t:
|
||||
台费分摊 = 每小时单价 × (助教在该台桌的服务时长 / 3600)
|
||||
|
||||
特殊情况:当助教总服务时长 < 台桌使用时长时
|
||||
按比例缩放:台费分摊 = (table_fee × 台桌使用时长对应比例) / 该台桌助教人数中按时长占比分配
|
||||
即:台费分摊 = (table_fee / 台桌使用时长 × MIN(助教总服务时长, 台桌使用时长))
|
||||
× (助教个人时长 / 助教总服务时长)
|
||||
```
|
||||
|
||||
更精确的公式(统一处理两种情况):
|
||||
```
|
||||
对于台桌 t:
|
||||
billable_seconds = MAX(SUM(助教服务时长), 台桌使用时长)
|
||||
对于助教 a:
|
||||
台费分摊_a = table_fee_t × (service_seconds_a / billable_seconds)
|
||||
```
|
||||
|
||||
> 注意:当 `SUM(助教服务时长) > 台桌使用时长` 时,`billable_seconds = SUM(助教服务时长)`,
|
||||
> 此时各助教按自己的时长占比分摊台费,总和 = table_fee。
|
||||
> 当 `SUM(助教服务时长) < 台桌使用时长` 时,`billable_seconds = 台桌使用时长`,
|
||||
> 此时各助教分摊的台费总和 < table_fee(未被助教覆盖的时段不分配给任何人)。
|
||||
|
||||
**步骤 3:助教个人服务费直接计入**
|
||||
```
|
||||
个人服务费 = 助教的 ledger_amount
|
||||
```
|
||||
|
||||
**步骤 4:酒水食品按助教总时长比例均分**
|
||||
```
|
||||
助教总时长 = SUM(所有助教在所有台桌的 service_seconds)
|
||||
对于助教 a:
|
||||
酒水食品分摊 = total_goods_amount × (助教 a 的总服务时长 / 助教总时长)
|
||||
```
|
||||
|
||||
**合成:**
|
||||
```
|
||||
time_weighted_revenue_a = SUM(各台桌台费分摊_a) + 个人服务费_a + 酒水食品分摊_a
|
||||
```
|
||||
|
||||
#### 4. 时效净贡献(time_weighted_net_revenue)
|
||||
|
||||
```
|
||||
time_weighted_net_revenue_a = time_weighted_revenue_a - commission_a
|
||||
```
|
||||
|
||||
#### 5. 超休/打赏课特殊处理
|
||||
|
||||
当助教为超休/打赏课类型(`course_type = BONUS`)时,该助教不参与订单级分摊:
|
||||
```
|
||||
order_gross_revenue = ledger_amount(个人服务流水)
|
||||
order_net_revenue = ledger_amount - commission
|
||||
time_weighted_revenue = ledger_amount
|
||||
time_weighted_net_revenue = ledger_amount - commission
|
||||
```
|
||||
|
||||
### MemberConsumptionTask 扩展
|
||||
|
||||
在现有任务中新增:
|
||||
|
||||
```python
|
||||
# extract 阶段新增
|
||||
def _extract_recharge_stats(self, site_id: int, stat_date: date) -> Dict[int, Dict]:
|
||||
"""从 dwd_recharge_order 提取 30/60/90 天充值统计"""
|
||||
...
|
||||
|
||||
# transform 阶段新增字段
|
||||
record['recharge_count_30d'] = recharge.get('count_30d', 0)
|
||||
record['recharge_count_60d'] = recharge.get('count_60d', 0)
|
||||
record['recharge_count_90d'] = recharge.get('count_90d', 0)
|
||||
record['recharge_amount_30d'] = recharge.get('amount_30d', Decimal('0'))
|
||||
record['recharge_amount_60d'] = recharge.get('amount_60d', Decimal('0'))
|
||||
record['recharge_amount_90d'] = recharge.get('amount_90d', Decimal('0'))
|
||||
record['avg_ticket_amount'] = (
|
||||
record['total_consume_amount'] / max(record['total_visit_count'], 1)
|
||||
)
|
||||
```
|
||||
|
||||
### AssistantDailyTask 扩展 — 惩罚检测
|
||||
|
||||
在现有任务的 transform 阶段新增惩罚检测逻辑:
|
||||
|
||||
```python
|
||||
# 惩罚检测核心逻辑
|
||||
@staticmethod
|
||||
def detect_overlap_violations(
|
||||
service_records: List[Dict],
|
||||
penalty_areas: Set[str]
|
||||
) -> Dict[Tuple[int, date], List[Dict]]:
|
||||
"""
|
||||
检测同一台桌同一时间段超过 2 名助教挂台的违规。
|
||||
|
||||
penalty_areas: 指定区域集合,如 {'A','B','C','S','TV','M1','M2',...,'M7'}
|
||||
|
||||
返回: {(assistant_id, stat_date): [violation_info, ...]}
|
||||
"""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def compute_penalty_minutes(
|
||||
actual_minutes: Decimal,
|
||||
per_hour_contribution: Decimal,
|
||||
threshold: Decimal = Decimal('24')
|
||||
) -> Decimal:
|
||||
"""
|
||||
计算惩罚分钟数。
|
||||
|
||||
per_hour_contribution >= threshold: 返回 0
|
||||
per_hour_contribution < threshold:
|
||||
返回 actual_minutes × (1 - per_hour_contribution / threshold)
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
**惩罚区域定义:**
|
||||
- 大厅:A、B、C、S、TV
|
||||
- 麻将房:M1、M2、M3、M4、M5、M6、M7
|
||||
|
||||
**时间重叠检测算法:**
|
||||
1. 按 `(台桌ID, 服务日期)` 分组所有服务记录
|
||||
2. 对每组内的服务记录,检查时间段是否有重叠(任意两个助教的 `[start_time, end_time]` 有交集)
|
||||
3. 若同一时间段内助教数 > 2,标记为违规
|
||||
4. 对违规记录计算 `per_hour_contribution = 台费每小时单价 / 该时段助教人数`
|
||||
5. 根据 `per_hour_contribution` 与 24 元阈值比较,计算 `penalty_minutes`
|
||||
|
||||
## 数据模型
|
||||
|
||||
### dws.dws_assistant_order_contribution(新建)
|
||||
|
||||
```sql
|
||||
CREATE TABLE dws.dws_assistant_order_contribution (
|
||||
contribution_id BIGSERIAL PRIMARY KEY,
|
||||
site_id INTEGER NOT NULL,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
assistant_id BIGINT NOT NULL,
|
||||
assistant_nickname VARCHAR(100),
|
||||
stat_date DATE NOT NULL,
|
||||
|
||||
-- 四项统计
|
||||
order_gross_revenue NUMERIC(14,2) DEFAULT 0,
|
||||
order_net_revenue NUMERIC(14,2) DEFAULT 0,
|
||||
time_weighted_revenue NUMERIC(14,2) DEFAULT 0,
|
||||
time_weighted_net_revenue NUMERIC(14,2) DEFAULT 0,
|
||||
|
||||
-- 辅助字段
|
||||
order_count INTEGER DEFAULT 0,
|
||||
total_service_seconds INTEGER DEFAULT 0,
|
||||
|
||||
-- 元数据
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_aoc_site_assistant_date
|
||||
ON dws.dws_assistant_order_contribution (site_id, assistant_id, stat_date);
|
||||
|
||||
CREATE INDEX idx_aoc_stat_date
|
||||
ON dws.dws_assistant_order_contribution (site_id, stat_date);
|
||||
```
|
||||
|
||||
### dws_member_consumption_summary 扩展字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE dws.dws_member_consumption_summary
|
||||
ADD COLUMN IF NOT EXISTS recharge_count_30d INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS recharge_count_60d INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS recharge_count_90d INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS recharge_amount_30d NUMERIC(14,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS recharge_amount_60d NUMERIC(14,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS recharge_amount_90d NUMERIC(14,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS avg_ticket_amount NUMERIC(14,2) DEFAULT 0;
|
||||
```
|
||||
|
||||
### dws_assistant_daily_detail 扩展字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE dws.dws_assistant_daily_detail
|
||||
ADD COLUMN IF NOT EXISTS penalty_minutes NUMERIC(10,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS penalty_reason TEXT,
|
||||
ADD COLUMN IF NOT EXISTS is_exempt BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS per_hour_contribution NUMERIC(14,2);
|
||||
```
|
||||
|
||||
### RLS 视图(app schema)
|
||||
|
||||
```sql
|
||||
-- 新建:助教订单流水统计
|
||||
CREATE OR REPLACE VIEW app.v_dws_assistant_order_contribution AS
|
||||
SELECT * FROM dws.dws_assistant_order_contribution
|
||||
WHERE site_id = current_setting('app.current_site_id')::bigint;
|
||||
|
||||
-- 已有视图无需修改:dws_member_consumption_summary 和 dws_assistant_daily_detail
|
||||
-- 的 RLS 视图使用 SELECT *,新增字段自动包含
|
||||
```
|
||||
|
||||
### FDW 映射(fdw_etl schema)
|
||||
|
||||
在 `test_zqyy_app.fdw_etl` 中新建外部表:
|
||||
|
||||
```sql
|
||||
CREATE FOREIGN TABLE fdw_etl.dws_assistant_order_contribution (
|
||||
contribution_id BIGINT,
|
||||
site_id INTEGER,
|
||||
tenant_id INTEGER,
|
||||
assistant_id BIGINT,
|
||||
assistant_nickname VARCHAR(100),
|
||||
stat_date DATE,
|
||||
order_gross_revenue NUMERIC(14,2),
|
||||
order_net_revenue NUMERIC(14,2),
|
||||
time_weighted_revenue NUMERIC(14,2),
|
||||
time_weighted_net_revenue NUMERIC(14,2),
|
||||
order_count INTEGER,
|
||||
total_service_seconds INTEGER,
|
||||
created_at TIMESTAMP WITH TIME ZONE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE
|
||||
) SERVER etl_server
|
||||
OPTIONS (schema_name 'app', table_name 'v_dws_assistant_order_contribution');
|
||||
```
|
||||
|
||||
对于扩展字段的表(`dws_member_consumption_summary`、`dws_assistant_daily_detail`),需要 `DROP` 并重建 FDW 外部表定义以包含新字段。
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*正确性属性(Correctness Property)是系统在所有合法执行路径上都应成立的行为特征——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
以下属性基于需求文档中的验收标准推导。四项统计的核心计算函数(`compute_order_gross_revenue`、`compute_time_weighted_revenue` 等)和惩罚计算函数(`compute_penalty_minutes`)设计为纯静态方法,不依赖数据库,可直接用于属性测试。
|
||||
|
||||
### Property 1: 订单级统计不变量 — gross/net 各助教相等
|
||||
|
||||
*For any* 订单数据(包含任意数量的台桌、助教服务和酒水食品),所有参与该订单的助教应获得相同的 `order_gross_revenue` 值,且获得相同的 `order_net_revenue` 值。
|
||||
|
||||
推导:`order_gross_revenue` 和 `order_net_revenue` 是订单级聚合值,不按助教个人拆分,因此所有参与助教共享同一个值。
|
||||
|
||||
**Validates: Requirements 2.2, 2.3, 10.1, 10.2**
|
||||
|
||||
### Property 2: 时效贡献流水之和约束
|
||||
|
||||
*For any* 订单数据,所有参与助教的 `time_weighted_revenue` 之和应满足:
|
||||
- 当所有台桌的助教总服务时长 ≥ 台桌使用时长时,之和 = `order_gross_revenue`
|
||||
- 当存在台桌的助教总服务时长 < 台桌使用时长时,之和 ≤ `order_gross_revenue`
|
||||
|
||||
且在所有情况下,之和 ≥ 0。
|
||||
|
||||
推导:台费按时长比例分摊,当助教完全覆盖台桌时长时分摊总和等于台费;酒水食品按时长比例均分总和等于酒水总额;助教服务费直接计入。因此总和 = 台费分摊总和 + 酒水分摊总和 + 服务费总和 ≤ order_gross_revenue。
|
||||
|
||||
**Validates: Requirements 2.4, 10.3**
|
||||
|
||||
### Property 3: 时效净贡献减法关系
|
||||
|
||||
*For any* 助教和订单数据,该助教的 `time_weighted_net_revenue` 应等于 `time_weighted_revenue - commission`(该助教个人的服务分成)。
|
||||
|
||||
推导:这是定义性等式,直接从需求 2.5 得出。
|
||||
|
||||
**Validates: Requirements 2.5, 10.4**
|
||||
|
||||
### Property 4: 惩罚分钟数分段公式
|
||||
|
||||
*For any* 非负的 `actual_minutes` 和非负的 `per_hour_contribution`:
|
||||
- 当 `per_hour_contribution >= 24` 时,`penalty_minutes = 0`
|
||||
- 当 `per_hour_contribution < 24` 时,`penalty_minutes = actual_minutes × (1 - per_hour_contribution / 24)`
|
||||
|
||||
且在所有情况下,`0 ≤ penalty_minutes ≤ actual_minutes`。
|
||||
|
||||
推导:直接从需求 6.3/6.4 的分段公式得出。上界 `actual_minutes` 在 `per_hour_contribution = 0` 时取到。
|
||||
|
||||
**Validates: Requirements 6.3, 6.4, 10.5, 10.6**
|
||||
|
||||
### Property 5: 次均消费公式
|
||||
|
||||
*For any* 非负的 `total_consume_amount` 和非负整数 `total_visit_count`,`avg_ticket_amount` 应等于 `total_consume_amount / MAX(total_visit_count, 1)`。
|
||||
|
||||
推导:直接从需求 3.4 得出。`MAX(total_visit_count, 1)` 防止除零。
|
||||
|
||||
**Validates: Requirements 3.4, 10.7**
|
||||
|
||||
### Property 6: 重叠检测正确性
|
||||
|
||||
*For any* 一组助教服务记录,若在指定区域的同一台桌上存在 3 名或以上助教的服务时间段有重叠,则 `detect_overlap_violations` 应返回非空的违规列表。
|
||||
|
||||
推导:需求 6.1 要求检测"同一台桌同一时间段超过 2 名助教挂台"。我们可以生成随机的服务记录(包含时间段重叠和不重叠的情况),验证检测函数的正确性。
|
||||
|
||||
**Validates: Requirements 6.1**
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 订单无助教服务记录 | 跳过该订单,不生成统计记录 |
|
||||
| 台桌使用时长为 0 | 台费分摊设为 0,避免除零 |
|
||||
| 助教总服务时长为 0 | 酒水食品分摊设为 0,避免除零 |
|
||||
| 会员无充值记录 | 充值次数/金额设为 0 |
|
||||
| 会员无消费记录 | avg_ticket_amount 设为 0 |
|
||||
| 助教当日无违规 | penalty_minutes = 0,penalty_reason = NULL |
|
||||
| 服务记录缺少时间段信息 | 跳过该记录的重叠检测,日志 WARNING |
|
||||
| per_hour_contribution 为负数 | 视为 0 处理(防御性编程) |
|
||||
| FDW 映射创建失败 | 事务回滚,报错终止 |
|
||||
| 数据库写入失败 | 事务回滚,抛出异常由调度器处理 |
|
||||
|
||||
> **注意:所有数据库操作均在测试库(`test_etl_feiqiu` / `test_zqyy_app`)中进行。**
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 属性测试(hypothesis)
|
||||
|
||||
属性测试位于 `tests/` 目录(Monorepo 级),使用 `hypothesis` 库。
|
||||
|
||||
每个属性测试对应设计文档中的一个 Property,最少运行 100 次迭代。
|
||||
|
||||
测试文件:`tests/test_dws_contribution_properties.py`
|
||||
|
||||
```python
|
||||
# Feature: 02-etl-dws-miniapp-extensions, Property 1: 订单级统计不变量
|
||||
@given(order_data=order_data_strategy())
|
||||
@settings(max_examples=200)
|
||||
def test_gross_net_equal_across_assistants(order_data):
|
||||
"""所有参与助教的 order_gross_revenue 和 order_net_revenue 应分别相等"""
|
||||
gross = AssistantOrderContributionTask.compute_order_gross_revenue(order_data)
|
||||
net = AssistantOrderContributionTask.compute_order_net_revenue(order_data)
|
||||
# 每个助教获得相同的 gross 和 net
|
||||
for assistant in order_data.assistants:
|
||||
assert assistant_gross == gross
|
||||
assert assistant_net == net
|
||||
```
|
||||
|
||||
```python
|
||||
# Feature: 02-etl-dws-miniapp-extensions, Property 4: 惩罚分钟数分段公式
|
||||
@given(
|
||||
actual_minutes=st.decimals(min_value=0, max_value=600, places=2),
|
||||
per_hour_contribution=st.decimals(min_value=0, max_value=200, places=2),
|
||||
)
|
||||
@settings(max_examples=200)
|
||||
def test_penalty_minutes_formula(actual_minutes, per_hour_contribution):
|
||||
"""惩罚分钟数应符合分段公式且在 [0, actual_minutes] 范围内"""
|
||||
result = AssistantDailyTask.compute_penalty_minutes(
|
||||
actual_minutes, per_hour_contribution
|
||||
)
|
||||
if per_hour_contribution >= 24:
|
||||
assert result == 0
|
||||
else:
|
||||
expected = actual_minutes * (1 - per_hour_contribution / 24)
|
||||
assert abs(result - expected) < Decimal('0.01')
|
||||
assert 0 <= result <= actual_minutes
|
||||
```
|
||||
|
||||
属性测试库:`hypothesis`(已在项目依赖中)
|
||||
|
||||
### 单元测试
|
||||
|
||||
单元测试位于 `apps/etl/connectors/feiqiu/tests/unit/`,使用 FakeDB/FakeAPI 工具。
|
||||
|
||||
重点覆盖:
|
||||
- PRD 示例数据验算:使用 PRD 中的具体订单示例(3 名助教、2 张台桌、酒水 600 元)验证四项统计的精确数值
|
||||
- 超休/打赏课边界:验证超休助教的四项统计等于个人流水
|
||||
- 零值边界:无台费、无酒水、无助教服务的极端情况
|
||||
- 惩罚计算边界:per_hour_contribution 恰好等于 24 元的临界值
|
||||
- 充值窗口:验证 30/60/90 天窗口的正确切分
|
||||
- 豁免逻辑:is_exempt = TRUE 时跳过惩罚
|
||||
|
||||
### 测试配置
|
||||
|
||||
- 属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
|
||||
- 单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/test_assistant_order_contribution.py -v`
|
||||
- 每个属性测试标注 `@settings(max_examples=200)`
|
||||
- 每个属性测试注释引用设计文档 Property 编号
|
||||
@@ -1,157 +0,0 @@
|
||||
# 需求文档:ETL DWS 层扩展 — 小程序数据支撑
|
||||
|
||||
## 简介
|
||||
|
||||
本 Spec 覆盖 P2 任务中 T4–T11 的 ETL DWS 层扩展,为小程序提供三类核心数据支撑:
|
||||
1. 助教订单流水四项统计(`dws_assistant_order_contribution`)
|
||||
2. 会员消费汇总扩展(充值窗口 + 次均消费)
|
||||
3. 定档折算惩罚检测与计算
|
||||
|
||||
同时包含新表的 RLS 视图创建、FDW 映射同步,以及影子跑数验证。
|
||||
|
||||
> SPI 消费力指数(T1–T3)已在独立 Spec `.kiro/specs/spi-spending-power-index/` 中完成,本文档不再重复。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **AssistantOrderContributionTask**:助教订单流水统计 ETL 任务,粒度 `(site_id, assistant_id, stat_date)`
|
||||
- **MemberConsumptionTask**:会员消费汇总 ETL 任务,粒度 `(site_id, member_id, stat_date)`
|
||||
- **AssistantDailyTask**:助教日度业绩明细 ETL 任务,粒度 `(site_id, assistant_id, stat_date)`
|
||||
- **dws_assistant_order_contribution**:助教订单流水四项统计结果表
|
||||
- **dws_member_consumption_summary**:会员消费汇总表(已有,需扩展字段)
|
||||
- **dws_assistant_daily_detail**:助教日度业绩明细表(已有,需扩展字段)
|
||||
- **order_gross_revenue**:订单总流水 — 助教参与订单的全部流水(台费 + 酒水食品 + 助教服务费)
|
||||
- **order_net_revenue**:订单净流水 — 订单总流水 - 该订单所有助教的服务分成总额
|
||||
- **time_weighted_revenue**:时效贡献流水 — 按助教个人服务时长折算的订单金额贡献
|
||||
- **time_weighted_net_revenue**:时效净贡献 — 时效贡献流水 - 该助教个人的服务分成
|
||||
- **penalty_minutes**:定档折算惩罚分钟数 — 因违规被扣减的定档业绩时长
|
||||
- **per_hour_contribution**:单人每小时贡献流水 — 台费/房费每小时实收单价 ÷ 本次基础课助教人数
|
||||
- **RLS 视图**:行级安全视图,位于 ETL 库 `app` schema,按 `site_id` 隔离数据
|
||||
- **FDW 映射**:外部数据包装器映射,将 ETL 库表映射到业务库 `fdw_etl` schema
|
||||
- **settle_type**:结算类型,1=台桌结账,3=商城订单,5=充值订单
|
||||
- **BaseDwsTask**:DWS 层任务基类,提供 delete-before-insert 幂等机制
|
||||
- **delete-before-insert**:幂等更新策略,先按条件删除旧记录再批量插入新记录
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:助教订单流水统计表创建(T4)
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我需要创建助教订单流水四项统计表,以便存储每名助教每日的订单流水贡献数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 开发者 SHALL 创建 `dws.dws_assistant_order_contribution` 表,主键为 `(site_id, assistant_id, stat_date)`
|
||||
2. THE dws_assistant_order_contribution 表 SHALL 包含四项统计字段:`order_gross_revenue`(订单总流水)、`order_net_revenue`(订单净流水)、`time_weighted_revenue`(时效贡献流水)、`time_weighted_net_revenue`(时效净贡献),精度为 `NUMERIC(14,2)`
|
||||
3. THE dws_assistant_order_contribution 表 SHALL 包含辅助字段:`order_count`(参与订单数)、`total_service_seconds`(总服务时长秒数)、`assistant_nickname`(助教昵称)
|
||||
4. THE dws_assistant_order_contribution 表 SHALL 包含元数据字段:`tenant_id`、`created_at`、`updated_at`
|
||||
5. THE 开发者 SHALL 编写迁移脚本 `db/etl_feiqiu/migrations/<日期>__create_dws_assistant_order_contribution.sql`,在测试库 `test_etl_feiqiu` 中执行建表
|
||||
6. WHEN DDL 在测试库执行成功后,THE 开发者 SHALL 运行 `gen_consolidated_ddl.py` 导出最新 DDL
|
||||
|
||||
### 需求 2:助教订单流水四项统计计算(T5)
|
||||
|
||||
**用户故事:** 作为产品经理,我需要助教订单流水四项统计(订单总流水/订单净流水/时效贡献流水/时效净贡献),以便评估助教个人能力。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE AssistantOrderContributionTask SHALL 从 `dwd.dwd_settlement_head`、`dwd.dwd_table_fee_log`、`dwd.dwd_assistant_service_log` 提取订单、台费和助教服务数据
|
||||
2. WHEN 计算 order_gross_revenue 时,THE AssistantOrderContributionTask SHALL 将助教参与订单的全部流水(台费 + 酒水食品 + 所有助教服务费)累加,每个参与助教获得相同的订单总流水值
|
||||
3. WHEN 计算 order_net_revenue 时,THE AssistantOrderContributionTask SHALL 从订单总流水中减去该订单所有助教的服务分成总额,每个参与助教获得相同的订单净流水值
|
||||
4. WHEN 计算 time_weighted_revenue 时,THE AssistantOrderContributionTask SHALL 按以下步骤折算个人贡献:
|
||||
- 确定每张台桌的有效计费时长:取 MAX(该台桌所有助教服务时长之和, 台桌使用时长)
|
||||
- 按助教在该台桌的服务时长占比分摊台费
|
||||
- 助教个人服务费直接计入
|
||||
- 酒水食品按助教个人服务总时长占所有助教服务总时长的比例均分
|
||||
5. WHEN 计算 time_weighted_net_revenue 时,THE AssistantOrderContributionTask SHALL 从该助教的时效贡献流水中减去该助教个人的服务分成
|
||||
6. WHEN 助教为超休/打赏课类型时,THE AssistantOrderContributionTask SHALL 将四项统计均设为该助教个人的服务流水和分成(不参与订单级分摊)
|
||||
7. THE AssistantOrderContributionTask SHALL 以任务代码 `DWS_ASSISTANT_ORDER_CONTRIBUTION` 注册到 task_registry
|
||||
8. THE AssistantOrderContributionTask SHALL 采用 delete-before-insert 策略按日期窗口幂等更新
|
||||
|
||||
### 需求 3:会员消费汇总扩展(T6)
|
||||
|
||||
**用户故事:** 作为产品经理,我需要客户 30/60/90 天充值次数和金额、次均消费,以便在客户看板中展示。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 开发者 SHALL 在 `dws.dws_member_consumption_summary` 表中新增以下字段:`recharge_count_30d`、`recharge_count_60d`、`recharge_count_90d`(充值次数,INTEGER)、`recharge_amount_30d`、`recharge_amount_60d`、`recharge_amount_90d`(充值金额,NUMERIC(14,2))、`avg_ticket_amount`(次均消费额度,NUMERIC(14,2))
|
||||
2. THE 开发者 SHALL 编写 ALTER TABLE 迁移脚本在测试库 `test_etl_feiqiu` 中执行字段扩展
|
||||
3. THE 充值数据 SHALL 从 `dwd.dwd_recharge_order` 提取,按 `member_id` 和时间窗口聚合
|
||||
4. THE avg_ticket_amount SHALL 按公式 `total_consume_amount / MAX(total_visit_count, 1)` 计算
|
||||
|
||||
### 需求 4:会员消费汇总任务修改(T7)
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我需要修改 MemberConsumptionTask 以填充新增的充值窗口和次均消费字段。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE MemberConsumptionTask SHALL 在 extract 阶段新增充值订单提取逻辑,从 `dwd.dwd_recharge_order` 按 30/60/90 天窗口聚合充值次数和金额
|
||||
2. THE MemberConsumptionTask SHALL 在 transform 阶段将充值统计和次均消费填充到输出记录中
|
||||
3. WHEN 会员无充值记录时,THE MemberConsumptionTask SHALL 将充值次数设为 0、充值金额设为 0.00
|
||||
4. WHEN 会员无消费记录时,THE MemberConsumptionTask SHALL 将 avg_ticket_amount 设为 0.00
|
||||
|
||||
### 需求 5:助教日度明细表扩展 — 定档折算惩罚字段(T8)
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我需要在助教日度明细表中新增定档折算惩罚相关字段,以便存储惩罚检测和计算结果。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 开发者 SHALL 在 `dws.dws_assistant_daily_detail` 表中新增以下字段:`penalty_minutes` (NUMERIC(10,2))、`penalty_reason` (TEXT)、`is_exempt` (BOOLEAN DEFAULT FALSE)、`per_hour_contribution` (NUMERIC(14,2))
|
||||
2. THE 开发者 SHALL 编写 ALTER TABLE 迁移脚本在测试库 `test_etl_feiqiu` 中执行字段扩展
|
||||
3. WHEN 助教当日无惩罚时,THE penalty_minutes SHALL 为 0,penalty_reason SHALL 为 NULL
|
||||
|
||||
### 需求 6:定档折算惩罚检测与计算逻辑(T9)
|
||||
|
||||
**用户故事:** 作为产品经理,我需要定档折算惩罚数据,以便在绩效页面展示折算详情,防止助教利用低价订单冲档位。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE AssistantDailyTask SHALL 检测规则 2 违规:在指定区域(大厅 A/B/C/S/TV 和麻将房 M1–M7)内,同一台桌同一时间段超过 2 名助教挂台(课程时间段有重叠即算)
|
||||
2. WHEN 检测到违规时,THE AssistantDailyTask SHALL 计算单人每小时贡献流水:台费/房费每小时实收单价 ÷ 本次基础课助教人数
|
||||
3. WHEN per_hour_contribution >= 24 元时,THE AssistantDailyTask SHALL 按满额计入定档业绩时长(penalty_minutes = 0)
|
||||
4. WHEN per_hour_contribution < 24 元时,THE AssistantDailyTask SHALL 按比例折算:`penalty_minutes = 实际服务分钟数 × (1 - per_hour_contribution / 24)`
|
||||
5. WHEN 订单标记为 is_exempt = TRUE 时,THE AssistantDailyTask SHALL 跳过惩罚计算,penalty_minutes 设为 0
|
||||
6. THE 定档折算惩罚 SHALL 仅影响定档业绩时长统计,不影响实际工资时长
|
||||
7. THE AssistantDailyTask SHALL 每日自动计算惩罚,计算频率与现有日度任务一致
|
||||
|
||||
### 需求 7:RLS 视图创建(T10)
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我需要为新表创建 RLS 视图,以便通过 FDW 安全地向业务库暴露数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 开发者 SHALL 在 ETL 库 `app` schema 中为 `dws_assistant_order_contribution` 创建 RLS 视图,按 `site_id` 过滤
|
||||
2. THE 开发者 SHALL 更新已有 RLS 视图以包含 `dws_member_consumption_summary` 和 `dws_assistant_daily_detail` 的新增字段
|
||||
3. THE RLS 视图 SHALL 使用 `current_setting('app.current_site_id')::INTEGER` 进行行级过滤
|
||||
|
||||
### 需求 8:FDW 映射同步(T10)
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要在业务库中通过 FDW 访问新建和扩展的 ETL 表,以便小程序后端读取数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 开发者 SHALL 在 `test_zqyy_app.fdw_etl` schema 中创建 `dws_assistant_order_contribution` 的外部表映射
|
||||
2. THE 开发者 SHALL 更新 `dws_member_consumption_summary` 和 `dws_assistant_daily_detail` 的 FDW 映射以包含新增字段
|
||||
3. THE FDW 映射 SHALL 通过 `app` schema 的 RLS 视图访问数据,而非直接访问 `dws` schema
|
||||
|
||||
### 需求 9:影子跑数验证(T11)
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我需要通过影子跑数验证新增统计的正确性,以便确保数据质量。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 开发者 SHALL 编写验证脚本,对照 PRD 示例数据验算四项统计的计算结果
|
||||
2. THE 验证脚本 SHALL 检查 `dws_assistant_order_contribution` 中四项统计数值的一致性:order_gross_revenue 各助教相等、order_net_revenue 各助教相等、time_weighted_revenue 各助教之和加上误差容忍度等于订单总流水
|
||||
3. THE 验证脚本 SHALL 检查 `dws_member_consumption_summary` 新增字段有值且充值金额与 `dwd_recharge_order` 源数据一致
|
||||
4. THE 验证脚本 SHALL 检查定档折算惩罚字段在符合惩罚条件的记录上正确填充
|
||||
|
||||
### 需求 10:算法正确性测试
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我需要通过属性测试(hypothesis)验证四项统计和惩罚计算的正确性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 属性测试 SHALL 验证:对于任意订单,所有参与助教的 order_gross_revenue 值相等
|
||||
2. THE 属性测试 SHALL 验证:对于任意订单,所有参与助教的 order_net_revenue 值相等
|
||||
3. THE 属性测试 SHALL 验证:对于任意订单,所有参与助教的 time_weighted_revenue 之和应在订单总流水的合理误差范围内(±0.01 元)
|
||||
4. THE 属性测试 SHALL 验证:对于任意助教,time_weighted_net_revenue = time_weighted_revenue - 该助教个人服务分成
|
||||
5. THE 属性测试 SHALL 验证:对于任意 per_hour_contribution >= 24 的记录,penalty_minutes 为 0
|
||||
6. THE 属性测试 SHALL 验证:对于任意 per_hour_contribution < 24 且 per_hour_contribution >= 0 的记录,penalty_minutes = 实际分钟数 × (1 - per_hour_contribution / 24)
|
||||
7. THE 属性测试 SHALL 验证:对于任意会员,avg_ticket_amount = total_consume_amount / MAX(total_visit_count, 1)
|
||||
@@ -1,152 +0,0 @@
|
||||
# 实现计划:ETL DWS 层扩展 — 小程序数据支撑
|
||||
|
||||
## 概述
|
||||
|
||||
基于设计文档,将实现拆分为:DDL 建表/扩展 → 助教订单流水统计任务 → 会员消费汇总扩展 → 定档折算惩罚 → RLS 视图 + FDW 映射 → 影子跑数验证 六个阶段。所有数据库操作在测试库(`test_etl_feiqiu` / `test_zqyy_app`)中进行。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. DDL 建表与字段扩展
|
||||
- [x] 1.1 编写迁移脚本创建 `dws.dws_assistant_order_contribution` 表
|
||||
- 新建 `db/etl_feiqiu/migrations/<日期>__create_dws_assistant_order_contribution.sql`
|
||||
- 包含表定义、唯一索引 `idx_aoc_site_assistant_date`、查询索引 `idx_aoc_stat_date`
|
||||
- 字段参照设计文档数据模型章节
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||
- [x] 1.2 编写迁移脚本扩展 `dws_member_consumption_summary` 字段
|
||||
- 新建 `db/etl_feiqiu/migrations/<日期>__alter_member_consumption_add_recharge_fields.sql`
|
||||
- ALTER TABLE 添加 `recharge_count_30d/60d/90d`、`recharge_amount_30d/60d/90d`、`avg_ticket_amount`
|
||||
- _Requirements: 3.1, 3.2_
|
||||
- [x] 1.3 编写迁移脚本扩展 `dws_assistant_daily_detail` 字段
|
||||
- 新建 `db/etl_feiqiu/migrations/<日期>__alter_assistant_daily_add_penalty_fields.sql`
|
||||
- ALTER TABLE 添加 `penalty_minutes`、`penalty_reason`、`is_exempt`、`per_hour_contribution`
|
||||
- _Requirements: 5.1, 5.2_
|
||||
- [x] 1.4 在测试库 `test_etl_feiqiu` 执行全部迁移脚本
|
||||
- 通过 `PG_DSN`(指向测试库)连接执行 SQL
|
||||
- _Requirements: 1.5, 3.2, 5.2_
|
||||
- [x] 1.5 运行 `gen_consolidated_ddl.py` 导出最新 DDL
|
||||
- 执行 `python scripts/ops/gen_consolidated_ddl.py`
|
||||
- 验证 `docs/database/ddl/etl_feiqiu__dws.sql` 已包含新表和扩展字段
|
||||
- _Requirements: 1.6_
|
||||
|
||||
- [x] 2. 实现助教订单流水统计任务
|
||||
- [x] 2.1 创建数据结构和 `AssistantOrderContributionTask` 骨架
|
||||
- 新建 `apps/etl/connectors/feiqiu/tasks/dws/assistant_order_contribution_task.py`
|
||||
- 定义 `TableUsage`、`AssistantService`、`OrderData` dataclass
|
||||
- 定义 `AssistantOrderContributionTask` 类继承 `BaseDwsTask`
|
||||
- 实现 `get_task_code`、`get_target_table`、`get_primary_keys`
|
||||
- _Requirements: 1.1, 2.7_
|
||||
- [x] 2.2 实现四项统计核心计算(纯函数)
|
||||
- 实现 `compute_order_gross_revenue` 静态方法
|
||||
- 实现 `compute_order_net_revenue` 静态方法
|
||||
- 实现 `compute_time_weighted_revenue` 静态方法(含台费分摊、酒水均分逻辑)
|
||||
- 实现 `compute_time_weighted_net_revenue` 静态方法
|
||||
- 处理超休/打赏课特殊情况
|
||||
- _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6_
|
||||
- [x] 2.3 编写属性测试:订单级统计不变量
|
||||
- **Property 1: 订单级统计不变量 — gross/net 各助教相等**
|
||||
- **Validates: Requirements 2.2, 2.3, 10.1, 10.2**
|
||||
- [x] 2.4 编写属性测试:时效贡献流水之和约束
|
||||
- **Property 2: 时效贡献流水之和 ≤ order_gross_revenue**
|
||||
- **Validates: Requirements 2.4, 10.3**
|
||||
- [x] 2.5 编写属性测试:时效净贡献减法关系
|
||||
- **Property 3: time_weighted_net_revenue = time_weighted_revenue - commission**
|
||||
- **Validates: Requirements 2.5, 10.4**
|
||||
- [x] 2.6 实现 `extract` 方法
|
||||
- 从 `dwd_settlement_head`、`dwd_table_fee_log`、`dwd_assistant_service_log` 提取数据
|
||||
- 按 `order_settle_id` 聚合为 `OrderData` 结构
|
||||
- _Requirements: 2.1_
|
||||
- [x] 2.7 实现 `transform` 方法
|
||||
- 遍历订单,调用四项统计计算函数
|
||||
- 按 `(assistant_id, stat_date)` 聚合日度统计
|
||||
- _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6_
|
||||
- [x] 2.8 注册任务到 task_registry 并导出模块
|
||||
- 在 `tasks/dws/__init__.py` 中导出 `AssistantOrderContributionTask`
|
||||
- 在 `orchestration/task_registry.py` 中注册 `DWS_ASSISTANT_ORDER_CONTRIBUTION`,`layer="DWS"`,`depends_on=["DWD_LOAD_FROM_ODS"]`
|
||||
- _Requirements: 2.7, 2.8_
|
||||
|
||||
- [x] 3. 检查点 — 确保助教订单流水统计测试通过
|
||||
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
|
||||
- 确保所有属性测试通过,如有问题请询问用户。
|
||||
|
||||
- [x] 4. 扩展会员消费汇总任务
|
||||
- [x] 4.1 在 `MemberConsumptionTask` 中新增充值统计提取
|
||||
- 新增 `_extract_recharge_stats` 方法,从 `dwd.dwd_recharge_order` 按 30/60/90 天窗口聚合
|
||||
- 在 `extract` 方法中调用并返回充值统计数据
|
||||
- _Requirements: 4.1, 3.3_
|
||||
- [x] 4.2 在 `MemberConsumptionTask.transform` 中填充新字段
|
||||
- 合并充值统计到输出记录
|
||||
- 计算 `avg_ticket_amount = total_consume_amount / MAX(total_visit_count, 1)`
|
||||
- 处理无充值/无消费的边界情况
|
||||
- _Requirements: 4.2, 4.3, 4.4, 3.4_
|
||||
- [x] 4.3 编写属性测试:次均消费公式
|
||||
- **Property 5: avg_ticket_amount = total_consume_amount / MAX(total_visit_count, 1)**
|
||||
- **Validates: Requirements 3.4, 10.7**
|
||||
|
||||
- [x] 5. 实现定档折算惩罚检测与计算
|
||||
- [x] 5.1 实现时间重叠检测逻辑
|
||||
- 在 `AssistantDailyTask` 中新增 `detect_overlap_violations` 静态方法
|
||||
- 定义惩罚区域集合(大厅 A/B/C/S/TV + 麻将房 M1–M7)
|
||||
- 按 `(table_id, service_date)` 分组,检测时间段重叠且助教数 > 2
|
||||
- _Requirements: 6.1_
|
||||
- [x] 5.2 实现惩罚分钟数计算
|
||||
- 在 `AssistantDailyTask` 中新增 `compute_penalty_minutes` 静态方法
|
||||
- 计算 `per_hour_contribution = 台费每小时单价 / 助教人数`
|
||||
- 按分段公式计算 `penalty_minutes`
|
||||
- 处理 `is_exempt = TRUE` 豁免逻辑
|
||||
- _Requirements: 6.2, 6.3, 6.4, 6.5_
|
||||
- [x] 5.3 集成惩罚逻辑到 `AssistantDailyTask.transform`
|
||||
- 在现有聚合逻辑后调用重叠检测和惩罚计算
|
||||
- 将 `penalty_minutes`、`penalty_reason`、`is_exempt`、`per_hour_contribution` 填充到输出记录
|
||||
- _Requirements: 6.6, 6.7_
|
||||
- [x] 5.4 编写属性测试:惩罚分钟数分段公式
|
||||
- **Property 4: 惩罚分钟数符合分段公式且在 [0, actual_minutes] 范围内**
|
||||
- **Validates: Requirements 6.3, 6.4, 10.5, 10.6**
|
||||
- [x] 5.5 编写属性测试:重叠检测正确性
|
||||
- **Property 6: 3+ 助教时间重叠时应检测到违规**
|
||||
- **Validates: Requirements 6.1**
|
||||
|
||||
- [x] 6. 检查点 — 确保惩罚计算和消费汇总测试通过
|
||||
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
|
||||
- 运行单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/ -k "contribution or penalty or consumption" -v`
|
||||
- 确保所有测试通过,如有问题请询问用户。
|
||||
|
||||
- [x] 7. RLS 视图与 FDW 映射
|
||||
- [x] 7.1 创建 `dws_assistant_order_contribution` 的 RLS 视图
|
||||
- 在测试库 `test_etl_feiqiu` 的 `app` schema 中创建 `v_dws_assistant_order_contribution` 视图
|
||||
- 使用 `current_setting('app.current_site_id')::bigint` 过滤
|
||||
- 授予 `app_reader` 角色 SELECT 权限
|
||||
- _Requirements: 7.1, 7.3_
|
||||
- [x] 7.2 验证已有 RLS 视图自动包含新增字段
|
||||
- 确认 `app.v_dws_member_consumption_summary` 和 `app.v_dws_assistant_daily_detail` 使用 `SELECT *`,新增字段自动包含
|
||||
- _Requirements: 7.2_
|
||||
- [x] 7.3 创建/更新 FDW 外部表映射
|
||||
- 在测试库 `test_zqyy_app` 的 `fdw_etl` schema 中创建 `dws_assistant_order_contribution` 外部表
|
||||
- 重建 `dws_member_consumption_summary` 和 `dws_assistant_daily_detail` 的 FDW 外部表以包含新字段
|
||||
- FDW 映射通过 `app` schema RLS 视图访问
|
||||
- _Requirements: 8.1, 8.2, 8.3_
|
||||
|
||||
- [x] 8. 影子跑数验证
|
||||
- [x] 8.1 编写验证脚本
|
||||
- 新建 `apps/etl/connectors/feiqiu/scripts/verify_dws_extensions.py`
|
||||
- 验证四项统计:对照 PRD 示例数据验算,检查 gross/net 各助教相等
|
||||
- 验证充值窗口:检查新增字段有值,充值金额与源数据一致
|
||||
- 验证惩罚字段:检查符合条件的记录正确填充
|
||||
- _Requirements: 9.1, 9.2, 9.3, 9.4_
|
||||
- [x] 8.2 编写数据库手册文档
|
||||
- 新建 `docs/database/BD_Manual_dws_assistant_order_contribution.md`
|
||||
- 包含表结构、字段说明、索引、验证 SQL(至少 3 条)、兼容性说明、回滚策略
|
||||
- 更新 `docs/database/` 中 `dws_member_consumption_summary` 和 `dws_assistant_daily_detail` 的文档
|
||||
- _Requirements: 1.1_
|
||||
|
||||
- [x] 9. 最终检查点 — 确保所有测试通过
|
||||
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
|
||||
- 运行单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/ -k "contribution or penalty or consumption" -v`
|
||||
- 确保所有测试通过,如有问题请询问用户。
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选(属性测试),可跳过以加速 MVP
|
||||
- 每个任务引用具体需求编号以确保可追溯
|
||||
- 所有数据库操作在测试库(`test_etl_feiqiu` / `test_zqyy_app`)中进行
|
||||
- 检查点确保增量验证
|
||||
- 属性测试验证全称正确性属性,单元测试验证具体示例和边界情况
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,859 +0,0 @@
|
||||
# 设计文档:小程序用户认证系统(miniapp-auth-system)
|
||||
|
||||
## 概述
|
||||
|
||||
本设计在 P1(miniapp-db-foundation)已建立的 `auth` Schema 基础上,实现完整的小程序用户认证链路:
|
||||
|
||||
1. **微信登录**:小程序端发送 `code` → 后端调用微信 `code2Session` → 获取 `openid` → 创建/查找用户 → 签发 JWT
|
||||
2. **用户申请**:新用户填写球房ID + 手机号 + 申请身份 → 系统自动匹配助教/员工 → 管理员审核
|
||||
3. **权限控制**:基于 `user_site_roles` + `role_permissions` 的 RBAC 模型,权限中间件拦截无权请求
|
||||
4. **多店铺支持**:一个用户可关联多个 `site_id`,切换店铺时重新签发 JWT
|
||||
|
||||
**环境变量依赖**:
|
||||
|
||||
| 环境变量 | 用途 | 来源 |
|
||||
|---------|------|------|
|
||||
| `APP_DB_DSN` / `DB_HOST` 等 | 业务库连接 | 根 `.env` |
|
||||
| `PG_DSN` / `ETL_DB_HOST` 等 | ETL 库连接(FDW 匹配) | 根 `.env` |
|
||||
| `JWT_SECRET_KEY` | JWT 签名密钥 | `.env.local` |
|
||||
| `WX_APPID` | 微信小程序 AppID | `.env.local` |
|
||||
| `WX_SECRET` | 微信小程序 AppSecret | `.env.local` |
|
||||
|
||||
**整体认证流程**:
|
||||
|
||||
```
|
||||
小程序端 FastAPI 后端 微信服务器
|
||||
│ │ │
|
||||
│── wx.login() ──► │ │
|
||||
│ 获取 code │ │
|
||||
│ │ │
|
||||
│── POST /api/xcx/login ──► │ │
|
||||
│ {code} │── GET code2Session ──────────► │
|
||||
│ │◄── {openid, session_key} ──── │
|
||||
│ │ │
|
||||
│ │── 查找/创建 auth.users ──► │
|
||||
│ │── 签发 JWT ──► │
|
||||
│◄── {jwt, status} ─────── │ │
|
||||
│ │ │
|
||||
│ [status=pending] │ │
|
||||
│── POST /api/xcx/apply ──► │ │
|
||||
│ {site_code, phone, ...} │── 创建 user_applications ──► │
|
||||
│◄── {application_id} ───── │ │
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
### 分层架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "小程序端"
|
||||
MP["微信小程序<br/>wx.login() / wx.request()"]
|
||||
end
|
||||
|
||||
subgraph "FastAPI 后端(apps/backend/)"
|
||||
subgraph "路由层"
|
||||
XCX_AUTH["routers/xcx_auth.py<br/>微信登录 + 申请"]
|
||||
XCX_USER["routers/xcx_user.py<br/>用户状态 + 店铺切换"]
|
||||
ADMIN_APP["routers/admin_applications.py<br/>管理端审核"]
|
||||
end
|
||||
|
||||
subgraph "中间件层"
|
||||
PERM_MW["middleware/permission.py<br/>权限中间件"]
|
||||
end
|
||||
|
||||
subgraph "服务层"
|
||||
WX_SVC["services/wechat.py<br/>code2Session 调用"]
|
||||
APP_SVC["services/application.py<br/>申请 CRUD + 审核"]
|
||||
MATCH_SVC["services/matching.py<br/>人员匹配"]
|
||||
ROLE_SVC["services/role.py<br/>角色权限查询"]
|
||||
end
|
||||
|
||||
subgraph "认证层(已有 + 扩展)"
|
||||
JWT["auth/jwt.py<br/>JWT 签发/验证(扩展)"]
|
||||
DEPS["auth/dependencies.py<br/>依赖注入(扩展)"]
|
||||
end
|
||||
|
||||
DB["database.py<br/>数据库连接"]
|
||||
end
|
||||
|
||||
subgraph "数据库"
|
||||
AUTH_SCHEMA["auth Schema<br/>users / applications / roles / ..."]
|
||||
FDW_ETL["fdw_etl Schema<br/>v_dim_assistant / v_dim_staff"]
|
||||
end
|
||||
|
||||
subgraph "外部服务"
|
||||
WX_API["微信 API<br/>code2Session"]
|
||||
end
|
||||
|
||||
MP --> XCX_AUTH
|
||||
MP --> XCX_USER
|
||||
XCX_AUTH --> PERM_MW
|
||||
XCX_USER --> PERM_MW
|
||||
ADMIN_APP --> PERM_MW
|
||||
PERM_MW --> JWT
|
||||
PERM_MW --> DEPS
|
||||
XCX_AUTH --> WX_SVC
|
||||
XCX_AUTH --> APP_SVC
|
||||
ADMIN_APP --> APP_SVC
|
||||
ADMIN_APP --> MATCH_SVC
|
||||
XCX_USER --> ROLE_SVC
|
||||
WX_SVC --> WX_API
|
||||
APP_SVC --> DB
|
||||
MATCH_SVC --> DB
|
||||
ROLE_SVC --> DB
|
||||
DB --> AUTH_SCHEMA
|
||||
DB --> FDW_ETL
|
||||
```
|
||||
|
||||
### 请求处理流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MP as 小程序
|
||||
participant MW as Permission Middleware
|
||||
participant R as Router
|
||||
participant S as Service
|
||||
participant DB as PostgreSQL
|
||||
|
||||
MP->>R: POST /api/xcx/login {code}
|
||||
R->>S: wechat.code2session(code)
|
||||
S-->>R: openid
|
||||
R->>DB: SELECT FROM auth.users WHERE wx_openid = ?
|
||||
alt 用户不存在
|
||||
R->>DB: INSERT INTO auth.users
|
||||
end
|
||||
R->>R: jwt.create_token_pair(user_id, site_id)
|
||||
R-->>MP: {access_token, refresh_token, status}
|
||||
|
||||
Note over MP,DB: 后续请求(需认证)
|
||||
|
||||
MP->>MW: GET /api/xcx/... (Bearer token)
|
||||
MW->>MW: decode_access_token(token)
|
||||
MW->>DB: SELECT permissions FROM auth.user_site_roles JOIN ...
|
||||
alt 权限不足
|
||||
MW-->>MP: 403 Forbidden
|
||||
else 权限通过
|
||||
MW->>R: 放行
|
||||
R->>S: 业务逻辑
|
||||
S->>DB: 数据操作
|
||||
R-->>MP: 200 OK
|
||||
end
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 组件 1:微信认证服务(services/wechat.py)
|
||||
|
||||
**职责**:封装微信 `code2Session` API 调用。
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from app.config import get
|
||||
|
||||
WX_APPID: str = get("WX_APPID", "")
|
||||
WX_SECRET: str = get("WX_SECRET", "")
|
||||
CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
|
||||
async def code2session(code: str) -> dict:
|
||||
"""
|
||||
调用微信 code2Session 接口。
|
||||
|
||||
返回:
|
||||
{"openid": str, "session_key": str, "unionid": str | None}
|
||||
|
||||
异常:
|
||||
WeChatAuthError: 微信接口返回错误码时抛出
|
||||
"""
|
||||
...
|
||||
|
||||
class WeChatAuthError(Exception):
|
||||
"""微信认证错误,包含 errcode 和 errmsg。"""
|
||||
def __init__(self, errcode: int, errmsg: str): ...
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- 使用 `httpx.AsyncClient` 异步调用微信 API,与 FastAPI 异步模型一致
|
||||
- `WX_APPID` / `WX_SECRET` 从环境变量读取,缺失时在调用时报错(而非启动时,因为非所有端点都需要微信认证)
|
||||
|
||||
### 组件 2:申请服务(services/application.py)
|
||||
|
||||
**职责**:处理用户申请的创建、查询、审核。
|
||||
|
||||
```python
|
||||
async def create_application(
|
||||
user_id: int,
|
||||
site_code: str,
|
||||
applied_role_text: str,
|
||||
phone: str,
|
||||
employee_number: str | None = None,
|
||||
nickname: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
创建用户申请。
|
||||
|
||||
1. 查找 site_code → site_id 映射
|
||||
2. 检查是否有 pending 申请(有则 409)
|
||||
3. 插入 user_applications 记录
|
||||
4. 更新 users.nickname(如提供)
|
||||
"""
|
||||
...
|
||||
|
||||
async def approve_application(
|
||||
application_id: int,
|
||||
reviewer_id: int,
|
||||
role_id: int,
|
||||
binding: dict | None = None, # {"assistant_id": ..., "staff_id": ..., "binding_type": ...}
|
||||
review_note: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
批准申请。
|
||||
|
||||
1. 检查申请状态为 pending(否则 409)
|
||||
2. 更新 user_applications.status = 'approved'
|
||||
3. 创建 user_site_roles 记录
|
||||
4. 创建 user_assistant_binding 记录(如有 binding)
|
||||
5. 更新 users.status = 'approved'(如果是首次通过)
|
||||
6. 记录 reviewer_id 和 reviewed_at
|
||||
"""
|
||||
...
|
||||
|
||||
async def reject_application(
|
||||
application_id: int,
|
||||
reviewer_id: int,
|
||||
review_note: str,
|
||||
) -> dict:
|
||||
"""
|
||||
拒绝申请。
|
||||
|
||||
1. 检查申请状态为 pending(否则 409)
|
||||
2. 更新 user_applications.status = 'rejected'
|
||||
3. 记录 reviewer_id、review_note、reviewed_at
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_user_applications(user_id: int) -> list[dict]:
|
||||
"""查询用户的所有申请记录。"""
|
||||
...
|
||||
```
|
||||
|
||||
### 组件 3:人员匹配服务(services/matching.py)
|
||||
|
||||
**职责**:根据申请信息在 FDW 外部表中查找候选匹配。
|
||||
|
||||
```python
|
||||
async def find_candidates(
|
||||
site_id: int,
|
||||
phone: str,
|
||||
employee_number: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
在助教表和员工表中查找匹配候选。
|
||||
|
||||
查询逻辑:
|
||||
1. fdw_etl.v_dim_assistant: WHERE site_id = ? AND mobile = ?
|
||||
2. fdw_etl.v_dim_staff + v_dim_staff_ex: WHERE site_id = ? AND (mobile = ? OR job_num = ?)
|
||||
3. 合并结果,每条包含 source_type / name / mobile / job_num
|
||||
|
||||
注意:查询 FDW 外部表前需设置 app.current_site_id(RLS 隔离)。
|
||||
但 fdw_etl 中的外部表映射的是 app schema 的 RLS 视图,
|
||||
所以需要在 ETL 库连接上设置 site_id。
|
||||
实际上,我们直接在业务库通过 fdw_etl 查询,
|
||||
FDW 会透传 session 变量到远端。
|
||||
|
||||
返回:
|
||||
[{"source_type": "assistant"|"staff", "id": int, "name": str, "mobile": str, "job_num": str | None}]
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- FDW 查询需要在业务库连接上设置 `app.current_site_id`,因为 FDW 外部表映射的是 ETL 库 `app` Schema 的 RLS 视图
|
||||
- 匹配查询使用业务库连接(`get_connection()`),通过 `SET LOCAL app.current_site_id` 设置隔离
|
||||
- 如果 `site_code` 无法映射到 `site_id`,直接返回空列表
|
||||
|
||||
### 组件 4:角色权限服务(services/role.py)
|
||||
|
||||
**职责**:查询用户在指定店铺下的角色和权限。
|
||||
|
||||
```python
|
||||
async def get_user_permissions(user_id: int, site_id: int) -> list[str]:
|
||||
"""
|
||||
获取用户在指定 site_id 下的权限 code 列表。
|
||||
|
||||
SQL: SELECT DISTINCT p.code
|
||||
FROM auth.user_site_roles usr
|
||||
JOIN auth.role_permissions rp ON usr.role_id = rp.role_id
|
||||
JOIN auth.permissions p ON rp.permission_id = p.id
|
||||
WHERE usr.user_id = ? AND usr.site_id = ?
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_user_sites(user_id: int) -> list[dict]:
|
||||
"""
|
||||
获取用户关联的所有店铺及对应角色。
|
||||
|
||||
返回: [{"site_id": int, "site_name": str, "roles": [{"code": str, "name": str}]}]
|
||||
"""
|
||||
...
|
||||
|
||||
async def check_user_has_site_role(user_id: int, site_id: int) -> bool:
|
||||
"""检查用户在指定 site_id 下是否有任何角色绑定。"""
|
||||
...
|
||||
```
|
||||
|
||||
### 组件 5:权限中间件(middleware/permission.py)
|
||||
|
||||
**职责**:基于 JWT 中的 `user_id` + `site_id` 检查用户权限。
|
||||
|
||||
```python
|
||||
from functools import wraps
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from app.auth.dependencies import get_current_user, CurrentUser
|
||||
|
||||
def require_permission(*permission_codes: str):
|
||||
"""
|
||||
权限装饰器/依赖,用于路由端点。
|
||||
|
||||
用法:
|
||||
@router.get("/finance")
|
||||
async def get_finance(
|
||||
user: CurrentUser = Depends(require_permission("view_board_finance"))
|
||||
):
|
||||
...
|
||||
|
||||
逻辑:
|
||||
1. 从 JWT 提取 user_id + site_id
|
||||
2. 查询 auth.users.status,非 approved 则 403
|
||||
3. 查询 user_site_roles + role_permissions 获取权限列表
|
||||
4. 检查是否包含所需权限,不包含则 403
|
||||
"""
|
||||
...
|
||||
|
||||
def require_approved():
|
||||
"""
|
||||
仅检查用户状态为 approved 的依赖(不检查具体权限)。
|
||||
用于通用的已认证端点。
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- 使用 FastAPI 依赖注入模式而非全局中间件,更灵活且可按端点配置
|
||||
- `pending` 用户只能访问申请提交和状态查询端点,其他端点需要 `approved` 状态
|
||||
- 权限检查结果可考虑短期缓存(当前版本不缓存,每次查库)
|
||||
|
||||
### 组件 6:JWT 服务扩展(auth/jwt.py 扩展)
|
||||
|
||||
**职责**:扩展现有 JWT 服务,支持微信登录场景。
|
||||
|
||||
**扩展内容**:
|
||||
|
||||
```python
|
||||
# 新增:创建受限令牌(pending 用户)
|
||||
def create_limited_token_pair(user_id: int) -> dict[str, str]:
|
||||
"""
|
||||
为 pending 用户签发受限令牌。
|
||||
payload 不含 site_id 和 roles,仅包含 user_id + type + limited=True。
|
||||
"""
|
||||
...
|
||||
|
||||
# 扩展:create_access_token payload 增加 roles 字段
|
||||
def create_access_token(user_id: int, site_id: int, roles: list[str] | None = None) -> str:
|
||||
"""
|
||||
生成 access_token。
|
||||
payload: sub=user_id, site_id, roles, type=access, exp
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- 保持向后兼容:现有 `create_access_token(user_id, site_id)` 调用不受影响(`roles` 默认 `None`)
|
||||
- `pending` 用户的受限令牌通过 `limited=True` 标记区分,权限中间件据此拦截
|
||||
|
||||
### 组件 7:路由端点
|
||||
|
||||
#### 7.1 小程序认证路由(routers/xcx_auth.py)
|
||||
|
||||
| 方法 | 路径 | 说明 | 认证要求 |
|
||||
|------|------|------|---------|
|
||||
| POST | `/api/xcx/login` | 微信登录 | 无(公开) |
|
||||
| POST | `/api/xcx/apply` | 提交申请 | JWT(含 pending) |
|
||||
| GET | `/api/xcx/me` | 查询自身状态 | JWT(含 pending) |
|
||||
| GET | `/api/xcx/me/sites` | 查询关联店铺 | JWT(approved) |
|
||||
| POST | `/api/xcx/switch-site` | 切换店铺 | JWT(approved) |
|
||||
| POST | `/api/xcx/refresh` | 刷新令牌 | refresh_token |
|
||||
|
||||
#### 7.2 管理端审核路由(routers/admin_applications.py)
|
||||
|
||||
| 方法 | 路径 | 说明 | 认证要求 |
|
||||
|------|------|------|---------|
|
||||
| GET | `/api/admin/applications` | 查询申请列表 | JWT + site_admin/tenant_admin |
|
||||
| GET | `/api/admin/applications/{id}` | 查询申请详情 + 候选匹配 | JWT + site_admin/tenant_admin |
|
||||
| POST | `/api/admin/applications/{id}/approve` | 批准申请 | JWT + site_admin/tenant_admin |
|
||||
| POST | `/api/admin/applications/{id}/reject` | 拒绝申请 | JWT + site_admin/tenant_admin |
|
||||
|
||||
### 组件 8:Pydantic 模型(schemas/xcx_auth.py)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
import re
|
||||
|
||||
class WxLoginRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, description="微信临时登录凭证")
|
||||
|
||||
class WxLoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user_status: str # pending / approved / rejected / disabled
|
||||
user_id: int
|
||||
|
||||
class ApplicationRequest(BaseModel):
|
||||
site_code: str = Field(..., pattern=r"^[A-Za-z]{2}\d{3}$", description="球房ID")
|
||||
applied_role_text: str = Field(..., min_length=1, max_length=100)
|
||||
phone: str = Field(..., pattern=r"^\d{11}$", description="手机号")
|
||||
employee_number: str | None = Field(None, max_length=50)
|
||||
nickname: str | None = Field(None, max_length=50)
|
||||
|
||||
class ApplicationResponse(BaseModel):
|
||||
id: int
|
||||
site_code: str
|
||||
applied_role_text: str
|
||||
status: str
|
||||
review_note: str | None = None
|
||||
created_at: str
|
||||
reviewed_at: str | None = None
|
||||
|
||||
class UserStatusResponse(BaseModel):
|
||||
user_id: int
|
||||
status: str
|
||||
nickname: str | None
|
||||
applications: list[ApplicationResponse]
|
||||
|
||||
class SiteInfo(BaseModel):
|
||||
site_id: int
|
||||
site_name: str
|
||||
roles: list[dict]
|
||||
|
||||
class SwitchSiteRequest(BaseModel):
|
||||
site_id: int
|
||||
|
||||
class MatchCandidate(BaseModel):
|
||||
source_type: str # assistant / staff
|
||||
id: int
|
||||
name: str
|
||||
mobile: str | None
|
||||
job_num: str | None
|
||||
|
||||
class ApproveRequest(BaseModel):
|
||||
role_id: int
|
||||
binding: dict | None = None # {"assistant_id": ..., "staff_id": ..., "binding_type": ...}
|
||||
review_note: str | None = None
|
||||
|
||||
class RejectRequest(BaseModel):
|
||||
review_note: str = Field(..., min_length=1)
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
### ER 图
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
users {
|
||||
serial id PK
|
||||
varchar wx_openid UK
|
||||
varchar wx_union_id
|
||||
varchar wx_avatar_url
|
||||
varchar nickname
|
||||
varchar phone
|
||||
varchar status "pending/approved/rejected/disabled"
|
||||
timestamptz created_at
|
||||
timestamptz updated_at
|
||||
}
|
||||
|
||||
user_applications {
|
||||
serial id PK
|
||||
int user_id FK
|
||||
varchar site_code
|
||||
int site_id "可空,映射后填入"
|
||||
varchar applied_role_text
|
||||
varchar employee_number "可选"
|
||||
varchar phone
|
||||
varchar status "pending/approved/rejected"
|
||||
int reviewer_id
|
||||
text review_note
|
||||
timestamptz created_at
|
||||
timestamptz reviewed_at
|
||||
}
|
||||
|
||||
site_code_mapping {
|
||||
serial id PK
|
||||
varchar site_code UK "2字母+3数字"
|
||||
bigint site_id UK
|
||||
varchar site_name
|
||||
int tenant_id
|
||||
timestamptz created_at
|
||||
}
|
||||
|
||||
roles {
|
||||
serial id PK
|
||||
varchar code UK
|
||||
varchar name
|
||||
text description
|
||||
timestamptz created_at
|
||||
}
|
||||
|
||||
permissions {
|
||||
serial id PK
|
||||
varchar code UK
|
||||
varchar name
|
||||
text description
|
||||
timestamptz created_at
|
||||
}
|
||||
|
||||
role_permissions {
|
||||
int role_id FK
|
||||
int permission_id FK
|
||||
}
|
||||
|
||||
user_site_roles {
|
||||
serial id PK
|
||||
int user_id FK
|
||||
bigint site_id
|
||||
int role_id FK
|
||||
timestamptz created_at
|
||||
}
|
||||
|
||||
user_assistant_binding {
|
||||
serial id PK
|
||||
int user_id FK
|
||||
bigint site_id
|
||||
bigint assistant_id "可空"
|
||||
bigint staff_id "可空"
|
||||
varchar binding_type "assistant/staff/manager"
|
||||
timestamptz created_at
|
||||
}
|
||||
|
||||
users ||--o{ user_applications : "提交申请"
|
||||
users ||--o{ user_site_roles : "店铺角色"
|
||||
users ||--o{ user_assistant_binding : "人员绑定"
|
||||
roles ||--o{ user_site_roles : "角色分配"
|
||||
roles ||--o{ role_permissions : "角色权限"
|
||||
permissions ||--o{ role_permissions : "权限定义"
|
||||
site_code_mapping ||--o{ user_applications : "球房映射"
|
||||
```
|
||||
|
||||
### 表 DDL 概要
|
||||
|
||||
所有表在 `auth` Schema 下,迁移脚本位于 `db/zqyy_app/migrations/`。
|
||||
|
||||
**关键约束**:
|
||||
- `users.wx_openid` UNIQUE — 一个微信用户对应一条记录
|
||||
- `site_code_mapping.site_code` UNIQUE — 球房ID 唯一
|
||||
- `site_code_mapping.site_id` UNIQUE — site_id 唯一映射
|
||||
- `user_site_roles (user_id, site_id, role_id)` UNIQUE — 防止重复分配
|
||||
- `role_permissions (role_id, permission_id)` 联合主键
|
||||
|
||||
**索引**:
|
||||
- `users`: `ix_users_wx_openid` (wx_openid)
|
||||
- `user_applications`: `ix_user_applications_user_id` (user_id), `ix_user_applications_status` (status)
|
||||
- `user_site_roles`: `ix_user_site_roles_user_site` (user_id, site_id)
|
||||
- `site_code_mapping`: `ix_site_code_mapping_site_code` (site_code)
|
||||
|
||||
### 迁移脚本清单
|
||||
|
||||
| 序号 | 文件名 | 内容 |
|
||||
|------|--------|------|
|
||||
| 1 | `YYYY-MM-DD__p3_create_auth_tables.sql` | 创建 users / user_applications / site_code_mapping / roles / permissions / role_permissions / user_site_roles / user_assistant_binding |
|
||||
| 2 | `YYYY-MM-DD__p3_seed_roles_permissions.sql` | 种子数据:权限列表 + 默认角色 + 角色权限映射 |
|
||||
|
||||
|
||||
|
||||
## 正确性属性(Correctness Properties)
|
||||
|
||||
*属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1:迁移脚本幂等性
|
||||
|
||||
*For any* 本次新增的迁移脚本(DDL + 种子数据),连续执行两次的结果应与执行一次相同——第二次执行不应产生错误,且数据库状态不变。
|
||||
|
||||
**Validates: Requirements 1.9, 2.4, 11.5**
|
||||
|
||||
### Property 2:登录创建/查找用户正确性
|
||||
|
||||
*For any* 有效的微信 `openid`,调用登录逻辑后:若该 `openid` 已存在于 `auth.users` 中,应返回已有用户的 `user_id`;若不存在,应创建新用户(status=`pending`)并返回新 `user_id`。无论哪种情况,返回的 JWT 中 `sub` 应等于该 `user_id`。
|
||||
|
||||
**Validates: Requirements 3.2, 3.3**
|
||||
|
||||
### Property 3:disabled 用户登录拒绝
|
||||
|
||||
*For any* `auth.users` 中 status 为 `disabled` 的用户,通过其 `openid` 登录时应返回 403 错误,不签发 JWT。
|
||||
|
||||
**Validates: Requirements 3.5**
|
||||
|
||||
### Property 4:申请创建正确性
|
||||
|
||||
*For any* 有效的申请数据(合法 `site_code` 格式、11 位手机号、非空 `applied_role_text`),提交申请后 `auth.user_applications` 中应新增一条 status=`pending` 的记录。若 `site_code` 在 `site_code_mapping` 中有映射,记录的 `site_id` 应等于映射值;若无映射,`site_id` 为 NULL 但申请仍成功。若提供了 `nickname`,`auth.users` 中该用户的 `nickname` 应更新。
|
||||
|
||||
**Validates: Requirements 4.1, 4.2, 4.3, 4.4**
|
||||
|
||||
### Property 5:手机号格式验证
|
||||
|
||||
*For any* 非 11 位纯数字的字符串作为 `phone` 提交申请,系统应返回 422 错误,`auth.user_applications` 中不应新增记录。
|
||||
|
||||
**Validates: Requirements 4.5**
|
||||
|
||||
### Property 6:重复申请拒绝
|
||||
|
||||
*For any* 已有一条 status=`pending` 申请的用户,再次提交申请时应返回 409 错误,`auth.user_applications` 中不应新增记录。
|
||||
|
||||
**Validates: Requirements 4.6**
|
||||
|
||||
### Property 7:人员匹配合并正确性
|
||||
|
||||
*For any* 有效的 `site_id` 和 `phone` 组合,匹配服务返回的候选列表应满足:(1) 每条候选的 `source_type` 为 `assistant` 或 `staff`;(2) 助教来源的候选来自 `v_dim_assistant` 表中 `site_id` 和 `mobile` 匹配的记录;(3) 员工来源的候选来自 `v_dim_staff` 表中 `site_id` 和 `mobile`(或 `job_num`)匹配的记录;(4) 列表是两个来源结果的并集,无遗漏。
|
||||
|
||||
**Validates: Requirements 5.1, 5.2, 5.3, 5.4**
|
||||
|
||||
### Property 8:审核操作正确性
|
||||
|
||||
*For any* status=`pending` 的申请:(1) 批准操作后,申请 status 变为 `approved`,`auth.user_site_roles` 中新增角色记录,`auth.users.status` 变为 `approved`,`reviewer_id` 和 `reviewed_at` 非空;(2) 若提供了 binding 信息,`auth.user_assistant_binding` 中新增绑定记录;(3) 拒绝操作后,申请 status 变为 `rejected`,`review_note` 非空,`reviewer_id` 和 `reviewed_at` 非空。
|
||||
|
||||
**Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5**
|
||||
|
||||
### Property 9:非 pending 申请审核拒绝
|
||||
|
||||
*For any* status 不是 `pending` 的申请(`approved` / `rejected`),对其执行批准或拒绝操作应返回 409 错误,申请状态不变。
|
||||
|
||||
**Validates: Requirements 6.6**
|
||||
|
||||
### Property 10:用户状态查询完整性
|
||||
|
||||
*For any* 用户,查询自身状态应返回:(1) 用户的 `status` 字段;(2) 该用户所有申请记录的完整列表。若用户 status 为 `approved`,还应返回已关联的店铺列表和对应角色。
|
||||
|
||||
**Validates: Requirements 7.1, 7.2**
|
||||
|
||||
### Property 11:多店铺角色独立分配
|
||||
|
||||
*For any* 用户和多个不同的 `site_id`,系统应允许为该用户在每个 `site_id` 下独立分配不同的角色,且 `auth.user_site_roles` 中的记录互不干扰。
|
||||
|
||||
**Validates: Requirements 8.1**
|
||||
|
||||
### Property 12:店铺切换令牌正确性
|
||||
|
||||
*For any* 拥有多店铺绑定的 approved 用户,切换到目标 `site_id` 后签发的新 JWT 中 `site_id` 应等于目标值,`roles` 应等于该用户在目标 `site_id` 下的角色列表。若用户在目标 `site_id` 下无角色绑定,切换应失败。
|
||||
|
||||
**Validates: Requirements 8.2, 10.4**
|
||||
|
||||
### Property 13:权限中间件拦截正确性
|
||||
|
||||
*For any* 用户、`site_id` 和所需权限 code 的组合:(1) 若用户 status 非 `approved`,返回 403;(2) 若用户在该 `site_id` 下的权限列表不包含所需权限,返回 403;(3) 若用户在该 `site_id` 下拥有所需权限且 status 为 `approved`,放行。
|
||||
|
||||
**Validates: Requirements 8.3, 9.1, 9.2, 9.3**
|
||||
|
||||
### Property 14:JWT payload 结构与状态一致性
|
||||
|
||||
*For any* 通过登录签发的 JWT:(1) 解码后应包含 `sub`(user_id)、`type`、`exp` 字段;(2) 若用户 status 为 `approved`,payload 应包含 `site_id` 和 `roles`;(3) 若用户 status 为 `pending`,payload 应包含 `limited=True`,不含 `site_id` 和 `roles`。
|
||||
|
||||
**Validates: Requirements 10.1, 10.2, 10.3**
|
||||
|
||||
### Property 15:JWT 过期/无效令牌拒绝
|
||||
|
||||
*For any* 过期的 JWT 或被篡改的 JWT 字符串,权限中间件应返回 401 错误,不放行请求。
|
||||
|
||||
**Validates: Requirements 9.4**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### API 错误码规范
|
||||
|
||||
| HTTP 状态码 | 场景 | 响应体 |
|
||||
|------------|------|--------|
|
||||
| 401 | JWT 无效/过期、微信 code2Session 失败 | `{"detail": "具体错误描述"}` |
|
||||
| 403 | 用户 disabled、权限不足、用户未 approved | `{"detail": "具体错误描述"}` |
|
||||
| 404 | 申请不存在 | `{"detail": "申请不存在"}` |
|
||||
| 409 | 重复提交 pending 申请、审核非 pending 申请 | `{"detail": "具体冲突描述"}` |
|
||||
| 422 | 请求体校验失败(手机号格式、site_code 格式等) | Pydantic 标准错误格式 |
|
||||
| 500 | 数据库连接失败、微信 API 超时 | `{"detail": "服务器内部错误"}` |
|
||||
|
||||
### 微信 API 错误处理
|
||||
|
||||
| 微信 errcode | 含义 | 处理方式 |
|
||||
|-------------|------|---------|
|
||||
| 0 | 成功 | 正常流程 |
|
||||
| 40029 | code 无效 | 返回 401,提示"登录凭证无效,请重新登录" |
|
||||
| 45011 | 频率限制 | 返回 429,提示"请求过于频繁" |
|
||||
| 40226 | 高风险用户 | 返回 403,提示"账号存在风险" |
|
||||
| 其他 | 未知错误 | 返回 401,记录日志,提示"微信登录失败" |
|
||||
|
||||
### 数据库错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| 连接失败 | 捕获 `psycopg2.OperationalError`,返回 500 |
|
||||
| 唯一约束冲突(wx_openid) | 并发创建时捕获 `UniqueViolation`,改为查询已有记录 |
|
||||
| 外键约束失败 | 返回 422,提示具体的关联数据不存在 |
|
||||
| FDW 查询失败 | 捕获异常,匹配服务返回空列表,记录日志 |
|
||||
|
||||
### 环境变量缺失处理
|
||||
|
||||
| 变量 | 缺失时行为 |
|
||||
|------|-----------|
|
||||
| `WX_APPID` / `WX_SECRET` | 微信登录端点返回 500,日志记录"微信配置缺失" |
|
||||
| `JWT_SECRET_KEY` | 应用启动时警告(空字符串不安全),JWT 签发/验证使用空密钥(仅开发环境) |
|
||||
| `DB_HOST` 等数据库参数 | 数据库连接失败,返回 500 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### DDL 测试库落库与文档同步
|
||||
|
||||
DDL 变更必须经过以下流程:
|
||||
|
||||
1. **测试库执行**:在 `test_zqyy_app` 中执行迁移脚本,验证无错误
|
||||
2. **幂等性验证**:连续执行两次,第二次无错误
|
||||
3. **数据库手册更新**:创建/更新 `docs/database/BD_Manual_auth_tables.md`,格式参照现有 `BD_Manual_auth_biz_schemas.md`
|
||||
4. **DDL 基线刷新**:运行 `python scripts/ops/gen_consolidated_ddl.py` 重新生成 `docs/database/ddl/zqyy_app__auth.sql`
|
||||
|
||||
### 小程序认证前端页面
|
||||
|
||||
#### 页面清单
|
||||
|
||||
| 页面 | 路径 | 说明 | H5 原型 |
|
||||
|------|------|------|---------|
|
||||
| login | `pages/login/login` | 微信登录页(自动调用 wx.login) | `docs/h5_ui/pages/login.html` |
|
||||
| apply | `pages/apply/apply` | 申请表单页(球房ID + 手机号 + 身份 + 编号 + 昵称) | `docs/h5_ui/pages/apply.html` |
|
||||
| reviewing | `pages/reviewing/reviewing` | 审核等待页(显示状态 + 申请摘要) | `docs/h5_ui/pages/reviewing.html` |
|
||||
| no-permission | `pages/no-permission/no-permission` | 无权限/已禁用页 | `docs/h5_ui/pages/no-permission.html` |
|
||||
|
||||
#### 认证路由流程
|
||||
|
||||
```
|
||||
app.ts onLaunch()
|
||||
│
|
||||
├── wx.login() → 获取 code
|
||||
│
|
||||
├── POST /api/xcx/login {code}
|
||||
│ │
|
||||
│ ├── 返回 user_status = "approved"
|
||||
│ │ └── 跳转主页(task-list 或 home)
|
||||
│ │
|
||||
│ ├── 返回 user_status = "pending"
|
||||
│ │ ├── 查询 /api/xcx/me → 有 pending 申请
|
||||
│ │ │ └── 跳转 reviewing 页面
|
||||
│ │ └── 查询 /api/xcx/me → 无 pending 申请
|
||||
│ │ └── 跳转 apply 页面
|
||||
│ │
|
||||
│ ├── 返回 user_status = "rejected"
|
||||
│ │ └── 跳转 reviewing 页面(显示拒绝原因 + 重新申请按钮)
|
||||
│ │
|
||||
│ └── 返回 403(disabled)
|
||||
│ └── 跳转 no-permission 页面
|
||||
│
|
||||
└── 登录失败(网络错误等)
|
||||
└── 显示错误提示,提供重试按钮
|
||||
```
|
||||
|
||||
#### app.ts 全局状态管理
|
||||
|
||||
```typescript
|
||||
// globalData 扩展
|
||||
interface IAppOption {
|
||||
globalData: {
|
||||
userInfo?: {
|
||||
userId: number;
|
||||
status: string; // pending / approved / rejected / disabled
|
||||
nickname?: string;
|
||||
};
|
||||
token?: string;
|
||||
refreshToken?: string;
|
||||
currentSiteId?: number;
|
||||
sites?: Array<{ siteId: number; siteName: string; roles: string[] }>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 请求封装(utils/request.ts)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 统一请求封装:
|
||||
* 1. 自动附加 Authorization: Bearer <token>
|
||||
* 2. 401 时自动尝试 refresh_token 刷新
|
||||
* 3. 刷新失败时跳转 login 页面
|
||||
*/
|
||||
function request(options: RequestOptions): Promise<any> { ... }
|
||||
```
|
||||
|
||||
### 开发模式联调
|
||||
|
||||
#### Mock 登录端点
|
||||
|
||||
后端在 `WX_DEV_MODE=true` 时注册 `POST /api/xcx/dev-login`:
|
||||
|
||||
```python
|
||||
@router.post("/api/xcx/dev-login")
|
||||
async def dev_login(openid: str, status: str = "approved"):
|
||||
"""
|
||||
开发模式 mock 登录。
|
||||
直接根据 openid 查找/创建用户,跳过微信 code2Session。
|
||||
可通过 status 参数模拟不同用户状态。
|
||||
仅在 WX_DEV_MODE=true 时可用。
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
#### 微信开发者工具联调步骤
|
||||
|
||||
联调指南文档位于 `apps/miniprogram/doc/auth-integration-guide.md`,包含:
|
||||
1. 微信开发者工具项目导入配置(appid、不校验合法域名)
|
||||
2. 后端启动命令(`cd apps/backend && uvicorn app.main:app --reload`)
|
||||
3. 小程序请求域名配置(开发环境指向 `http://localhost:8000`)
|
||||
4. 测试流程:登录 → 申请 → 管理端审核 → 重新登录验证
|
||||
5. Mock 模式使用说明
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
使用 Python `hypothesis` 框架,测试目录:`tests/`(Monorepo 级属性测试目录)。
|
||||
|
||||
每个属性测试至少运行 100 次迭代。每个测试用注释标注对应的设计属性编号。
|
||||
|
||||
标注格式:`# Feature: miniapp-auth-system, Property N: <属性标题>`
|
||||
|
||||
**属性测试清单**:
|
||||
|
||||
| 属性 | 测试文件 | 测试方法 | 生成器 |
|
||||
|------|---------|---------|--------|
|
||||
| P2 登录创建/查找用户 | `tests/test_auth_system_properties.py` | 生成随机 openid,模拟登录,验证用户创建/查找逻辑 | `hypothesis.strategies.text` 生成 openid |
|
||||
| P4 申请创建正确性 | `tests/test_auth_system_properties.py` | 生成随机合法申请数据,验证申请记录创建 | 自定义 strategy 生成 site_code(2字母+3数字)、phone(11位数字) |
|
||||
| P5 手机号格式验证 | `tests/test_auth_system_properties.py` | 生成随机非法手机号,验证 422 拒绝 | `hypothesis.strategies.text` 过滤非 11 位数字 |
|
||||
| P6 重复申请拒绝 | `tests/test_auth_system_properties.py` | 生成随机用户+申请,提交两次,验证第二次 409 | 复用申请数据生成器 |
|
||||
| P7 人员匹配合并 | `tests/test_auth_system_properties.py` | 生成随机助教/员工数据,验证匹配结果合并 | 自定义 strategy 生成匹配数据 |
|
||||
| P8 审核操作正确性 | `tests/test_auth_system_properties.py` | 生成随机 pending 申请,执行批准/拒绝,验证状态流转 | 自定义 strategy 生成审核数据 |
|
||||
| P9 非 pending 审核拒绝 | `tests/test_auth_system_properties.py` | 生成随机非 pending 申请,验证 409 | `hypothesis.strategies.sampled_from(["approved", "rejected"])` |
|
||||
| P11 多店铺角色独立 | `tests/test_auth_system_properties.py` | 生成随机用户+多个 site_id,验证角色独立分配 | `hypothesis.strategies.lists` 生成 site_id 列表 |
|
||||
| P12 店铺切换令牌 | `tests/test_auth_system_properties.py` | 生成多店铺用户,切换店铺,验证 JWT 内容 | 复用多店铺生成器 |
|
||||
| P13 权限中间件拦截 | `tests/test_auth_system_properties.py` | 生成随机用户+权限组合,验证中间件判断 | 自定义 strategy 生成权限矩阵 |
|
||||
| P14 JWT payload 结构 | `tests/test_auth_system_properties.py` | 生成随机用户(不同 status),签发 JWT,验证 payload | `hypothesis.strategies.sampled_from(["pending", "approved"])` |
|
||||
| P15 JWT 过期/无效拒绝 | `tests/test_auth_system_properties.py` | 生成随机过期/篡改 JWT,验证 401 | 自定义 strategy 生成无效 JWT |
|
||||
|
||||
**注意**:P1(迁移幂等性)、P3(disabled 登录拒绝)、P10(状态查询完整性)作为集成测试在后端测试目录实现,因为它们需要真实数据库环境或涉及具体的数据库状态验证。
|
||||
|
||||
### 单元测试
|
||||
|
||||
单元测试位于 `apps/backend/tests/`,聚焦于:
|
||||
|
||||
- `test_xcx_auth_router.py`:微信登录路由测试(mock 微信 API)
|
||||
- `test_application_service.py`:申请服务的边界情况
|
||||
- `test_matching_service.py`:匹配逻辑的边界情况(空结果、FDW 异常)
|
||||
- `test_permission_middleware.py`:权限中间件的各种组合
|
||||
- `test_jwt_extended.py`:扩展 JWT 的 limited token 逻辑
|
||||
|
||||
### 集成测试
|
||||
|
||||
集成测试通过验证脚本实现,覆盖:
|
||||
|
||||
- 迁移脚本幂等性验证(执行两次无错误)
|
||||
- 种子数据完整性验证(权限和角色数量正确)
|
||||
- 完整认证流程:登录 → 申请 → 审核 → 权限验证
|
||||
@@ -1,196 +0,0 @@
|
||||
# 需求文档:小程序用户认证系统(miniapp-auth-system)
|
||||
|
||||
## 简介
|
||||
|
||||
本 SPEC 实现小程序用户认证系统,涵盖微信登录、用户申请审核、人员匹配、多店铺权限管理等完整认证链路。系统基于 P1(miniapp-db-foundation)已建立的 `auth` Schema 和 FDW 映射,在 `test_zqyy_app.auth` 中创建用户、申请、角色、绑定等业务表,并在 FastAPI 后端实现对应的 API 端点和权限中间件。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Auth_System**:小程序用户认证系统,负责微信登录、用户管理、申请审核、权限控制的完整后端服务
|
||||
- **WeChat_Auth_Service**:微信认证服务模块,负责调用微信 `code2Session` 接口换取 `openid` 和 `session_key`
|
||||
- **Application_Service**:用户申请服务模块,负责处理用户提交的入驻申请、状态流转和审核操作
|
||||
- **Matching_Service**:人员匹配服务模块,负责根据球房ID和手机号/编号在助教表和员工表中查找候选匹配
|
||||
- **Permission_Middleware**:权限中间件,基于用户的 `site_id` + `role` 拦截无权请求
|
||||
- **JWT_Service**:JWT 令牌服务,负责签发和刷新 access_token / refresh_token(已有实现,本 SPEC 扩展)
|
||||
- **site_code**:球房ID,格式为 2 字母 + 3 数字(如 `AB123`),与 `site_id` 一一映射
|
||||
- **site_id**:门店标识符,类型为 `BIGINT`,用于多门店数据隔离
|
||||
- **user_status**:用户状态枚举,取值为 `pending`(审核中)/ `approved`(已通过)/ `rejected`(已拒绝)/ `disabled`(已禁用)
|
||||
- **binding_type**:绑定类型枚举,取值为 `assistant`(助教)/ `staff`(员工)/ `manager`(管理员)
|
||||
- **FDW**:`postgres_fdw` 外部数据包装器,通过 `fdw_etl` Schema 读取 ETL 库数据
|
||||
- **Migration_Script**:存放在 `db/zqyy_app/migrations/` 中的纯 SQL 迁移脚本,以日期前缀命名
|
||||
- **BD_Manual**:数据库手册文档,存放在 `docs/database/` 中,记录表结构变更、兼容性影响、回滚策略和验证 SQL
|
||||
- **DDL_Baseline**:DDL 基线文件,存放在 `docs/database/ddl/` 中,由 `gen_consolidated_ddl.py` 自动生成
|
||||
- **Miniprogram_Auth_Pages**:小程序认证相关前端页面,包括登录页、申请表单页、审核等待页、无权限页
|
||||
- **Dev_Login**:开发模式下的 mock 登录端点,绕过微信 code2Session 调用,用于联调测试
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:认证数据表创建
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要在 `auth` Schema 中创建用户认证相关的数据表,以便支撑完整的认证和权限管理功能。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `users` 表,包含 `id`(SERIAL PK)、`wx_openid`(UNIQUE)、`wx_union_id`、`wx_avatar_url`、`nickname`、`phone`、`status`(默认 `pending`)、`created_at`、`updated_at` 字段
|
||||
2. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `user_applications` 表,包含 `id`(SERIAL PK)、`user_id`(FK → users)、`site_code`、`applied_role_text`、`employee_number`(可选)、`phone`、`status`(默认 `pending`)、`reviewer_id`、`review_note`、`created_at`、`reviewed_at` 字段
|
||||
3. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `site_code_mapping` 表,包含 `id`(SERIAL PK)、`site_code`(UNIQUE,格式 2 字母 + 3 数字)、`site_id`(BIGINT UNIQUE)、`site_name`、`tenant_id`、`created_at` 字段
|
||||
4. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `user_site_roles` 表,包含 `id`(SERIAL PK)、`user_id`(FK → users)、`site_id`(BIGINT)、`role_id`(FK → roles)、`created_at` 字段,并对 `(user_id, site_id, role_id)` 建立唯一约束
|
||||
5. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `user_assistant_binding` 表,包含 `id`(SERIAL PK)、`user_id`(FK → users)、`site_id`(BIGINT)、`assistant_id`(BIGINT,可选)、`staff_id`(BIGINT,可选)、`binding_type`、`created_at` 字段
|
||||
6. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `roles` 表,包含 `id`(SERIAL PK)、`code`(UNIQUE)、`name`、`description`、`created_at` 字段
|
||||
7. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `permissions` 表,包含 `id`(SERIAL PK)、`code`(UNIQUE)、`name`、`description`、`created_at` 字段
|
||||
8. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `role_permissions` 表,包含 `role_id`(FK → roles)、`permission_id`(FK → permissions)字段,并以 `(role_id, permission_id)` 为联合主键
|
||||
9. THE Migration_Script SHALL 使用 `IF NOT EXISTS` / `OR REPLACE` 等幂等语法,确保重复执行不会报错
|
||||
10. THE Migration_Script SHALL 在脚本中包含回滚语句(以注释形式)
|
||||
|
||||
### 需求 2:种子数据预置
|
||||
|
||||
**用户故事:** 作为系统管理员,我需要系统预置固定的权限列表和默认角色,以便审核时可直接分配。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 种子数据脚本执行完成, THE Auth_System SHALL 在 `auth.permissions` 表中插入 5 条固定权限记录:`view_tasks`、`view_board`、`view_board_finance`、`view_board_customer`、`view_board_coach`
|
||||
2. WHEN 种子数据脚本执行完成, THE Auth_System SHALL 在 `auth.roles` 表中插入默认角色(至少包含 `coach`(助教)、`staff`(员工)、`site_admin`(店铺管理员)、`tenant_admin`(租户管理员))
|
||||
3. WHEN 种子数据脚本执行完成, THE Auth_System SHALL 在 `auth.role_permissions` 表中为每个默认角色分配对应的权限组合
|
||||
4. THE 种子数据脚本 SHALL 使用 `ON CONFLICT DO NOTHING` 语法,确保重复执行不会产生重复数据
|
||||
|
||||
### 需求 3:微信登录
|
||||
|
||||
**用户故事:** 作为球房工作人员,我需要通过微信登录小程序,以便快速进入系统。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 小程序端发送微信临时登录凭证(`code`), THE WeChat_Auth_Service SHALL 调用微信 `code2Session` 接口换取 `openid` 和 `session_key`
|
||||
2. WHEN `code2Session` 返回有效 `openid` 且该 `openid` 已存在于 `auth.users` 表中, THE Auth_System SHALL 返回该用户的 JWT 令牌对(access_token + refresh_token)和用户状态信息
|
||||
3. WHEN `code2Session` 返回有效 `openid` 且该 `openid` 不存在于 `auth.users` 表中, THE Auth_System SHALL 创建新用户记录(status 为 `pending`),返回 JWT 令牌对和 `pending` 状态标识
|
||||
4. IF `code2Session` 接口调用失败或返回错误码, THEN THE WeChat_Auth_Service SHALL 返回 HTTP 401 错误,包含具体的错误描述
|
||||
5. WHEN 用户状态为 `disabled`, THE Auth_System SHALL 返回 HTTP 403 错误,拒绝登录
|
||||
|
||||
### 需求 4:用户申请提交
|
||||
|
||||
**用户故事:** 作为球房工作人员,我需要在首次登录后填写申请表单(球房ID、申请身份、手机号、编号、昵称),以便管理员审核我的身份。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户提交申请表单(包含 `site_code`、`applied_role_text`、`phone`,可选 `employee_number`), THE Application_Service SHALL 在 `auth.user_applications` 表中创建一条 status 为 `pending` 的申请记录
|
||||
2. WHEN 用户提交的 `site_code` 在 `auth.site_code_mapping` 中存在映射, THE Application_Service SHALL 将申请记录关联到对应的 `site_id`
|
||||
3. WHEN 用户提交的 `site_code` 在 `auth.site_code_mapping` 中不存在映射, THE Application_Service SHALL 仍然接受申请,申请记录中保留 `site_code` 文本,管理端显示"未找到关联信息"
|
||||
4. WHEN 用户提交申请时提供了 `nickname`, THE Auth_System SHALL 更新 `auth.users` 表中该用户的 `nickname` 字段
|
||||
5. IF 用户提交的 `phone` 为空或格式无效(非 11 位数字), THEN THE Application_Service SHALL 返回 HTTP 422 错误,包含具体的校验失败信息
|
||||
6. WHEN 用户已有一条 `pending` 状态的申请, THE Application_Service SHALL 拒绝重复提交,返回 HTTP 409 错误
|
||||
|
||||
### 需求 5:人员匹配
|
||||
|
||||
**用户故事:** 作为系统,我需要根据球房ID和手机号自动建议用户与助教/员工的对应关系,以便管理员快速审核。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 管理员查看某条申请详情时, THE Matching_Service SHALL 根据申请中的 `site_id` 和 `phone` 在 `fdw_etl.v_dim_assistant` 中按 `site_id` + `mobile` 匹配助教记录
|
||||
2. WHEN 管理员查看某条申请详情时, THE Matching_Service SHALL 根据申请中的 `site_id` 和 `phone` 在 `fdw_etl.v_dim_staff` 和 `fdw_etl.v_dim_staff_ex` 中按 `site_id` + `mobile` 匹配员工记录
|
||||
3. WHEN 申请中包含 `employee_number`, THE Matching_Service SHALL 额外按 `job_num` 字段匹配员工记录
|
||||
4. THE Matching_Service SHALL 将助教匹配结果和员工匹配结果合并为统一的候选列表返回,每条候选包含来源类型(`assistant` / `staff`)、姓名、手机号、编号
|
||||
5. WHEN 助教表和员工表均无匹配结果, THE Matching_Service SHALL 返回空候选列表,管理端显示"未找到关联信息"
|
||||
6. WHEN 申请的 `site_code` 无法映射到 `site_id`, THE Matching_Service SHALL 跳过匹配,返回空候选列表
|
||||
|
||||
### 需求 6:申请审核
|
||||
|
||||
**用户故事:** 作为租户管理员,我需要审核用户申请,将用户关联到对应的助教/员工,并分配身份权限。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 管理员批准申请并选择了候选匹配对象, THE Application_Service SHALL 将申请状态更新为 `approved`,在 `auth.user_assistant_binding` 中创建绑定记录,在 `auth.user_site_roles` 中分配角色
|
||||
2. WHEN 管理员批准申请但无候选匹配(手动审核), THE Application_Service SHALL 将申请状态更新为 `approved`,仅在 `auth.user_site_roles` 中分配角色,不创建绑定记录
|
||||
3. WHEN 管理员拒绝申请, THE Application_Service SHALL 将申请状态更新为 `rejected`,记录 `review_note`(拒绝原因)
|
||||
4. WHEN 申请审核通过后, THE Auth_System SHALL 将 `auth.users` 表中该用户的 `status` 更新为 `approved`
|
||||
5. WHEN 审核操作完成, THE Application_Service SHALL 记录 `reviewer_id` 和 `reviewed_at` 时间戳
|
||||
6. IF 审核目标申请的状态不是 `pending`, THEN THE Application_Service SHALL 返回 HTTP 409 错误,拒绝重复审核
|
||||
|
||||
### 需求 7:用户状态查询
|
||||
|
||||
**用户故事:** 作为用户,我需要看到自己的申请状态(审核中/通过/拒绝),以便了解审核进度。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户查询自身状态, THE Auth_System SHALL 返回用户的 `status`、所有申请记录列表(含每条申请的 `site_code`、`applied_role_text`、`status`、`review_note`)
|
||||
2. WHEN 用户状态为 `approved`, THE Auth_System SHALL 同时返回用户已关联的店铺列表和对应角色
|
||||
|
||||
### 需求 8:多店铺支持与店铺切换
|
||||
|
||||
**用户故事:** 作为用户,我可以同时属于多个店铺(连锁场景),切换店铺后数据正确隔离。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Auth_System SHALL 允许一个用户通过多次申请关联到多个不同的 `site_id`,每个 `site_id` 独立分配角色
|
||||
2. WHEN 用户切换当前店铺, THE JWT_Service SHALL 签发包含新 `site_id` 的 JWT 令牌对
|
||||
3. WHEN 用户携带某 `site_id` 的 JWT 访问 API, THE Permission_Middleware SHALL 仅允许访问该 `site_id` 下用户拥有权限的资源
|
||||
|
||||
### 需求 9:权限中间件
|
||||
|
||||
**用户故事:** 作为系统,我需要权限中间件正确拦截无权请求,确保数据安全。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户携带有效 JWT 访问受保护端点, THE Permission_Middleware SHALL 从 JWT 中提取 `user_id` 和 `site_id`,查询 `auth.user_site_roles` 和 `auth.role_permissions` 获取用户在该店铺下的权限列表
|
||||
2. WHEN 用户的权限列表不包含端点所需的权限 code, THE Permission_Middleware SHALL 返回 HTTP 403 错误
|
||||
3. WHEN 用户的 `status` 不是 `approved`, THE Permission_Middleware SHALL 返回 HTTP 403 错误,拒绝访问受保护端点
|
||||
4. WHEN JWT 令牌过期或无效, THE Permission_Middleware SHALL 返回 HTTP 401 错误
|
||||
|
||||
### 需求 10:JWT 令牌扩展
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要扩展现有 JWT 服务以支持微信登录场景和多店铺切换。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE JWT_Service SHALL 在 JWT payload 中包含 `user_id`(sub)、`site_id`、`roles`(角色 code 列表)、`type`(access/refresh)、`exp` 字段
|
||||
2. WHEN 用户通过微信登录且状态为 `approved`, THE JWT_Service SHALL 使用用户默认店铺(第一个关联的 site_id)签发令牌
|
||||
3. WHEN 用户通过微信登录且状态为 `pending`, THE JWT_Service SHALL 签发不含 `site_id` 和 `roles` 的受限令牌,仅允许访问申请提交和状态查询端点
|
||||
4. WHEN 用户请求切换店铺, THE JWT_Service SHALL 验证用户在目标 `site_id` 下有角色绑定后签发新令牌
|
||||
|
||||
### 需求 11:迁移脚本管理
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要所有数据库变更都有对应的迁移脚本,以便变更可追溯、可重放。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Migration_Script SHALL 将所有认证相关表的 DDL 存放在 `db/zqyy_app/migrations/` 目录中
|
||||
2. THE Migration_Script SHALL 使用日期前缀命名(格式:`YYYY-MM-DD__<描述>.sql`)
|
||||
3. THE Migration_Script SHALL 使用 UTF-8 编码,纯 SQL(非 ORM)
|
||||
4. THE Migration_Script SHALL 在每个脚本中包含回滚语句(以注释形式)
|
||||
5. THE Migration_Script SHALL 使用幂等语法(`IF NOT EXISTS`、`ON CONFLICT DO NOTHING`),确保重复执行不会报错
|
||||
|
||||
### 需求 12:DDL 测试库落库与文档同步
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要所有 DDL 变更在测试库(`test_zqyy_app`)中实际执行验证,并同步更新数据库手册和 DDL 基线,确保文档与实际 Schema 一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 迁移脚本编写完成, THE Auth_System SHALL 在 `test_zqyy_app` 测试库中执行迁移脚本,验证无错误
|
||||
2. WHEN 迁移脚本执行成功, THE Auth_System SHALL 创建或更新 `docs/database/BD_Manual_auth_tables.md` 数据库手册,包含变更说明、兼容性影响、回滚策略、验证 SQL(至少 3 条)
|
||||
3. WHEN 迁移脚本执行成功, THE Auth_System SHALL 运行 `python scripts/ops/gen_consolidated_ddl.py` 重新生成 DDL 基线文件 `docs/database/ddl/zqyy_app__auth.sql`
|
||||
4. WHEN 种子数据脚本执行成功, THE Auth_System SHALL 在数据库手册中记录种子数据内容(角色、权限、角色-权限映射)
|
||||
|
||||
### 需求 13:小程序认证前端页面
|
||||
|
||||
**用户故事:** 作为球房工作人员,我需要在小程序中看到登录页、申请表单页、审核状态页,以便完成从微信登录到正式使用的完整流程。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户首次打开小程序, THE Auth_System SHALL 展示登录页面,调用 `wx.login()` 获取 code 并发送到后端 `/api/xcx/login`
|
||||
2. WHEN 后端返回 `user_status=pending` 且用户无 pending 申请, THE Auth_System SHALL 跳转到申请表单页面,包含球房ID(`site_code`)、申请身份、手机号、编号(选填)、昵称输入框
|
||||
3. WHEN 用户提交申请表单, THE Auth_System SHALL 调用 `/api/xcx/apply` 提交申请,成功后跳转到审核等待页面
|
||||
4. WHEN 用户状态为 `pending` 且已有 pending 申请, THE Auth_System SHALL 展示审核等待页面,显示"审核中"状态和申请信息摘要
|
||||
5. WHEN 用户状态为 `rejected`, THE Auth_System SHALL 在审核等待页面显示拒绝原因,并提供"重新申请"按钮
|
||||
6. WHEN 用户状态为 `approved`, THE Auth_System SHALL 跳转到小程序主页(任务列表)
|
||||
7. WHEN 用户状态为 `disabled`, THE Auth_System SHALL 展示无权限页面,提示账号已被禁用
|
||||
8. THE Auth_System SHALL 在小程序 `app.ts` 的 `onLaunch` 中实现自动登录逻辑,根据用户状态路由到对应页面
|
||||
9. WHEN 用户拥有多个店铺, THE Auth_System SHALL 在主页提供店铺切换入口
|
||||
|
||||
### 需求 14:前后端联调验证
|
||||
|
||||
**用户故事:** 作为开发者,我需要在微信开发者工具中验证完整的认证流程(登录→申请→审核→进入主页),确保前后端接口对接正确。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Auth_System SHALL 提供联调验证脚本或文档,说明如何在微信开发者工具中测试完整认证流程
|
||||
2. THE Auth_System SHALL 在后端提供开发模式下的 mock 登录端点(`POST /api/xcx/dev-login`),接受任意 openid 直接返回 JWT,绕过微信 code2Session 调用
|
||||
3. WHEN 开发模式启用时, THE Auth_System SHALL 允许通过环境变量 `WX_DEV_MODE=true` 切换到 mock 模式
|
||||
4. THE Auth_System SHALL 在 `apps/miniprogram/doc/` 中提供联调指南文档,包含微信开发者工具配置、后端启动步骤、测试账号说明
|
||||
@@ -1,288 +0,0 @@
|
||||
# 实现计划:小程序用户认证系统(miniapp-auth-system)
|
||||
|
||||
## 概述
|
||||
|
||||
基于已批准的需求和设计文档,将小程序用户认证系统拆分为增量式编码任务。每个任务构建在前一个任务之上,最终完成完整的认证链路。后端使用 Python + FastAPI,数据库使用 PostgreSQL 纯 SQL,属性测试使用 hypothesis。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 创建认证数据表和种子数据
|
||||
- [x] 1.1 创建迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p3_create_auth_tables.sql`
|
||||
- 在 `auth` Schema 下创建 `users`、`user_applications`、`site_code_mapping`、`roles`、`permissions`、`role_permissions`、`user_site_roles`、`user_assistant_binding` 共 8 张表
|
||||
- 包含所有字段定义、约束、索引、外键
|
||||
- 使用 `IF NOT EXISTS` 幂等语法
|
||||
- 包含回滚语句(注释形式)
|
||||
- _Requirements: 1.1-1.10_
|
||||
|
||||
- [x] 1.2 创建种子数据脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p3_seed_roles_permissions.sql`
|
||||
- 插入 5 条固定权限:`view_tasks`、`view_board`、`view_board_finance`、`view_board_customer`、`view_board_coach`
|
||||
- 插入默认角色:`coach`、`staff`、`site_admin`、`tenant_admin`
|
||||
- 插入角色-权限映射
|
||||
- 使用 `ON CONFLICT DO NOTHING` 幂等语法
|
||||
- _Requirements: 2.1-2.4_
|
||||
|
||||
- [x] 1.3 在测试库执行迁移脚本并验证
|
||||
- 在 `test_zqyy_app` 中执行建表脚本和种子数据脚本
|
||||
- 验证幂等性:连续执行两次无错误
|
||||
- 验证表结构、约束、索引正确
|
||||
- 验证种子数据完整(5 权限、4 角色、角色-权限映射)
|
||||
- _Requirements: 12.1_
|
||||
|
||||
- [x] 1.4 更新数据库手册和 DDL 基线
|
||||
- 创建 `docs/database/BD_Manual_auth_tables.md`,包含变更说明、兼容性影响、回滚策略、验证 SQL(至少 3 条)
|
||||
- 运行 `python scripts/ops/gen_consolidated_ddl.py` 刷新 DDL 基线
|
||||
- 在数据库手册中记录种子数据内容
|
||||
- _Requirements: 12.2, 12.3, 12.4_
|
||||
|
||||
- [x] 1.5 编写迁移脚本幂等性属性测试
|
||||
- **Property 1: 迁移脚本幂等性**
|
||||
- **Validates: Requirements 1.9, 2.4, 11.5**
|
||||
|
||||
- [x] 2. 扩展 JWT 服务和认证依赖
|
||||
- [x] 2.1 扩展 `apps/backend/app/auth/jwt.py`
|
||||
- 新增 `create_limited_token_pair(user_id)` 函数(pending 用户受限令牌)
|
||||
- 扩展 `create_access_token` 支持 `roles` 参数
|
||||
- 保持向后兼容
|
||||
- _Requirements: 10.1, 10.2, 10.3_
|
||||
|
||||
- [x] 2.2 扩展 `apps/backend/app/auth/dependencies.py`
|
||||
- 扩展 `CurrentUser` 数据类,增加 `roles`、`status`、`limited` 字段
|
||||
- 新增 `get_current_user_or_limited` 依赖(允许 pending 用户)
|
||||
- _Requirements: 10.3, 9.1_
|
||||
|
||||
- [x] 2.3 编写 JWT payload 结构属性测试
|
||||
- **Property 14: JWT payload 结构与状态一致性**
|
||||
- **Validates: Requirements 10.1, 10.2, 10.3**
|
||||
|
||||
- [x] 2.4 编写 JWT 过期/无效拒绝属性测试
|
||||
- **Property 15: JWT 过期/无效令牌拒绝**
|
||||
- **Validates: Requirements 9.4**
|
||||
|
||||
- [x] 3. 检查点 - 确保所有测试通过
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 4. 实现微信认证服务
|
||||
- [x] 4.1 创建 `apps/backend/app/services/wechat.py`
|
||||
- 实现 `code2session(code)` 异步函数
|
||||
- 使用 `httpx.AsyncClient` 调用微信 API
|
||||
- 从环境变量读取 `WX_APPID` / `WX_SECRET`
|
||||
- 定义 `WeChatAuthError` 异常类
|
||||
- _Requirements: 3.1, 3.4_
|
||||
|
||||
- [x] 4.2 创建 Pydantic 模型 `apps/backend/app/schemas/xcx_auth.py`
|
||||
- 定义 `WxLoginRequest`、`WxLoginResponse`、`ApplicationRequest`、`ApplicationResponse`、`UserStatusResponse`、`SiteInfo`、`SwitchSiteRequest`、`MatchCandidate`、`ApproveRequest`、`RejectRequest`
|
||||
- `site_code` 使用正则校验 `^[A-Za-z]{2}\d{3}$`
|
||||
- `phone` 使用正则校验 `^\d{11}$`
|
||||
- _Requirements: 4.5_
|
||||
|
||||
- [x] 4.3 创建小程序认证路由 `apps/backend/app/routers/xcx_auth.py`
|
||||
- 实现 `POST /api/xcx/login`:微信登录(查找/创建用户 + 签发 JWT)
|
||||
- 实现 `POST /api/xcx/apply`:提交申请
|
||||
- 实现 `GET /api/xcx/me`:查询自身状态
|
||||
- 实现 `GET /api/xcx/me/sites`:查询关联店铺
|
||||
- 实现 `POST /api/xcx/switch-site`:切换店铺
|
||||
- 实现 `POST /api/xcx/refresh`:刷新令牌
|
||||
- 在 `apps/backend/app/main.py` 中注册路由
|
||||
- _Requirements: 3.2, 3.3, 3.5, 4.1-4.6, 7.1, 7.2, 8.2_
|
||||
|
||||
- [x] 4.4 编写登录创建/查找用户属性测试
|
||||
- **Property 2: 登录创建/查找用户正确性**
|
||||
- **Validates: Requirements 3.2, 3.3**
|
||||
|
||||
- [x] 4.5 编写申请创建正确性属性测试
|
||||
- **Property 4: 申请创建正确性**
|
||||
- **Validates: Requirements 4.1, 4.2, 4.3, 4.4**
|
||||
|
||||
- [x] 4.6 编写手机号格式验证属性测试
|
||||
- **Property 5: 手机号格式验证**
|
||||
- **Validates: Requirements 4.5**
|
||||
|
||||
- [x] 4.7 编写重复申请拒绝属性测试
|
||||
- **Property 6: 重复申请拒绝**
|
||||
- **Validates: Requirements 4.6**
|
||||
|
||||
- [x] 5. 实现申请服务和人员匹配
|
||||
- [x] 5.1 创建申请服务 `apps/backend/app/services/application.py`
|
||||
- 实现 `create_application()`:创建申请 + site_code 映射查找
|
||||
- 实现 `approve_application()`:批准 + 创建绑定/角色
|
||||
- 实现 `reject_application()`:拒绝 + 记录原因
|
||||
- 实现 `get_user_applications()`:查询用户申请列表
|
||||
- _Requirements: 4.1-4.4, 6.1-6.6_
|
||||
|
||||
- [x] 5.2 创建人员匹配服务 `apps/backend/app/services/matching.py`
|
||||
- 实现 `find_candidates(site_id, phone, employee_number)`
|
||||
- 通过 FDW 查询 `fdw_etl.v_dim_assistant` 和 `fdw_etl.v_dim_staff` / `v_dim_staff_ex`
|
||||
- 设置 `app.current_site_id` 进行 RLS 隔离
|
||||
- 合并助教和员工匹配结果
|
||||
- _Requirements: 5.1-5.6_
|
||||
|
||||
- [x] 5.3 创建角色权限服务 `apps/backend/app/services/role.py`
|
||||
- 实现 `get_user_permissions(user_id, site_id)`
|
||||
- 实现 `get_user_sites(user_id)`
|
||||
- 实现 `check_user_has_site_role(user_id, site_id)`
|
||||
- _Requirements: 8.1, 9.1_
|
||||
|
||||
- [x] 5.4 编写人员匹配合并属性测试
|
||||
- **Property 7: 人员匹配合并正确性**
|
||||
- **Validates: Requirements 5.1, 5.2, 5.3, 5.4**
|
||||
|
||||
- [x] 5.5 编写审核操作正确性属性测试
|
||||
- **Property 8: 审核操作正确性**
|
||||
- **Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5**
|
||||
|
||||
- [x] 5.6 编写非 pending 审核拒绝属性测试
|
||||
- **Property 9: 非 pending 申请审核拒绝**
|
||||
- **Validates: Requirements 6.6**
|
||||
|
||||
- [x] 6. 检查点 - 确保所有测试通过
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 7. 实现权限中间件和管理端路由
|
||||
- [x] 7.1 创建权限中间件 `apps/backend/app/middleware/permission.py`
|
||||
- 实现 `require_permission(*permission_codes)` 依赖
|
||||
- 实现 `require_approved()` 依赖
|
||||
- 检查用户 status + 权限列表
|
||||
- _Requirements: 9.1-9.4_
|
||||
|
||||
- [x] 7.2 创建管理端审核路由 `apps/backend/app/routers/admin_applications.py`
|
||||
- 实现 `GET /api/admin/applications`:查询申请列表
|
||||
- 实现 `GET /api/admin/applications/{id}`:查询申请详情 + 候选匹配
|
||||
- 实现 `POST /api/admin/applications/{id}/approve`:批准申请
|
||||
- 实现 `POST /api/admin/applications/{id}/reject`:拒绝申请
|
||||
- 在 `apps/backend/app/main.py` 中注册路由
|
||||
- _Requirements: 6.1-6.6, 5.1-5.6_
|
||||
|
||||
- [x] 7.3 编写权限中间件拦截属性测试
|
||||
- **Property 13: 权限中间件拦截正确性**
|
||||
- **Validates: Requirements 8.3, 9.1, 9.2, 9.3**
|
||||
|
||||
- [x] 7.4 编写多店铺角色独立分配属性测试
|
||||
- **Property 11: 多店铺角色独立分配**
|
||||
- **Validates: Requirements 8.1**
|
||||
|
||||
- [x] 7.5 编写店铺切换令牌属性测试
|
||||
- **Property 12: 店铺切换令牌正确性**
|
||||
- **Validates: Requirements 8.2, 10.4**
|
||||
|
||||
- [x] 8. 集成与端到端验证
|
||||
- [x] 8.1 更新 `apps/backend/app/config.py` 新增微信配置项
|
||||
- 新增 `WX_APPID`、`WX_SECRET`、`WX_DEV_MODE` 配置读取
|
||||
- _Requirements: 3.1, 14.3_
|
||||
|
||||
- [x] 8.2 更新 `apps/backend/app/main.py` 注册所有新路由
|
||||
- 确保 `xcx_auth` 和 `admin_applications` 路由已注册
|
||||
- 验证无路由冲突
|
||||
- _Requirements: 全部_
|
||||
|
||||
- [x] 8.3 实现开发模式 mock 登录端点
|
||||
- 在 `routers/xcx_auth.py` 中新增 `POST /api/xcx/dev-login`
|
||||
- 仅在 `WX_DEV_MODE=true` 时注册
|
||||
- 接受 `openid` 和可选 `status` 参数,直接查找/创建用户并返回 JWT
|
||||
- _Requirements: 14.2, 14.3_
|
||||
|
||||
- [x] 8.4 编写用户状态查询完整性属性测试
|
||||
- **Property 10: 用户状态查询完整性**
|
||||
- **Validates: Requirements 7.1, 7.2**
|
||||
|
||||
- [x] 8.5 编写 disabled 用户登录拒绝属性测试
|
||||
- **Property 3: disabled 用户登录拒绝**
|
||||
- **Validates: Requirements 3.5**
|
||||
|
||||
- [x] 9. 小程序认证前端页面
|
||||
- [x] 9.1 实现请求封装工具 `apps/miniprogram/miniprogram/utils/request.ts`
|
||||
- 统一请求封装:自动附加 Authorization header
|
||||
- 401 时自动尝试 refresh_token 刷新
|
||||
- 刷新失败时跳转 login 页面
|
||||
- 后端 base URL 从配置读取(开发环境 `http://localhost:8000`)
|
||||
- _Requirements: 13.8_
|
||||
|
||||
- [x] 9.2 实现登录页 `apps/miniprogram/miniprogram/pages/login/`
|
||||
- 调用 `wx.login()` 获取 code
|
||||
- 发送 code 到 `POST /api/xcx/login`
|
||||
- 根据返回的 `user_status` 路由到对应页面
|
||||
- 存储 token 到 globalData 和 Storage
|
||||
- 参考 H5 原型 `docs/h5_ui/pages/login.html`
|
||||
- _Requirements: 13.1, 13.6, 13.7, 13.8_
|
||||
|
||||
- [x] 9.3 实现申请表单页 `apps/miniprogram/miniprogram/pages/apply/`
|
||||
- 表单字段:球房ID(site_code)、申请身份、手机号、编号(选填)、昵称
|
||||
- 前端校验:site_code 格式(2字母+3数字)、手机号(11位数字)
|
||||
- 提交到 `POST /api/xcx/apply`
|
||||
- 成功后跳转 reviewing 页面
|
||||
- 参考 H5 原型 `docs/h5_ui/pages/apply.html`
|
||||
- _Requirements: 13.2, 13.3_
|
||||
|
||||
- [x] 9.4 实现审核等待页 `apps/miniprogram/miniprogram/pages/reviewing/`
|
||||
- 显示当前申请状态(审核中/已拒绝)
|
||||
- 显示申请信息摘要(球房ID、申请身份、手机号)
|
||||
- 拒绝时显示拒绝原因 + "重新申请"按钮
|
||||
- 支持下拉刷新查询最新状态
|
||||
- 参考 H5 原型 `docs/h5_ui/pages/reviewing.html`
|
||||
- _Requirements: 13.4, 13.5_
|
||||
|
||||
- [x] 9.5 实现无权限页 `apps/miniprogram/miniprogram/pages/no-permission/`
|
||||
- 显示账号已禁用提示
|
||||
- 参考 H5 原型 `docs/h5_ui/pages/no-permission.html`
|
||||
- _Requirements: 13.7_
|
||||
|
||||
- [x] 9.6 更新 `app.ts` 和 `app.json`
|
||||
- 在 `app.json` 中注册新页面(login、apply、reviewing、no-permission)
|
||||
- 在 `app.ts` 的 `onLaunch` 中实现自动登录逻辑
|
||||
- 根据用户状态路由到对应页面
|
||||
- 扩展 globalData 类型定义(token、userInfo、currentSiteId、sites)
|
||||
- _Requirements: 13.8_
|
||||
|
||||
- [x] 10. 前后端联调验证
|
||||
- [x] 10.1 编写联调指南文档 `apps/miniprogram/doc/auth-integration-guide.md`
|
||||
- 微信开发者工具项目导入配置说明
|
||||
- 后端启动步骤(含 `WX_DEV_MODE=true` 配置)
|
||||
- 测试流程:mock 登录 → 申请 → 管理端审核 → 重新登录验证
|
||||
- 常见问题排查
|
||||
- _Requirements: 14.1, 14.4_
|
||||
|
||||
- [x] 10.2 在微信开发者工具中执行联调验证
|
||||
- 验证登录流程:wx.login → 后端 → JWT 返回
|
||||
- 验证申请流程:表单提交 → 后端创建申请 → 审核等待页展示
|
||||
- 验证状态路由:pending/approved/rejected/disabled 各状态正确跳转
|
||||
- 验证 token 刷新:access_token 过期后自动刷新
|
||||
- _Requirements: 14.1_
|
||||
|
||||
- [x] 11. 属性测试全量运行(100 次迭代)— ✅ 15/15 全部通过
|
||||
- 前面各任务中的属性测试仅用 5 次迭代快速验证逻辑正确性
|
||||
- 本任务集中对所有属性测试执行 100 次迭代,确保健壮性
|
||||
- 运行脚本:`scripts/ops/_run_auth_pbt_full.py`
|
||||
- 结果报告:`export/reports/auth_pbt_full_20260227_034401.md`
|
||||
- 总耗时 375s,15 个属性测试全部通过(100 次迭代/每个)
|
||||
- [x] 11.1 P1 迁移脚本幂等性 — ✅ 25.0s
|
||||
- [x] 11.2 P2 登录创建/查找用户 — ✅ 49.8s
|
||||
- [x] 11.3 P3 disabled 用户登录拒绝 — ✅ 37.9s
|
||||
- [x] 11.4 P4 申请创建正确性 — ✅ 21.3s
|
||||
- [x] 11.5 P5 手机号格式验证 — ✅ 2.5s
|
||||
- [x] 11.6 P6 重复申请拒绝 — ✅ 24.7s
|
||||
- [x] 11.7 P7 人员匹配合并正确性 — ✅ 14.9s
|
||||
- [x] 11.8 P8 审核操作正确性 — ✅ 22.7s
|
||||
- [x] 11.9 P9 非 pending 审核拒绝 — ✅ 18.8s
|
||||
- [x] 11.10 P10 用户状态查询完整性 — ✅ 28.6s
|
||||
- [x] 11.11 P11 多店铺角色独立分配 — ✅ 46.6s
|
||||
- [x] 11.12 P12 店铺切换令牌正确性 — ✅ 45.3s
|
||||
- [x] 11.13 P13 权限中间件拦截正确性 — ✅ 11.9s
|
||||
- [x] 11.14 P14 JWT payload 结构一致性 — ✅ 4.6s
|
||||
- [x] 11.15 P15 JWT 过期/无效拒绝 — ✅ 3.2s
|
||||
|
||||
- [x] 12. 最终检查点
|
||||
- 任务 1-12 全部完成
|
||||
- 15 个属性测试在 100 次迭代下全部通过(报告见 `export/reports/auth_pbt_full_20260227_034401.md`)
|
||||
- 小程序 4 个认证页面(login/apply/reviewing/no-permission)已创建
|
||||
- app.ts / app.json 已更新为认证感知版本
|
||||
- 联调指南文档已编写
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的任务为可选,可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号,确保可追溯
|
||||
- 检查点确保增量验证
|
||||
- **属性测试策略**:开发阶段各任务中属性测试用 5 次迭代快速验证;任务 11 集中用 100 次迭代全量运行,逐个报告进度
|
||||
- 单元测试验证具体例子和边界情况
|
||||
- 所有数据库操作在测试库 `test_zqyy_app` 进行
|
||||
- 迁移脚本放在 `db/zqyy_app/migrations/` 目录
|
||||
- 属性测试放在 `tests/` 目录(Monorepo 级)
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "27029642-a405-4932-8c22-5bc54fad5173", "workflowType": "requirements-first", "specType": "feature"}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,278 +0,0 @@
|
||||
# 需求文档:小程序核心业务模块(miniapp-core-business)
|
||||
|
||||
## 简介
|
||||
|
||||
本 SPEC 实现小程序的核心业务逻辑,涵盖助教任务系统(生成、分配、状态流转、完成检测)、备注系统(CRUD、星星评分、类型区分)、以及后台触发器/轮询调度框架。系统基于 P1(miniapp-db-foundation)的数据库基础设施、P2(etl-dws-miniapp-extensions)的 DWS 指数数据、P3(miniapp-auth-system)的用户认证体系,在 `test_zqyy_app.biz` Schema 中创建任务、备注、触发器等业务表,并在 FastAPI 后端实现对应的 API 端点和后台调度逻辑。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Task_Generator**:任务生成器,每日 4:00 后运行,基于 WBI/NCI/RS 指数为每个助教分配 4 种类型任务的后台服务
|
||||
- **Task_Manager**:任务管理服务,负责任务 CRUD、置顶、放弃、状态流转的后端模块
|
||||
- **Task_Expiry_Checker**:任务有效期轮询器,每小时检查 `expires_at` 并将过期任务标记为无效
|
||||
- **Recall_Completion_Detector**:召回完成检测器,ETL 数据更新后检查助教是否为匹配客户提供了服务
|
||||
- **Note_Reclassifier**:备注回溯重分类器,召回完成时回溯检查是否有普通备注需重分类为回访备注
|
||||
- **Note_Service**:备注服务模块,负责备注 CRUD、星星评分存储与读取
|
||||
- **Trigger_Scheduler**:触发器调度框架,支持 cron/interval/event 三种触发方式的统一调度引擎
|
||||
- **coach_tasks**:助教任务表,位于 `biz` Schema,存储任务分配、状态、有效期等信息
|
||||
- **coach_task_history**:任务变更历史表,记录任务关闭/新建的追溯链
|
||||
- **notes**:统一备注表,位于 `biz` Schema,通过 `type` 字段区分普通备注/回访备注/放弃原因
|
||||
- **trigger_jobs**:触发器配置表,位于 `biz` Schema,存储轮询/事件触发器的配置与执行状态
|
||||
- **task_type**:任务类型枚举,取值为 `high_priority_recall`(高优先召回)/ `priority_recall`(优先召回)/ `follow_up_visit`(客户回访)/ `relationship_building`(关系构建)
|
||||
- **task_status**:任务状态枚举,取值为 `active`(有效)/ `inactive`(无效)/ `completed`(已完成)/ `abandoned`(已放弃)
|
||||
- **note_type**:备注类型枚举,取值为 `normal`(普通备注)/ `follow_up`(回访备注)/ `abandon_reason`(放弃原因)
|
||||
- **priority_score**:优先级分数,取 `max(WBI, NCI)` 的快照值,用于任务排序
|
||||
- **expires_at**:有效期时间戳,默认 NULL(无限期),填充后表示任务将在该时间点过期
|
||||
- **FDW**:`postgres_fdw` 外部数据包装器,通过 `fdw_etl` Schema 读取 ETL 库指数数据
|
||||
- **Migration_Script**:存放在 `db/zqyy_app/migrations/` 中的纯 SQL 迁移脚本,以日期前缀命名
|
||||
- **site_id**:门店标识符,类型为 `BIGINT`,用于多门店数据隔离
|
||||
- **member_retention_clue**:维客线索表,位于 `public` Schema,存储助教为客户记录的维护线索(大类 + 摘要 + 详情),独立于 ETL 数据。当前已有基础表结构和 CRUD API(`/api/retention-clue`),若不足以支撑本 SPEC 的任务系统需求,可对其 DDL、Pydantic 模型及路由进行扩展或修改
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:业务数据表创建
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要在 `biz` Schema 中创建任务、备注、触发器等业务表,以便支撑核心业务功能。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Migration_Script 执行完成, THE Task_Manager SHALL 在 `biz` Schema 中创建 `coach_tasks` 表,包含 `id`(BIGSERIAL PK)、`site_id`(BIGINT NOT NULL)、`assistant_id`(BIGINT NOT NULL)、`member_id`(BIGINT NOT NULL)、`task_type`(VARCHAR NOT NULL)、`status`(VARCHAR NOT NULL DEFAULT 'active')、`priority_score`(NUMERIC(5,2))、`expires_at`(TIMESTAMPTZ,可空)、`is_pinned`(BOOLEAN DEFAULT FALSE)、`abandon_reason`(TEXT,可空)、`completed_at`(TIMESTAMPTZ,可空)、`completed_task_type`(VARCHAR,可空)、`parent_task_id`(BIGINT,可空,FK → coach_tasks)、`created_at`(TIMESTAMPTZ DEFAULT NOW())、`updated_at`(TIMESTAMPTZ DEFAULT NOW())字段
|
||||
2. WHEN Migration_Script 执行完成, THE Task_Manager SHALL 在 `biz` Schema 中创建 `coach_task_history` 表,包含 `id`(BIGSERIAL PK)、`task_id`(BIGINT FK → coach_tasks)、`action`(VARCHAR NOT NULL)、`old_status`(VARCHAR)、`new_status`(VARCHAR)、`old_task_type`(VARCHAR)、`new_task_type`(VARCHAR)、`detail`(JSONB)、`created_at`(TIMESTAMPTZ DEFAULT NOW())字段
|
||||
3. WHEN Migration_Script 执行完成, THE Note_Service SHALL 在 `biz` Schema 中创建 `notes` 表,包含 `id`(BIGSERIAL PK)、`site_id`(BIGINT NOT NULL)、`user_id`(INTEGER NOT NULL)、`target_type`(VARCHAR NOT NULL)、`target_id`(BIGINT NOT NULL)、`type`(VARCHAR NOT NULL DEFAULT 'normal')、`content`(TEXT NOT NULL)、`rating_service_willingness`(SMALLINT,可空,CHECK 1-5)、`rating_revisit_likelihood`(SMALLINT,可空,CHECK 1-5)、`task_id`(BIGINT,可空,FK → coach_tasks)、`ai_score`(SMALLINT,可空)、`ai_analysis`(TEXT,可空)、`created_at`(TIMESTAMPTZ DEFAULT NOW())、`updated_at`(TIMESTAMPTZ DEFAULT NOW())字段
|
||||
4. WHEN Migration_Script 执行完成, THE Trigger_Scheduler SHALL 在 `biz` Schema 中创建 `trigger_jobs` 表,包含 `id`(SERIAL PK)、`job_type`(VARCHAR NOT NULL)、`job_name`(VARCHAR NOT NULL UNIQUE)、`trigger_condition`(VARCHAR NOT NULL)、`trigger_config`(JSONB NOT NULL)、`last_run_at`(TIMESTAMPTZ,可空)、`next_run_at`(TIMESTAMPTZ,可空)、`status`(VARCHAR NOT NULL DEFAULT 'enabled')、`created_at`(TIMESTAMPTZ DEFAULT NOW())字段
|
||||
5. THE Migration_Script SHALL 对 `coach_tasks` 表创建唯一索引 `idx_coach_tasks_site_assistant_member_type` 在 `(site_id, assistant_id, member_id, task_type)` 上,仅对 `status = 'active'` 的记录生效(部分唯一索引)
|
||||
6. THE Migration_Script SHALL 对 `coach_tasks` 表创建索引 `idx_coach_tasks_assistant_status` 在 `(site_id, assistant_id, status)` 上,用于助教任务列表查询
|
||||
7. THE Migration_Script SHALL 对 `notes` 表创建索引 `idx_notes_target` 在 `(site_id, target_type, target_id)` 上,用于按目标查询备注
|
||||
8. THE Migration_Script SHALL 使用 `IF NOT EXISTS` 幂等语法,确保重复执行不会报错
|
||||
9. THE Migration_Script SHALL 在脚本中包含回滚语句(以注释形式)
|
||||
|
||||
### 需求 2:触发器种子数据预置
|
||||
|
||||
**用户故事:** 作为系统管理员,我需要系统预置核心触发器配置,以便后台调度任务自动运行。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `task_generator` 记录(trigger_condition='cron',trigger_config 包含 cron 表达式 '0 4 * * *')
|
||||
2. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `task_expiry_check` 记录(trigger_condition='interval',trigger_config 包含间隔秒数 3600)
|
||||
3. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `recall_completion_check` 记录(trigger_condition='event',trigger_config 包含事件名 'etl_data_updated')
|
||||
4. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `note_reclassify_backfill` 记录(trigger_condition='event',trigger_config 包含事件名 'recall_completed')
|
||||
5. THE 种子数据脚本 SHALL 使用 `ON CONFLICT (job_name) DO NOTHING` 语法,确保重复执行不会产生重复数据
|
||||
|
||||
### 需求 3:任务生成器
|
||||
|
||||
**用户故事:** 作为助教,我每天打开小程序能看到系统为我分配的任务列表,按优先级排序。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Task_Generator 运行时, THE Task_Generator SHALL 通过 FDW 读取 `fdw_etl` 中的 `dws_member_winback_index`(WBI)和 `dws_member_newconv_index`(NCI)指数数据,计算 `priority_score = max(WBI, NCI)`
|
||||
2. WHEN `priority_score > 7`, THE Task_Generator SHALL 为该客户-助教对生成 `high_priority_recall`(高优先召回)类型任务
|
||||
3. WHEN `priority_score > 5` 且 `priority_score <= 7`, THE Task_Generator SHALL 为该客户-助教对生成 `priority_recall`(优先召回)类型任务
|
||||
4. WHEN 助教完成某客户的召回任务后该客户无回访备注, THE Task_Generator SHALL 为该客户-助教对生成 `follow_up_visit`(客户回访)类型任务
|
||||
5. WHEN 客户-助教对的 RS 指数 < 6(通过 FDW 读取 `dws_member_assistant_relation_index`), THE Task_Generator SHALL 为该客户-助教对生成 `relationship_building`(关系构建)类型任务
|
||||
6. WHEN Task_Generator 生成任务时发现已存在相同 `(site_id, assistant_id, member_id, task_type)` 且 `status = 'active'` 的任务, THE Task_Generator SHALL 跳过该任务不做任何操作
|
||||
7. WHEN Task_Generator 生成任务时发现已存在相同 `(site_id, assistant_id, member_id)` 但 `task_type` 不同且 `status = 'active'` 的任务, THE Task_Generator SHALL 将旧任务状态设为 `inactive`,创建新任务,并在 `coach_task_history` 中记录变更
|
||||
8. THE Task_Generator SHALL 按优先级从高到低的顺序处理任务类型:`high_priority_recall`(0)> `priority_recall`(0)> `follow_up_visit`(1)> `relationship_building`(2),高优先级任务覆盖低优先级任务
|
||||
9. THE Task_Generator SHALL 通过 `auth.user_assistant_binding` 确定助教与小程序用户的映射关系,仅为已绑定的助教生成任务
|
||||
10. THE Task_Generator SHALL 在 `trigger_jobs` 中更新 `last_run_at` 和 `next_run_at` 时间戳
|
||||
|
||||
### 需求 4:48 小时回访滞留机制
|
||||
|
||||
**用户故事:** 作为系统,回访任务至少保留 48 小时,到期后自动失效。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Task_Generator 生成 `follow_up_visit` 类型任务时, THE Task_Generator SHALL 将 `expires_at` 设为 NULL(无限期有效),`status` 设为 `active`
|
||||
2. WHEN Task_Generator 检测到某 `follow_up_visit` 任务的触发条件不再满足(指数变化), THE Task_Generator SHALL 将该任务的 `expires_at` 填充为 `created_at + 48 小时`,`status` 保持 `active`
|
||||
3. WHEN Task_Expiry_Checker 轮询检查时发现某任务的 `expires_at` 不为 NULL 且当前时间超过 `expires_at`, THE Task_Expiry_Checker SHALL 将该任务 `status` 设为 `inactive`
|
||||
4. WHEN 新的 `follow_up_visit` 任务生成时发现同一 `(site_id, assistant_id, member_id)` 已存在一个有 `expires_at` 的 `follow_up_visit` 任务, THE Task_Generator SHALL 将旧任务标记为 `inactive`,创建新的 `active` 任务(`expires_at` 为 NULL)
|
||||
5. THE Task_Expiry_Checker SHALL 每小时运行一次,由 `trigger_jobs` 中的 `task_expiry_check` 配置驱动
|
||||
|
||||
### 需求 5:任务类型变更与状态流转
|
||||
|
||||
**用户故事:** 作为系统,当客户指数变化导致任务类型变更时,系统正确关闭旧任务并创建新任务。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 任务类型从 `priority_recall` 变更为 `high_priority_recall`, THE Task_Generator SHALL 将旧 `priority_recall` 任务标记为 `inactive`(`expires_at` 保持 NULL),创建新的 `high_priority_recall` 任务
|
||||
2. WHEN 任务类型从 `follow_up_visit` 变更为 `high_priority_recall` 或 `priority_recall`, THE Task_Generator SHALL 将旧 `follow_up_visit` 任务标记为 `active` 并填充 `expires_at = created_at + 48 小时`,创建新的召回任务
|
||||
3. WHEN 任务类型从召回类型变回 `follow_up_visit`, THE Task_Generator SHALL 检查是否存在有 `expires_at` 的旧 `follow_up_visit` 任务,若存在则将旧任务标记为 `inactive`,创建新的 `follow_up_visit` 任务
|
||||
4. THE Task_Manager SHALL 在每次状态变更时在 `coach_task_history` 中记录 `action`、`old_status`、`new_status`、`old_task_type`、`new_task_type`
|
||||
|
||||
### 需求 6:召回完成检测
|
||||
|
||||
**用户故事:** 作为助教,我完成召回任务后(客户到店被服务),系统自动标记任务完成。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL 数据更新后, THE Recall_Completion_Detector SHALL 通过 FDW 读取 `fdw_etl.dwd_assistant_service_log` 中的新增服务记录
|
||||
2. WHEN 发现某助教为某客户提供了服务, THE Recall_Completion_Detector SHALL 查找该 `(site_id, assistant_id, member_id)` 下所有 `status = 'active'` 的任务
|
||||
3. WHEN 匹配到活跃任务, THE Recall_Completion_Detector SHALL 将任务 `status` 设为 `completed`,记录 `completed_at` 为服务时间,记录 `completed_task_type` 为完成时的任务类型
|
||||
4. WHEN 召回完成后, THE Recall_Completion_Detector SHALL 触发 `note_reclassify_backfill` 事件,通知 Note_Reclassifier 执行备注回溯
|
||||
5. THE Recall_Completion_Detector SHALL 由 `trigger_jobs` 中的 `recall_completion_check` 配置驱动,在 ETL 数据更新事件后触发
|
||||
|
||||
### 需求 7:备注回溯重分类
|
||||
|
||||
**用户故事:** 作为系统,当 ETL 数据延迟导致召回完成晚于备注提交时,需要回溯重分类备注。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 召回完成事件触发后, THE Note_Reclassifier SHALL 查找该 `(site_id, assistant_id, member_id)` 在召回服务结束时间之后提交的第一条 `type = 'normal'` 的备注
|
||||
2. WHEN 找到符合条件的普通备注, THE Note_Reclassifier SHALL 将该备注的 `type` 从 `normal` 更新为 `follow_up`
|
||||
3. WHEN 备注重分类完成后, THE Note_Reclassifier SHALL 触发 AI 应用 6 对该备注进行含金量评分(评分逻辑由 P5 AI 集成层实现,本 SPEC 仅定义触发接口)
|
||||
4. WHEN AI 应用 6 返回评分 >= 6, THE Note_Reclassifier SHALL 生成一条 `follow_up_visit` 任务并标记为 `completed`(回溯完成)
|
||||
5. WHEN AI 应用 6 返回评分 < 6, THE Note_Reclassifier SHALL 生成一条 `follow_up_visit` 任务,`status` 为 `active`(回访未完成,需助教重新备注)
|
||||
|
||||
### 需求 8:任务 CRUD API
|
||||
|
||||
**用户故事:** 作为助教,我可以查看任务列表、置顶/放弃任务、取消置顶/取消放弃。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 助教请求任务列表, THE Task_Manager SHALL 返回该助教在当前 `site_id` 下所有 `status = 'active'` 的任务,按 `is_pinned DESC, priority_score DESC, created_at ASC` 排序
|
||||
2. WHEN 助教请求任务列表, THE Task_Manager SHALL 在每条任务中包含客户基本信息(通过 FDW 读取 `dim_member`)、RS 指数(通过 FDW 读取 `dws_member_assistant_relation_index`)、爱心 icon 档位(💖>8.5 / 🧡>7 / 💛>5 / 💙<5)
|
||||
3. WHEN 助教置顶某任务, THE Task_Manager SHALL 将该任务的 `is_pinned` 设为 TRUE,并在 `coach_task_history` 中记录
|
||||
4. WHEN 助教放弃某任务, THE Task_Manager SHALL 将该任务 `status` 设为 `abandoned`,记录 `abandon_reason`(必填),并在 `coach_task_history` 中记录
|
||||
5. WHEN 助教取消置顶某任务, THE Task_Manager SHALL 将该任务的 `is_pinned` 设为 FALSE
|
||||
6. WHEN 助教取消放弃某任务, THE Task_Manager SHALL 将该任务 `status` 恢复为 `active`,清空 `abandon_reason`
|
||||
7. IF 助教放弃任务时未提供 `abandon_reason`, THEN THE Task_Manager SHALL 返回 HTTP 422 错误
|
||||
8. THE Task_Manager SHALL 通过 Permission_Middleware 验证用户身份,仅允许操作自己的任务
|
||||
|
||||
### 需求 9:备注 CRUD API
|
||||
|
||||
**用户故事:** 作为助教,我给客户添加备注后,系统正确存储备注内容和星星评分。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 助教创建备注时, THE Note_Service SHALL 在 `biz.notes` 表中创建记录,包含 `site_id`、`user_id`、`target_type`('member')、`target_id`(member_id)、`type`、`content`、可选的 `rating_service_willingness`(1-5)、可选的 `rating_revisit_likelihood`(1-5)、可选的 `task_id`
|
||||
2. WHEN 备注关联的任务类型为 `follow_up_visit`, THE Note_Service SHALL 将备注 `type` 自动设为 `follow_up`
|
||||
3. WHEN 备注关联的任务类型不是 `follow_up_visit`, THE Note_Service SHALL 将备注 `type` 设为 `normal`
|
||||
4. WHEN 备注创建成功且 `type = 'follow_up'`, THE Note_Service SHALL 触发 AI 应用 6 备注分析接口(由 P5 实现),传入备注内容和客户信息
|
||||
5. WHEN AI 应用 6 返回评分 >= 6 且备注关联的 `follow_up_visit` 任务 `status = 'active'`, THE Note_Service SHALL 将该任务标记为 `completed`
|
||||
6. WHEN 助教查询某客户的备注列表, THE Note_Service SHALL 返回该客户在当前 `site_id` 下的所有备注,按 `created_at DESC` 排序,包含星星评分和 AI 评分
|
||||
7. WHEN 助教删除备注, THE Note_Service SHALL 执行软删除或硬删除(根据业务需要),删除前需二次确认(前端实现)
|
||||
8. IF 星星评分值不在 1-5 范围内, THEN THE Note_Service SHALL 返回 HTTP 422 错误
|
||||
9. THE Note_Service 的星星评分 SHALL 不参与回访完成判定(完成判定仅看 AI 应用 6 评分 >= 6),不参与 AI 应用 6 分析,仅作辅助数据存储
|
||||
|
||||
### 需求 10:触发器调度框架
|
||||
|
||||
**用户故事:** 作为系统,我需要一个统一的触发器调度框架,支持定时、间隔、事件驱动三种触发方式。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Trigger_Scheduler SHALL 支持 `cron` 类型触发器,按 cron 表达式计算下次运行时间
|
||||
2. THE Trigger_Scheduler SHALL 支持 `interval` 类型触发器,按固定间隔秒数计算下次运行时间
|
||||
3. THE Trigger_Scheduler SHALL 支持 `event` 类型触发器,在指定事件发生时立即执行
|
||||
4. WHEN 触发器执行完成, THE Trigger_Scheduler SHALL 更新 `trigger_jobs` 表中的 `last_run_at` 和 `next_run_at`
|
||||
5. WHEN 触发器 `status = 'disabled'`, THE Trigger_Scheduler SHALL 跳过该触发器不执行
|
||||
6. THE Trigger_Scheduler SHALL 提供 `fire_event(event_name, payload)` 方法,用于触发事件驱动型任务
|
||||
7. IF 触发器执行过程中发生错误, THEN THE Trigger_Scheduler SHALL 记录错误日志但不中断其他触发器的执行
|
||||
|
||||
### 需求 11:迁移脚本管理
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要所有数据库变更都有对应的迁移脚本,以便变更可追溯、可重放。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Migration_Script SHALL 将所有业务表的 DDL 存放在 `db/zqyy_app/migrations/` 目录中
|
||||
2. THE Migration_Script SHALL 使用日期前缀命名(格式:`YYYY-MM-DD__<描述>.sql`)
|
||||
3. THE Migration_Script SHALL 使用 UTF-8 编码,纯 SQL(非 ORM)
|
||||
4. THE Migration_Script SHALL 在每个脚本中包含回滚语句(以注释形式)
|
||||
5. THE Migration_Script SHALL 使用幂等语法(`IF NOT EXISTS`、`ON CONFLICT DO NOTHING`),确保重复执行不会报错
|
||||
|
||||
### 需求 12:DDL 测试库落库与文档同步
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要所有 DDL 变更在测试库中实际执行验证,并同步更新数据库手册和 DDL 基线。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 迁移脚本编写完成, THE Task_Manager SHALL 在 `test_zqyy_app` 测试库中执行迁移脚本,验证无错误
|
||||
2. WHEN 迁移脚本执行成功, THE Task_Manager SHALL 创建或更新 `docs/database/BD_Manual_biz_tables.md` 数据库手册,包含变更说明、兼容性影响、回滚策略、验证 SQL(至少 3 条)
|
||||
3. WHEN 迁移脚本执行成功, THE Task_Manager SHALL 运行 `python scripts/ops/gen_consolidated_ddl.py` 重新生成 DDL 基线文件
|
||||
4. WHEN 种子数据脚本执行成功, THE Task_Manager SHALL 在数据库手册中记录种子数据内容(触发器配置)
|
||||
|
||||
### 需求 13:小程序前端页面原型还原(强制)
|
||||
|
||||
**用户故事:** 作为产品经理,我需要小程序前端页面严格忠于 `docs/h5_ui/pages/` 中的 H5 原型图结构和视觉细节,确保最终实现与设计稿高度一致。
|
||||
|
||||
#### 原型图索引
|
||||
|
||||
| 原型文件 | 对应小程序页面 | 说明 |
|
||||
|---------|--------------|------|
|
||||
| `docs/h5_ui/pages/task-list.html` | `pages/task-list/task-list` | 任务列表页(首页),含业绩进度卡片、置顶/一般/已放弃三区域 |
|
||||
| `docs/h5_ui/pages/task-detail.html` | `pages/task-detail/task-detail` | 任务详情页 - 高优先召回(theme-red Banner) |
|
||||
| `docs/h5_ui/pages/task-detail-priority.html` | `pages/task-detail/task-detail` | 任务详情页 - 优先召回(theme-orange Banner) |
|
||||
| `docs/h5_ui/pages/task-detail-relationship.html` | `pages/task-detail/task-detail` | 任务详情页 - 关系构建(theme-pink Banner) |
|
||||
| `docs/h5_ui/pages/task-detail-callback.html` | `pages/task-detail/task-detail` | 任务详情页 - 客户回访(theme-teal Banner) |
|
||||
| `docs/h5_ui/pages/notes.html` | `pages/notes/notes` | 备注记录页 |
|
||||
| `docs/h5_ui/pages/customer-detail.html` | `pages/customer-detail/customer-detail` | 客户详情页 |
|
||||
|
||||
#### 验收标准
|
||||
|
||||
##### 13.A 结构还原(强制)
|
||||
|
||||
1. WHEN 实现任务列表页时, THE 小程序页面 SHALL 严格还原原型图中的以下结构层次:顶部用户信息区(头像 + 姓名 + 角色标签 + 门店名)→ 业绩进度卡片(5 段档位进度条 + 课时数据含红戳 + 奖金激励 + 预计收入)→ 任务列表区(📌 置顶区 / 一般任务区 / 已放弃区三个分区,每个分区有标签 + 计数)
|
||||
2. WHEN 实现任务卡片时, THE 每张任务卡片 SHALL 包含原型图中的全部元素:左侧 4px 彩色边框(高优先=红、优先=橙、关系构建=粉、客户回访=青)、任务类型标签(渐变色圆角矩形)、客户姓名、爱心 icon(💖/🧡/💛/💙)、备注指示器(📝)、描述行(最近到店 + 余额)、AI 建议行(含 AI 机器人 icon)、右侧箭头
|
||||
3. WHEN 实现任务详情页时, THE 页面 SHALL 严格还原原型图中的以下模块顺序:通栏 Banner(导航栏 + 客户信息 + 放弃按钮)→ 维客线索卡片(客户基础/消费习惯/玩法偏好/重要反馈,每条含大类标签 + 摘要 + 详情 + 来源标注)→ 与我的关系卡片(爱心档位标签 + 进度条 + RS 分数 + 描述 + 近期服务记录列表)→ 任务建议卡片(建议执行 + 话术参考含复制按钮)→ 我给 TA 的备注卡片(备注列表含星星评分 + 删除按钮)→ 底部操作栏(问问助手 + 备注两个按钮)
|
||||
4. WHEN 实现备注弹窗时, THE 弹窗 SHALL 包含原型图中的全部元素:标题行(添加备注 + 展开评价按钮)、可折叠的星星评分区(再次服务意愿 1-5 星 + 再来店可能性 1-5 星,各含文字提示)、文本输入区、保存按钮
|
||||
5. WHEN 实现长按上下文菜单时, THE 菜单 SHALL 还原原型图中的交互:遮罩层 + 圆角菜单面板(置顶/取消置顶、备注、放弃/取消放弃等选项)
|
||||
6. WHEN 实现备注记录页时, THE 页面 SHALL 还原原型图中的列表结构:每条备注含内容文本 + 底部标签(助教/客户类型标签 + 时间戳)
|
||||
|
||||
##### 13.B 视觉还原(强制)
|
||||
|
||||
1. THE 小程序页面 SHALL 使用与原型图一致的 TDesign 色彩体系:primary=#0052d9、success=#00a870、warning=#ed7b2f、error=#e34d59,灰阶色板 gray-1(#f3f3f3) 至 gray-13(#242424)
|
||||
2. THE 任务详情页 Banner SHALL 根据任务类型使用不同主题色:高优先召回=theme-red、优先召回=theme-orange、关系构建=theme-pink、客户回访=theme-teal,与原型图中的渐变背景一致
|
||||
3. THE 维客线索大类标签 SHALL 使用原型图中的配色方案:客户基础=primary/10 底色 + primary 文字、消费习惯=success/10 底色 + success 文字、玩法偏好=purple-500/10 底色 + purple-600 文字、重要反馈=error/10 底色 + error 文字
|
||||
4. THE 星星评分组件 SHALL 还原原型图中的视觉效果:填充星/空心星 SVG、支持半星显示(用于展示 AI 评分映射)
|
||||
5. THE 业绩进度卡片 SHALL 还原原型图中的 5 段档位进度条(按比例宽度:0-100 占 45.45%、100-130/130-160/160-190/190-220 各占 13.64%)、红戳动画(盖戳效果)、奖金金额突出样式
|
||||
|
||||
##### 13.C WXML/WXSS 技术规范(强制)
|
||||
|
||||
1. THE 小程序页面 SHALL 使用 WXML 语法而非 HTML 语法:`<view>` 替代 `<div>`、`<text>` 替代 `<span>`/`<p>`、`<image>` 替代 `<img>`、`<navigator>` 替代 `<a>`,禁止使用 HTML 标签
|
||||
2. THE 小程序样式 SHALL 使用 WXSS 语法:使用 `rpx` 单位替代 `px`(750rpx = 屏幕宽度)、使用 `@import` 导入公共样式、禁止使用 `rem`/`em`/`vw`/`vh` 等 CSS 单位
|
||||
3. THE 小程序页面 SHALL 使用 `wx:for` 替代 JavaScript 循环渲染、`wx:if`/`wx:elif`/`wx:else` 替代条件渲染、`bind:tap` 替代 `onclick`、`data-*` + `e.currentTarget.dataset` 替代 DOM 操作
|
||||
4. THE 小程序页面 SHALL 禁止使用以下 Web 特性:`document.*`、`window.*`、`localStorage`(用 `wx.setStorageSync`)、`fetch`/`XMLHttpRequest`(用 `wx.request`)、CSS `position: fixed` 的 `bottom: 0` 底部栏(用小程序安全区域适配)
|
||||
5. THE 小程序样式 SHALL 仅使用小程序支持的 CSS 选择器:`.class`、`#id`、`element`、`element, element`、`::after`、`::before`,禁止使用 `>`(子选择器)、`+`(相邻兄弟)、`~`(通用兄弟)、`[attr]`(属性选择器)等不支持的选择器
|
||||
6. THE 小程序页面 SHALL 使用 `<block>` 标签作为无渲染包裹容器(替代 HTML 的 `<template>` 或 React 的 `<Fragment>`),`<block>` 不会生成真实 DOM 节点
|
||||
|
||||
##### 13.D TDesign 组件使用规范(强制)
|
||||
|
||||
1. THE 小程序页面 SHALL 优先使用 TDesign 组件库中的组件,组件引入路径格式为 `tdesign-miniprogram/{组件名}/{组件名}`,在页面 `.json` 的 `usingComponents` 中注册
|
||||
2. THE 以下 UI 元素 SHALL 使用对应的 TDesign 组件:导航栏→`t-navbar`、底部标签栏→`t-tab-bar`、对话框→`t-dialog`、轻提示→`t-toast`、弹出层→`t-popup`、空状态→`t-empty`、加载→`t-loading`、骨架屏→`t-skeleton`、标签→`t-tag`、搜索框→`t-search`
|
||||
3. THE TDesign 组件样式覆盖 SHALL 使用以下 4 种方式之一:`style`/`custom-style` 属性、解除样式隔离(`addGlobalClass`)、外部样式类(`t-class`)、CSS 变量(`--td-*`),禁止直接修改 `node_modules` 中的组件源码
|
||||
4. THE 小程序 `app.json` SHALL 移除 `"style": "v2"` 配置项,避免 TDesign 组件样式错乱
|
||||
5. WHEN 原型图中的 UI 元素无法用 TDesign 组件直接实现时(如自定义进度条、红戳动画、话术气泡等), THE 开发者 SHALL 使用原生 WXML + WXSS 自定义实现,但视觉效果必须与原型图一致
|
||||
|
||||
##### 13.E 原型图参考流程(强制)
|
||||
|
||||
1. WHEN 开始实现任何小程序页面前, THE 开发者 SHALL 首先阅读对应的 `docs/h5_ui/pages/*.html` 原型文件,提取页面结构、组件层次、样式细节、交互行为
|
||||
2. WHEN 原型图中使用 Tailwind CSS 类名时, THE 开发者 SHALL 将其转换为等效的 WXSS 样式(如 `px-4` → `padding: 0 32rpx`、`rounded-xl` → `border-radius: 24rpx`、`text-sm` → `font-size: 28rpx`)
|
||||
3. WHEN 原型图中使用 `<iframe>` 嵌套页面时, THE 开发者 SHALL 理解这是原型展示方式,实际小程序中使用 `wx.navigateTo` 页面跳转
|
||||
4. WHEN 原型图中使用 `onclick`/`history.back()` 等 Web API 时, THE 开发者 SHALL 转换为小程序等效 API:`bind:tap` + `wx.navigateBack()`
|
||||
5. THE 开发者 SHALL 在实现前加载 `wechat-miniprogram` Power 的相关 steering 文件(`view-layer.md`、`tdesign.md`、`builtin-components.md`),确保使用正确的小程序语法和 TDesign 组件规范
|
||||
6. THE 开发者 SHALL 在实现前阅读项目内的 H5 转小程序避坑指南 `apps/miniprogram/doc/h5-to-miniprogram-pitfalls.md`,该文档基于本项目已转换页面的实际踩坑经验整理,涵盖标签映射、rpx 换算、事件系统、TDesign 覆盖方式、高频踩坑清单及新页面开发 Checklist,所有条目具有强制参考效力
|
||||
|
||||
### 需求 14:任务系统属性测试
|
||||
|
||||
**用户故事:** 作为后端开发者,我需要通过属性测试验证任务系统核心逻辑的正确性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 属性测试 SHALL 验证:对于任意 `(site_id, assistant_id, member_id, task_type)` 组合,`status = 'active'` 的任务最多只有一条(唯一性不变量)
|
||||
2. THE 属性测试 SHALL 验证:对于任意任务类型变更操作,旧任务被标记为 `inactive` 且新任务被创建为 `active`(状态机正确性)
|
||||
3. THE 属性测试 SHALL 验证:对于任意 `follow_up_visit` 任务,当 `expires_at` 不为 NULL 且当前时间超过 `expires_at` 时,轮询后 `status` 变为 `inactive`(有效期机制)
|
||||
4. THE 属性测试 SHALL 验证:对于任意任务放弃操作,`abandon_reason` 不为空字符串(放弃原因必填)
|
||||
5. THE 属性测试 SHALL 验证:对于任意备注创建操作,`rating_service_willingness` 和 `rating_revisit_likelihood` 的值在 NULL 或 1-5 范围内(评分范围约束)
|
||||
6. THE 属性测试 SHALL 验证:对于任意召回完成事件,`completed_task_type` 记录了完成时的任务类型快照(完成类型快照不变量)
|
||||
7. THE 属性测试 SHALL 验证:对于任意备注回溯操作,重分类后的备注 `type` 从 `normal` 变为 `follow_up`(回溯分类正确性)
|
||||
|
||||
---
|
||||
|
||||
## 附录:原型还原强制规则摘要
|
||||
|
||||
> 以下规则适用于本 SPEC 及所有后续小程序页面开发 SPEC,具有全局约束力。
|
||||
|
||||
1. **原型图是唯一视觉真相**:`docs/h5_ui/pages/*.html` 中的结构、层次、元素、配色、间距、交互行为是小程序页面实现的唯一参考标准。任何偏离原型图的实现都需要明确的产品确认。
|
||||
2. **WXML ≠ HTML**:严禁在小程序中使用 HTML 标签(div/span/p/a/img 等),必须使用小程序原生标签(view/text/image/navigator 等)。
|
||||
3. **WXSS ≠ CSS**:使用 rpx 单位、仅支持有限选择器、无 DOM/BOM API、样式隔离机制不同。Tailwind CSS 类名必须手动转换为 WXSS。
|
||||
4. **TDesign 优先**:凡 TDesign 组件库能覆盖的 UI 元素,必须使用 TDesign 组件;自定义实现仅限 TDesign 无法覆盖的场景。
|
||||
5. **Power 文档优先**:实现前必须加载 `wechat-miniprogram` Power 的相关 steering 文件,确保语法和组件用法正确。
|
||||
6. **项目踩坑指南必读**:实现前必须阅读 `apps/miniprogram/doc/h5-to-miniprogram-pitfalls.md`,该文档是基于本项目实际转换经验的避坑手册,涵盖 WXML/WXSS 差异、事件系统、TDesign 用法、rpx 换算规则及新页面开发 Checklist。
|
||||
@@ -1,239 +0,0 @@
|
||||
# 实现计划:小程序核心业务模块(miniapp-core-business)
|
||||
|
||||
## 概述
|
||||
|
||||
基于已批准的需求和设计文档,将小程序核心业务模块拆分为增量式编码任务。按照"DDL 建表 → 触发器调度框架 → 任务生成器 → 任务管理 → 有效期轮询 → 召回检测 → 备注系统 → 路由集成"的顺序实现。后端使用 Python + FastAPI,数据库使用 PostgreSQL 纯 SQL,属性测试使用 hypothesis。所有数据库操作在测试库 `test_zqyy_app` 中进行。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 创建业务数据表和种子数据
|
||||
- [x] 1.1 创建迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p4_create_biz_tables.sql`
|
||||
- 在 `biz` Schema 下创建 `coach_tasks`、`coach_task_history`、`notes`、`trigger_jobs` 共 4 张表
|
||||
- 包含所有字段定义、约束、CHECK 约束(评分 1-5)、外键(coach_task_history → coach_tasks、notes → coach_tasks、coach_tasks → coach_tasks 自引用)
|
||||
- 创建部分唯一索引 `idx_coach_tasks_site_assistant_member_type`(仅 status='active')
|
||||
- 创建查询索引 `idx_coach_tasks_assistant_status`、`idx_notes_target`
|
||||
- 使用 `IF NOT EXISTS` 幂等语法
|
||||
- 包含回滚语句(注释形式)
|
||||
- _Requirements: 1.1-1.9_
|
||||
|
||||
- [x] 1.2 创建种子数据脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p4_seed_trigger_jobs.sql`
|
||||
- 插入 4 条触发器配置:`task_generator`(cron, 0 4 * * *)、`task_expiry_check`(interval, 3600s)、`recall_completion_check`(event, etl_data_updated)、`note_reclassify_backfill`(event, recall_completed)
|
||||
- 使用 `ON CONFLICT (job_name) DO NOTHING` 幂等语法
|
||||
- _Requirements: 2.1-2.5_
|
||||
|
||||
- [x] 1.3 在测试库执行迁移脚本并验证
|
||||
- 在 `test_zqyy_app` 中执行建表脚本和种子数据脚本
|
||||
- 验证幂等性:连续执行两次无错误
|
||||
- 验证表结构、约束、索引正确
|
||||
- 验证种子数据完整(4 条触发器配置)
|
||||
- _Requirements: 11.1-11.5, 12.1_
|
||||
|
||||
- [x] 1.4 更新数据库手册和 DDL 基线
|
||||
- 创建 `docs/database/BD_Manual_biz_tables.md`,包含变更说明、兼容性影响、回滚策略、验证 SQL(至少 3 条)
|
||||
- 运行 `python scripts/ops/gen_consolidated_ddl.py` 刷新 DDL 基线
|
||||
- 在数据库手册中记录种子数据内容(触发器配置)
|
||||
- _Requirements: 12.2, 12.3, 12.4_
|
||||
|
||||
- [x] 1.5 编写迁移脚本幂等性属性测试
|
||||
- **Property 13: 迁移脚本幂等性**
|
||||
- 对 DDL 脚本和种子数据脚本连续执行两次,验证第二次执行无错误且数据库状态不变
|
||||
- **Validates: Requirements 1.8, 2.5, 11.4, 11.5**
|
||||
|
||||
- [x] 2. 检查点 - 确保 DDL 和种子数据正确
|
||||
- 确保迁移脚本在测试库中执行成功,幂等性验证通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 3. 实现 Pydantic 模型和纯函数核心逻辑
|
||||
- [x] 3.1 创建 Pydantic 模型 `apps/backend/app/schemas/xcx_tasks.py`
|
||||
- 定义 `TaskListItem`(含 member_name、member_phone、rs_score、heart_icon)
|
||||
- 定义 `AbandonRequest`(reason 必填,min_length=1)
|
||||
- _Requirements: 8.1, 8.2, 8.4, 8.7_
|
||||
|
||||
- [x] 3.2 创建 Pydantic 模型 `apps/backend/app/schemas/xcx_notes.py`
|
||||
- 定义 `NoteCreateRequest`(含 target_type、target_id、content、task_id、rating_service_willingness、rating_revisit_likelihood,评分 ge=1 le=5)
|
||||
- 定义 `NoteOut`(含 type、content、评分、ai_score、ai_analysis)
|
||||
- _Requirements: 9.1, 9.8_
|
||||
|
||||
- [x] 3.3 创建任务生成器核心纯函数 `apps/backend/app/services/task_generator.py`
|
||||
- 定义 `TaskPriority` 枚举、`TASK_TYPE_PRIORITY` 映射
|
||||
- 定义 `IndexData` 数据类
|
||||
- 实现 `determine_task_type(index_data)` 纯函数:根据 WBI/NCI/RS 指数确定任务类型
|
||||
- 实现 `should_replace_task(existing_type, new_type)` 纯函数:判断是否应替换现有任务
|
||||
- 实现 `compute_heart_icon(rs_score)` 纯函数:根据 RS 指数计算爱心 icon 档位
|
||||
- _Requirements: 3.1-3.5, 3.8, 8.2_
|
||||
|
||||
- [x] 3.4 编写任务类型确定正确性属性测试
|
||||
- **Property 1: 任务类型确定正确性**
|
||||
- 生成随机 WBI/NCI/RS 值(Decimal, 0-10, 2 位小数),验证 `determine_task_type()` 返回值符合优先级规则
|
||||
- **Validates: Requirements 3.1, 3.2, 3.3, 3.5**
|
||||
|
||||
- [x] 3.5 编写星星评分范围约束属性测试
|
||||
- **Property 9: 星星评分范围约束**
|
||||
- 生成随机整数(-100 到 100),验证 Pydantic 模型对 1-5 范围外的值拒绝(ValidationError)
|
||||
- **Validates: Requirements 9.8, 14.5**
|
||||
|
||||
- [x] 3.6 编写爱心 icon 档位计算属性测试
|
||||
- **Property 11: 爱心 icon 档位计算**
|
||||
- 生成随机 RS 值(Decimal, 0-10, 1 位小数),验证 `compute_heart_icon()` 返回正确 icon
|
||||
- **Validates: Requirements 8.2**
|
||||
|
||||
- [x] 4. 实现触发器调度框架
|
||||
- [x] 4.1 创建 `apps/backend/app/services/trigger_scheduler.py`
|
||||
- 实现 `_JOB_REGISTRY` 注册表和 `register_job(job_type, handler)` 函数
|
||||
- 实现 `fire_event(event_name, payload)` 方法:查找 event 类型触发器并执行
|
||||
- 实现 `check_scheduled_jobs()` 方法:检查 cron/interval 到期 job 并执行
|
||||
- 实现 `_calculate_next_run(trigger_condition, trigger_config)` 方法:计算下次运行时间
|
||||
- 每个 job 独立事务,失败不影响其他触发器
|
||||
- _Requirements: 10.1-10.7_
|
||||
|
||||
- [x] 4.2 编写触发器 next_run_at 计算属性测试
|
||||
- **Property 12: 触发器 next_run_at 计算**
|
||||
- 生成随机 cron/interval 配置和当前时间,验证 cron 类型 next_run_at > 当前时间,interval 类型 next_run_at = 当前时间 + interval_seconds
|
||||
- **Validates: Requirements 10.1, 10.2**
|
||||
|
||||
- [x] 5. 实现任务生成器完整流程
|
||||
- [x] 5.1 实现 `TaskGenerator.run()` 主流程
|
||||
- 通过 `auth.user_assistant_binding` 获取所有已绑定助教
|
||||
- 对每个助教,通过 FDW 读取 WBI/NCI/RS 指数(`SET LOCAL app.current_site_id`)
|
||||
- 调用 `determine_task_type()` 确定任务类型
|
||||
- 检查已存在的 active 任务:相同 task_type → 跳过;不同 task_type → 关闭旧任务 + 创建新任务 + 记录 history
|
||||
- 处理 `follow_up_visit` 的 48 小时滞留机制(expires_at 填充)
|
||||
- 更新 `trigger_jobs` 时间戳
|
||||
- _Requirements: 3.1-3.10, 4.1-4.5, 5.1-5.4_
|
||||
|
||||
- [x] 5.2 编写活跃任务唯一性不变量属性测试
|
||||
- **Property 2: 活跃任务唯一性不变量**
|
||||
- 生成随机 (site_id, assistant_id, member_id, task_type) 组合,模拟插入操作,验证 active 任务最多一条
|
||||
- **Validates: Requirements 1.5, 3.6, 14.1**
|
||||
|
||||
- [x] 5.3 编写任务类型变更状态机属性测试
|
||||
- **Property 3: 任务类型变更状态机**
|
||||
- 生成随机现有任务 + 新任务类型,执行变更,验证旧任务 inactive + 新任务 active + history 记录
|
||||
- **Validates: Requirements 3.7, 5.1, 5.4, 14.2**
|
||||
|
||||
- [x] 5.4 编写 48 小时滞留机制属性测试
|
||||
- **Property 4: 48 小时滞留机制**
|
||||
- 生成随机 follow_up_visit 任务 + 时间偏移,验证 expires_at 填充和过期逻辑
|
||||
- **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 14.3**
|
||||
|
||||
- [x] 6. 检查点 - 确保任务生成器测试通过
|
||||
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_core_business_properties.py -v -k "property_1 or property_2 or property_3 or property_4"`
|
||||
- 确保所有属性测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 7. 实现任务管理服务
|
||||
- [x] 7.1 创建 `apps/backend/app/services/task_manager.py`
|
||||
- 实现 `get_task_list(user_id, site_id)` 异步方法:查询活跃任务 + FDW 读取客户信息和 RS 指数 + 爱心 icon 计算 + 排序
|
||||
- 实现 `pin_task(task_id, user_id, site_id)` 异步方法:验证归属 + 设置 is_pinned=TRUE + 记录 history
|
||||
- 实现 `unpin_task(task_id, user_id, site_id)` 异步方法:验证归属 + 设置 is_pinned=FALSE
|
||||
- 实现 `abandon_task(task_id, user_id, site_id, reason)` 异步方法:验证 reason 非空 + 设置 abandoned + 记录 history
|
||||
- 实现 `cancel_abandon(task_id, user_id, site_id)` 异步方法:恢复 active + 清空 abandon_reason + 记录 history
|
||||
- 实现 `_record_history()` 内部方法
|
||||
- _Requirements: 8.1-8.8_
|
||||
|
||||
- [x] 7.2 编写放弃与取消放弃往返属性测试
|
||||
- **Property 5: 放弃与取消放弃往返**
|
||||
- 生成随机 active 任务 + 非空放弃原因,执行放弃→取消放弃,验证状态恢复;空原因应返回 422
|
||||
- **Validates: Requirements 8.4, 8.6, 8.7, 14.4**
|
||||
|
||||
- [x] 7.3 编写任务列表排序正确性属性测试
|
||||
- **Property 10: 任务列表排序正确性**
|
||||
- 生成随机任务列表(不同 is_pinned/priority_score/created_at),验证排序为 is_pinned DESC, priority_score DESC, created_at ASC
|
||||
- **Validates: Requirements 8.1**
|
||||
|
||||
- [x] 7.4 编写状态变更历史完整性属性测试
|
||||
- **Property 15: 状态变更历史完整性**
|
||||
- 生成随机状态变更操作序列(置顶/放弃/取消放弃),验证 history 记录数量和内容正确
|
||||
- **Validates: Requirements 5.4, 8.3**
|
||||
|
||||
- [x] 8. 实现有效期轮询器
|
||||
- [x] 8.1 创建 `apps/backend/app/services/task_expiry.py`
|
||||
- 实现 `run()` 方法:查询 expires_at 不为 NULL 且已过期的 active 任务,标记为 inactive,记录 history
|
||||
- _Requirements: 4.3, 4.5_
|
||||
|
||||
- [x] 9. 实现召回完成检测器
|
||||
- [x] 9.1 创建 `apps/backend/app/services/recall_detector.py`
|
||||
- 实现 `run(payload)` 方法:通过 FDW 读取新增服务记录,匹配 active 任务标记 completed,记录 completed_at 和 completed_task_type 快照,触发 `recall_completed` 事件
|
||||
- _Requirements: 6.1-6.5_
|
||||
|
||||
- [x] 9.2 编写召回完成检测与类型快照属性测试
|
||||
- **Property 6: 召回完成检测与类型快照**
|
||||
- 生成随机 active 任务 + 服务记录,执行完成检测,验证 completed_task_type 记录了完成时的 task_type 快照
|
||||
- **Validates: Requirements 6.2, 6.3, 14.6**
|
||||
|
||||
- [x] 10. 检查点 - 确保任务管理和召回检测测试通过
|
||||
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_core_business_properties.py -v -k "property_5 or property_6 or property_10 or property_15"`
|
||||
- 确保所有属性测试通过,如有问题请向用户确认。
|
||||
|
||||
- [-] 11. 实现备注系统
|
||||
- [x] 11.1 创建备注服务 `apps/backend/app/services/note_service.py`
|
||||
- 实现 `create_note()` 异步方法:验证评分范围 + 确定 note type(关联 follow_up_visit 任务 → follow_up,否则 normal)+ INSERT + 触发 AI 应用 6 接口(占位)+ 若 ai_score >= 6 标记任务 completed
|
||||
- 实现 `get_notes()` 异步方法:按 created_at DESC 排序,包含评分和 AI 评分
|
||||
- 实现 `delete_note()` 异步方法:验证归属后硬删除
|
||||
- _Requirements: 9.1-9.9_
|
||||
|
||||
- [x] 11.2 创建备注回溯重分类器 `apps/backend/app/services/note_reclassifier.py`
|
||||
- 实现 `run(payload)` 方法:查找 service_time 之后的第一条 normal 备注 → 更新为 follow_up → 触发 AI 应用 6 接口(占位)→ 根据 ai_score 生成 follow_up_visit 任务
|
||||
- 实现 `ai_analyze_note(note_id)` 占位函数(返回 None,P5 实现后替换)
|
||||
- _Requirements: 7.1-7.5_
|
||||
|
||||
- [x] 11.3 编写备注回溯重分类属性测试
|
||||
- **Property 7: 备注回溯重分类**
|
||||
- 生成随机备注列表 + service_time,执行回溯,验证符合条件的 normal 备注 type 变为 follow_up
|
||||
- **Validates: Requirements 7.1, 7.2, 14.7**
|
||||
|
||||
- [x] 11.4 编写备注类型自动设置属性测试
|
||||
- **Property 8: 备注类型自动设置**
|
||||
- 生成随机 task_type + 备注创建,验证关联 follow_up_visit → type=follow_up,其他 → type=normal
|
||||
- **Validates: Requirements 9.2, 9.3**
|
||||
|
||||
- [x] 11.5 编写 AI 评分驱动的任务完成判定属性测试
|
||||
- **Property 14: AI 评分驱动的任务完成判定**
|
||||
- 生成随机 ai_score + 任务状态,验证 ai_score >= 6 且 active → completed,ai_score < 6 → 保持 active
|
||||
- **Validates: Requirements 7.4, 7.5, 9.5**
|
||||
|
||||
- [x] 12. 实现 API 路由层
|
||||
- [x] 12.1 创建小程序任务路由 `apps/backend/app/routers/xcx_tasks.py`
|
||||
- 实现 `GET /api/xcx/tasks`:获取任务列表(require_approved)
|
||||
- 实现 `POST /api/xcx/tasks/{id}/pin`:置顶任务
|
||||
- 实现 `POST /api/xcx/tasks/{id}/unpin`:取消置顶
|
||||
- 实现 `POST /api/xcx/tasks/{id}/abandon`:放弃任务(AbandonRequest 校验)
|
||||
- 实现 `POST /api/xcx/tasks/{id}/cancel-abandon`:取消放弃
|
||||
- _Requirements: 8.1-8.8_
|
||||
|
||||
- [x] 12.2 创建小程序备注路由 `apps/backend/app/routers/xcx_notes.py`
|
||||
- 实现 `POST /api/xcx/notes`:创建备注(NoteCreateRequest 校验)
|
||||
- 实现 `GET /api/xcx/notes`:查询备注列表(query: target_type, target_id)
|
||||
- 实现 `DELETE /api/xcx/notes/{id}`:删除备注
|
||||
- _Requirements: 9.1-9.9_
|
||||
|
||||
- [x] 12.3 在 `apps/backend/app/main.py` 中注册新路由
|
||||
- 注册 `xcx_tasks` 和 `xcx_notes` 路由
|
||||
- 验证无路由冲突
|
||||
- _Requirements: 全部_
|
||||
|
||||
- [x] 12.4 注册触发器 job handler
|
||||
- 在应用启动时调用 `register_job()` 注册 `task_generator`、`task_expiry_check`、`recall_completion_check`、`note_reclassify_backfill` 四个 handler
|
||||
- _Requirements: 10.1-10.6_
|
||||
|
||||
- [x] 13. 检查点 - 确保所有测试通过
|
||||
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_core_business_properties.py -v`
|
||||
- 26/26 全部通过(16.81s)
|
||||
|
||||
- [x] 14. 最终检查点 - 全量验证
|
||||
- 运行全部属性测试:26/26 通过(16.81s)
|
||||
- 验证迁移脚本幂等性:Property 13(3 个测试)通过
|
||||
- 验证种子数据完整性:4 条触发器配置全部存在
|
||||
- 验证表结构:coach_tasks / coach_task_history / notes / trigger_jobs 全部存在
|
||||
- 验证部分唯一索引:idx_coach_tasks_site_assistant_member_type 存在
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选(属性测试),可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号,确保可追溯
|
||||
- 检查点确保增量验证
|
||||
- 属性测试验证通用正确性属性(hypothesis,最少 200 次迭代)
|
||||
- 所有数据库操作在测试库 `test_zqyy_app` 进行
|
||||
- 迁移脚本放在 `db/zqyy_app/migrations/` 目录
|
||||
- 属性测试放在 `tests/test_core_business_properties.py`(Monorepo 级)
|
||||
- AI 应用 6 接口为占位实现(返回 None),由 P5 AI 集成层替换
|
||||
- 维客线索功能由独立模块 `routers/member_retention_clue.py` 处理,不在本 SPEC 范围内
|
||||
- FDW 查询需在事务中 `SET LOCAL app.current_site_id` 设置 RLS 隔离
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "cf5c24d6-ec72-4c49-8650-264ef414e10e", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -1,878 +0,0 @@
|
||||
# 设计文档:P5 AI 集成层(miniapp-ai-integration)
|
||||
|
||||
## 概述
|
||||
|
||||
本设计文档描述 P5-A 阶段 AI 集成层的技术架构与实现方案。系统在现有 FastAPI 后端(`apps/backend/`)中新增 AI 模块,通过阿里云百炼 API(通义千问)为 8 个 AI 应用提供统一的调用能力。
|
||||
|
||||
核心交付物:
|
||||
- 3 张新表(`biz.ai_conversations`、`biz.ai_messages`、`biz.ai_cache`)
|
||||
- 百炼 API 统一封装层(流式 + 非流式)
|
||||
- 应用 1 SSE 流式对话端点
|
||||
- 应用 2 财务洞察(Prompt 完整)+ 应用 8 维客线索整理(Prompt 完整)
|
||||
- 应用 3/4/5/6/7 触发机制与调用骨架
|
||||
- 事件调度与调用链编排
|
||||
- AI 缓存读写 API
|
||||
|
||||
设计原则:
|
||||
- **统一封装**:所有 AI 调用经 `BailianClient` 统一出口,便于重试、计量、日志
|
||||
- **事件驱动**:复用现有 `trigger_scheduler.fire_event()` 机制,扩展支持串行调用链
|
||||
- **骨架优先**:P5-A 只实现管道和框架,Prompt 细化留给 P5-B 阶段
|
||||
- **site_id 隔离**:所有表和查询强制 site_id 过滤
|
||||
|
||||
## 架构
|
||||
|
||||
### 系统架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 微信小程序
|
||||
MP_CHAT[对话页面]
|
||||
MP_PAGES[其他页面<br/>财务看板/任务详情/客户详情]
|
||||
end
|
||||
|
||||
subgraph FastAPI 后端
|
||||
subgraph AI 模块 - apps/backend/app/ai/
|
||||
SSE[SSE 端点<br/>/api/ai/chat/stream]
|
||||
CACHE_API[缓存 API<br/>/api/ai/cache]
|
||||
HISTORY_API[历史对话 API<br/>/api/ai/conversations]
|
||||
DISPATCHER[AI Event Dispatcher<br/>调用链编排]
|
||||
BAILIAN[BailianClient<br/>百炼 API 封装]
|
||||
end
|
||||
|
||||
subgraph 现有服务
|
||||
NOTE_SVC[note_service<br/>备注服务]
|
||||
TRIGGER[trigger_scheduler<br/>触发器调度]
|
||||
TASK_GEN[task_generator<br/>任务生成]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph 外部服务
|
||||
BAILIAN_API[阿里云百炼 API<br/>通义千问]
|
||||
end
|
||||
|
||||
subgraph PostgreSQL - zqyy_app
|
||||
AI_CONV[biz.ai_conversations]
|
||||
AI_MSG[biz.ai_messages]
|
||||
AI_CACHE_T[biz.ai_cache]
|
||||
CLUE_T[member_retention_clue]
|
||||
end
|
||||
|
||||
MP_CHAT -->|SSE| SSE
|
||||
MP_PAGES -->|REST| CACHE_API
|
||||
MP_CHAT -->|REST| HISTORY_API
|
||||
|
||||
SSE --> BAILIAN
|
||||
DISPATCHER --> BAILIAN
|
||||
BAILIAN -->|HTTP/SSE| BAILIAN_API
|
||||
|
||||
NOTE_SVC -->|备注提交事件| DISPATCHER
|
||||
TRIGGER -->|消费/任务分配事件| DISPATCHER
|
||||
|
||||
DISPATCHER -->|写入| AI_CONV
|
||||
DISPATCHER -->|写入| AI_MSG
|
||||
DISPATCHER -->|写入| AI_CACHE_T
|
||||
DISPATCHER -->|全量替换 AI 线索| CLUE_T
|
||||
|
||||
SSE -->|写入| AI_CONV
|
||||
SSE -->|写入| AI_MSG
|
||||
```
|
||||
|
||||
### 事件调用链
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant E as 业务事件
|
||||
participant D as AI Dispatcher
|
||||
participant A3 as App3 线索
|
||||
participant A8 as App8 整理
|
||||
participant A7 as App7 客户分析
|
||||
participant A4 as App4 关系分析
|
||||
participant A5 as App5 话术
|
||||
|
||||
Note over E,A5: 消费事件链(无助教)
|
||||
E->>D: consumption_event(member_id, site_id)
|
||||
D->>A3: 调用(串行)
|
||||
A3-->>D: 线索结果 → ai_cache
|
||||
D->>A8: 调用(串行)
|
||||
A8-->>D: 整合线索 → ai_cache + member_retention_clue
|
||||
D->>A7: 调用(串行)
|
||||
A7-->>D: 客户分析 → ai_cache
|
||||
|
||||
Note over E,A5: 消费事件链(有助教)
|
||||
E->>D: consumption_event(member_id, assistant_id, site_id)
|
||||
D->>A3: 调用
|
||||
A3-->>D: 线索结果
|
||||
D->>A8: 调用
|
||||
A8-->>D: 整合线索
|
||||
D->>A7: 调用
|
||||
D->>A4: 调用(A8 完成后)
|
||||
A4-->>D: 关系分析
|
||||
D->>A5: 调用(A4 完成后)
|
||||
A5-->>D: 话术参考
|
||||
|
||||
Note over E,A5: 备注事件链
|
||||
E->>D: note_event(member_id, note_id, site_id)
|
||||
D->>+A6: 调用
|
||||
Note right of A6: App6 备注分析
|
||||
A6-->>-D: 线索 + 评分
|
||||
D->>A8: 调用
|
||||
A8-->>D: 整合线索
|
||||
|
||||
Note over E,A5: 任务分配事件链
|
||||
E->>D: task_assign_event(assistant_id, member_id, site_id)
|
||||
D->>A4: 调用(读已有 A8 缓存)
|
||||
A4-->>D: 关系分析
|
||||
D->>A5: 调用
|
||||
A5-->>D: 话术参考
|
||||
```
|
||||
|
||||
### 模块目录结构
|
||||
|
||||
```
|
||||
apps/backend/app/ai/
|
||||
├── __init__.py
|
||||
├── bailian_client.py # 百炼 API 统一封装
|
||||
├── dispatcher.py # AI 事件调度与调用链编排
|
||||
├── cache_service.py # AI 缓存读写服务
|
||||
├── conversation_service.py # 对话记录持久化服务
|
||||
├── apps/
|
||||
│ ├── __init__.py
|
||||
│ ├── app1_chat.py # 应用 1 通用对话
|
||||
│ ├── app2_finance.py # 应用 2 财务洞察
|
||||
│ ├── app3_clue.py # 应用 3 客户数据维客线索
|
||||
│ ├── app4_analysis.py # 应用 4 关系分析
|
||||
│ ├── app5_tactics.py # 应用 5 话术参考
|
||||
│ ├── app6_note.py # 应用 6 备注分析
|
||||
│ ├── app7_customer.py # 应用 7 客户分析
|
||||
│ └── app8_consolidation.py # 应用 8 维客线索整理
|
||||
├── prompts/
|
||||
│ ├── __init__.py
|
||||
│ ├── app2_finance_prompt.py # 应用 2 完整 Prompt
|
||||
│ └── app8_consolidation_prompt.py # 应用 8 完整 Prompt
|
||||
└── schemas.py # Pydantic 模型
|
||||
|
||||
apps/backend/app/routers/
|
||||
├── xcx_ai_chat.py # SSE 对话路由
|
||||
└── xcx_ai_cache.py # 缓存查询路由
|
||||
```
|
||||
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 1. BailianClient(百炼 API 统一封装)
|
||||
|
||||
文件:`apps/backend/app/ai/bailian_client.py`
|
||||
|
||||
技术方案(基于百炼官方文档):
|
||||
- **流式调用**:使用 OpenAI 兼容接口(百炼支持 OpenAI SDK 协议),`stream=True` 返回 SSE 事件流
|
||||
- **非流式调用**:`stream=False`,返回完整 JSON 响应
|
||||
- **JSON 输出模式**:通过 System Prompt 约束 + `response_format={"type": "json_object"}` 参数(百炼兼容 OpenAI 的 JSON mode)
|
||||
- **重试策略**:指数退避,最多 3 次,基础间隔 1s → 2s → 4s
|
||||
- **SDK 选择**:使用 `openai` Python SDK(百炼兼容 OpenAI 协议),`base_url` 指向百炼端点
|
||||
|
||||
```python
|
||||
class BailianClient:
|
||||
"""百炼 API 统一封装层。"""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str, model: str):
|
||||
"""
|
||||
Args:
|
||||
api_key: 百炼 API Key(从 BAILIAN_API_KEY 环境变量读取)
|
||||
base_url: 百炼 API 端点(从 BAILIAN_BASE_URL 环境变量读取)
|
||||
model: 模型标识(如 qwen-plus)
|
||||
"""
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict],
|
||||
*,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""流式调用,逐 chunk 返回文本。用于应用 1 SSE。"""
|
||||
|
||||
async def chat_json(
|
||||
self,
|
||||
messages: list[dict],
|
||||
*,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 4000,
|
||||
) -> tuple[dict, int]:
|
||||
"""非流式调用,返回解析后的 JSON dict 和 tokens_used。
|
||||
用于应用 2-8。
|
||||
|
||||
Raises:
|
||||
BailianJsonParseError: JSON 解析失败时抛出
|
||||
BailianApiError: API 调用失败(重试耗尽后)
|
||||
"""
|
||||
|
||||
def _inject_current_time(self, messages: list[dict]) -> list[dict]:
|
||||
"""在首条消息的 content JSON 中注入 current_time 字段。"""
|
||||
|
||||
async def _call_with_retry(self, **kwargs) -> Any:
|
||||
"""带指数退避的重试封装。"""
|
||||
```
|
||||
|
||||
环境变量(新增到 `.env` / `.env.template`):
|
||||
```
|
||||
BAILIAN_API_KEY=sk-xxx
|
||||
BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
BAILIAN_MODEL=qwen-plus
|
||||
```
|
||||
|
||||
### 2. AI Event Dispatcher(事件调度器)
|
||||
|
||||
文件:`apps/backend/app/ai/dispatcher.py`
|
||||
|
||||
调度器负责根据业务事件编排 AI 应用调用链。与现有 `trigger_scheduler` 的关系:
|
||||
- `trigger_scheduler.fire_event()` 触发业务事件 → 调用 `ai_dispatcher` 对应的 handler
|
||||
- `ai_dispatcher` 内部管理串行调用链的执行顺序
|
||||
|
||||
```python
|
||||
class AIDispatcher:
|
||||
"""AI 应用调用链编排器。"""
|
||||
|
||||
async def handle_consumption_event(
|
||||
self,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
settle_id: int,
|
||||
assistant_id: int | None = None,
|
||||
) -> None:
|
||||
"""消费事件链:App3 → App8 → App7(+ App4 → App5 如有助教)。"""
|
||||
|
||||
async def handle_note_event(
|
||||
self,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
note_id: int,
|
||||
note_content: str,
|
||||
noted_by_name: str,
|
||||
) -> None:
|
||||
"""备注事件链:App6 → App8。"""
|
||||
|
||||
async def handle_task_assign_event(
|
||||
self,
|
||||
assistant_id: int,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
task_type: str,
|
||||
) -> None:
|
||||
"""任务分配事件链:App4 → App5(读已有 App8 缓存)。"""
|
||||
|
||||
async def _run_chain(
|
||||
self,
|
||||
chain: list[Callable],
|
||||
context: dict,
|
||||
) -> None:
|
||||
"""串行执行调用链,某步失败记录日志后继续。"""
|
||||
```
|
||||
|
||||
容错策略:
|
||||
- 调用链中某个应用失败 → 记录错误日志 + 写入 `ai_conversations`(标记失败)
|
||||
- 后续应用使用已有缓存继续执行,不阻塞整条链
|
||||
- 整条链在后台异步执行,不阻塞业务请求
|
||||
|
||||
### 3. AI Cache Service(缓存读写服务)
|
||||
|
||||
文件:`apps/backend/app/ai/cache_service.py`
|
||||
|
||||
```python
|
||||
class AICacheService:
|
||||
"""AI 缓存读写服务。"""
|
||||
|
||||
def get_latest(
|
||||
self,
|
||||
cache_type: str,
|
||||
site_id: int,
|
||||
target_id: str,
|
||||
) -> dict | None:
|
||||
"""查询最新缓存记录。"""
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
cache_type: str,
|
||||
site_id: int,
|
||||
target_id: str,
|
||||
limit: int = 2,
|
||||
) -> list[dict]:
|
||||
"""查询历史缓存记录(按 created_at DESC),用于 Prompt reference。"""
|
||||
|
||||
def write_cache(
|
||||
self,
|
||||
cache_type: str,
|
||||
site_id: int,
|
||||
target_id: str,
|
||||
result_json: dict,
|
||||
triggered_by: str | None = None,
|
||||
score: int | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> int:
|
||||
"""写入缓存记录,返回 id。写入后异步清理超限记录。"""
|
||||
|
||||
def _cleanup_excess(
|
||||
self,
|
||||
cache_type: str,
|
||||
site_id: int,
|
||||
target_id: str,
|
||||
max_count: int = 500,
|
||||
) -> int:
|
||||
"""清理超限记录,保留最近 max_count 条,返回删除数量。"""
|
||||
```
|
||||
|
||||
### 4. Conversation Service(对话记录持久化)
|
||||
|
||||
文件:`apps/backend/app/ai/conversation_service.py`
|
||||
|
||||
```python
|
||||
class ConversationService:
|
||||
"""AI 对话记录持久化服务。"""
|
||||
|
||||
def create_conversation(
|
||||
self,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
app_id: str,
|
||||
site_id: int,
|
||||
source_page: str | None = None,
|
||||
source_context: dict | None = None,
|
||||
) -> int:
|
||||
"""创建对话记录,返回 conversation_id。
|
||||
系统自动调用时 user_id 为 'system'。"""
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
content: str,
|
||||
tokens_used: int | None = None,
|
||||
) -> int:
|
||||
"""添加消息记录,返回 message_id。"""
|
||||
|
||||
def get_conversations(
|
||||
self,
|
||||
user_id: int,
|
||||
site_id: int,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> list[dict]:
|
||||
"""查询用户历史对话列表,按时间倒序,懒加载。"""
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
conversation_id: int,
|
||||
) -> list[dict]:
|
||||
"""查询对话的所有消息。"""
|
||||
```
|
||||
|
||||
### 5. Clue Writer(维客线索写入器)
|
||||
|
||||
文件:集成在 `apps/backend/app/ai/apps/app8_consolidation.py` 中
|
||||
|
||||
```python
|
||||
class ClueWriter:
|
||||
"""维客线索全量替换写入器。"""
|
||||
|
||||
def replace_ai_clues(
|
||||
self,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
clues: list[dict],
|
||||
) -> int:
|
||||
"""全量替换该客户的 AI 来源线索。
|
||||
|
||||
1. DELETE FROM member_retention_clue
|
||||
WHERE member_id = %s AND site_id = %s
|
||||
AND source IN ('ai_consumption', 'ai_note')
|
||||
2. INSERT 新线索(人工线索 source='manual' 不受影响)
|
||||
|
||||
字段映射:
|
||||
- category → category
|
||||
- emoji + summary → summary(如 "📅 偏好周末下午时段消费")
|
||||
- detail → detail
|
||||
- providers → recorded_by_name
|
||||
- source: 纯 App3 → ai_consumption,纯 App6 → ai_note,混合 → ai_consumption
|
||||
- recorded_by_assistant_id: NULL(系统触发)
|
||||
|
||||
返回写入的线索数量。
|
||||
"""
|
||||
```
|
||||
|
||||
### 6. API 端点
|
||||
|
||||
#### 6.1 SSE 对话端点
|
||||
|
||||
路由文件:`apps/backend/app/routers/xcx_ai_chat.py`
|
||||
|
||||
```
|
||||
POST /api/ai/chat/stream
|
||||
Content-Type: application/json
|
||||
Accept: text/event-stream
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"message": "string",
|
||||
"source_page": "string",
|
||||
"page_context": {},
|
||||
"screen_content": "string" // 页面可见内容文本化
|
||||
}
|
||||
|
||||
SSE Events:
|
||||
data: {"type": "chunk", "content": "..."}
|
||||
data: {"type": "done", "conversation_id": 123, "tokens_used": 456}
|
||||
data: {"type": "error", "message": "..."}
|
||||
|
||||
认证:JWT Token(从 xcx_auth 获取)
|
||||
隔离:从 JWT 中提取 user_id、site_id、nickname、role
|
||||
```
|
||||
|
||||
#### 6.2 历史对话 API
|
||||
|
||||
```
|
||||
GET /api/ai/conversations?page=1&page_size=20
|
||||
→ [{ id, app_id, source_page, created_at, first_message_preview }]
|
||||
|
||||
GET /api/ai/conversations/{conversation_id}/messages
|
||||
→ [{ id, role, content, tokens_used, created_at }]
|
||||
```
|
||||
|
||||
#### 6.3 缓存查询 API
|
||||
|
||||
路由文件:`apps/backend/app/routers/xcx_ai_cache.py`
|
||||
|
||||
```
|
||||
GET /api/ai/cache/{cache_type}?target_id=xxx
|
||||
→ { id, cache_type, target_id, result_json, score, created_at }
|
||||
|
||||
认证:JWT Token
|
||||
隔离:site_id 从 JWT 提取,强制过滤
|
||||
```
|
||||
|
||||
### 7. 各应用骨架接口
|
||||
|
||||
每个应用实现统一的调用接口:
|
||||
|
||||
```python
|
||||
# 应用基类模式(非继承,约定接口)
|
||||
async def run(
|
||||
context: dict, # 包含 member_id, site_id, 及应用特定参数
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""
|
||||
执行 AI 应用调用。
|
||||
|
||||
1. 构建 Prompt(build_prompt)
|
||||
2. 调用百炼 API(bailian.chat_json)
|
||||
3. 写入 ai_conversations + ai_messages
|
||||
4. 写入 ai_cache
|
||||
5. 返回结果 dict
|
||||
"""
|
||||
```
|
||||
|
||||
骨架应用(App3/4/5/6/7)的 `build_prompt` 函数留接口:
|
||||
|
||||
```python
|
||||
def build_prompt(context: dict) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段:由对应页面 spec 补充完整 Prompt。
|
||||
"""
|
||||
# TODO: P5-B 细化
|
||||
return [
|
||||
{"role": "system", "content": json.dumps({
|
||||
"task": "...",
|
||||
"current_time": "", # BailianClient 自动注入
|
||||
# 以下字段待细化
|
||||
"data": {},
|
||||
"reference": {},
|
||||
})},
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
## 数据模型
|
||||
|
||||
### DDL:biz.ai_conversations
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS biz.ai_conversations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(50) NOT NULL, -- 用户 ID 或 'system'
|
||||
nickname VARCHAR(100) NOT NULL DEFAULT '',
|
||||
app_id VARCHAR(30) NOT NULL, -- app1_chat / app2_finance / ... / app8_consolidation
|
||||
site_id BIGINT NOT NULL,
|
||||
source_page VARCHAR(100), -- 来源页面标识
|
||||
source_context JSONB, -- 页面上下文 JSON
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE biz.ai_conversations IS 'AI 对话记录:每次 AI 调用(用户主动或系统自动)创建一条';
|
||||
COMMENT ON COLUMN biz.ai_conversations.app_id IS '应用标识:app1_chat / app2_finance / app3_clue / app4_analysis / app5_tactics / app6_note / app7_customer / app8_consolidation';
|
||||
COMMENT ON COLUMN biz.ai_conversations.user_id IS '用户 ID,系统自动调用时为 system';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conv_user_site ON biz.ai_conversations (user_id, site_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conv_app_site ON biz.ai_conversations (app_id, site_id, created_at DESC);
|
||||
```
|
||||
|
||||
### DDL:biz.ai_messages
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS biz.ai_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
conversation_id BIGINT NOT NULL REFERENCES biz.ai_conversations(id) ON DELETE CASCADE,
|
||||
role VARCHAR(10) NOT NULL
|
||||
CONSTRAINT chk_ai_msg_role CHECK (role IN ('user', 'assistant', 'system')),
|
||||
content TEXT NOT NULL,
|
||||
tokens_used INTEGER, -- 本条消息消耗的 token 数
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE biz.ai_messages IS 'AI 消息记录:对话中的每条消息(输入/输出/系统)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_msg_conv ON biz.ai_messages (conversation_id, created_at);
|
||||
```
|
||||
|
||||
### DDL:biz.ai_cache
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS biz.ai_cache (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
cache_type VARCHAR(30) NOT NULL
|
||||
CONSTRAINT chk_ai_cache_type CHECK (
|
||||
cache_type IN (
|
||||
'app2_finance', 'app3_clue', 'app4_analysis',
|
||||
'app5_tactics', 'app6_note_analysis',
|
||||
'app7_customer_analysis', 'app8_clue_consolidated'
|
||||
)
|
||||
),
|
||||
site_id BIGINT NOT NULL,
|
||||
target_id VARCHAR(100) NOT NULL, -- 含义因 cache_type 而异
|
||||
result_json JSONB NOT NULL,
|
||||
score INTEGER, -- 应用 6 专用评分
|
||||
triggered_by VARCHAR(100), -- 触发来源标识
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ -- 可选过期时间
|
||||
);
|
||||
|
||||
COMMENT ON TABLE biz.ai_cache IS 'AI 应用缓存:各应用的结构化输出结果';
|
||||
COMMENT ON COLUMN biz.ai_cache.target_id IS '目标 ID:App2=时间维度编码 / App3,6,7,8=member_id / App4,5={assistant_id}_{member_id}';
|
||||
COMMENT ON COLUMN biz.ai_cache.score IS '评分:仅应用 6 使用(1-10 分)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_cache_lookup ON biz.ai_cache (cache_type, site_id, target_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_cache_cleanup ON biz.ai_cache (cache_type, site_id, target_id, created_at);
|
||||
```
|
||||
|
||||
### target_id 约定
|
||||
|
||||
| cache_type | target_id 格式 | 示例 |
|
||||
|---|---|---|
|
||||
| app2_finance | 时间维度编码 | `this_month`, `last_week` |
|
||||
| app3_clue | member_id | `12345` |
|
||||
| app4_analysis | `{assistant_id}_{member_id}` | `100_12345` |
|
||||
| app5_tactics | `{assistant_id}_{member_id}` | `100_12345` |
|
||||
| app6_note_analysis | member_id | `12345` |
|
||||
| app7_customer_analysis | member_id | `12345` |
|
||||
| app8_clue_consolidated | member_id | `12345` |
|
||||
|
||||
### 应用 2 时间维度编码
|
||||
|
||||
| 编码 | 含义 | 计算规则 |
|
||||
|---|---|---|
|
||||
| `this_month` | 本月 | 当前营业日所在月 |
|
||||
| `last_month` | 上月 | 当前月 - 1 |
|
||||
| `this_week` | 本周 | 当前营业日所在周(周一起) |
|
||||
| `last_week` | 上周 | 当前周 - 1 |
|
||||
| `last_3_months` | 前 3 月(不含本月) | 当前月 - 3 ~ 当前月 - 1 |
|
||||
| `this_quarter` | 本季 | 当前营业日所在季度 |
|
||||
| `last_quarter` | 上季 | 当前季度 - 1 |
|
||||
| `last_6_months` | 近 6 月(不含本月) | 当前月 - 6 ~ 当前月 - 1 |
|
||||
|
||||
营业日分界点:每日 08:00(`BUSINESS_DAY_START_HOUR` 环境变量)。
|
||||
|
||||
### 应用输出 JSON Schema
|
||||
|
||||
#### 应用 3/6 线索格式(写入 ai_cache.result_json)
|
||||
|
||||
```json
|
||||
{
|
||||
"clues": [
|
||||
{
|
||||
"category": "消费习惯",
|
||||
"summary": "偏好周末下午时段消费",
|
||||
"detail": "近 3 个月 8 次消费中 6 次在周六/日 14:00-18:00",
|
||||
"emoji": "📅"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
应用 6 额外包含 `score` 字段(1-10),写入 `ai_cache.score`。
|
||||
|
||||
#### 应用 8 整合线索格式
|
||||
|
||||
```json
|
||||
{
|
||||
"clues": [
|
||||
{
|
||||
"category": "消费习惯",
|
||||
"summary": "偏好周末下午时段消费",
|
||||
"detail": "近 3 个月 8 次消费中 6 次在周六/日 14:00-18:00",
|
||||
"emoji": "📅",
|
||||
"providers": "系统,张三"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 应用 4 关系分析格式
|
||||
|
||||
```json
|
||||
{
|
||||
"task_description": "...",
|
||||
"action_suggestions": ["建议1", "建议2"],
|
||||
"one_line_summary": "..."
|
||||
}
|
||||
```
|
||||
|
||||
#### 应用 5 话术参考格式
|
||||
|
||||
```json
|
||||
{
|
||||
"tactics": [
|
||||
{ "scenario": "...", "script": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 应用 7 客户分析格式
|
||||
|
||||
```json
|
||||
{
|
||||
"strategies": [
|
||||
{ "title": "...", "content": "..." }
|
||||
],
|
||||
"summary": "..."
|
||||
}
|
||||
```
|
||||
|
||||
#### 应用 2 财务洞察格式
|
||||
|
||||
```json
|
||||
{
|
||||
"insights": [
|
||||
{ "seq": 1, "title": "...", "body": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 与现有表的关系
|
||||
|
||||
- `member_retention_clue`:应用 8 的 `ClueWriter` 全量替换 `source IN ('ai_consumption', 'ai_note')` 的记录,`source='manual'` 的人工线索不受影响
|
||||
- `biz.notes`:应用 6 触发点,`note_service.create_note()` 中的 `ai_analyze_note()` 占位函数将被替换为真实调用
|
||||
- `biz.trigger_jobs`:新增 AI 相关的事件触发器配置(`consumption_settled`、`note_created`、`task_assigned`)
|
||||
- `biz.coach_tasks`:应用 4 触发条件之一(任务分配事件)
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*正确性属性是系统在所有合法执行路径上都应保持为真的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: BailianClient 双模式调用一致性
|
||||
|
||||
*For any* 合法的消息列表,`chat_stream` 应返回非空的 chunk 序列(拼接后为完整文本),`chat_json` 应返回可解析的 JSON dict 和正整数 tokens_used。两种模式对相同输入都应成功返回(mock API 正常响应时)。
|
||||
|
||||
**Validates: Requirements 2.1, 2.3, 2.4**
|
||||
|
||||
### Property 2: 指数退避重试策略
|
||||
|
||||
*For any* 失败次数 n(1 ≤ n ≤ max_retries),BailianClient 应在第 n 次失败后等待 base_interval × 2^(n-1) 秒后重试;当失败次数超过 max_retries 时,应抛出 BailianApiError。
|
||||
|
||||
**Validates: Requirements 2.2**
|
||||
|
||||
### Property 3: JSON 解析失败错误处理
|
||||
|
||||
*For any* 非法 JSON 字符串作为 API 响应,`chat_json` 应抛出 BailianJsonParseError 而非静默返回空值或崩溃。
|
||||
|
||||
**Validates: Requirements 2.5**
|
||||
|
||||
### Property 4: current_time 注入不变量
|
||||
|
||||
*For any* 消息列表,经 `_inject_current_time` 处理后,首条消息的 content(解析为 JSON)应包含 `current_time` 字段,且值为 ISO 格式的时间字符串(精确到秒),其余消息不受影响。
|
||||
|
||||
**Validates: Requirements 2.6**
|
||||
|
||||
### Property 5: AI 调用记录持久化 round-trip
|
||||
|
||||
*For any* AI 应用调用(app1-app8),调用完成后:(a) `ai_conversations` 应包含一条匹配 app_id、site_id 的记录;(b) `ai_messages` 应包含至少一条 role='system' 或 role='user' 的输入消息和一条 role='assistant' 的输出消息;(c) 输出消息的 tokens_used 应为正整数。
|
||||
|
||||
**Validates: Requirements 3.2, 3.4, 3.5, 13.1, 13.2, 13.3**
|
||||
|
||||
### Property 6: 历史对话列表排序与分页
|
||||
|
||||
*For any* 用户和 site_id,查询历史对话列表返回的记录应按 created_at 严格降序排列,且每页数量不超过 page_size(默认 20)。
|
||||
|
||||
**Validates: Requirements 3.7**
|
||||
|
||||
### Property 7: 缓存写入 round-trip
|
||||
|
||||
*For any* AI 应用(app2-app8)的调用结果,写入 `ai_cache` 后,按 (cache_type, site_id, target_id) 查询最新记录应返回与写入内容一致的 result_json。
|
||||
|
||||
**Validates: Requirements 4.7, 5.6, 6.6, 7.5, 8.6, 9.5, 10.10**
|
||||
|
||||
### Property 8: AI 应用输出 JSON 结构验证
|
||||
|
||||
*For any* AI 应用调用结果的 result_json:
|
||||
- App2: 应包含 `insights` 数组,每项含 `seq`(正整数)、`title`(非空字符串)、`body`(非空字符串)
|
||||
- App3: 应包含 `clues` 数组,每条含 `category`(∈ {客户基础, 消费习惯, 玩法偏好})、`summary`、`detail`、`emoji`
|
||||
- App4: 应包含 `task_description`、`action_suggestions`(数组)、`one_line_summary`
|
||||
- App5: 应包含 `tactics` 数组
|
||||
- App6: 应包含 `score`(1-10 整数)和 `clues` 数组,每条 category ∈ 6 个枚举值
|
||||
- App7: 应包含 `strategies` 数组(每项含 `title`、`content`)和 `summary`
|
||||
- App8: 应包含 `clues` 数组,每条含 `category`(∈ 6 个枚举值)、`summary`、`detail`、`emoji`、`providers`
|
||||
|
||||
**Validates: Requirements 4.4, 5.2, 5.3, 5.4, 6.3, 7.3, 8.2, 8.3, 8.4, 9.2, 10.4, 10.5**
|
||||
|
||||
### Property 9: Prompt reference 历史注入
|
||||
|
||||
*For any* 应用 3/4/5/6/7/8 的 Prompt 构建,reference 字段应包含相关应用的缓存结果(如有),且历史记录附带 `generated_at` 时间戳。当缓存不存在时,reference 应为空对象。
|
||||
|
||||
**Validates: Requirements 5.8, 6.4, 6.5, 7.2, 7.4, 8.8, 9.7**
|
||||
|
||||
### Property 10: 事件调用链顺序正确性
|
||||
|
||||
*For any* 业务事件:
|
||||
- 消费事件(无助教):调用顺序严格为 App3 → App8 → App7
|
||||
- 消费事件(有助教):调用顺序严格为 App3 → App8 → {App7, App4 → App5}(App7 和 App4 均在 App8 之后)
|
||||
- 备注事件:调用顺序严格为 App6 → App8
|
||||
- 任务分配事件:调用顺序严格为 App4 → App5
|
||||
|
||||
**Validates: Requirements 11.1, 11.2, 11.3, 11.4, 11.5, 11.6**
|
||||
|
||||
### Property 11: 调用链容错不变量
|
||||
|
||||
*For any* 调用链执行过程中某个应用调用失败,后续应用应继续执行(使用已有缓存),整条链不应因单点失败而中断。失败的应用应有错误日志记录。
|
||||
|
||||
**Validates: Requirements 11.7**
|
||||
|
||||
### Property 12: ClueWriter 全量替换不变量
|
||||
|
||||
*For any* member_id 和 site_id,执行 `ClueWriter.replace_ai_clues(member_id, site_id, new_clues)` 后:
|
||||
- (a) 该客户的 AI 来源线索(source IN ('ai_consumption', 'ai_note'))应恰好等于 new_clues 的数量
|
||||
- (b) 人工线索(source='manual')的数量应与替换前完全一致
|
||||
- (c) 写入的记录中 recorded_by_assistant_id 应为 NULL
|
||||
- (d) summary 字段应为 emoji + 空格 + 原始 summary 的拼接格式
|
||||
|
||||
**Validates: Requirements 10.7, 10.8, 10.9**
|
||||
|
||||
### Property 13: 缓存查询 site_id 隔离
|
||||
|
||||
*For any* 两个不同的 site_id(A 和 B),写入 site_id=A 的缓存记录后,以 site_id=B 查询应返回空结果(即使 cache_type 和 target_id 相同)。
|
||||
|
||||
**Validates: Requirements 12.1, 12.5**
|
||||
|
||||
### Property 14: 缓存保留上限
|
||||
|
||||
*For any* (cache_type, site_id, target_id) 组合,无论写入多少条记录,清理后该组合的记录总数应 ≤ 500。
|
||||
|
||||
**Validates: Requirements 12.3**
|
||||
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 百炼 API 层
|
||||
|
||||
| 错误场景 | 处理策略 |
|
||||
|---|---|
|
||||
| API 超时 | 指数退避重试(最多 3 次),超时阈值 30s |
|
||||
| API 返回 HTTP 4xx | 不重试,立即抛出 BailianApiError |
|
||||
| API 返回 HTTP 5xx | 指数退避重试 |
|
||||
| 响应非法 JSON | 抛出 BailianJsonParseError,记录原始响应到日志 |
|
||||
| API Key 无效 | 不重试,抛出 BailianAuthError,记录告警日志 |
|
||||
| 流式连接中断 | 已接收的 chunk 拼接为部分回复,标记 incomplete |
|
||||
|
||||
### 事件调度层
|
||||
|
||||
| 错误场景 | 处理策略 |
|
||||
|---|---|
|
||||
| 调用链中某应用失败 | 记录错误日志 + 写入失败 conversation 记录,后续应用使用已有缓存继续 |
|
||||
| 数据库连接失败 | 整条链中止,记录错误日志 |
|
||||
| 缓存查询失败 | 传空 reference 继续执行,不阻塞 |
|
||||
|
||||
### SSE 端点层
|
||||
|
||||
| 错误场景 | 处理策略 |
|
||||
|---|---|
|
||||
| 用户未认证 | 返回 HTTP 401 |
|
||||
| 消息为空 | 返回 HTTP 422 |
|
||||
| 流式过程中百炼 API 失败 | 发送 `{"type": "error", "message": "..."}` SSE 事件 |
|
||||
| 客户端断开连接 | 取消百炼 API 调用,清理资源 |
|
||||
|
||||
### 缓存服务层
|
||||
|
||||
| 错误场景 | 处理策略 |
|
||||
|---|---|
|
||||
| 查询无结果 | 返回 null/None,不抛异常 |
|
||||
| 写入失败 | 抛出异常,由调用方处理 |
|
||||
| 清理超限失败 | 记录警告日志,不影响写入操作 |
|
||||
|
||||
### ClueWriter 层
|
||||
|
||||
| 错误场景 | 处理策略 |
|
||||
|---|---|
|
||||
| 全量替换事务失败 | 回滚整个事务,保留原有线索不变 |
|
||||
| 线索数据不符合 CHECK 约束 | 回滚事务,记录错误日志(category 枚举不匹配) |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
- **测试库**:hypothesis(Python)
|
||||
- **最小迭代次数**:每个属性测试 100 次
|
||||
- **测试文件位置**:`tests/test_p5_ai_integration_properties.py`(Monorepo 级)+ `apps/backend/tests/test_ai_*.py`(模块级)
|
||||
- **标签格式**:`# Feature: 05-miniapp-ai-integration, Property {N}: {property_text}`
|
||||
|
||||
每个正确性属性对应一个属性测试:
|
||||
|
||||
| Property | 测试策略 | 生成器 |
|
||||
|---|---|---|
|
||||
| P1: 双模式调用 | Mock 百炼 API,验证两种模式返回格式 | 随机消息列表 |
|
||||
| P2: 重试策略 | Mock 可控失败次数的 API | 随机失败次数 (0-5) |
|
||||
| P3: JSON 解析失败 | Mock 返回非法 JSON | 随机非 JSON 字符串 |
|
||||
| P4: current_time 注入 | 纯函数测试 | 随机消息列表 |
|
||||
| P5: 记录持久化 | Mock 百炼 + 真实 DB(test_zqyy_app) | 随机 app_id、消息内容 |
|
||||
| P6: 历史列表排序 | 真实 DB | 随机对话记录(随机时间戳) |
|
||||
| P7: 缓存 round-trip | 真实 DB | 随机 cache_type、target_id、result_json |
|
||||
| P8: 输出 JSON 结构 | JSON Schema 验证 | 随机 AI 响应(符合各应用 schema) |
|
||||
| P9: reference 历史注入 | Mock 缓存数据 | 随机缓存记录(含/不含历史) |
|
||||
| P10: 调用链顺序 | Mock 所有应用,记录调用序列 | 随机事件类型和参数 |
|
||||
| P11: 调用链容错 | Mock 随机应用失败 | 随机失败位置 |
|
||||
| P12: ClueWriter 替换 | 真实 DB | 随机线索列表 + 预置人工线索 |
|
||||
| P13: site_id 隔离 | 真实 DB | 随机 site_id 对 |
|
||||
| P14: 缓存上限 | 真实 DB | 批量写入(>500 条) |
|
||||
|
||||
### 单元测试
|
||||
|
||||
单元测试聚焦于具体示例和边界条件,与属性测试互补:
|
||||
|
||||
| 测试范围 | 测试内容 |
|
||||
|---|---|
|
||||
| 表结构验证 | 验证 3 张表的列、类型、约束、索引(需求 1.1-1.5) |
|
||||
| App2 时间维度 | 验证 8 个时间维度编码的计算逻辑(营业日分界点 08:00) |
|
||||
| App2 字段映射 | 验证 Prompt 使用 items_sum 口径而非 consume_money |
|
||||
| SSE 协议 | 验证 Content-Type: text/event-stream 和事件格式 |
|
||||
| ClueWriter 字段映射 | 验证 emoji+summary 拼接、source 判断逻辑 |
|
||||
| 缓存 CHECK 约束 | 验证非法 cache_type 被拒绝 |
|
||||
| App6 评分范围 | 验证 score 字段存储在 ai_cache.score |
|
||||
|
||||
### 集成测试
|
||||
|
||||
| 测试范围 | 测试内容 |
|
||||
|---|---|
|
||||
| 完整消费事件链 | Mock 百炼 API,验证 App3→App8→App7 全链路 |
|
||||
| 备注事件链 | Mock 百炼 API,验证 App6→App8 全链路 |
|
||||
| note_service 集成 | 验证 `ai_analyze_note` 占位函数被替换后的调用流程 |
|
||||
| SSE 端到端 | 使用 httpx 的 SSE 客户端验证流式响应 |
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
# 需求文档:P5 AI 集成层(miniapp-ai-integration)
|
||||
|
||||
## 简介
|
||||
|
||||
本文档定义小程序 AI 集成层的需求规格,覆盖 P5-A 阶段(管道 + 骨架)。系统为台球门店助教和管理者提供 8 个 AI 应用,包括通用对话、财务洞察、客户数据分析、关系分析、话术参考、备注分析、客户分析和维客线索整理。技术栈为 FastAPI 后端 + 微信小程序前端 + PostgreSQL(zqyy_app 业务库),通过阿里云百炼 API(通义千问)提供 AI 能力。
|
||||
|
||||
P5-A 阶段交付"管道":建表、百炼封装、缓存 API、SSE 框架,以及 Prompt 已完全确定的应用(应用 2、应用 8)。应用 3/4/5/6/7 只实现触发机制和调用骨架(Prompt 拼接函数留接口)。
|
||||
|
||||
> P5-B 阶段(Prompt 细化)不在本 spec 范围,将分散到 P6(task-detail)和 P9(customer-detail)的对应任务中完成。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **AI_Integration_System**:P5 AI 集成层系统整体,包含后端 API、百炼封装、事件调度、缓存管理等
|
||||
- **Bailian_Client**:百炼 API 统一封装层,负责与阿里云通义千问 API 的通信(流式/非流式)
|
||||
- **SSE_Endpoint**:Server-Sent Events 流式返回端点,用于应用 1 通用对话的逐字推送
|
||||
- **AI_Cache_Service**:AI 缓存读写服务,管理 `biz.ai_cache` 表的 CRUD 和保留策略
|
||||
- **Event_Dispatcher**:事件调度器,负责根据业务事件(消费、备注、任务分配)触发对应 AI 应用调用链
|
||||
- **Clue_Writer**:维客线索写入器,负责将应用 8 整合后的线索全量替换写入 `member_retention_clue` 表
|
||||
- **App1_Chat**:应用 1 通用对话,用户主动发起的流式对话
|
||||
- **App2_Finance**:应用 2 财务洞察,每日自动生成 8 个时间维度的财务分析
|
||||
- **App3_Clue**:应用 3 客户数据维客线索分析,客户新增消费时自动触发
|
||||
- **App4_Analysis**:应用 4 关系分析/任务建议,助教参与新结算或任务分配时触发
|
||||
- **App5_Tactics**:应用 5 话术参考,联动应用 4 自动触发
|
||||
- **App6_Note**:应用 6 备注分析,备注提交时自动触发
|
||||
- **App7_Customer**:应用 7 客户分析,消费事件链中应用 8 完成后触发
|
||||
- **App8_Consolidation**:应用 8 维客线索整理,应用 3 或应用 6 产出后触发
|
||||
- **items_sum**:校准后的消费金额口径,= table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money,禁止使用 consume_money
|
||||
- **ai_conversations**:AI 对话表,记录每次对话的元信息
|
||||
- **ai_messages**:AI 消息表,记录对话中的每条消息
|
||||
- **ai_cache**:AI 缓存表,存储各应用的结构化输出结果
|
||||
- **member_retention_clue**:维客线索表,存储整合后的客户维护线索
|
||||
- **营业日分界点**:每日 08:00,用于时间维度计算的日切点
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:数据库表结构
|
||||
|
||||
**用户故事:** 作为系统,我需要持久化存储所有 AI 对话记录和缓存结果,以便支撑 8 个 AI 应用的数据读写需求。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE AI_Integration_System SHALL 在 `biz` schema 下创建 `ai_conversations` 表,包含字段:id、user_id、nickname、app_id、site_id、source_page、source_context(JSON)、created_at
|
||||
2. THE AI_Integration_System SHALL 在 `biz` schema 下创建 `ai_messages` 表,包含字段:id、conversation_id(外键关联 ai_conversations)、role(user/assistant/system)、content、tokens_used、created_at
|
||||
3. THE AI_Integration_System SHALL 在 `biz` schema 下创建 `ai_cache` 表,包含字段:id、cache_type(枚举:app2_finance / app3_clue / app4_analysis / app5_tactics / app6_note_analysis / app7_customer_analysis / app8_clue_consolidated)、site_id、target_id、result_json、score(应用 6 专用)、triggered_by(trigger_job_id)、created_at、expires_at
|
||||
4. THE AI_Integration_System SHALL 对 ai_cache 表的 target_id 按应用约定存储:应用 2 存时间维度编码、应用 3/6/7/8 存 member_id、应用 4/5 存 `{assistant_id}_{member_id}` 格式
|
||||
5. THE AI_Integration_System SHALL 对所有三张表启用 site_id 字段以支持多门店隔离
|
||||
|
||||
### 需求 2:百炼 API 统一封装层
|
||||
|
||||
**用户故事:** 作为开发者,我需要一个统一的百炼 API 封装层,以便所有 AI 应用通过一致的接口调用阿里云通义千问,降低重复代码和维护成本。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Bailian_Client SHALL 支持流式调用模式(用于应用 1 SSE 推送)和非流式调用模式(用于应用 2-8 结构化输出)
|
||||
2. THE Bailian_Client SHALL 在 API 调用失败时执行自动重试(含指数退避策略)
|
||||
3. THE Bailian_Client SHALL 记录每次 API 调用的 tokens_used 统计信息
|
||||
4. THE Bailian_Client SHALL 支持 JSON 输出模式,确保应用 2-8 返回的内容可解析为结构化 JSON
|
||||
5. IF 百炼 API 返回非预期格式或解析失败,THEN THE Bailian_Client SHALL 记录错误日志并返回明确的错误信息
|
||||
6. THE Bailian_Client SHALL 在每次调用的首条 Prompt JSON 中统一注入 `current_time` 字段(精确到秒)
|
||||
|
||||
### 需求 3:应用 1 通用对话(SSE 流式)
|
||||
|
||||
**用户故事:** 作为助教,我可以在任意页面点击 AI 按钮,跳转到对话页面与 AI 交流,AI 了解当前页面上下文。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE SSE_Endpoint SHALL 以 Server-Sent Events 协议向前端推送 AI 回复,实现逐字展示效果
|
||||
2. WHEN 用户从任意页面进入 chat 页面时,THE App1_Chat SHALL 始终新建一条 ai_conversations 记录(不复用已有对话)
|
||||
3. THE App1_Chat SHALL 在首条消息中注入页面上下文,包含 source_page(来源页面标识)、page_context(页面上下文摘要)、screen_content(屏幕可见内容文本化描述)
|
||||
4. WHEN 用户发送消息时,THE App1_Chat SHALL 立即将用户消息写入 ai_messages(role=user)
|
||||
5. WHEN 流式返回完成后,THE App1_Chat SHALL 将完整的 assistant 回复写入 ai_messages(role=assistant),包含 tokens_used
|
||||
6. THE App1_Chat SHALL 通过 `biz_params.user_prompt_params` 传入 User_ID、Role(助教/管理者)、Nickname 实现信息隔离
|
||||
7. THE App1_Chat SHALL 提供历史对话列表接口,按时间倒序展示,每页 20 条懒加载
|
||||
8. THE App1_Chat SHALL 搭建上下文注入框架,页面文本化工具留接口(P5-B 阶段各页面逐步实现)
|
||||
|
||||
### 需求 4:应用 2 财务洞察
|
||||
|
||||
**用户故事:** 作为管理者,我在财务看板能看到 AI 生成的财务洞察分析,覆盖多个时间维度。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE App2_Finance SHALL 由 ETL 调度器在每日 08:00(营业日分界点)后的首次任务执行时触发
|
||||
2. THE App2_Finance SHALL 在 DWS 日更数据更新完成后,依次对 8 个时间维度发起独立调用(共 8 次百炼 API 调用)
|
||||
3. THE App2_Finance SHALL 覆盖 8 个时间维度:本月(this_month)、上月(last_month)、本周(this_week)、上周(last_week)、前 3 月不含本月(last_3_months)、本季(this_quarter)、上季(last_quarter)、近 6 月不含本月(last_6_months)
|
||||
4. THE App2_Finance SHALL 返回结构化 JSON,格式为序号 + 标题 + 正文的数组
|
||||
5. THE App2_Finance SHALL 在 Prompt 中包含当期和上期的收入结构(table_fee、assistant_pd、assistant_cx、goods、recharge)、储值资产、费用汇总、平台结算数据
|
||||
6. THE App2_Finance SHALL 使用已校准的收入结构字段映射:table_fee = table_charge_money、assistant_pd = assistant_pd_money、assistant_cx = assistant_cx_money、goods = goods_money、recharge = 充值 pay_amount(settle_type=5)
|
||||
7. THE App2_Finance SHALL 将每次调用结果写入 ai_cache(cache_type=app2_finance,target_id=时间维度编码)
|
||||
8. IF ETL 调度器中尚无应用 2 的调度逻辑,THEN THE AI_Integration_System SHALL 在 P5-A 阶段补充该调度任务
|
||||
|
||||
### 需求 5:应用 3 客户数据维客线索分析(骨架)
|
||||
|
||||
**用户故事:** 作为系统,客户新增消费时自动通过 AI 分析客户数据,提取维客线索。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 客户新增消费(结账单出现)时,THE Event_Dispatcher SHALL 触发 App3_Clue 调用
|
||||
2. THE App3_Clue SHALL 返回 JSON 格式的线索数组,每条线索包含 category(分类标签)、summary(摘要)、detail(详情)、emoji
|
||||
3. THE App3_Clue SHALL 将分类标签限定为 3 个枚举值:客户基础、消费习惯、玩法偏好
|
||||
4. THE App3_Clue SHALL 将线索提供者统一标记为"系统"
|
||||
5. THE App3_Clue SHALL 使用 items_sum 作为消费金额口径(= table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money),禁止使用 consume_money
|
||||
6. THE App3_Clue SHALL 将结果写入 ai_cache(cache_type=app3_clue,target_id=member_id)
|
||||
7. THE App3_Clue SHALL 实现触发机制和调用框架,Prompt 拼接函数留接口(consumption_records 等字段待 P9-T1 细化)
|
||||
8. THE App3_Clue SHALL 在 Prompt 的 reference 中包含应用 6 的线索结果(如有)和最近 2 套应用 8 的历史信息(附 generated_at 时间)
|
||||
|
||||
### 需求 6:应用 4 关系分析/任务建议(骨架)
|
||||
|
||||
**用户故事:** 作为系统,助教参与新结算或被分配召回任务时,自动生成关系分析和任务建议。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 助教参与新结算时,THE Event_Dispatcher SHALL 在消费事件链中等待应用 3 → 应用 8 完成后触发 App4_Analysis
|
||||
2. WHEN 优先召回任务分配或高优先召回任务分配时,THE Event_Dispatcher SHALL 直接触发 App4_Analysis(读取应用 8 已有缓存)
|
||||
3. THE App4_Analysis SHALL 返回 JSON 格式,包含任务描述、行动建议数组、一句话总结
|
||||
4. THE App4_Analysis SHALL 在 Prompt 的 reference 中包含应用 8 当前最新维客线索和最近 2 套历史信息(附 generated_at 时间)
|
||||
5. IF 应用 8 缓存不存在(如新客户首次结算),THEN THE App4_Analysis SHALL 在 reference 中传空对象,Prompt 中标注"暂无历史线索"
|
||||
6. THE App4_Analysis SHALL 将结果写入 ai_cache(cache_type=app4_analysis,target_id=`{assistant_id}_{member_id}`)
|
||||
7. THE App4_Analysis SHALL 实现触发机制和调用框架,Prompt 拼接函数留接口(service_history、assistant_info 等字段待 P6-T4 细化)
|
||||
|
||||
### 需求 7:应用 5 话术参考(骨架)
|
||||
|
||||
**用户故事:** 作为系统,应用 4 生成任务建议后,自动联动生成沟通话术参考。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN App4_Analysis 调用完成后,THE Event_Dispatcher SHALL 自动触发 App5_Tactics
|
||||
2. THE App5_Tactics SHALL 接收应用 4 的完整返回结果作为 Prompt 中的 task_suggestion 字段
|
||||
3. THE App5_Tactics SHALL 返回 JSON 格式的话术内容数组
|
||||
4. THE App5_Tactics SHALL 在 Prompt 的 reference 中包含最近 2 套应用 8 的历史信息(附 generated_at 时间)
|
||||
5. THE App5_Tactics SHALL 将结果写入 ai_cache(cache_type=app5_tactics,target_id=`{assistant_id}_{member_id}`)
|
||||
6. THE App5_Tactics SHALL 实现联动框架,Prompt 拼接函数留接口(service_history、assistant_info 等字段随应用 4 同步在 P6-T4 细化)
|
||||
|
||||
### 需求 8:应用 6 备注分析(骨架)
|
||||
|
||||
**用户故事:** 作为系统,助教提交备注后,自动通过 AI 分析备注内容,提取维客线索并评分。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 备注提交时,THE Event_Dispatcher SHALL 触发 App6_Note 调用
|
||||
2. THE App6_Note SHALL 返回 JSON 格式,包含 score(评分 1-10)和 clues(线索数组,每条含 category、summary、detail、emoji)
|
||||
3. THE App6_Note SHALL 将分类标签限定为 6 个枚举值:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈
|
||||
4. THE App6_Note SHALL 将线索提供者标记为当前备注提供人
|
||||
5. THE App6_Note SHALL 使用 6 分为标准分的评分规则:重复信息/低价值/时效性低酌情扣分,高价值信息酌情加分
|
||||
6. THE App6_Note SHALL 将结果写入 ai_cache(cache_type=app6_note_analysis,target_id=member_id),score 字段存储评分
|
||||
7. THE App6_Note SHALL 实现触发机制和调用框架,Prompt 拼接函数留接口(consumption_data 等字段待 P9-T1 细化)
|
||||
8. THE App6_Note SHALL 在 Prompt 的 reference 中包含应用 3 的线索结果(如有)和最近 2 套应用 8 的历史信息(附 generated_at 时间)
|
||||
|
||||
### 需求 9:应用 7 客户分析(骨架)
|
||||
|
||||
**用户故事:** 作为系统,客户结账单出现后自动生成客户全量分析与运营建议。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 消费事件链中 App8_Consolidation 完成后,THE Event_Dispatcher SHALL 串行触发 App7_Customer(确保读到本次消费触发的最新线索)
|
||||
2. THE App7_Customer SHALL 返回 JSON 格式,包含 strategies 数组(每条含 title 和 content)和 summary(一句话总结)
|
||||
3. THE App7_Customer SHALL 使用 items_sum 作为消费金额口径,禁止使用 consume_money
|
||||
4. THE App7_Customer SHALL 对主观信息(来自备注)标注【来源:XXX,请甄别信息真实性】
|
||||
5. THE App7_Customer SHALL 将结果写入 ai_cache(cache_type=app7_customer_analysis,target_id=member_id)
|
||||
6. THE App7_Customer SHALL 实现触发机制和调用框架,Prompt 拼接函数留接口(objective_data 等字段待 P9-T1 细化)
|
||||
7. THE App7_Customer SHALL 在 Prompt 的 reference 中包含最新 + 最近 2 套应用 8 的历史信息(附 generated_at 时间)
|
||||
|
||||
### 需求 10:应用 8 维客线索整理
|
||||
|
||||
**用户故事:** 作为系统,应用 3 或应用 6 产出新线索后,自动整合去重生成统一维客线索,并写入 member_retention_clue 表。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN App3_Clue 产出新线索后,THE Event_Dispatcher SHALL 立即触发 App8_Consolidation
|
||||
2. WHEN App6_Note 产出新线索后,THE Event_Dispatcher SHALL 立即触发 App8_Consolidation
|
||||
3. THE App8_Consolidation SHALL 接收应用 3 和应用 6 的全部线索内容作为输入(附 generated_at 时间)
|
||||
4. THE App8_Consolidation SHALL 返回 JSON 格式的整合后线索数组,每条含 category、summary、detail、emoji、providers
|
||||
5. THE App8_Consolidation SHALL 将分类标签限定为 6 个枚举值:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈(与 member_retention_clue 表 CHECK 约束一致)
|
||||
6. THE App8_Consolidation SHALL 合并相似线索(多提供者以逗号分隔),其余线索原文返回,遵循最小改动原则
|
||||
7. THE App8_Consolidation SHALL 将整合后的线索全量替换该客户在 member_retention_clue 中的所有 AI 来源线索(source IN ('ai_consumption', 'ai_note')),人工线索(source='manual')不受影响
|
||||
8. THE Clue_Writer SHALL 按以下字段映射写入 member_retention_clue:category → category、emoji + summary → summary(emoji 拼接在前,如"📅 偏好周末下午时段消费")、detail → detail、providers → recorded_by_name、source 根据线索来源判断(纯应用 3 → ai_consumption,纯应用 6 → ai_note,混合来源 → ai_consumption)
|
||||
9. THE Clue_Writer SHALL 对系统触发的线索将 recorded_by_assistant_id 填 NULL
|
||||
10. THE App8_Consolidation SHALL 将结果同时写入 ai_cache(cache_type=app8_clue_consolidated,target_id=member_id)
|
||||
|
||||
### 需求 11:事件调度与调用链编排
|
||||
|
||||
**用户故事:** 作为系统,我需要根据业务事件(消费、备注、任务分配)自动编排 AI 应用调用链,确保执行顺序和数据依赖正确。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 消费事件(结账单)发生时,THE Event_Dispatcher SHALL 按严格串行顺序执行:应用 3 → 应用 8 → 应用 7
|
||||
2. WHEN 消费事件中该结算单有助教参与时,THE Event_Dispatcher SHALL 在应用 8 完成后额外执行:应用 4 → 应用 5
|
||||
3. WHEN 备注提交事件发生时,THE Event_Dispatcher SHALL 按串行顺序执行:应用 6 → 应用 8
|
||||
4. WHEN 任务分配事件(优先召回/高优先召回)发生时,THE Event_Dispatcher SHALL 执行:应用 4 → 应用 5(直接读取应用 8 已有缓存)
|
||||
5. THE Event_Dispatcher SHALL 确保消费事件链中应用 7 等待应用 8 完成后再启动,保证读到本次消费触发的最新线索
|
||||
6. THE Event_Dispatcher SHALL 确保消费事件链中应用 4 等待应用 3 → 应用 8 完成后再执行,确保读到本次消费的最新线索
|
||||
7. IF 调用链中某个应用调用失败,THEN THE Event_Dispatcher SHALL 记录错误日志,后续应用使用已有缓存继续执行(不阻塞整条链)
|
||||
|
||||
### 需求 12:AI 缓存读写 API
|
||||
|
||||
**用户故事:** 作为前端,我需要通过 API 读取各 AI 应用的缓存结果,以便在对应页面展示 AI 分析内容。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE AI_Cache_Service SHALL 提供按 cache_type + site_id + target_id 查询最新缓存结果的 API
|
||||
2. THE AI_Cache_Service SHALL 支持以下前端消费场景:应用 2 结果展示在 board-finance 财务看板、应用 4/5 结果展示在 task-detail 任务详情页、应用 6 的 score 以打星方式展示在备注卡片、应用 7 结果展示在 customer-detail 客户详情页、应用 8 结果通过 member_retention_clue 表展示
|
||||
3. THE AI_Cache_Service SHALL 对每个 (cache_type, site_id, target_id) 组合保留最近 500 条记录,超过时删除最旧的
|
||||
4. WHEN 写入新缓存记录后,THE AI_Cache_Service SHALL 异步检查并清理超限记录
|
||||
5. THE AI_Cache_Service SHALL 对所有查询和写入操作执行 site_id 隔离
|
||||
|
||||
### 需求 13:AI 调用记录持久化
|
||||
|
||||
**用户故事:** 作为系统,所有 AI 对话(含用户主动和系统自动调用)都要持久化记录,以便追溯和统计。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE AI_Integration_System SHALL 对所有 8 个应用的每次 AI 调用创建 ai_conversations 记录,包含 conversation_id、app_id、user_id(系统调用时为系统标识)、nickname、site_id
|
||||
2. THE AI_Integration_System SHALL 对每次 AI 调用的输入和输出分别写入 ai_messages,包含 role(user/assistant/system)、content、tokens_used、created_at
|
||||
3. THE AI_Integration_System SHALL 在 ai_conversations 中记录 source_page 和 source_context(JSON),标识调用来源
|
||||
|
||||
### 需求 14:百炼技术方案确认
|
||||
|
||||
**用户故事:** 作为开发者,我需要确认百炼 API 的流式返回技术方案和 JSON 输出最佳实践,以便正确实现封装层。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE AI_Integration_System SHALL 查阅百炼官方文档,确认流式返回的技术方案(SSE vs WebSocket)
|
||||
2. THE AI_Integration_System SHALL 确认百炼 API 的 JSON 输出模式配置方式(response_format 参数或 System Prompt 约束)
|
||||
3. THE AI_Integration_System SHALL 基于确认结果输出技术方案文档,作为 Bailian_Client 实现的依据
|
||||
|
||||
---
|
||||
|
||||
## 范围说明
|
||||
|
||||
### P5-A 阶段(本 spec 覆盖)
|
||||
|
||||
| 任务 | 对应需求 | 说明 |
|
||||
|------|---------|------|
|
||||
| T1 | 需求 1 | 建表:ai_conversations + ai_messages + ai_cache |
|
||||
| T2 | 需求 2 | 百炼 API 统一封装层 |
|
||||
| T3 | 需求 3 | 应用 1 通用对话 SSE |
|
||||
| T5 | 需求 4 | 应用 2 财务洞察(Prompt 已确定) |
|
||||
| T6-骨架 | 需求 5 | 应用 3 触发机制 + 调用框架 |
|
||||
| T7-骨架 | 需求 6 | 应用 4 触发机制 + 调用框架 |
|
||||
| T8-骨架 | 需求 7 | 应用 5 联动框架 |
|
||||
| T9-骨架 | 需求 8 | 应用 6 触发机制 + 调用框架 |
|
||||
| T10-骨架 | 需求 9 | 应用 7 触发机制 + 调用框架 |
|
||||
| T11 | 需求 10 | 应用 8 维客线索整理(Prompt 已确定) |
|
||||
| T12 | 需求 12 | AI 缓存读写 API |
|
||||
| T13 | 需求 14 | 百炼技术方案确认 |
|
||||
| — | 需求 11 | 事件调度与调用链编排(贯穿 T6-T11) |
|
||||
| — | 需求 13 | AI 调用记录持久化(贯穿所有应用) |
|
||||
|
||||
### P5-B 阶段(不在本 spec 范围)
|
||||
|
||||
以下任务将分散到对应页面的开发 spec 中完成:
|
||||
|
||||
- T4:页面内容文本化工具 → 随 P6-P9 各页面逐步实现
|
||||
- T6-完整:应用 3 Prompt JSON 细化 → P9-T1(customer-detail API)
|
||||
- T7-完整:应用 4 Prompt JSON 细化 → P6-T4(task-detail API)
|
||||
- T8-完整:应用 5 Prompt JSON 细化 → P6-T4(task-detail API)
|
||||
- T9-完整:应用 6 Prompt JSON 细化 → P9-T1(customer-detail API)
|
||||
- T10-完整:应用 7 Prompt JSON 细化 → P9-T1(customer-detail API)
|
||||
@@ -1,322 +0,0 @@
|
||||
# 实现计划:P5 AI 集成层(miniapp-ai-integration)
|
||||
|
||||
## 概述
|
||||
|
||||
基于 P5-A 阶段设计,在 `apps/backend/app/ai/` 新建 AI 模块,实现百炼 API 封装、SSE 对话、事件调度、缓存服务、8 个 AI 应用(其中 App2/App8 含完整 Prompt,App3/4/5/6/7 仅骨架)。每个任务增量构建,最终通过路由和事件调度器串联所有组件。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 数据库表结构与基础模块搭建
|
||||
- [x] 1.1 创建 DDL 迁移脚本,在 `biz` schema 下建表 `ai_conversations`、`ai_messages`、`ai_cache`
|
||||
- 按设计文档中的 DDL 创建三张表,包含所有字段、CHECK 约束、索引
|
||||
- DDL 文件放置于 `db/zqyy_app/migrations/` 目录,日期前缀命名
|
||||
- _需求: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||
|
||||
- [x] 1.2 创建 AI 模块目录结构和 Pydantic Schema
|
||||
- 创建 `apps/backend/app/ai/` 目录及 `__init__.py`
|
||||
- 创建 `apps/backend/app/ai/apps/` 子目录及 `__init__.py`
|
||||
- 创建 `apps/backend/app/ai/prompts/` 子目录及 `__init__.py`
|
||||
- 在 `apps/backend/app/ai/schemas.py` 中定义所有 Pydantic 模型:
|
||||
- `ChatStreamRequest`(message, source_page, page_context, screen_content)
|
||||
- `SSEEvent`(type, content, conversation_id, tokens_used, message)
|
||||
- `CacheTypeEnum`(7 个枚举值)
|
||||
- `ClueItem`(category, summary, detail, emoji)
|
||||
- `ConsolidatedClueItem`(含 providers)
|
||||
- `App2InsightItem`、`App4Result`、`App5TacticsItem`、`App6Result`、`App7Result`
|
||||
- `App2Result`、`App3Result`、`App8Result`
|
||||
- _需求: 4.4, 5.2, 5.3, 6.3, 7.3, 8.2, 8.3, 9.2, 10.4, 10.5_
|
||||
|
||||
- [x] 1.3 编写属性测试:AI 应用输出 JSON 结构验证
|
||||
- **Property 8: AI 应用输出 JSON 结构验证**
|
||||
- 使用 hypothesis 生成随机 JSON,验证各应用 Pydantic 模型的解析和校验
|
||||
- 验证 App3 category ∈ {客户基础, 消费习惯, 玩法偏好},App6/8 category ∈ 6 个枚举值
|
||||
- 测试文件:`tests/test_p5_ai_integration_properties.py`
|
||||
- **验证: 需求 4.4, 5.2, 5.3, 5.4, 6.3, 7.3, 8.2, 8.3, 8.4, 9.2, 10.4, 10.5**
|
||||
|
||||
- [x] 2. 百炼 API 统一封装层(BailianClient)
|
||||
- [x] 2.1 实现 BailianClient 核心逻辑
|
||||
- 文件:`apps/backend/app/ai/bailian_client.py`
|
||||
- 使用 `openai` Python SDK,`base_url` 指向百炼端点
|
||||
- 实现 `chat_stream`(流式,AsyncGenerator[str, None])
|
||||
- 实现 `chat_json`(非流式,返回 tuple[dict, int])
|
||||
- 实现 `_inject_current_time`(首条消息注入 current_time)
|
||||
- 实现 `_call_with_retry`(指数退避,最多 3 次,1s→2s→4s)
|
||||
- 定义异常类:`BailianApiError`、`BailianJsonParseError`、`BailianAuthError`
|
||||
- 环境变量:`BAILIAN_API_KEY`、`BAILIAN_BASE_URL`、`BAILIAN_MODEL`
|
||||
- _需求: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_
|
||||
|
||||
- [x] 2.2 编写属性测试:双模式调用一致性
|
||||
- **Property 1: BailianClient 双模式调用一致性**
|
||||
- Mock 百炼 API,验证 `chat_stream` 返回非空 chunk 序列,`chat_json` 返回可解析 JSON + 正整数 tokens_used
|
||||
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
|
||||
- **验证: 需求 2.1, 2.3, 2.4**
|
||||
|
||||
- [x] 2.3 编写属性测试:指数退避重试策略
|
||||
- **Property 2: 指数退避重试策略**
|
||||
- Mock 可控失败次数的 API,验证重试间隔为 base_interval × 2^(n-1),超过 max_retries 抛出 BailianApiError
|
||||
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
|
||||
- **验证: 需求 2.2**
|
||||
|
||||
- [x] 2.4 编写属性测试:JSON 解析失败错误处理
|
||||
- **Property 3: JSON 解析失败错误处理**
|
||||
- Mock 返回非法 JSON 字符串,验证 `chat_json` 抛出 BailianJsonParseError
|
||||
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
|
||||
- **验证: 需求 2.5**
|
||||
|
||||
- [x] 2.5 编写属性测试:current_time 注入不变量
|
||||
- **Property 4: current_time 注入不变量**
|
||||
- 纯函数测试,随机消息列表,验证首条消息注入 current_time(ISO 格式精确到秒),其余消息不变
|
||||
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
|
||||
- **验证: 需求 2.6**
|
||||
|
||||
- [x] 3. 对话记录持久化服务(ConversationService)
|
||||
- [x] 3.1 实现 ConversationService
|
||||
- 文件:`apps/backend/app/ai/conversation_service.py`
|
||||
- `create_conversation`:创建 ai_conversations 记录,系统调用时 user_id='system'
|
||||
- `add_message`:写入 ai_messages 记录(role, content, tokens_used)
|
||||
- `get_conversations`:按 user_id + site_id 查询,created_at DESC,分页(page_size=20)
|
||||
- `get_messages`:按 conversation_id 查询所有消息
|
||||
- _需求: 3.2, 3.4, 3.5, 3.7, 13.1, 13.2, 13.3_
|
||||
|
||||
- [x] 3.2 编写属性测试:AI 调用记录持久化 round-trip
|
||||
- **Property 5: AI 调用记录持久化 round-trip**
|
||||
- 使用 test_zqyy_app 数据库,随机 app_id 和消息内容,验证写入后查询一致
|
||||
- 测试文件:`apps/backend/tests/test_ai_conversation.py`
|
||||
- **验证: 需求 3.2, 3.4, 3.5, 13.1, 13.2, 13.3**
|
||||
|
||||
- [x] 3.3 编写属性测试:历史对话列表排序与分页
|
||||
- **Property 6: 历史对话列表排序与分页**
|
||||
- 使用 test_zqyy_app 数据库,随机时间戳创建对话,验证返回严格降序且每页 ≤ page_size
|
||||
- 测试文件:`apps/backend/tests/test_ai_conversation.py`
|
||||
- **验证: 需求 3.7**
|
||||
|
||||
- [x] 4. AI 缓存读写服务(AICacheService)
|
||||
- [x] 4.1 实现 AICacheService
|
||||
- 文件:`apps/backend/app/ai/cache_service.py`
|
||||
- `get_latest`:按 (cache_type, site_id, target_id) 查询最新记录
|
||||
- `get_history`:查询历史记录(created_at DESC,默认 limit=2),用于 Prompt reference
|
||||
- `write_cache`:写入缓存记录,写入后异步清理超限记录
|
||||
- `_cleanup_excess`:保留最近 500 条,删除最旧的
|
||||
- _需求: 12.1, 12.2, 12.3, 12.4, 12.5_
|
||||
|
||||
- [x] 4.2 编写属性测试:缓存写入 round-trip
|
||||
- **Property 7: 缓存写入 round-trip**
|
||||
- 使用 test_zqyy_app 数据库,随机 cache_type、target_id、result_json,验证写入后查询一致
|
||||
- 测试文件:`apps/backend/tests/test_ai_cache.py`
|
||||
- **验证: 需求 4.7, 5.6, 6.6, 7.5, 8.6, 9.5, 10.10**
|
||||
|
||||
- [x] 4.3 编写属性测试:缓存查询 site_id 隔离
|
||||
- **Property 13: 缓存查询 site_id 隔离**
|
||||
- 使用 test_zqyy_app 数据库,写入 site_id=A 的记录,以 site_id=B 查询应返回空
|
||||
- 测试文件:`apps/backend/tests/test_ai_cache.py`
|
||||
- **验证: 需求 12.1, 12.5**
|
||||
|
||||
- [x] 4.4 编写属性测试:缓存保留上限
|
||||
- **Property 14: 缓存保留上限**
|
||||
- 使用 test_zqyy_app 数据库,批量写入 >500 条记录,验证清理后 ≤ 500
|
||||
- 测试文件:`apps/backend/tests/test_ai_cache.py`
|
||||
- **验证: 需求 12.3**
|
||||
|
||||
- [x] 5. 检查点 - 基础服务验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
- 验证 BailianClient、ConversationService、AICacheService 三个核心服务可独立工作
|
||||
|
||||
- [x] 6. 应用 1 通用对话 SSE 端点
|
||||
- [x] 6.1 实现 App1 Chat 核心逻辑
|
||||
- 文件:`apps/backend/app/ai/apps/app1_chat.py`
|
||||
- 每次进入 chat 页面新建 ai_conversations 记录(不复用)
|
||||
- 首条消息注入页面上下文(source_page、page_context、screen_content)
|
||||
- 用户消息立即写入 ai_messages(role=user)
|
||||
- 流式返回完成后写入完整 assistant 回复(含 tokens_used)
|
||||
- 通过 `biz_params.user_prompt_params` 传入 User_ID、Role、Nickname
|
||||
- 上下文注入框架留接口(页面文本化工具 P5-B 实现)
|
||||
- _需求: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.8_
|
||||
|
||||
- [x] 6.2 实现 SSE 路由端点
|
||||
- 文件:`apps/backend/app/routers/xcx_ai_chat.py`
|
||||
- `POST /api/ai/chat/stream`:SSE 协议推送,Content-Type: text/event-stream
|
||||
- SSE 事件格式:chunk / done / error
|
||||
- `GET /api/ai/conversations`:历史对话列表(分页,每页 20 条)
|
||||
- `GET /api/ai/conversations/{conversation_id}/messages`:对话消息列表
|
||||
- JWT 认证,从 token 提取 user_id、site_id、nickname、role
|
||||
- 注册路由到 FastAPI app
|
||||
- _需求: 3.1, 3.7_
|
||||
|
||||
- [x] 6.3 编写单元测试:SSE 端点
|
||||
- 验证 SSE Content-Type 和事件格式(chunk/done/error)
|
||||
- 验证未认证返回 401、空消息返回 422
|
||||
- 测试文件:`apps/backend/tests/test_ai_chat.py`
|
||||
- _需求: 3.1_
|
||||
|
||||
- [x] 7. 应用 2 财务洞察(完整 Prompt)
|
||||
- [x] 7.1 实现 App2 Finance Prompt 模板
|
||||
- 文件:`apps/backend/app/ai/prompts/app2_finance_prompt.py`
|
||||
- 完整 Prompt 包含:当期和上期收入结构(table_fee=table_charge_money、assistant_pd=assistant_pd_money、assistant_cx=assistant_cx_money、goods=goods_money、recharge=充值 pay_amount settle_type=5)
|
||||
- 包含储值资产、费用汇总、平台结算数据
|
||||
- 使用 items_sum 口径,禁止 consume_money
|
||||
- _需求: 4.5, 4.6_
|
||||
|
||||
- [x] 7.2 实现 App2 Finance 核心逻辑
|
||||
- 文件:`apps/backend/app/ai/apps/app2_finance.py`
|
||||
- 8 个时间维度独立调用(this_month, last_month, this_week, last_week, last_3_months, this_quarter, last_quarter, last_6_months)
|
||||
- 营业日分界点 08:00(`BUSINESS_DAY_START_HOUR` 环境变量)
|
||||
- 每次调用结果写入 ai_cache(cache_type=app2_finance,target_id=时间维度编码)
|
||||
- 每次调用创建 ai_conversations + ai_messages 记录
|
||||
- 返回结构化 JSON(insights 数组:seq + title + body)
|
||||
- _需求: 4.1, 4.2, 4.3, 4.4, 4.7_
|
||||
|
||||
- [x] 7.3 编写单元测试:App2 时间维度计算
|
||||
- 验证 8 个时间维度编码的计算逻辑(营业日分界点 08:00)
|
||||
- 验证 Prompt 使用 items_sum 口径字段映射
|
||||
- 测试文件:`apps/backend/tests/test_ai_app2.py`
|
||||
- _需求: 4.3, 4.6_
|
||||
|
||||
- [x] 8. 应用 3/4/5/6/7 骨架实现
|
||||
- [x] 8.1 实现 App3 Clue 骨架
|
||||
- 文件:`apps/backend/app/ai/apps/app3_clue.py`
|
||||
- `run` 函数:构建 Prompt → 调用百炼 → 写入 conversation + cache
|
||||
- `build_prompt`:留接口,返回占位 Prompt,标注待细化字段(consumption_records 等待 P9-T1)
|
||||
- 线索 category 限定 3 个枚举值,providers 标记为"系统"
|
||||
- 使用 items_sum 口径
|
||||
- Prompt reference 包含 App6 线索 + 最近 2 套 App8 历史(附 generated_at)
|
||||
- 结果写入 ai_cache(cache_type=app3_clue,target_id=member_id)
|
||||
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8_
|
||||
|
||||
- [x] 8.2 实现 App4 Analysis 骨架
|
||||
- 文件:`apps/backend/app/ai/apps/app4_analysis.py`
|
||||
- `build_prompt`:留接口(service_history、assistant_info 待 P6-T4)
|
||||
- Prompt reference 包含 App8 最新 + 最近 2 套历史(附 generated_at)
|
||||
- 缓存不存在时 reference 传空对象,标注"暂无历史线索"
|
||||
- 结果写入 ai_cache(cache_type=app4_analysis,target_id=`{assistant_id}_{member_id}`)
|
||||
- _需求: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7_
|
||||
|
||||
- [x] 8.3 实现 App5 Tactics 骨架
|
||||
- 文件:`apps/backend/app/ai/apps/app5_tactics.py`
|
||||
- 接收 App4 完整返回结果作为 Prompt 中的 task_suggestion 字段
|
||||
- `build_prompt`:留接口(service_history、assistant_info 随 App4 同步在 P6-T4)
|
||||
- Prompt reference 包含最近 2 套 App8 历史(附 generated_at)
|
||||
- 结果写入 ai_cache(cache_type=app5_tactics,target_id=`{assistant_id}_{member_id}`)
|
||||
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
|
||||
|
||||
- [x] 8.4 实现 App6 Note 骨架
|
||||
- 文件:`apps/backend/app/ai/apps/app6_note.py`
|
||||
- `build_prompt`:留接口(consumption_data 待 P9-T1)
|
||||
- 返回 score(1-10)+ clues 数组,category 限定 6 个枚举值
|
||||
- 线索提供者标记为当前备注提供人
|
||||
- 评分规则:6 分为标准分
|
||||
- Prompt reference 包含 App3 线索 + 最近 2 套 App8 历史(附 generated_at)
|
||||
- 结果写入 ai_cache(cache_type=app6_note_analysis,target_id=member_id),score 存入 ai_cache.score
|
||||
- _需求: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8_
|
||||
|
||||
- [x] 8.5 实现 App7 Customer 骨架
|
||||
- 文件:`apps/backend/app/ai/apps/app7_customer.py`
|
||||
- `build_prompt`:留接口(objective_data 待 P9-T1)
|
||||
- 使用 items_sum 口径
|
||||
- 对主观信息标注【来源:XXX,请甄别信息真实性】
|
||||
- Prompt reference 包含最新 + 最近 2 套 App8 历史(附 generated_at)
|
||||
- 结果写入 ai_cache(cache_type=app7_customer_analysis,target_id=member_id)
|
||||
- _需求: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7_
|
||||
|
||||
- [x] 8.6 编写属性测试:Prompt reference 历史注入
|
||||
- **Property 9: Prompt reference 历史注入**
|
||||
- Mock 缓存数据,验证各应用 build_prompt 的 reference 字段包含正确的缓存结果和 generated_at 时间戳
|
||||
- 缓存不存在时 reference 为空对象
|
||||
- 测试文件:`apps/backend/tests/test_ai_apps_prompt.py`
|
||||
- **验证: 需求 5.8, 6.4, 6.5, 7.2, 7.4, 8.8, 9.7**
|
||||
|
||||
- [x] 9. 应用 8 维客线索整理(完整 Prompt)+ ClueWriter
|
||||
- [x] 9.1 实现 App8 Consolidation Prompt 模板
|
||||
- 文件:`apps/backend/app/ai/prompts/app8_consolidation_prompt.py`
|
||||
- 完整 Prompt:接收 App3 和 App6 全部线索(附 generated_at),整合去重
|
||||
- 分类标签限定 6 个枚举值(与 member_retention_clue CHECK 约束一致)
|
||||
- 合并相似线索(多提供者逗号分隔),其余原文返回,最小改动原则
|
||||
- _需求: 10.3, 10.4, 10.5, 10.6_
|
||||
|
||||
- [x] 9.2 实现 ClueWriter 全量替换逻辑
|
||||
- 集成在 `apps/backend/app/ai/apps/app8_consolidation.py`
|
||||
- DELETE source IN ('ai_consumption', 'ai_note') → INSERT 新线索(事务)
|
||||
- 字段映射:emoji+summary 拼接、providers→recorded_by_name、source 判断逻辑
|
||||
- recorded_by_assistant_id 填 NULL
|
||||
- 人工线索(source='manual')不受影响
|
||||
- _需求: 10.7, 10.8, 10.9_
|
||||
|
||||
- [x] 9.3 实现 App8 Consolidation 核心逻辑
|
||||
- 文件:`apps/backend/app/ai/apps/app8_consolidation.py`
|
||||
- `run` 函数:构建 Prompt → 调用百炼 → 写入 conversation + cache + member_retention_clue
|
||||
- 结果同时写入 ai_cache(cache_type=app8_clue_consolidated,target_id=member_id)
|
||||
- _需求: 10.1, 10.2, 10.10_
|
||||
|
||||
- [x] 9.4 编写属性测试:ClueWriter 全量替换不变量
|
||||
- **Property 12: ClueWriter 全量替换不变量**
|
||||
- 使用 test_zqyy_app 数据库,随机线索列表 + 预置人工线索
|
||||
- 验证:AI 线索数量 = new_clues 数量、人工线索不变、recorded_by_assistant_id=NULL、summary=emoji+空格+原始 summary
|
||||
- 测试文件:`apps/backend/tests/test_ai_clue_writer.py`
|
||||
- **验证: 需求 10.7, 10.8, 10.9**
|
||||
|
||||
- [x] 10. 检查点 - 应用层验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
- 验证 8 个应用的 run 函数可独立调用(Mock 百炼 API)
|
||||
|
||||
- [x] 11. 事件调度与调用链编排(AIDispatcher) ✅
|
||||
- [x] 11.1 实现 AIDispatcher 核心逻辑
|
||||
- 文件:`apps/backend/app/ai/dispatcher.py`
|
||||
- `handle_consumption_event`:App3 → App8 → App7(+ App4 → App5 如有助教)
|
||||
- `handle_note_event`:App6 → App8
|
||||
- `handle_task_assign_event`:App4 → App5(读已有 App8 缓存)
|
||||
- `_run_chain`:串行执行调用链,某步失败记录日志后继续
|
||||
- 容错:失败应用记录错误日志 + 写入失败 conversation,后续应用使用已有缓存
|
||||
- 整条链后台异步执行,不阻塞业务请求
|
||||
- _需求: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_
|
||||
|
||||
- [x] 11.2 集成事件触发点
|
||||
- 在 `trigger_scheduler.fire_event()` 中注册 AI 事件处理器
|
||||
- 消费事件(consumption_settled)→ `ai_dispatcher.handle_consumption_event`
|
||||
- 备注事件(note_created)→ `ai_dispatcher.handle_note_event`
|
||||
- 任务分配事件(task_assigned)→ `ai_dispatcher.handle_task_assign_event`
|
||||
- _需求: 5.1, 6.1, 6.2, 7.1, 8.1, 9.1, 11.1, 11.2, 11.3, 11.4_
|
||||
|
||||
- [x] 11.3 编写属性测试:事件调用链顺序正确性
|
||||
- **Property 10: 事件调用链顺序正确性**
|
||||
- Mock 所有应用,记录调用序列,验证四种事件链的严格顺序
|
||||
- 测试文件:`apps/backend/tests/test_ai_dispatcher.py`
|
||||
- **验证: 需求 11.1, 11.2, 11.3, 11.4, 11.5, 11.6**
|
||||
|
||||
- [x] 11.4 编写属性测试:调用链容错不变量
|
||||
- **Property 11: 调用链容错不变量**
|
||||
- Mock 随机应用失败,验证后续应用继续执行且失败应用有错误日志
|
||||
- 测试文件:`apps/backend/tests/test_ai_dispatcher.py`
|
||||
- **验证: 需求 11.7**
|
||||
|
||||
- [x] 12. 缓存查询路由与环境配置
|
||||
- [x] 12.1 实现缓存查询路由
|
||||
- 文件:`apps/backend/app/routers/xcx_ai_cache.py`
|
||||
- `GET /api/ai/cache/{cache_type}?target_id=xxx`:查询最新缓存
|
||||
- JWT 认证,site_id 从 token 提取强制过滤
|
||||
- 注册路由到 FastAPI app
|
||||
- _需求: 12.1, 12.2, 12.5_
|
||||
|
||||
- [x] 12.2 新增环境变量配置
|
||||
- 在 `.env.template` 中添加 `BAILIAN_API_KEY`、`BAILIAN_BASE_URL`、`BAILIAN_MODEL`、`BUSINESS_DAY_START_HOUR`
|
||||
- 在后端配置加载逻辑中读取这些变量,缺失时报错
|
||||
- _需求: 2.1, 14.1, 14.2_
|
||||
|
||||
- [x] 13. 百炼技术方案确认文档 ✅
|
||||
- [x] 13.1 输出百炼技术方案确认文档
|
||||
- 文件:`docs/reports/bailian-technical-solution.md`
|
||||
- 确认流式返回方案(OpenAI 兼容 SSE)
|
||||
- 确认 JSON 输出模式(response_format + System Prompt 约束)
|
||||
- 确认 SDK 选择(openai Python SDK + base_url 指向百炼)
|
||||
- 作为 BailianClient 实现的依据
|
||||
- _需求: 14.1, 14.2, 14.3_
|
||||
|
||||
- [x] 14. 最终检查点 - 全量验证 ✅
|
||||
- 全部 9 个测试文件、62 个测试用例通过(2026-03-09)
|
||||
- 验证所有路由注册正确、事件触发点集成完毕、环境变量配置完整
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的任务为可选,可跳过以加速 MVP 交付
|
||||
- 每个任务引用具体需求编号以确保可追溯
|
||||
- 属性测试验证设计文档中定义的 14 个正确性属性
|
||||
- 使用 test_zqyy_app 测试库执行数据库相关测试,禁止连接正式库
|
||||
- App3/4/5/6/7 的 Prompt 细化将在 P5-B 阶段(P6/P9 对应任务)中完成
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,252 +0,0 @@
|
||||
# 设计文档:ETL 全流程前后端联调(etl-fullstack-integration)
|
||||
|
||||
## 概述
|
||||
|
||||
本 Spec 是一个运维联调任务,不涉及新功能开发。目标是验证 `admin-web-console` Spec 产出的前后端代码在真实环境下的端到端正确性,同时收集性能数据。
|
||||
|
||||
核心流程:
|
||||
1. 启动后端 + 前端服务
|
||||
2. 通过 API 登录获取 JWT
|
||||
3. 提交全流程 ETL 任务(api_full, full_window, force-full, 全选常用任务, 自定义窗口 2025-11-01~当前时间, 30天切分, 全部门店)
|
||||
4. 实时监控执行过程,捕获错误/警告
|
||||
4. 执行完成后进行黑盒数据一致性测试(全链路检查器 `scripts/ops/etl_consistency_check.py` + FlowRunner 内置 `ConsistencyChecker`)
|
||||
5. 生成综合报告(含性能数据和黑盒测试结果)
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
联调脚本 (scripts/ops/)
|
||||
│
|
||||
├── 1. 启动服务
|
||||
│ ├── uvicorn app.main:app (后端 :8000)
|
||||
│ └── pnpm dev (前端 :5173)
|
||||
│
|
||||
├── 2. API 调用链
|
||||
│ ├── POST /api/auth/login → JWT
|
||||
│ ├── GET /api/tasks/registry → 任务列表
|
||||
│ ├── GET /api/tasks/sync-check → 同步检查
|
||||
│ ├── POST /api/tasks/validate → CLI 预览
|
||||
│ └── POST /api/execution/run → 触发执行
|
||||
│
|
||||
├── 3. 监控循环
|
||||
│ ├── GET /api/execution/queue → 状态轮询
|
||||
│ ├── GET /api/execution/{id}/logs → 日志获取
|
||||
│ └── 错误/警告检测
|
||||
│
|
||||
├── 4. 黑盒数据一致性测试
|
||||
│ ├── 全链路检查器(scripts/ops/etl_consistency_check.py)
|
||||
│ │ ├── API JSON vs ODS:字段完整性 + 值采样比对
|
||||
│ │ ├── ODS vs DWD:行数 + 字段映射 + 值比对(含 EX 表合并)
|
||||
│ │ ├── DWD vs DWS:聚合表行数 + 数值列健全性检查
|
||||
│ │ └── 白名单机制:ETL_META_COLS / SCD2_COLS / 空字符串≡None
|
||||
│ ├── FlowRunner 内置检查(quality/consistency_checker.py,自动执行)
|
||||
│ └── etl-data-consistency Hook(可选手动触发)
|
||||
│
|
||||
└── 5. 报告生成
|
||||
└── 输出到 SYSTEM_LOG_ROOT
|
||||
```
|
||||
|
||||
## 任务参数
|
||||
|
||||
根据用户需求,联调任务的具体参数:
|
||||
|
||||
```python
|
||||
INTEGRATION_TASK_CONFIG = {
|
||||
"flow": "api_full", # 全流程:API → ODS → DWD → DWS → INDEX
|
||||
"processing_mode": "full_window", # 全窗口处理
|
||||
"window_mode": "custom", # 自定义时间范围
|
||||
"window_start": "2025-11-01 00:00",
|
||||
"window_end": "", # 补充当前时间
|
||||
"window_split": "day", # 按天切分
|
||||
"window_split_days": 30, # 30天一个切片
|
||||
"force_full": True, # 强制全量
|
||||
"dry_run": False,
|
||||
"tasks": [ # 全选 is_common=True 的任务
|
||||
# ODS 层(22 个)
|
||||
"ODS_ASSISTANT_ACCOUNT", "ODS_ASSISTANT_LEDGER", "ODS_ASSISTANT_ABOLISH",
|
||||
"ODS_SETTLEMENT_RECORDS", "ODS_TABLE_USE", "ODS_TABLE_FEE_DISCOUNT",
|
||||
"ODS_TABLES", "ODS_PAYMENT", "ODS_REFUND", "ODS_PLATFORM_COUPON",
|
||||
"ODS_MEMBER", "ODS_MEMBER_CARD", "ODS_MEMBER_BALANCE", "ODS_RECHARGE_SETTLE",
|
||||
"ODS_GROUP_PACKAGE", "ODS_GROUP_BUY_REDEMPTION",
|
||||
"ODS_INVENTORY_STOCK", "ODS_INVENTORY_CHANGE",
|
||||
"ODS_GOODS_CATEGORY", "ODS_STORE_GOODS", "ODS_STORE_GOODS_SALES", "ODS_TENANT_GOODS",
|
||||
# DWD 层(1 个常用)
|
||||
"DWD_LOAD_FROM_ODS",
|
||||
# DWS 层(15 个常用,排除 DWS_MAINTENANCE)
|
||||
"DWS_BUILD_ORDER_SUMMARY", "DWS_ASSISTANT_DAILY", "DWS_ASSISTANT_MONTHLY",
|
||||
"DWS_ASSISTANT_CUSTOMER", "DWS_ASSISTANT_SALARY", "DWS_ASSISTANT_FINANCE",
|
||||
"DWS_MEMBER_CONSUMPTION", "DWS_MEMBER_VISIT",
|
||||
"DWS_FINANCE_DAILY", "DWS_FINANCE_RECHARGE", "DWS_FINANCE_INCOME_STRUCTURE",
|
||||
"DWS_FINANCE_DISCOUNT_DETAIL",
|
||||
"DWS_GOODS_STOCK_DAILY", "DWS_GOODS_STOCK_WEEKLY", "DWS_GOODS_STOCK_MONTHLY",
|
||||
# INDEX 层(3 个常用,排除 DWS_ML_MANUAL_IMPORT)
|
||||
"DWS_WINBACK_INDEX", "DWS_NEWCONV_INDEX", "DWS_RELATION_INDEX",
|
||||
],
|
||||
# store_id 由后端从 JWT 注入(默认管理员 site_id=1)
|
||||
# 注意:用户要求"全部门店",但当前系统只有 site_id=1,后续多门店需逐个执行
|
||||
}
|
||||
```
|
||||
|
||||
## 监控策略
|
||||
|
||||
- 轮询间隔:30 秒
|
||||
- 最长等待:30 分钟(无新日志输出时)
|
||||
- 错误检测:日志行匹配 `ERROR`、`CRITICAL`、`Traceback`、`Exception`
|
||||
- 警告检测:日志行匹配 `WARNING`、`WARN`
|
||||
- 计时解析:从日志中提取时间戳,计算阶段耗时
|
||||
|
||||
## 黑盒数据一致性测试
|
||||
|
||||
### 两套检查工具的定位
|
||||
|
||||
| 工具 | 路径 | 触发方式 | 覆盖范围 | 适用场景 |
|
||||
|------|------|---------|---------|---------|
|
||||
| 全链路检查器 | `scripts/ops/etl_consistency_check.py` | 手动运行 / `etl-data-consistency` Hook | API→ODS→DWD→DWS 四层全链路 | 联调后独立全面验证(本 Spec 主要使用) |
|
||||
| FlowRunner 内置检查 | `apps/etl/connectors/feiqiu/quality/consistency_checker.py` | FlowRunner 自动调用 `_run_post_consistency_check()` | API→ODS 字段 + ODS→DWD 映射/值 | ETL 执行后自动轻量检查 |
|
||||
|
||||
联调场景下,FlowRunner 在 ETL 执行完成后已自动运行内置检查并输出报告到 `ETL_REPORT_ROOT`。联调脚本额外运行全链路检查器,覆盖 DWD→DWS 聚合验证和更深入的值采样比对。
|
||||
|
||||
### etl-data-consistency Hook
|
||||
|
||||
`.kiro/hooks/etl-data-consistency.kiro.hook` 提供手动触发入口,执行 `scripts/ops/etl_consistency_check.py`。联调任务 5 也可通过此 Hook 替代手动运行脚本。
|
||||
|
||||
### 白名单机制
|
||||
|
||||
全链路检查器在值比对时使用三层白名单过滤,避免 ETL 框架自动填充的列和已知等价差异产生误报:
|
||||
|
||||
#### 1. ETL 元数据列白名单(`ETL_META_COLS`)
|
||||
|
||||
不参与 API↔ODS 值比对的列:
|
||||
|
||||
```python
|
||||
ETL_META_COLS = {"source_file", "source_endpoint", "fetched_at", "payload", "content_hash"}
|
||||
```
|
||||
|
||||
这些列由 ETL 框架在 ODS 落库时自动填充,API 源数据中不存在。
|
||||
|
||||
#### 2. SCD2 管理列白名单(`SCD2_COLS`)
|
||||
|
||||
不参与 ODS↔DWD 值比对的列:
|
||||
|
||||
```python
|
||||
SCD2_COLS = {
|
||||
"valid_from", "valid_to", "is_current", "etl_loaded_at", "etl_batch_id",
|
||||
"scd2_start_time", "scd2_end_time", "scd2_is_current", "scd2_version",
|
||||
}
|
||||
```
|
||||
|
||||
这些列由 DWD 层 SCD2 逻辑自动维护,ODS 源数据中不存在。
|
||||
|
||||
#### 3. 空字符串 vs None 等价规则(`_values_differ()`)
|
||||
|
||||
API 返回空字符串 `""` 而数据库存储为 `None` 时,视为等价(不算差异),但标记为 `whitelist`:
|
||||
|
||||
```python
|
||||
# API 空字符串 "" vs DB None → 白名单(等价但标记)
|
||||
if api_val is not None and ods_val is None:
|
||||
if isinstance(api_val, str) and api_val.strip() == "":
|
||||
return False, "whitelist"
|
||||
```
|
||||
|
||||
报告中白名单差异以折叠 `<details>` 块展示,不计入失败统计。
|
||||
|
||||
#### 4. FlowRunner 内置检查的白名单
|
||||
|
||||
`consistency_checker.py` 使用类似但略有不同的白名单:
|
||||
- `ODS_META_COLUMNS`:与 `ETL_META_COLS` 相同,额外包含 `record_index`
|
||||
- `KNOWN_NO_SOURCE`:按表配置的已知无源字段(如 `dwd.dim_member.update_time`),标记为已知无源而非报错
|
||||
|
||||
### 调用方式
|
||||
|
||||
联调脚本在 ETL 全流程执行完成后,运行全链路检查器:
|
||||
|
||||
```bash
|
||||
cd C:\NeoZQYY
|
||||
uv run python scripts/ops/etl_consistency_check.py
|
||||
```
|
||||
|
||||
脚本自动完成:
|
||||
1. 从 `LOG_ROOT` 找到最近一次 ETL 日志,解析执行的任务列表
|
||||
2. 从 `FETCH_ROOT` 读取 API JSON 落盘文件
|
||||
3. 连接数据库(`PG_DSN`),逐表逐字段比对:
|
||||
- API JSON vs ODS:字段完整性 + 值采样比对(随机 5 条)
|
||||
- ODS vs DWD:行数 + 字段映射 + 值比对(含 EX 表合并)
|
||||
- DWD vs DWS:聚合表行数 + 数值列健全性(NULL 率、负值、min/max)
|
||||
4. 输出 Markdown 报告到 `ETL_REPORT_ROOT`
|
||||
|
||||
### 检查内容
|
||||
|
||||
| 检查类型 | 对比对象 | 检查项 | 白名单处理 |
|
||||
|---------|---------|--------|-----------|
|
||||
| API vs ODS | API JSON 缓存 vs ODS 表 | 字段完整性 + 值采样比对(5 条记录) | `ETL_META_COLS` 排除;空字符串≡None |
|
||||
| ODS vs DWD | ODS 表 vs DWD 表(含 EX 表) | 行数对比 + 字段映射 + 值比对 | `SCD2_COLS` 排除;空字符串≡None |
|
||||
| DWD vs DWS | DWD 源表 vs DWS 聚合表 | 行数非空 + 数值列健全性(NULL 率、负值、min/max) | 无(DWS 为聚合结果,不做逐行值比对) |
|
||||
|
||||
### 报告格式
|
||||
|
||||
全链路检查器输出 Markdown 报告,包含:
|
||||
1. ETL 执行概览(任务列表、成功/失败/跳过统计)
|
||||
2. API↔ODS 数据一致性(逐表逐字段值比对,白名单差异折叠展示)
|
||||
3. ODS↔DWD 数据一致性(行数对比 + 映射验证 + 值采样,含字段级统计)
|
||||
4. DWD↔DWS 数据一致性(DWS 表概览 + 数值列健全性检查)
|
||||
5. 异常汇总与建议
|
||||
|
||||
### 参考数据
|
||||
|
||||
`dataflow-field-completion` 的实际执行结果:API vs ODS 22/22 通过,ODS vs DWD 38/42 通过。本次联调执行 api_full 全流程后,预期结果应与此一致或更优(因为联调包含最新的字段补全)。
|
||||
|
||||
## 报告格式
|
||||
|
||||
报告输出为 Markdown 文件,路径:`{SYSTEM_LOG_ROOT}/{date}__etl_integration_report.md`
|
||||
|
||||
```markdown
|
||||
# ETL 全流程联调报告
|
||||
|
||||
## 执行概要
|
||||
- 任务参数:...
|
||||
- 开始时间 / 结束时间 / 总时长
|
||||
- 退出码 / 最终状态
|
||||
|
||||
## 性能报告
|
||||
- 各窗口切片耗时对比表
|
||||
- Top-5 耗时阶段
|
||||
- 总体吞吐量估算
|
||||
|
||||
## 黑盒测试报告
|
||||
- API vs ODS:X/Y 张表通过(白名单差异 N 处)
|
||||
- ODS vs DWD:X/Y 张表通过(白名单差异 N 处)
|
||||
- DWD vs DWS:X 张表有数据 / Y 张总计,异常指标 N 个
|
||||
- 失败表清单及差异明细
|
||||
|
||||
## DEBUG 报告(如有)
|
||||
- 错误摘要
|
||||
- 警告摘要
|
||||
- 相关日志片段
|
||||
- 可能的原因分析
|
||||
```
|
||||
|
||||
## 正确性属性
|
||||
|
||||
本 Spec 为运维联调任务,不涉及新功能代码开发,因此不定义形式化的属性测试。验证通过以下方式进行:
|
||||
- 服务健康检查通过
|
||||
- 任务提交成功并开始执行
|
||||
- 执行完成后退出码和日志符合预期
|
||||
- 黑盒数据一致性测试通过(全链路检查器覆盖 API→ODS→DWD→DWS 四层,白名单差异不计入失败)
|
||||
- 报告文件成功生成(含性能报告和黑盒测试报告)
|
||||
|
||||
## 测试策略
|
||||
|
||||
本 Spec 本身就是一次集成测试。不额外编写单元测试或属性测试。验证标准:
|
||||
- 后端 API 响应正确
|
||||
- ETL CLI 子进程正常启动和执行
|
||||
- 日志正确捕获和推送
|
||||
- 黑盒数据一致性测试通过(全链路检查器 API→ODS→DWD→DWS + FlowRunner 内置检查)
|
||||
- 报告文件正确生成到 ETL_REPORT_ROOT(全链路检查报告)和 SYSTEM_LOG_ROOT(联调综合报告)
|
||||
|
||||
### 黑盒测试验证标准
|
||||
|
||||
- API vs ODS:所有已采集的 ODS 表字段完整性和值采样检查通过(白名单差异不计入失败)
|
||||
- ODS vs DWD:所有已配置映射的表行数和值比对检查通过(SCD2 列排除,白名单差异不计入失败)
|
||||
- DWD vs DWS:所有 DWS 聚合表行数非空,数值列无异常(高 NULL 率、金额负值等)
|
||||
- 全链路检查报告 `consistency_check_<timestamp>.md` 成功生成到 ETL_REPORT_ROOT
|
||||
- 综合联调报告中包含黑盒测试结果摘要(含白名单差异统计)
|
||||
@@ -1,85 +0,0 @@
|
||||
# 需求文档:ETL 全流程前后端联调(etl-fullstack-integration)
|
||||
|
||||
## 简介
|
||||
|
||||
基于已完成的 `admin-web-console` Spec 产出的前后端代码,进行一次完整的端到端联调验证。通过管理后台 API 提交 ETL 全流程任务(api_full),覆盖 ODS → DWD → DWS → INDEX 全链路,验证前后端协作、子进程执行、日志推送、错误处理等环节的正确性。同时收集精细计时数据,定位性能瓶颈。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **联调**:将 admin-web 后端 API 与 feiqiu ETL CLI 串联,通过真实 API 调用触发 ETL 全流程执行
|
||||
- **全选常用任务**:任务注册表中 `is_common=True` 的所有任务(排除工具类、手动导入、维护类等 `is_common=False` 的任务)
|
||||
- **精细计时**:在 ETL 执行过程中,通过日志解析或 CLI 输出,记录每个阶段(ODS/DWD/DWS/INDEX)和每个子任务的耗时
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:服务启动与健康检查
|
||||
|
||||
**用户故事:** 作为开发者,我希望一键启动后端和前端服务,并确认服务健康可用,以便开始联调。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 启动后端服务, THE Backend_API SHALL 在 `http://localhost:8000` 上响应请求
|
||||
2. WHEN 启动前端服务, THE Admin_Web SHALL 在 `http://localhost:5173` 上可访问
|
||||
3. WHEN 调用 `POST /api/auth/login`, THE Backend_API SHALL 返回有效的 JWT 令牌
|
||||
4. WHEN 调用 `GET /api/tasks/registry`, THE Backend_API SHALL 返回非空的任务注册表
|
||||
5. WHEN 调用 `GET /api/tasks/sync-check`, THE Backend_API SHALL 确认后端任务注册表与 ETL 真实注册表同步
|
||||
|
||||
### 需求 2:全流程任务提交与执行
|
||||
|
||||
**用户故事:** 作为开发者,我希望通过后端 API 提交一个覆盖全链路的 ETL 任务,验证任务配置、CLI 构建、子进程执行的完整流程。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 提交 TaskConfig(api_full + full_window + 全选常用任务 + 自定义窗口 2025-11-01~当前时间 + 30天切分 + force-full), THE Backend_API SHALL 验证配置有效并返回 CLI 命令预览
|
||||
2. WHEN 提交任务到执行队列, THE Backend_API SHALL 创建队列任务并自动开始执行
|
||||
3. WHILE ETL 子进程运行中, THE Backend_API SHALL 通过 WebSocket 推送实时日志
|
||||
4. WHEN ETL 子进程完成, THE Backend_API SHALL 记录退出码、执行时长、完整日志到 task_execution_log
|
||||
|
||||
### 需求 3:执行监控与错误处理
|
||||
|
||||
**用户故事:** 作为开发者,我希望在任务执行过程中实时监控状态,对报错或警告及时发现并进行 DEBUG。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHILE 任务执行中, THE 监控脚本 SHALL 每 30 秒轮询执行状态和日志
|
||||
2. WHEN 日志中出现 ERROR 或 WARNING 级别信息, THE 监控脚本 SHALL 立即记录并标记
|
||||
3. WHEN 任务执行完成(成功或失败), THE 监控脚本 SHALL 停止轮询并收集最终状态
|
||||
4. IF 任务执行超过 30 分钟无新日志输出, THEN THE 监控脚本 SHALL 报告超时警告
|
||||
5. IF 任务执行失败, THEN THE 监控脚本 SHALL 收集完整的 stderr 和错误上下文
|
||||
|
||||
### 需求 4:性能计时与瓶颈分析
|
||||
|
||||
**用户故事:** 作为开发者,我希望获得精细粒度的执行计时数据,以便发现性能瓶颈。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 任务执行完成, THE 报告 SHALL 包含总执行时长
|
||||
2. WHEN 日志中包含阶段性时间戳, THE 报告 SHALL 解析并展示每个窗口切片的耗时
|
||||
3. THE 报告 SHALL 标注耗时最长的 Top-5 阶段/任务
|
||||
4. THE 报告 SHALL 包含每个窗口切片(30天)的独立耗时对比
|
||||
|
||||
### 需求 5:黑盒数据一致性测试
|
||||
|
||||
**用户故事:** 作为开发者,我希望在 ETL 全流程执行完成后,以黑盒视角对比数据上下游的字段差异和值差异,验证数据从 API 到 ODS 再到 DWD 再到 DWS 的完整性和正确性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL 全流程执行完成后, THE 联调脚本 SHALL 运行全链路检查器 `scripts/ops/etl_consistency_check.py`,执行 API→ODS→DWD→DWS 四层数据一致性检查
|
||||
2. WHEN 执行 API vs ODS 检查时, THE 检查器 SHALL 对比 API JSON 落盘数据与 ODS 落库数据的字段完整性和值采样(随机 5 条记录的关键字段),覆盖所有已采集的 ODS 表
|
||||
3. WHEN 执行 ODS vs DWD 检查时, THE 检查器 SHALL 对比 ODS 数据与 DWD 落库数据的行数、字段映射正确性和值一致性(含 EX 表合并比对)
|
||||
4. WHEN 执行 DWD vs DWS 检查时, THE 检查器 SHALL 验证 DWS 聚合表的行数非空、数值列健全性(NULL 率、负值、min/max),标注异常指标
|
||||
5. WHEN 值比对遇到白名单场景时, THE 检查器 SHALL 将 ETL 元数据列(`source_file`、`source_endpoint`、`fetched_at`、`payload`、`content_hash`)和 SCD2 管理列排除在值比对之外,并将 API 空字符串 `""` vs DB `None` 视为等价(标记为 whitelist)
|
||||
6. WHEN 黑盒测试完成后, THE 检查器 SHALL 输出 Markdown 报告到 `ETL_REPORT_ROOT` 环境变量指定的目录
|
||||
7. WHEN 黑盒测试报告生成后, THE 报告 SHALL 包含每张表的检查结果、差异明细(含白名单差异折叠展示)、通过/失败状态、字段级统计、以及汇总统计
|
||||
|
||||
### 需求 6:联调报告输出
|
||||
|
||||
**用户故事:** 作为开发者,我希望联调完成后获得一份综合报告,包含执行情况、性能数据、黑盒测试结果和可能的 DEBUG 信息。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 报告 SHALL 包含:执行概要(任务参数、开始/结束时间、总时长、退出码)
|
||||
2. THE 报告 SHALL 包含:性能报告(各阶段耗时、窗口切片耗时对比、Top-5 瓶颈)
|
||||
3. THE 报告 SHALL 包含:黑盒测试结果摘要(API vs ODS 通过数/总数、ODS vs DWD 通过数/总数、DWD vs DWS 表概览、失败表清单、白名单差异数量)
|
||||
4. IF 执行过程中出现错误或警告, THEN THE 报告 SHALL 包含 DEBUG 报告(错误摘要、相关日志片段、可能的原因分析)
|
||||
5. THE 报告 SHALL 输出到 `SYSTEM_LOG_ROOT` 环境变量指定的目录
|
||||
@@ -1,131 +0,0 @@
|
||||
# 实现计划:ETL 全流程前后端联调(etl-fullstack-integration)
|
||||
|
||||
## 概述
|
||||
|
||||
基于 `admin-web-console` 已完成的前后端代码,进行端到端联调验证。全程使用 Playwright 浏览器模拟真实用户操作(登录、配置、提交、监控),不直接调用 API。通过管理后台 UI 提交 api_full 全流程 ETL 任务(自定义窗口 2025-11-01~当前时间,30天切分,force-full,全选常用任务),实时监控执行过程,收集性能数据,执行黑盒数据一致性测试(全链路检查器 `scripts/ops/etl_consistency_check.py` + FlowRunner 内置 `ConsistencyChecker`),最终生成综合报告。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 服务启动与健康检查
|
||||
- [x] 1.1 启动后端服务(`apps/backend/`,uvicorn :8000),确认 API 可达
|
||||
- 使用 `controlPwshProcess` 启动 `uvicorn app.main:app --host 0.0.0.0 --port 8000`,cwd 为 `apps/backend/`
|
||||
- 等待服务就绪,验证 `http://localhost:8000/docs` 可访问
|
||||
- _Requirements: 1.1_
|
||||
|
||||
- [x] 1.2 启动前端服务(`apps/admin-web/`,pnpm dev :5173),确认页面可访问
|
||||
- 使用 `controlPwshProcess` 启动 `pnpm dev`,cwd 为 `apps/admin-web/`
|
||||
- 等待 Vite 就绪,验证 `http://localhost:5173` 可访问
|
||||
- _Requirements: 1.2_
|
||||
|
||||
- [x] 1.3 浏览器登录与健康检查
|
||||
- 使用 Playwright 打开 `http://localhost:5173`,应自动跳转到 `/login`
|
||||
- 在登录表单中输入用户名 `admin`、密码 `admin123`,点击登录按钮
|
||||
- 验证登录成功后跳转到任务配置页面(`/`)
|
||||
- 确认侧边栏导航菜单正常渲染(任务配置、任务管理、ETL 状态、数据库、日志、环境配置、运维面板)
|
||||
- _Requirements: 1.3, 1.4, 1.5_
|
||||
|
||||
- [-] 2. 浏览器操作:任务配置与提交
|
||||
- [x] 2.1 在任务配置页面填写全流程参数
|
||||
- 在任务配置页面(`/`),选择 Flow 为 `api_full`(API → ODS → DWD → DWS → INDEX)
|
||||
- 选择处理模式为 `full_window`(全窗口)
|
||||
- 设置时间窗口模式为"自定义",填入开始时间 `2025-11-01`、结束时间 当前时间
|
||||
- 设置窗口切分为"按天",切分天数为 `30`
|
||||
- 勾选 `force_full`(强制全量)
|
||||
- 在任务选择区域,全选 `is_common=True` 的常用任务(共 41 个)
|
||||
- 确认 CLI 命令预览区域显示完整参数(--flow api_full --processing-mode full_window --window-start ... --window-end ... --window-split day --window-split-days 30 --force-full --tasks ...)
|
||||
- _Requirements: 2.1_
|
||||
|
||||
- [x] 2.2 通过浏览器提交任务执行
|
||||
- 点击"直接执行"按钮(SendOutlined 图标),触发 `POST /api/execution/run`
|
||||
- 确认页面显示任务提交成功的提示消息
|
||||
- 记录返回的 execution_id(从页面响应或跳转中获取)
|
||||
- _Requirements: 2.2, 2.4_
|
||||
|
||||
- [x] 3. 浏览器操作:执行监控与 DEBUG
|
||||
- [x] 3.1 在任务管理页面监控执行状态
|
||||
- 导航到"任务管理"页面(`/task-manager`),点击侧边栏"任务管理"菜单
|
||||
- 在"队列"Tab 中确认刚提交的任务状态为 `running`
|
||||
- 点击 running 状态的任务行,打开 WebSocket 实时日志流抽屉
|
||||
- 持续观察日志输出,每 30 秒检查一次页面状态
|
||||
- 检测日志中的 ERROR / CRITICAL / Traceback / Exception / WARNING 关键字
|
||||
- 如果连续 30 分钟无新日志输出,报告超时警告
|
||||
- 任务完成(success/failed/cancelled)时停止监控
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
|
||||
|
||||
- [x] 3.2 对执行过程中发现的错误/警告进行 DEBUG 分析
|
||||
- 从日志流中收集所有 ERROR 和 WARNING 日志行及其上下文
|
||||
- 分析错误类型:API 超时、数据库连接、数据质量、配置问题等
|
||||
- 如果任务失败,切换到"历史"Tab 查看完整执行详情和日志
|
||||
- 记录 DEBUG 发现到报告中
|
||||
- _Requirements: 3.2, 3.5_
|
||||
|
||||
- [x] 4. 性能计时与报告生成
|
||||
- [x] 4.1 从浏览器获取执行日志,提取精细计时数据
|
||||
- 在"任务管理"→"历史"Tab 中,点击已完成的任务查看执行详情
|
||||
- 通过 `GET /api/execution/{id}/logs` 获取完整日志(可通过浏览器或 API 辅助)
|
||||
- 从日志中提取每个窗口切片(30天)的开始/结束时间
|
||||
- 计算每个切片的耗时
|
||||
- 识别 ODS / DWD / DWS / INDEX 各阶段的耗时
|
||||
- 标注 Top-5 耗时最长的阶段/任务
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4_
|
||||
|
||||
- [x] 4.2 生成综合联调报告,输出到 SYSTEM_LOG_ROOT
|
||||
- 报告包含:执行概要(参数、时间、退出码)
|
||||
- 报告包含:性能报告(各切片耗时对比、Top-5 瓶颈)
|
||||
- 报告包含:DEBUG 报告(如有错误/警告)
|
||||
- 黑盒测试结果摘要将在任务 5.3 中追加
|
||||
- 输出路径:`{SYSTEM_LOG_ROOT}/{date}__etl_integration_report.md`
|
||||
- 路径通过 `SYSTEM_LOG_ROOT` 环境变量获取,缺失时报错
|
||||
- _Requirements: 6.1, 6.2, 6.4, 6.5_
|
||||
|
||||
- [x] 5. 黑盒数据一致性测试
|
||||
- [x] 5.1 运行全链路检查器,执行 API→ODS→DWD→DWS 四层数据一致性检查
|
||||
- 运行 `uv run python scripts/ops/etl_consistency_check.py`(cwd 为项目根目录 `C:\NeoZQYY`)
|
||||
- 脚本自动从 `LOG_ROOT` 找到最近一次 ETL 日志,解析本次执行的任务列表
|
||||
- 脚本自动从 `FETCH_ROOT` 读取 API JSON 落盘文件
|
||||
- 脚本连接数据库(`PG_DSN`),逐表逐字段比对:
|
||||
- API JSON vs ODS:字段完整性 + 值采样比对(随机 5 条记录),`ETL_META_COLS` 白名单列排除
|
||||
- ODS vs DWD:行数对比 + 字段映射 + 值比对(含 EX 表合并),`SCD2_COLS` 白名单列排除
|
||||
- DWD vs DWS:聚合表行数非空检查 + 数值列健全性(NULL 率、负值、min/max)
|
||||
- 白名单处理:API 空字符串 `""` vs DB `None` 视为等价,标记为 whitelist,不计入失败
|
||||
- 报告自动输出到 `ETL_REPORT_ROOT`(环境变量,缺失时报错)
|
||||
- 备选触发方式:可通过 `etl-data-consistency` Hook 手动触发(效果等同)
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
|
||||
|
||||
- [x] 5.2 检查 FlowRunner 内置一致性报告
|
||||
- FlowRunner 在 ETL 执行完成后已自动调用 `_run_post_consistency_check()` 生成报告到 `ETL_REPORT_ROOT`
|
||||
- 确认内置报告已生成,检查 API vs ODS 和 ODS vs DWD 的通过/失败统计
|
||||
- 内置检查使用 `ODS_META_COLUMNS` 白名单(含 `record_index`)和 `KNOWN_NO_SOURCE` 按表白名单
|
||||
- 对比两份报告的结论是否一致(全链路检查器 vs FlowRunner 内置检查)
|
||||
- _Requirements: 5.1, 5.2, 5.3_
|
||||
|
||||
- [x] 5.3 将黑盒测试结果摘要写入综合联调报告
|
||||
- 在任务 4.2 生成的联调报告中追加"黑盒测试报告"章节
|
||||
- 包含:API vs ODS 通过数/总数、ODS vs DWD 通过数/总数、DWD vs DWS 表概览
|
||||
- 包含:白名单差异数量统计、失败表清单
|
||||
- 引用全链路检查报告的完整路径
|
||||
- _Requirements: 6.3_
|
||||
|
||||
- [x] 6. 服务清理
|
||||
- [x] 6.1 关闭浏览器,停止后端和前端服务,清理资源
|
||||
- 关闭 Playwright 浏览器实例
|
||||
- 停止 uvicorn 后端进程(`controlPwshProcess` stop)
|
||||
- 停止 pnpm dev 前端进程(`controlPwshProcess` stop)
|
||||
- 报告联调完成状态
|
||||
|
||||
## 说明
|
||||
|
||||
- 本 Spec 为运维联调任务,不涉及新功能代码开发
|
||||
- 不编写属性测试或单元测试,联调本身即为集成验证
|
||||
- **全程使用 Playwright 浏览器模拟真实用户操作**:登录、页面导航、表单填写、按钮点击、日志查看等均通过浏览器完成
|
||||
- **黑盒测试使用两套工具**:
|
||||
- 全链路检查器 `scripts/ops/etl_consistency_check.py`:覆盖 API→ODS→DWD→DWS 四层,联调主要使用
|
||||
- FlowRunner 内置 `ConsistencyChecker`(`quality/consistency_checker.py`):ETL 执行后自动运行,覆盖 API→ODS + ODS→DWD
|
||||
- **白名单机制**:`ETL_META_COLS`(ODS 元数据列)、`SCD2_COLS`(SCD2 管理列)排除在值比对之外;API 空字符串 `""` vs DB `None` 视为等价
|
||||
- **`etl-data-consistency` Hook**(`.kiro/hooks/etl-data-consistency.kiro.hook`)可作为手动触发全链路检查的替代方式
|
||||
- 黑盒测试在 ETL 全流程执行完成后、服务清理前执行,确保数据库中有最新数据可供对比
|
||||
- 全选常用任务 = 任务注册表中 `is_common=True` 的所有任务(共 41 个)
|
||||
- "全部门店":当前系统仅有 site_id=1(默认管理员绑定),如需多门店需逐个执行
|
||||
- 监控允许空闲等待,最长 30 分钟无新日志才报超时
|
||||
- 报告输出路径遵循 export-paths 规范:全链路检查报告输出到 `ETL_REPORT_ROOT`,联调综合报告输出到 `SYSTEM_LOG_ROOT`
|
||||
- 全链路检查器需要 `PG_DSN`、`FETCH_ROOT`、`LOG_ROOT`、`ETL_REPORT_ROOT` 环境变量,缺失时报错
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,750 +0,0 @@
|
||||
# 设计文档:Web 管理后台(admin-web-console)
|
||||
|
||||
## 概述
|
||||
|
||||
将现有 PySide6 桌面 GUI 替换为 BS 架构的 Web 管理后台。系统分为两部分:
|
||||
|
||||
- **后端**:在现有 `apps/backend/` FastAPI 骨架上扩展,新增 ETL 管理相关的 RESTful API 和 WebSocket 端点
|
||||
- **前端**:在 `apps/admin-web/` 下使用 React + Vite + Ant Design 构建 SPA 应用
|
||||
|
||||
核心设计原则:
|
||||
1. 后端通过子进程调用现有 ETL_CLI,不重写 ETL 逻辑
|
||||
2. 调度任务从本地 JSON 迁移至 PostgreSQL(`zqyy_app` 库)
|
||||
3. 前后端通过 JSON API 通信,实时日志通过 WebSocket 推送
|
||||
4. 数据库查询限制为只读,防止误操作
|
||||
5. **多门店隔离**:通过 `site_id` 贯穿全链路,Operator 登录后绑定门店,所有 API 请求自动携带 site_id
|
||||
6. **执行流程(Flow)分离**:完整保留现有 7 种 Flow 和 4 种处理模式,前端按 Flow 动态展示可选层和任务
|
||||
|
||||
## 架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "前端 (apps/admin-web/)"
|
||||
FE[React SPA<br/>Vite + Ant Design]
|
||||
end
|
||||
|
||||
subgraph "后端 (apps/backend/)"
|
||||
API[FastAPI 应用]
|
||||
AUTH[JWT 认证中间件]
|
||||
WS[WebSocket 端点<br/>实时日志推送]
|
||||
EXEC[TaskExecutor<br/>子进程管理]
|
||||
QUEUE[TaskQueue<br/>队列管理]
|
||||
SCHED[Scheduler<br/>定时调度]
|
||||
end
|
||||
|
||||
subgraph "数据层"
|
||||
PG_APP[(zqyy_app<br/>用户/队列/调度/历史)]
|
||||
PG_ETL[(etl_feiqiu<br/>ODS/DWD/DWS)]
|
||||
ENV[.env 文件]
|
||||
end
|
||||
|
||||
subgraph "ETL Connector"
|
||||
CLI[ETL CLI<br/>子进程]
|
||||
end
|
||||
|
||||
FE -->|HTTP/WS| API
|
||||
API --> AUTH
|
||||
API --> WS
|
||||
API --> EXEC
|
||||
API --> QUEUE
|
||||
API --> SCHED
|
||||
EXEC -->|subprocess| CLI
|
||||
CLI --> PG_ETL
|
||||
QUEUE --> PG_APP
|
||||
SCHED --> PG_APP
|
||||
SCHED -->|到期触发| QUEUE
|
||||
API -->|只读查询| PG_ETL
|
||||
API -->|读写| PG_APP
|
||||
API -->|读写| ENV
|
||||
```
|
||||
|
||||
### 请求流程
|
||||
|
||||
1. 前端发起 HTTP 请求 → JWT 中间件验证令牌(提取 site_id)→ 路由处理
|
||||
2. 任务执行:API 接收 TaskConfig(含 site_id)→ TaskQueue 入队 → TaskExecutor 取出执行 → 子进程调用 ETL_CLI(`--store-id {site_id}`)→ stdout/stderr 通过 WebSocket 推送
|
||||
3. 调度触发:Scheduler 定时检查到期任务 → 自动入队 → 同上执行流程
|
||||
|
||||
### 多门店隔离设计
|
||||
|
||||
现有系统通过 `site_id`(即 `store_id`,CLI 参数名为 `--store-id`)实现多门店数据隔离:
|
||||
- ETL 数据库层:所有业务表包含 `site_id` 字段,`app` schema 通过 RLS 按 `current_setting('app.current_site_id')` 自动过滤
|
||||
- ETL CLI 层:通过 `--store-id` 参数指定门店
|
||||
|
||||
Web 管理后台的隔离策略:
|
||||
|
||||
1. **用户-门店绑定**:`admin_users` 表增加 `site_id` 字段,每个 Operator 绑定一个门店
|
||||
2. **JWT 令牌携带 site_id**:登录时将 `site_id` 写入 JWT payload,后续请求自动提取
|
||||
3. **API 层自动注入**:所有涉及 ETL 操作的 API,从 JWT 中提取 `site_id`,自动注入到 TaskConfig 和数据库查询中
|
||||
4. **数据库查看器隔离**:查询 ETL 数据库时,设置 `SET LOCAL app.current_site_id = '{site_id}'`,利用 RLS 自动过滤
|
||||
5. **队列和调度隔离**:`task_queue` 和 `scheduled_tasks` 表增加 `site_id` 字段,查询时按 site_id 过滤
|
||||
|
||||
### 执行流程(Flow)配置设计
|
||||
|
||||
> 术语说明:**Connector**(数据源连接器)指对接的上游 SaaS 平台(如飞球),对应 `apps/etl/pipelines/{connector}/`;**Flow**(执行流程)指 ETL 任务的处理链路,描述数据从哪一层流到哪一层。CLI 参数 `--pipeline` 实际传递的是 Flow ID。
|
||||
|
||||
完整保留现有 7 种 Flow,前端根据选择动态展示:
|
||||
|
||||
| Flow ID | 显示名称 | 包含层 |
|
||||
|---------|---------|--------|
|
||||
| `api_ods` | API → ODS | ODS |
|
||||
| `api_ods_dwd` | API → ODS → DWD | ODS, DWD |
|
||||
| `api_full` | API → ODS → DWD → DWS汇总 → DWS指数 | ODS, DWD, DWS, INDEX |
|
||||
| `ods_dwd` | ODS → DWD | DWD |
|
||||
| `dwd_dws` | DWD → DWS汇总 | DWS |
|
||||
| `dwd_dws_index` | DWD → DWS汇总 → DWS指数 | DWS, INDEX |
|
||||
| `dwd_index` | DWD → DWS指数 | INDEX |
|
||||
|
||||
4 种处理模式:
|
||||
- `increment_only`:仅增量处理
|
||||
- `verify_only`:校验并修复(可选"校验前从 API 获取")
|
||||
- `increment_verify`:增量 + 校验并修复
|
||||
- `full_window`:用 API 返回数据的实际时间范围处理全部层,无需校验
|
||||
|
||||
时间窗口模式:
|
||||
- `lookback`:回溯 + 冗余(lookback_hours + overlap_seconds)
|
||||
- `custom`:自定义时间范围(window_start + window_end)
|
||||
- 窗口切分:不切分 / 按天(1天/10天/30天)
|
||||
|
||||
前端交互逻辑:
|
||||
1. Operator 选择 Flow → 前端根据 Flow 包含的层,动态显示/隐藏 ODS 任务选择、DWD 表选择、DWS 任务选择
|
||||
2. Operator 选择处理模式 → 前端根据模式显示/隐藏校验相关选项
|
||||
3. Operator 选择时间窗口模式 → 前端切换回溯配置或自定义日期选择器
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 后端 API 路由
|
||||
|
||||
```
|
||||
apps/backend/app/
|
||||
├── main.py # FastAPI 入口(已有,扩展)
|
||||
├── config.py # 配置加载(已有)
|
||||
├── database.py # 数据库连接(已有,扩展)
|
||||
├── auth/
|
||||
│ ├── __init__.py
|
||||
│ ├── jwt.py # JWT 令牌生成/验证
|
||||
│ └── dependencies.py # FastAPI 依赖注入(当前用户)
|
||||
├── routers/
|
||||
│ ├── auth.py # POST /api/auth/login, POST /api/auth/refresh
|
||||
│ ├── tasks.py # 任务注册表 & 配置 API
|
||||
│ ├── execution.py # 任务执行 & 队列 API
|
||||
│ ├── schedules.py # 调度任务 CRUD API
|
||||
│ ├── env_config.py # 环境配置 API
|
||||
│ ├── db_viewer.py # 数据库查看器 API
|
||||
│ └── etl_status.py # ETL 状态 API
|
||||
├── schemas/
|
||||
│ ├── auth.py # 认证相关 Pydantic 模型
|
||||
│ ├── tasks.py # 任务配置 Pydantic 模型
|
||||
│ ├── execution.py # 执行记录 Pydantic 模型
|
||||
│ ├── schedules.py # 调度配置 Pydantic 模型
|
||||
│ └── db_viewer.py # 数据库查看器 Pydantic 模型
|
||||
├── services/
|
||||
│ ├── task_executor.py # 子进程管理,执行 ETL_CLI
|
||||
│ ├── task_queue.py # 任务队列管理
|
||||
│ ├── scheduler.py # 定时调度器
|
||||
│ └── cli_builder.py # CLI 命令构建(从 gui/utils/cli_builder.py 迁移)
|
||||
├── middleware/
|
||||
│ └── auth.py # JWT 认证中间件
|
||||
└── ws/
|
||||
└── logs.py # WebSocket 日志推送端点
|
||||
```
|
||||
|
||||
### 前端结构
|
||||
|
||||
```
|
||||
apps/admin-web/
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
├── index.html
|
||||
├── src/
|
||||
│ ├── main.tsx # 入口
|
||||
│ ├── App.tsx # 根组件(Layout + Router)
|
||||
│ ├── api/ # API 客户端
|
||||
│ │ ├── client.ts # axios 实例(JWT 拦截器)
|
||||
│ │ ├── auth.ts
|
||||
│ │ ├── tasks.ts
|
||||
│ │ ├── execution.ts
|
||||
│ │ ├── schedules.ts
|
||||
│ │ ├── envConfig.ts
|
||||
│ │ ├── dbViewer.ts
|
||||
│ │ └── etlStatus.ts
|
||||
│ ├── pages/
|
||||
│ │ ├── Login.tsx
|
||||
│ │ ├── TaskConfig.tsx # 任务配置
|
||||
│ │ ├── TaskManager.tsx # 任务管理(队列 + 调度)
|
||||
│ │ ├── EnvConfig.tsx # 环境配置
|
||||
│ │ ├── DBViewer.tsx # 数据库查看器
|
||||
│ │ ├── ETLStatus.tsx # ETL 状态
|
||||
│ │ └── LogViewer.tsx # 日志查看器
|
||||
│ ├── components/ # 可复用组件
|
||||
│ │ ├── TaskSelector.tsx # 任务选择器(按业务域分组)
|
||||
│ │ ├── DwdTableSelector.tsx
|
||||
│ │ ├── TimeWindowForm.tsx
|
||||
│ │ └── LogStream.tsx # WebSocket 日志流组件
|
||||
│ ├── hooks/
|
||||
│ │ ├── useAuth.ts
|
||||
│ │ └── useWebSocket.ts
|
||||
│ ├── store/ # 状态管理(React Context 或 Zustand)
|
||||
│ │ └── authStore.ts
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
│ └── index.ts
|
||||
```
|
||||
|
||||
### 核心 API 端点
|
||||
|
||||
| 方法 | 路径 | 说明 | 需求 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/auth/login` | 用户登录,返回 JWT | 1 |
|
||||
| POST | `/api/auth/refresh` | 刷新访问令牌 | 1 |
|
||||
| GET | `/api/tasks/registry` | 获取任务注册表(按业务域分组) | 2 |
|
||||
| GET | `/api/tasks/dwd-tables` | 获取 DWD 表定义(按业务域分组) | 2 |
|
||||
| POST | `/api/tasks/validate` | 验证 TaskConfig | 2, 11 |
|
||||
| GET | `/api/tasks/flows` | 获取执行流程列表(7 种 Flow + 4 种处理模式) | 2 |
|
||||
| POST | `/api/execution/run` | 提交任务执行 | 3 |
|
||||
| GET | `/api/execution/queue` | 获取当前队列 | 4 |
|
||||
| POST | `/api/execution/queue` | 添加任务到队列 | 4 |
|
||||
| PUT | `/api/execution/queue/reorder` | 调整队列顺序 | 4 |
|
||||
| DELETE | `/api/execution/queue/{id}` | 从队列删除任务 | 4 |
|
||||
| POST | `/api/execution/{id}/cancel` | 取消执行中的任务 | 3 |
|
||||
| GET | `/api/execution/history` | 获取执行历史 | 4 |
|
||||
| GET | `/api/execution/{id}/logs` | 获取历史任务日志 | 9 |
|
||||
| GET | `/api/schedules` | 获取调度任务列表 | 5 |
|
||||
| POST | `/api/schedules` | 创建调度任务 | 5 |
|
||||
| PUT | `/api/schedules/{id}` | 更新调度任务 | 5 |
|
||||
| DELETE | `/api/schedules/{id}` | 删除调度任务 | 5 |
|
||||
| PATCH | `/api/schedules/{id}/toggle` | 启用/禁用调度任务 | 5 |
|
||||
| GET | `/api/env-config` | 获取环境配置 | 6 |
|
||||
| PUT | `/api/env-config` | 更新环境配置 | 6 |
|
||||
| GET | `/api/env-config/export` | 导出配置(去敏感值) | 6 |
|
||||
| GET | `/api/db/schemas` | 获取 Schema 列表 | 7 |
|
||||
| GET | `/api/db/schemas/{name}/tables` | 获取表列表和行数 | 7 |
|
||||
| GET | `/api/db/tables/{schema}/{table}/columns` | 获取表列定义 | 7 |
|
||||
| POST | `/api/db/query` | 执行只读 SQL 查询 | 7 |
|
||||
| GET | `/api/etl-status/cursors` | 获取 ETL 游标状态 | 8 |
|
||||
| GET | `/api/etl-status/recent-runs` | 获取最近执行记录 | 8 |
|
||||
| WS | `/ws/logs/{execution_id}` | 实时日志 WebSocket | 9 |
|
||||
|
||||
### 关键服务组件
|
||||
|
||||
#### TaskExecutor(任务执行器)
|
||||
|
||||
```python
|
||||
class TaskExecutor:
|
||||
"""管理 ETL_CLI 子进程的生命周期"""
|
||||
|
||||
async def execute(self, config: TaskConfig, execution_id: str) -> None:
|
||||
"""
|
||||
以子进程方式调用 ETL_CLI。
|
||||
- 使用 asyncio.create_subprocess_exec 启动子进程
|
||||
- 逐行读取 stdout/stderr,广播到 WebSocket 连接
|
||||
- 记录退出码和执行时长到数据库
|
||||
"""
|
||||
...
|
||||
|
||||
async def cancel(self, execution_id: str) -> bool:
|
||||
"""向子进程发送 SIGTERM,等待退出后标记为已取消"""
|
||||
...
|
||||
```
|
||||
|
||||
#### TaskQueue(任务队列)
|
||||
|
||||
```python
|
||||
class TaskQueue:
|
||||
"""基于 PostgreSQL 的任务队列"""
|
||||
|
||||
async def enqueue(self, config: TaskConfig) -> str:
|
||||
"""入队,返回队列任务 ID"""
|
||||
...
|
||||
|
||||
async def dequeue(self) -> Optional[QueuedTask]:
|
||||
"""取出队首待执行任务"""
|
||||
...
|
||||
|
||||
async def reorder(self, task_id: str, new_position: int) -> None:
|
||||
"""调整任务在队列中的位置"""
|
||||
...
|
||||
|
||||
async def process_loop(self) -> None:
|
||||
"""后台循环:队列非空且无运行中任务时,自动取出执行"""
|
||||
...
|
||||
```
|
||||
|
||||
#### Scheduler(调度器)
|
||||
|
||||
```python
|
||||
class Scheduler:
|
||||
"""基于 PostgreSQL 的定时调度器"""
|
||||
|
||||
async def check_and_enqueue(self) -> None:
|
||||
"""检查到期的调度任务,将其 TaskConfig 加入队列"""
|
||||
...
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动后台定时检查循环(每 30 秒检查一次)"""
|
||||
...
|
||||
```
|
||||
|
||||
#### CLIBuilder(CLI 命令构建器)
|
||||
|
||||
从现有 `gui/utils/cli_builder.py` 迁移,核心逻辑不变:
|
||||
|
||||
```python
|
||||
class CLIBuilder:
|
||||
"""将 TaskConfig 转换为 ETL_CLI 命令行参数列表"""
|
||||
|
||||
def build_command(self, config: TaskConfig, etl_project_path: str) -> list[str]:
|
||||
"""
|
||||
构建完整命令:
|
||||
[python, -m, cli.main, --pipeline, {flow_id}, --tasks, ..., --store-id, {site_id}, ...]
|
||||
注意:CLI 参数名 --pipeline 传递的是 Flow ID
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 数据库表(zqyy_app)
|
||||
|
||||
#### admin_users 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE admin_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
display_name VARCHAR(128),
|
||||
site_id BIGINT NOT NULL, -- 绑定的门店 ID
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_admin_users_site ON admin_users(site_id);
|
||||
```
|
||||
|
||||
#### task_queue 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE task_queue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id BIGINT NOT NULL, -- 门店隔离
|
||||
config JSONB NOT NULL, -- 序列化的 TaskConfig
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
-- pending / running / success / failed / cancelled
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
exit_code INTEGER,
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_queue_status ON task_queue(status);
|
||||
CREATE INDEX idx_task_queue_site_position ON task_queue(site_id, position) WHERE status = 'pending';
|
||||
```
|
||||
|
||||
#### task_execution_log 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE task_execution_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
queue_id UUID REFERENCES task_queue(id),
|
||||
site_id BIGINT NOT NULL, -- 门店隔离
|
||||
task_codes TEXT[] NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ,
|
||||
exit_code INTEGER,
|
||||
duration_ms INTEGER,
|
||||
command TEXT, -- 实际执行的 CLI 命令
|
||||
output_log TEXT, -- stdout 完整日志
|
||||
error_log TEXT, -- stderr 日志
|
||||
summary JSONB, -- 执行摘要
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_execution_log_site_started ON task_execution_log(site_id, started_at DESC);
|
||||
```
|
||||
|
||||
#### scheduled_tasks 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE scheduled_tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id BIGINT NOT NULL, -- 门店隔离
|
||||
name VARCHAR(256) NOT NULL,
|
||||
task_codes TEXT[] NOT NULL,
|
||||
task_config JSONB NOT NULL, -- 序列化的 TaskConfig
|
||||
schedule_config JSONB NOT NULL, -- 序列化的 ScheduleConfig
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
next_run_at TIMESTAMPTZ,
|
||||
run_count INTEGER DEFAULT 0,
|
||||
last_status VARCHAR(20),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scheduled_tasks_site ON scheduled_tasks(site_id);
|
||||
CREATE INDEX idx_scheduled_tasks_next_run ON scheduled_tasks(next_run_at)
|
||||
WHERE enabled = TRUE;
|
||||
```
|
||||
|
||||
### Pydantic 模型(后端 schemas)
|
||||
|
||||
```python
|
||||
# schemas/tasks.py
|
||||
class TaskConfigSchema(BaseModel):
|
||||
"""任务配置 — 前后端传输格式"""
|
||||
tasks: list[str]
|
||||
pipeline: str = "api_ods_dwd" # 执行流程 Flow ID(7 种之一),对应 CLI --pipeline 参数
|
||||
processing_mode: str = "increment_only" # 处理模式(3 种之一)
|
||||
pipeline_flow: str = "FULL" # 传统模式兼容(已弃用,保留向后兼容)
|
||||
dry_run: bool = False
|
||||
window_mode: str = "lookback" # lookback / custom
|
||||
window_start: str | None = None
|
||||
window_end: str | None = None
|
||||
window_split: str | None = None # none / day
|
||||
window_split_days: int | None = None # 1 / 10 / 30
|
||||
lookback_hours: int = 24
|
||||
overlap_seconds: int = 600
|
||||
fetch_before_verify: bool = False
|
||||
skip_ods_when_fetch_before_verify: bool = False
|
||||
ods_use_local_json: bool = False
|
||||
store_id: int | None = None # 门店 ID(由后端从 JWT 注入,前端不传)
|
||||
dwd_only_tables: list[str] | None = None # DWD 表级选择
|
||||
extra_args: dict[str, Any] = {}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_window(self) -> "TaskConfigSchema":
|
||||
"""验证时间窗口:结束日期不早于开始日期"""
|
||||
if self.window_start and self.window_end:
|
||||
if self.window_end < self.window_start:
|
||||
raise ValueError("window_end 不能早于 window_start")
|
||||
return self
|
||||
|
||||
class PipelineDefinition(BaseModel):
|
||||
"""执行流程(Flow)定义 — 注意:字段名保留 pipeline 以兼容 CLI 参数"""
|
||||
id: str
|
||||
name: str
|
||||
layers: list[str]
|
||||
|
||||
class ProcessingModeDefinition(BaseModel):
|
||||
"""处理模式定义"""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
# schemas/schedules.py
|
||||
class ScheduleConfigSchema(BaseModel):
|
||||
"""调度配置"""
|
||||
schedule_type: Literal["once", "interval", "daily", "weekly", "cron"]
|
||||
interval_value: int = 1
|
||||
interval_unit: Literal["minutes", "hours", "days"] = "hours"
|
||||
daily_time: str = "04:00"
|
||||
weekly_days: list[int] = [1]
|
||||
weekly_time: str = "04:00"
|
||||
cron_expression: str = "0 4 * * *"
|
||||
enabled: bool = True
|
||||
start_date: str | None = None
|
||||
end_date: str | None = None
|
||||
```
|
||||
|
||||
### TypeScript 类型(前端)
|
||||
|
||||
```typescript
|
||||
// types/index.ts
|
||||
interface TaskConfig {
|
||||
tasks: string[];
|
||||
pipeline: string; // 执行流程 Flow ID(对应 CLI --pipeline)
|
||||
processing_mode: string; // 处理模式
|
||||
pipeline_flow: string; // 传统模式兼容(已弃用)
|
||||
dry_run: boolean;
|
||||
window_mode: string; // lookback / custom
|
||||
window_start: string | null;
|
||||
window_end: string | null;
|
||||
window_split: string | null; // none / day
|
||||
window_split_days: number | null; // 1 / 10 / 30
|
||||
lookback_hours: number;
|
||||
overlap_seconds: number;
|
||||
fetch_before_verify: boolean;
|
||||
skip_ods_when_fetch_before_verify: boolean;
|
||||
ods_use_local_json: boolean;
|
||||
store_id: number | null; // 由后端注入
|
||||
dwd_only_tables: string[] | null; // DWD 表级选择
|
||||
extra_args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PipelineDefinition {
|
||||
id: string; // Flow ID(字段名保留 pipeline 兼容 CLI)
|
||||
name: string;
|
||||
layers: string[]; // 包含的层:ODS / DWD / DWS / INDEX
|
||||
}
|
||||
|
||||
interface ProcessingModeDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface TaskDefinition {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
domain: string;
|
||||
requires_window: boolean;
|
||||
is_ods: boolean;
|
||||
is_dimension: boolean;
|
||||
default_enabled: boolean;
|
||||
}
|
||||
|
||||
interface ScheduleConfig {
|
||||
schedule_type: "once" | "interval" | "daily" | "weekly" | "cron";
|
||||
interval_value: number;
|
||||
interval_unit: "minutes" | "hours" | "days";
|
||||
daily_time: string;
|
||||
weekly_days: number[];
|
||||
weekly_time: string;
|
||||
cron_expression: string;
|
||||
enabled: boolean;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
}
|
||||
|
||||
interface QueuedTask {
|
||||
id: string;
|
||||
site_id: number;
|
||||
config: TaskConfig;
|
||||
status: "pending" | "running" | "success" | "failed" | "cancelled";
|
||||
position: number;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
exit_code: number | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
interface ExecutionLog {
|
||||
id: string;
|
||||
site_id: number;
|
||||
task_codes: string[];
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
exit_code: number | null;
|
||||
duration_ms: number | null;
|
||||
command: string;
|
||||
summary: Record<string, unknown> | null;
|
||||
}
|
||||
```
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是指在系统所有有效执行中都应成立的特征或行为——本质上是对系统行为的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: TaskConfig 序列化往返一致性
|
||||
|
||||
*For any* 有效的 TaskConfigSchema 对象,将其序列化为 JSON 字符串后再反序列化,应产生与原始对象等价的结果。
|
||||
|
||||
**Validates: Requirements 11.1, 11.2, 11.3**
|
||||
|
||||
### Property 2: 无效凭据始终被拒绝
|
||||
|
||||
*For any* 不存在于 admin_users 表中的用户名/密码组合,登录接口应返回 401 状态码。
|
||||
|
||||
**Validates: Requirements 1.2**
|
||||
|
||||
### Property 3: 有效 JWT 令牌授权访问
|
||||
|
||||
*For any* 由系统签发且未过期的 JWT 令牌,携带该令牌访问受保护端点应返回非 401 状态码。
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 4: 任务注册表按业务域正确分组
|
||||
|
||||
*For any* Task_Registry 中的任务集合,API 返回的分组结果中,每个任务应出现在且仅出现在其所属业务域的分组中。
|
||||
|
||||
**Validates: Requirements 2.1**
|
||||
|
||||
### Property 5: Flow 层级过滤正确性
|
||||
|
||||
*For any* Flow 选择和任务列表,过滤后的结果应只包含与所选 Flow 包含的层兼容的任务,且不遗漏任何兼容任务。
|
||||
|
||||
**Validates: Requirements 2.2**
|
||||
|
||||
### Property 6: 时间窗口验证
|
||||
|
||||
*For any* 两个日期字符串,当 window_end 早于 window_start 时,TaskConfigSchema 验证应失败;当 window_end 不早于 window_start 时,验证应通过。
|
||||
|
||||
**Validates: Requirements 2.3**
|
||||
|
||||
### Property 7: TaskConfig 到 CLI 命令转换完整性
|
||||
|
||||
*For any* 有效的 TaskConfigSchema,CLIBuilder 生成的命令参数列表应包含 TaskConfig 中所有非空字段对应的 CLI 参数。
|
||||
|
||||
**Validates: Requirements 2.5, 2.6**
|
||||
|
||||
### Property 8: 队列 CRUD 不变量
|
||||
|
||||
*For any* 任务队列状态,入队一个任务后队列长度增加 1 且新任务状态为 pending;删除一个 pending 任务后队列长度减少 1 且该任务不再出现在队列中。
|
||||
|
||||
**Validates: Requirements 4.1, 4.4**
|
||||
|
||||
### Property 9: 队列出队顺序
|
||||
|
||||
*For any* 包含多个 pending 任务的队列,dequeue 操作应返回 position 值最小的任务。
|
||||
|
||||
**Validates: Requirements 4.2**
|
||||
|
||||
### Property 10: 队列重排一致性
|
||||
|
||||
*For any* 队列和重排操作(将任务移动到新位置),重排后队列中任务的相对顺序应与请求一致。
|
||||
|
||||
**Validates: Requirements 4.3**
|
||||
|
||||
### Property 11: 执行历史排序与限制
|
||||
|
||||
*For any* 执行历史记录集合,API 返回的结果应按 started_at 降序排列,且结果数量不超过请求的 limit 值。
|
||||
|
||||
**Validates: Requirements 4.5, 8.2**
|
||||
|
||||
### Property 12: 调度任务 CRUD 往返
|
||||
|
||||
*For any* 有效的 ScheduleConfigSchema,创建调度任务后再查询该任务,返回的调度配置应与创建时提交的配置等价。
|
||||
|
||||
**Validates: Requirements 5.1, 5.4**
|
||||
|
||||
### Property 13: 到期调度任务自动入队
|
||||
|
||||
*For any* enabled 为 true 且 next_run_at 早于当前时间的调度任务,check_and_enqueue 执行后该任务的 TaskConfig 应出现在执行队列中。
|
||||
|
||||
**Validates: Requirements 5.2**
|
||||
|
||||
### Property 14: 调度任务启用/禁用状态
|
||||
|
||||
*For any* 调度任务,禁用后 next_run_at 应为 NULL;重新启用后 next_run_at 应被重新计算为非 NULL 值(对于非一次性调度)。
|
||||
|
||||
**Validates: Requirements 5.3**
|
||||
|
||||
### Property 15: .env 解析与敏感值掩码
|
||||
|
||||
*For any* 包含敏感键(PASSWORD、TOKEN、SECRET、DSN)的 .env 文件内容,API 返回的键值对列表中这些键的值应被掩码替换,不包含原始敏感值。
|
||||
|
||||
**Validates: Requirements 6.1, 6.3**
|
||||
|
||||
### Property 16: .env 写入往返一致性
|
||||
|
||||
*For any* 有效的键值对集合(不含注释和空行),写入 .env 文件后再读取解析,应得到与原始集合等价的键值对。
|
||||
|
||||
**Validates: Requirements 6.2**
|
||||
|
||||
### Property 17: SQL 写操作拦截
|
||||
|
||||
*For any* 包含 INSERT、UPDATE、DELETE、DROP 或 TRUNCATE 关键词(不区分大小写)的 SQL 语句,数据库查看器 API 应拒绝执行并返回错误。
|
||||
|
||||
**Validates: Requirements 7.5**
|
||||
|
||||
### Property 18: SQL 查询结果行数限制
|
||||
|
||||
*For any* SQL 查询执行结果,返回的行数应不超过 1000。
|
||||
|
||||
**Validates: Requirements 7.4**
|
||||
|
||||
### Property 19: 日志过滤正确性
|
||||
|
||||
*For any* 日志行集合和过滤关键词,过滤后的结果应只包含含有该关键词的日志行,且不遗漏任何匹配行。
|
||||
|
||||
**Validates: Requirements 9.2**
|
||||
|
||||
### Property 20: 门店隔离 — 队列和调度数据不跨站泄露
|
||||
|
||||
*For any* 两个不同 site_id 的 Operator,一个 Operator 查询队列/调度/执行历史时,结果中不应包含另一个 site_id 的数据。
|
||||
|
||||
**Validates: Requirements 1.3(隐含的多门店隔离要求)**
|
||||
|
||||
### Property 21: Flow 层级与任务兼容性
|
||||
|
||||
*For any* Flow 类型和任务定义,当 Flow 包含的层不包含该任务所属层时,该任务不应出现在可选列表中;当 Flow 包含该任务所属层时,该任务应出现在可选列表中。
|
||||
|
||||
**Validates: Requirements 2.2**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 认证错误
|
||||
- 无效凭据:返回 `401 Unauthorized`,响应体包含 `{"detail": "用户名或密码错误"}`
|
||||
- 令牌过期:返回 `401 Unauthorized`,响应体包含 `{"detail": "令牌已过期"}`
|
||||
- 令牌无效:返回 `401 Unauthorized`,响应体包含 `{"detail": "无效的令牌"}`
|
||||
|
||||
### 任务执行错误
|
||||
- TaskConfig 验证失败:返回 `422 Unprocessable Entity`,响应体包含字段级错误详情
|
||||
- ETL_CLI 子进程超时:记录错误日志,任务状态标记为 `failed`,error_message 记录超时信息
|
||||
- ETL_CLI 子进程异常退出:记录 exit_code 和 stderr 输出,任务状态标记为 `failed`
|
||||
- 取消不存在或已完成的任务:返回 `404 Not Found` 或 `409 Conflict`
|
||||
|
||||
### 队列错误
|
||||
- 删除非 pending 状态的任务:返回 `409 Conflict`,提示只能删除待执行任务
|
||||
- 重排包含非 pending 任务:忽略非 pending 任务,只重排 pending 任务
|
||||
|
||||
### 数据库查看器错误
|
||||
- SQL 写操作拦截:返回 `400 Bad Request`,提示只允许只读查询
|
||||
- SQL 查询超时(30 秒):终止查询,返回 `408 Request Timeout`
|
||||
- SQL 语法错误:返回 `400 Bad Request`,包含 PostgreSQL 原始错误信息
|
||||
|
||||
### 环境配置错误
|
||||
- .env 文件不存在:返回 `404 Not Found`
|
||||
- 配置格式错误:返回 `422 Unprocessable Entity`,包含错误行号和描述
|
||||
- 文件写入权限不足:返回 `500 Internal Server Error`,记录详细错误日志
|
||||
|
||||
### 通用错误
|
||||
- 所有 API 错误响应统一格式:`{"detail": "错误描述", "code": "ERROR_CODE"}`
|
||||
- 未捕获异常:返回 `500 Internal Server Error`,日志记录完整堆栈
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试框架
|
||||
|
||||
- **后端单元测试 & 属性测试**:pytest + hypothesis
|
||||
- 路径:`apps/backend/tests/`
|
||||
- 运行:`cd apps/backend && pytest tests/ -v`
|
||||
- **前端单元测试**:Vitest + React Testing Library
|
||||
- 路径:`apps/admin-web/src/__tests__/`
|
||||
- 运行:`cd apps/admin-web && pnpm test`
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
使用 hypothesis 库,每个属性测试至少运行 100 次迭代。
|
||||
|
||||
每个属性测试必须用注释标注对应的设计文档属性编号:
|
||||
|
||||
```python
|
||||
# Feature: admin-web-console, Property 1: TaskConfig 序列化往返一致性
|
||||
@given(config=st.builds(TaskConfigSchema, ...))
|
||||
def test_task_config_round_trip(config):
|
||||
...
|
||||
```
|
||||
|
||||
属性测试重点覆盖:
|
||||
- Property 1:TaskConfig 序列化往返(核心数据模型)
|
||||
- Property 6:时间窗口验证(输入验证)
|
||||
- Property 7:TaskConfig 到 CLI 命令转换(关键业务逻辑)
|
||||
- Property 8-10:队列 CRUD 不变量(状态管理)
|
||||
- Property 15-16:.env 解析与写入往返(配置管理)
|
||||
- Property 17:SQL 写操作拦截(安全关键)
|
||||
- Property 19:日志过滤(数据过滤逻辑)
|
||||
|
||||
### 单元测试
|
||||
|
||||
单元测试覆盖具体示例和边界条件:
|
||||
- JWT 令牌生成/验证/过期
|
||||
- 调度器 next_run 计算(各种调度类型)
|
||||
- CLI 命令构建的具体场景
|
||||
- API 端点的请求/响应格式
|
||||
- 前端组件渲染和交互
|
||||
|
||||
### 集成测试
|
||||
|
||||
需要数据库环境的测试:
|
||||
- 任务队列的完整生命周期
|
||||
- 调度任务的创建/触发/执行
|
||||
- 数据库查看器的 Schema 浏览和查询执行
|
||||
- ETL 状态查询
|
||||
@@ -1,177 +0,0 @@
|
||||
# 需求文档:Web 管理后台(admin-web-console)
|
||||
|
||||
## 简介
|
||||
|
||||
将现有 PySide6 桌面 GUI(`gui/`)替换为基于浏览器-服务器(BS)架构的 Web 管理后台。后端 API 在现有 FastAPI 骨架(`apps/backend/`)上扩展,前端部署在 `apps/admin-web/`。功能覆盖现有 GUI 的六大模块:任务配置、任务管理(队列与调度)、环境配置、数据库查看器、ETL 状态监控、日志查看器。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Admin_Web**:Web 管理后台前端应用,部署在 `apps/admin-web/`
|
||||
- **Backend_API**:FastAPI 后端服务,部署在 `apps/backend/`,为 Admin_Web 提供 RESTful API
|
||||
- **ETL_CLI**:现有 ETL 命令行工具(`apps/etl/pipelines/feiqiu/cli/main.py`),Backend_API 通过子进程调用
|
||||
- **Connector**:数据源连接器,指对接的上游 SaaS 平台(如飞球),对应目录 `apps/etl/pipelines/{connector_name}/`。后续可扩展更多 Connector(台账类、球房类、财务类管理平台)
|
||||
- **Flow**:执行流程,指 ETL 任务的处理链路,描述数据从哪一层流到哪一层(如 `api_ods_dwd` 表示 API → ODS → DWD)。对应 CLI 参数 `--pipeline`
|
||||
- **Task_Registry**:任务注册表,定义所有可用 ETL 任务及其业务域分组
|
||||
- **Task_Config**:任务执行配置,包含 Flow 类型、时间窗口、任务列表等参数
|
||||
- **Schedule_Store**:调度任务持久化存储(从本地 JSON 迁移至 PostgreSQL)
|
||||
- **Operator**:管理后台操作员,即门店运营人员
|
||||
- **Site**:门店,通过 `site_id` 标识,是多门店数据隔离的基本单位
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:用户认证与会话管理
|
||||
|
||||
**用户故事:** 作为 Operator,我希望通过用户名密码登录管理后台,以确保只有授权人员能操作 ETL 系统。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Operator 提交有效的用户名和密码, THE Backend_API SHALL 返回一个 JWT 访问令牌和刷新令牌
|
||||
2. WHEN Operator 提交无效的凭据, THE Backend_API SHALL 返回 401 状态码和错误描述
|
||||
3. WHILE Operator 持有有效的 JWT 令牌, THE Backend_API SHALL 允许访问受保护的 API 端点
|
||||
4. WHEN JWT 访问令牌过期, THE Backend_API SHALL 返回 401 状态码,Admin_Web SHALL 使用刷新令牌自动获取新的访问令牌
|
||||
5. WHEN 刷新令牌也过期, THE Admin_Web SHALL 将 Operator 重定向到登录页面
|
||||
|
||||
### 需求 2:ETL 任务配置
|
||||
|
||||
**用户故事:** 作为 Operator,我希望在 Web 界面上选择并配置 ETL 任务的执行参数,以便灵活控制数据处理的运行方式。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Operator 打开任务配置页面, THE Admin_Web SHALL 从 Backend_API 获取 Task_Registry 中所有可用任务,并按业务域(会员、结算、助教、商品、台桌、团购、库存等)分组展示
|
||||
2. WHEN Operator 选择执行流程 Flow(如 api_ods_dwd、dwd_dws 等), THE Admin_Web SHALL 根据 Flow 包含的层(ODS / DWD / DWS / INDEX),仅显示与所选层兼容的任务
|
||||
3. WHEN Operator 配置时间窗口参数(开始日期、结束日期、窗口分割策略), THE Admin_Web SHALL 验证结束日期不早于开始日期
|
||||
4. WHEN Operator 选择 DWD 装载任务, THE Admin_Web SHALL 展示 DWD 表级选择界面,允许按业务域分组勾选目标表
|
||||
5. WHEN Operator 切换高级选项(dry-run、force-full、skip-quality 等), THE Admin_Web SHALL 将这些选项反映到最终的 Task_Config 中
|
||||
6. WHEN Operator 提交任务配置, THE Backend_API SHALL 验证 Task_Config 的完整性,并将其转换为有效的 ETL_CLI 命令参数
|
||||
|
||||
### 需求 3:ETL 任务执行
|
||||
|
||||
**用户故事:** 作为 Operator,我希望通过 Web 界面触发 ETL 任务执行,并实时查看执行进度和结果。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Operator 提交一个有效的 Task_Config, THE Backend_API SHALL 以子进程方式调用 ETL_CLI 执行任务,并返回一个任务执行 ID
|
||||
2. WHILE ETL_CLI 子进程正在运行, THE Backend_API SHALL 捕获 stdout 和 stderr 输出,并通过 API 端点提供实时日志流
|
||||
3. WHEN ETL_CLI 子进程执行完毕, THE Backend_API SHALL 记录退出码、执行时长和输出摘要到任务历史
|
||||
4. IF ETL_CLI 子进程执行超时或异常退出, THEN THE Backend_API SHALL 记录错误信息并将任务状态标记为失败
|
||||
5. WHEN Operator 请求取消正在执行的任务, THE Backend_API SHALL 向 ETL_CLI 子进程发送终止信号并将任务状态标记为已取消
|
||||
|
||||
### 需求 4:任务队列管理
|
||||
|
||||
**用户故事:** 作为 Operator,我希望管理任务执行队列,以便批量安排和控制任务的执行顺序。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Operator 将一个 Task_Config 添加到队列, THE Backend_API SHALL 创建一个状态为"待执行"的队列任务项并返回其 ID
|
||||
2. WHEN 队列中存在待执行任务且当前无任务正在运行, THE Backend_API SHALL 按队列顺序自动取出下一个任务并开始执行
|
||||
3. WHEN Operator 调整队列中任务的顺序, THE Backend_API SHALL 更新任务的执行优先级
|
||||
4. WHEN Operator 从队列中删除一个待执行任务, THE Backend_API SHALL 将该任务从队列中移除
|
||||
5. WHEN Operator 查看任务历史, THE Backend_API SHALL 返回按时间倒序排列的历史执行记录,包含任务编码、状态、开始时间、执行时长和退出码
|
||||
|
||||
### 需求 5:调度任务管理
|
||||
|
||||
**用户故事:** 作为 Operator,我希望创建和管理定时调度任务,以便 ETL Connector 能按计划自动运行。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Operator 创建调度任务时, THE Backend_API SHALL 接受调度配置(一次性 / 固定间隔 / 每日 / 每周 / Cron 表达式)并持久化到 Schedule_Store
|
||||
2. WHEN 调度任务到达预定执行时间, THE Backend_API SHALL 自动将对应的 Task_Config 加入执行队列
|
||||
3. WHEN Operator 启用或禁用一个调度任务, THE Backend_API SHALL 更新该任务的启用状态并重新计算下次执行时间
|
||||
4. WHEN Operator 编辑调度任务的配置, THE Backend_API SHALL 验证新配置的有效性并更新 Schedule_Store
|
||||
5. WHEN Operator 删除一个调度任务, THE Backend_API SHALL 从 Schedule_Store 中移除该任务及其执行历史
|
||||
6. WHEN Operator 查看调度任务列表, THE Backend_API SHALL 返回所有调度任务及其最近执行状态、下次执行时间和执行次数
|
||||
|
||||
### 需求 6:环境配置管理
|
||||
|
||||
**用户故事:** 作为 Operator,我希望通过 Web 界面查看和编辑 ETL 的 .env 配置文件,以便调整运行参数而无需直接操作服务器文件。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Operator 打开环境配置页面, THE Backend_API SHALL 读取当前 .env 文件内容并返回键值对列表(敏感值如密码和令牌以掩码形式展示)
|
||||
2. WHEN Operator 修改配置项并提交, THE Backend_API SHALL 验证配置格式的正确性,写入 .env 文件,并返回更新结果
|
||||
3. WHEN Operator 导出配置, THE Backend_API SHALL 生成一份去除敏感值的配置文件供下载
|
||||
4. IF Operator 提交的配置包含格式错误的键值对, THEN THE Backend_API SHALL 返回具体的错误位置和描述,拒绝写入
|
||||
|
||||
### 需求 7:数据库查看器
|
||||
|
||||
**用户故事:** 作为 Operator,我希望通过 Web 界面查看数据库表结构和执行查询,以便快速检查 ETL 数据质量。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Operator 打开数据库查看器页面, THE Admin_Web SHALL 展示可用的 Schema 列表(meta、ods、dwd、core、dws、app)
|
||||
2. WHEN Operator 选择一个 Schema, THE Backend_API SHALL 返回该 Schema 下所有表的名称和行数统计
|
||||
3. WHEN Operator 选择一张表, THE Backend_API SHALL 返回该表的列定义(列名、数据类型、是否可空、默认值)
|
||||
4. WHEN Operator 提交一条 SQL 查询, THE Backend_API SHALL 在只读事务中执行该查询,限制返回行数上限为 1000 行,并返回结果集
|
||||
5. IF Operator 提交的 SQL 包含写操作(INSERT / UPDATE / DELETE / DROP / TRUNCATE), THEN THE Backend_API SHALL 拒绝执行并返回错误提示
|
||||
6. IF SQL 查询执行超过 30 秒, THEN THE Backend_API SHALL 终止查询并返回超时错误
|
||||
|
||||
### 需求 8:ETL 状态监控
|
||||
|
||||
**用户故事:** 作为 Operator,我希望在 Web 界面上查看 ETL Connector 的运行状态和数据游标信息,以便及时发现异常。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Operator 打开 ETL 状态页面, THE Backend_API SHALL 返回各 ODS 表的最新数据游标(最后抓取时间、记录数)
|
||||
2. WHEN Operator 查看最近执行记录, THE Backend_API SHALL 返回最近 50 条任务执行记录,包含任务名称、状态、开始时间、执行时长
|
||||
3. WHEN Operator 刷新状态页面, THE Backend_API SHALL 返回最新的游标和执行状态数据
|
||||
|
||||
### 需求 9:实时日志查看
|
||||
|
||||
**用户故事:** 作为 Operator,我希望在 Web 界面上实时查看 ETL 任务的执行日志,以便监控任务进度和排查问题。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHILE 一个 ETL 任务正在执行, THE Admin_Web SHALL 通过 WebSocket 或 SSE 连接实时展示该任务的日志输出
|
||||
2. WHEN Operator 在日志查看器中输入过滤关键词, THE Admin_Web SHALL 仅展示包含该关键词的日志行
|
||||
3. WHEN 任务执行完毕, THE Admin_Web SHALL 保留完整日志内容供 Operator 回顾
|
||||
4. WHEN Operator 查看历史任务的日志, THE Backend_API SHALL 返回该任务的完整日志记录
|
||||
|
||||
### 需求 10:响应式布局与导航
|
||||
|
||||
**用户故事:** 作为 Operator,我希望管理后台具有清晰的导航结构和响应式布局,以便在不同设备上高效操作。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Admin_Web SHALL 提供侧边栏导航,包含八个功能模块入口:任务配置、任务管理、环境配置、数据库、ETL 状态、日志、球房编号管理、租户管理员管理
|
||||
2. WHEN Operator 点击导航项, THE Admin_Web SHALL 切换到对应的功能模块页面,且不触发整页刷新
|
||||
3. THE Admin_Web SHALL 在状态栏区域展示当前数据库连接状态和任务执行状态
|
||||
4. WHILE 有任务正在执行, THE Admin_Web SHALL 在导航栏或状态栏显示执行中的视觉指示
|
||||
|
||||
### 需求 11:租户管理员账号管理
|
||||
|
||||
**用户故事:** 作为系统管理员(Operator),我需要在系统管理后台中创建和管理租户管理员账号,以便租户管理员能登录独立的租户管理后台(`apps/tenant-admin/`)进行用户审核、Excel 上传等操作。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Operator 打开租户管理员管理页面, THE Admin_Web SHALL 展示所有租户管理员账号列表,包含用户名、所属租户、管辖球房列表、账号状态(启用/禁用)、创建时间
|
||||
2. WHEN Operator 创建租户管理员账号, THE Backend_API SHALL 接受用户名、初始密码、所属租户标识、管辖球房 ID 列表(`site_id` 数组),并在 `auth` Schema 中创建对应记录,密码以 bcrypt 哈希存储
|
||||
3. WHEN Operator 编辑租户管理员账号, THE Backend_API SHALL 允许修改管辖球房列表、账号状态(启用/禁用),以及重置密码
|
||||
4. WHEN Operator 禁用某租户管理员账号, THE Backend_API SHALL 将该账号状态设为禁用,该管理员后续登录租户管理后台时 SHALL 被拒绝
|
||||
5. WHEN Operator 为租户管理员分配球房, THE Backend_API SHALL 验证球房 ID(`site_id`)在 `auth.site_code_mapping` 中存在,不存在时返回 422 错误
|
||||
6. THE Backend_API SHALL 确保同一用户名不可重复创建(唯一约束)
|
||||
7. WHEN Operator 查看某租户管理员详情, THE Admin_Web SHALL 展示该管理员管辖的球房列表及每个球房的球房代码(`site_code`)和名称
|
||||
|
||||
### 需求 12:球房编号管理
|
||||
|
||||
**用户故事:** 作为系统管理员(Operator),我需要在系统管理后台中为每个门店(`site_id`)分配球房编号(`site_code`),以便小程序用户申请时通过球房编号定位到对应门店。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Operator 打开球房编号管理页面, THE Admin_Web SHALL 展示 `auth.site_code_mapping` 中所有球房编号映射列表,包含球房编号(`site_code`)、门店 ID(`site_id`)、创建时间
|
||||
2. WHEN Operator 新增球房编号映射, THE Backend_API SHALL 接受 `site_code`(格式:2 字母 + 3 数字,如 `AB123`)和 `site_id`(BIGINT),验证格式正确后写入 `auth.site_code_mapping`
|
||||
3. IF 提交的 `site_code` 已存在, THEN THE Backend_API SHALL 返回 409 冲突错误
|
||||
4. IF 提交的 `site_id` 已绑定其他 `site_code`, THEN THE Backend_API SHALL 返回 409 冲突错误(`site_code` 与 `site_id` 一对一)
|
||||
5. WHEN Operator 编辑球房编号映射, THE Backend_API SHALL 允许修改 `site_code`(需验证新编号不与其他记录冲突)
|
||||
6. WHEN Operator 删除球房编号映射, THE Backend_API SHALL 检查是否有用户申请引用该 `site_code`,若有则拒绝删除并提示关联数据存在
|
||||
7. THE Admin_Web SHALL 在球房编号管理页面提供搜索功能,支持按 `site_code` 或 `site_id` 搜索
|
||||
|
||||
### 需求 13:Task_Config 序列化与反序列化
|
||||
|
||||
**用户故事:** 作为 Operator,我希望任务配置能在前后端之间正确传输和持久化,以确保配置不丢失。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Backend_API SHALL 将 Task_Config 序列化为 JSON 格式进行传输和存储
|
||||
2. WHEN Backend_API 接收到 JSON 格式的 Task_Config, THE Backend_API SHALL 反序列化为内部 Task_Config 对象并验证所有必填字段
|
||||
3. FOR ALL 有效的 Task_Config 对象, 序列化后再反序列化 SHALL 产生与原始对象等价的结果(往返一致性)
|
||||
4. IF 反序列化时遇到缺失或类型错误的字段, THEN THE Backend_API SHALL 返回包含具体字段名和错误原因的验证错误
|
||||
@@ -1,292 +0,0 @@
|
||||
# 实现计划:Web 管理后台(admin-web-console)
|
||||
|
||||
## 概述
|
||||
|
||||
将现有 PySide6 桌面 GUI 替换为 BS 架构的 Web 管理后台。后端在 `apps/backend/` 上扩展 FastAPI API,前端在 `apps/admin-web/` 下使用 React + Vite + Ant Design 构建。实现按"后端基础设施 → 核心 API → 前端骨架 → 功能模块逐个对接"的顺序推进。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 后端基础设施搭建
|
||||
- [x] 1.1 创建数据库迁移脚本,在 `zqyy_app` 库中创建 4 张新表(admin_users、task_queue、task_execution_log、scheduled_tasks),所有表包含 site_id 字段
|
||||
- 迁移脚本放在 `db/zqyy_app/migrations/`,日期前缀命名
|
||||
- 包含索引创建(site_id 相关的复合索引)
|
||||
- 包含种子数据:插入一个默认管理员账号
|
||||
- _Requirements: 1.1, 4.1, 5.1_
|
||||
|
||||
- [x] 1.2 实现 JWT 认证模块(`apps/backend/app/auth/`)
|
||||
- `jwt.py`:JWT 令牌生成(access_token + refresh_token)、验证、解码,payload 包含 user_id 和 site_id
|
||||
- `dependencies.py`:FastAPI 依赖注入函数 `get_current_user`,从 JWT 提取用户信息和 site_id
|
||||
- 新增依赖:`python-jose[cryptography]`、`passlib[bcrypt]` 到 `apps/backend/pyproject.toml`
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4_
|
||||
|
||||
- [x] 1.3 实现认证路由(`apps/backend/app/routers/auth.py`)
|
||||
- `POST /api/auth/login`:验证用户名密码,返回 JWT 令牌对
|
||||
- `POST /api/auth/refresh`:用刷新令牌换取新的访问令牌
|
||||
- Pydantic schemas:`LoginRequest`、`TokenResponse`
|
||||
- _Requirements: 1.1, 1.2, 1.4_
|
||||
|
||||
- [x] 1.4 编写认证模块属性测试
|
||||
- **Property 2: 无效凭据始终被拒绝**
|
||||
- **Property 3: 有效 JWT 令牌授权访问**
|
||||
- **Validates: Requirements 1.2, 1.3**
|
||||
|
||||
- [x] 1.5 扩展 `apps/backend/app/database.py`,新增 ETL 数据库只读连接函数
|
||||
- `get_etl_readonly_connection(site_id)`:连接 ETL 数据库,设置 `SET LOCAL app.current_site_id`
|
||||
- 配置项从 .env 读取 ETL 数据库连接参数
|
||||
- _Requirements: 7.4, 7.5_
|
||||
|
||||
- [x] 1.6 在 `apps/backend/app/main.py` 中注册所有路由和中间件,配置 CORS 允许前端开发服务器访问
|
||||
- _Requirements: 10.2_
|
||||
|
||||
- [x] 2. 检查点 — 确保认证模块测试通过
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 3. 任务配置与执行 API
|
||||
- [x] 3.1 迁移 CLIBuilder 到后端(`apps/backend/app/services/cli_builder.py`)
|
||||
- 从 `gui/utils/cli_builder.py` 迁移核心逻辑
|
||||
- 适配新的 TaskConfigSchema,自动注入 `--store-id` 参数
|
||||
- 支持 7 种 Flow 和 4 种处理模式
|
||||
- _Requirements: 2.6_
|
||||
|
||||
- [x] 3.2 实现任务注册表 API(`apps/backend/app/routers/tasks.py`)
|
||||
- `GET /api/tasks/registry`:返回按业务域分组的任务列表
|
||||
- `GET /api/tasks/dwd-tables`:返回按业务域分组的 DWD 表定义
|
||||
- `GET /api/tasks/flows`:返回 7 种 Flow 定义和 4 种处理模式定义
|
||||
- `POST /api/tasks/validate`:验证 TaskConfig 并返回生成的 CLI 命令预览
|
||||
- Pydantic schemas 在 `apps/backend/app/schemas/tasks.py`
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 11.1, 11.2_
|
||||
|
||||
- [x] 3.3 编写 TaskConfig 属性测试
|
||||
- **Property 1: TaskConfig 序列化往返一致性**
|
||||
- **Property 6: 时间窗口验证**
|
||||
- **Property 7: TaskConfig 到 CLI 命令转换完整性**
|
||||
- **Validates: Requirements 2.3, 2.5, 2.6, 11.1, 11.2, 11.3**
|
||||
|
||||
- [x] 3.4 实现 TaskExecutor 服务(`apps/backend/app/services/task_executor.py`)
|
||||
- 使用 `asyncio.create_subprocess_exec` 启动 ETL_CLI 子进程
|
||||
- 逐行读取 stdout/stderr,存储到内存缓冲区并广播到 WebSocket
|
||||
- 记录退出码、执行时长到 task_execution_log 表
|
||||
- 支持取消(发送 SIGTERM)
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
|
||||
|
||||
- [x] 3.5 实现 TaskQueue 服务(`apps/backend/app/services/task_queue.py`)
|
||||
- `enqueue(config, site_id)`:入队,自动分配 position
|
||||
- `dequeue(site_id)`:取出 position 最小的 pending 任务
|
||||
- `reorder(task_id, new_position, site_id)`:调整顺序
|
||||
- `delete(task_id, site_id)`:删除 pending 任务
|
||||
- `process_loop()`:后台协程,自动取出并执行
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4_
|
||||
|
||||
- [x] 3.6 实现执行与队列路由(`apps/backend/app/routers/execution.py`)
|
||||
- `POST /api/execution/run`:直接执行任务
|
||||
- `GET /api/execution/queue`:获取当前队列(按 site_id 过滤)
|
||||
- `POST /api/execution/queue`:添加到队列
|
||||
- `PUT /api/execution/queue/reorder`:重排
|
||||
- `DELETE /api/execution/queue/{id}`:删除
|
||||
- `POST /api/execution/{id}/cancel`:取消
|
||||
- `GET /api/execution/history`:执行历史(按 site_id 过滤,limit 参数)
|
||||
- `GET /api/execution/{id}/logs`:获取历史日志
|
||||
- _Requirements: 3.1, 3.5, 4.1, 4.2, 4.3, 4.4, 4.5_
|
||||
|
||||
- [x] 3.7 编写队列属性测试
|
||||
- **Property 8: 队列 CRUD 不变量**
|
||||
- **Property 9: 队列出队顺序**
|
||||
- **Property 10: 队列重排一致性**
|
||||
- **Property 11: 执行历史排序与限制**
|
||||
- **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 8.2**
|
||||
|
||||
- [x] 4. 检查点 — 确保任务配置与执行 API 测试通过
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 5. 调度与辅助 API
|
||||
- [x] 5.1 实现 Scheduler 服务(`apps/backend/app/services/scheduler.py`)
|
||||
- `check_and_enqueue()`:查询 enabled=true 且 next_run_at <= now 的调度任务,将其 TaskConfig 入队
|
||||
- `start()`:启动后台 asyncio 循环,每 30 秒检查一次
|
||||
- 在 FastAPI lifespan 中启动/停止
|
||||
- _Requirements: 5.2_
|
||||
|
||||
- [x] 5.2 实现调度路由(`apps/backend/app/routers/schedules.py`)
|
||||
- `GET /api/schedules`:列表(按 site_id 过滤)
|
||||
- `POST /api/schedules`:创建
|
||||
- `PUT /api/schedules/{id}`:更新
|
||||
- `DELETE /api/schedules/{id}`:删除
|
||||
- `PATCH /api/schedules/{id}/toggle`:启用/禁用
|
||||
- Pydantic schemas 在 `apps/backend/app/schemas/schedules.py`
|
||||
- _Requirements: 5.1, 5.3, 5.4, 5.5, 5.6_
|
||||
|
||||
- [x] 5.3 编写调度属性测试
|
||||
- **Property 12: 调度任务 CRUD 往返**
|
||||
- **Property 13: 到期调度任务自动入队**
|
||||
- **Property 14: 调度任务启用/禁用状态**
|
||||
- **Validates: Requirements 5.1, 5.2, 5.3, 5.4**
|
||||
|
||||
- [x] 5.4 实现环境配置路由(`apps/backend/app/routers/env_config.py`)
|
||||
- `GET /api/env-config`:读取 .env,敏感值掩码
|
||||
- `PUT /api/env-config`:验证并写入 .env
|
||||
- `GET /api/env-config/export`:导出去敏感值的配置文件
|
||||
- 敏感键列表:PASSWORD、TOKEN、SECRET、DSN
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4_
|
||||
|
||||
- [x] 5.5 编写环境配置属性测试
|
||||
- **Property 15: .env 解析与敏感值掩码**
|
||||
- **Property 16: .env 写入往返一致性**
|
||||
- **Validates: Requirements 6.1, 6.2, 6.3**
|
||||
|
||||
- [x] 5.6 实现数据库查看器路由(`apps/backend/app/routers/db_viewer.py`)
|
||||
- `GET /api/db/schemas`:返回 Schema 列表
|
||||
- `GET /api/db/schemas/{name}/tables`:返回表列表和行数
|
||||
- `GET /api/db/tables/{schema}/{table}/columns`:返回列定义
|
||||
- `POST /api/db/query`:只读 SQL 执行(写操作拦截、1000 行限制、30 秒超时)
|
||||
- 使用 `get_etl_readonly_connection(site_id)` 确保 RLS 隔离
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
|
||||
|
||||
- [x] 5.7 编写数据库查看器属性测试
|
||||
- **Property 17: SQL 写操作拦截**
|
||||
- **Property 18: SQL 查询结果行数限制**
|
||||
- **Validates: Requirements 7.4, 7.5**
|
||||
|
||||
- [x] 5.8 实现 ETL 状态路由(`apps/backend/app/routers/etl_status.py`)
|
||||
- `GET /api/etl-status/cursors`:查询 etl_admin.etl_cursor 表,返回各任务游标
|
||||
- `GET /api/etl-status/recent-runs`:查询 task_execution_log 表,返回最近 50 条记录
|
||||
- _Requirements: 8.1, 8.2, 8.3_
|
||||
|
||||
- [x] 5.9 实现 WebSocket 日志推送(`apps/backend/app/ws/logs.py`)
|
||||
- `WS /ws/logs/{execution_id}`:实时推送任务执行日志
|
||||
- TaskExecutor 执行时广播日志行到已连接的 WebSocket 客户端
|
||||
- _Requirements: 9.1, 9.4_
|
||||
|
||||
- [x] 6. 检查点 — 确保所有后端 API 测试通过
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 7. 前端项目初始化
|
||||
- [x] 7.1 在 `apps/admin-web/` 下初始化 React + Vite + TypeScript 项目
|
||||
- `pnpm create vite . --template react-ts`
|
||||
- 安装核心依赖:`antd`、`@ant-design/icons`、`react-router-dom`、`axios`、`zustand`
|
||||
- 配置 `vite.config.ts`:API 代理到后端 `http://localhost:8000`
|
||||
- 配置中文 locale(antd ConfigProvider)
|
||||
- _Requirements: 10.1, 10.2_
|
||||
|
||||
- [x] 7.2 实现 API 客户端(`src/api/client.ts`)
|
||||
- 创建 axios 实例,baseURL 指向 `/api`
|
||||
- 请求拦截器:自动附加 JWT Authorization header
|
||||
- 响应拦截器:401 时尝试刷新令牌,刷新失败跳转登录页
|
||||
- _Requirements: 1.3, 1.4, 1.5_
|
||||
|
||||
- [x] 7.3 实现认证状态管理(`src/store/authStore.ts`)和登录页(`src/pages/Login.tsx`)
|
||||
- Zustand store:存储 token、user info、site_id
|
||||
- 登录页:Ant Design Form,用户名 + 密码
|
||||
- 登录成功后存储令牌到 localStorage 并跳转首页
|
||||
- _Requirements: 1.1, 1.2_
|
||||
|
||||
- [x] 7.4 实现主布局(`src/App.tsx`)和路由配置
|
||||
- Ant Design Layout:Sider(侧边栏导航)+ Content + Footer(状态栏)
|
||||
- react-router-dom:6 个功能页面路由 + 登录页路由
|
||||
- 路由守卫:未登录重定向到登录页
|
||||
- 侧边栏导航项:任务配置、任务管理、环境配置、数据库、ETL 状态、日志
|
||||
- _Requirements: 10.1, 10.2, 10.3_
|
||||
|
||||
- [x] 8. 前端功能页面 — 任务配置
|
||||
- [x] 8.1 实现任务配置页面(`src/pages/TaskConfig.tsx`)
|
||||
- Flow 选择器:Radio Group,7 种 Flow,选择后动态显示/隐藏任务区域
|
||||
- 处理模式选择器:3 种模式 + 校验附加选项
|
||||
- 时间窗口配置:回溯模式(lookback_hours + overlap_seconds)/ 自定义模式(DatePicker)
|
||||
- 窗口切分选项:不切分 / 按天(1/10/30)
|
||||
- 高级选项折叠面板:dry-run、force-full 等 Checkbox
|
||||
- _Requirements: 2.2, 2.3, 2.5_
|
||||
|
||||
- [x] 8.2 实现 TaskSelector 组件(`src/components/TaskSelector.tsx`)
|
||||
- 从 `/api/tasks/registry` 获取任务列表
|
||||
- 按业务域分组展示(Collapse + Checkbox Group)
|
||||
- 根据当前 Flow 包含的层过滤可见任务
|
||||
- 全选/反选功能
|
||||
- _Requirements: 2.1, 2.2_
|
||||
|
||||
- [x] 8.3 实现 DwdTableSelector 组件(`src/components/DwdTableSelector.tsx`)
|
||||
- 从 `/api/tasks/dwd-tables` 获取 DWD 表定义
|
||||
- 按业务域分组展示(Collapse + Checkbox Group)
|
||||
- 仅在 Flow 包含 DWD 层时显示
|
||||
- _Requirements: 2.4_
|
||||
|
||||
- [x] 8.4 实现任务提交和 CLI 命令预览
|
||||
- 提交前调用 `/api/tasks/validate` 预览生成的 CLI 命令
|
||||
- 提交到队列(`/api/execution/queue`)或直接执行(`/api/execution/run`)
|
||||
- 提交成功后跳转到任务管理页面
|
||||
- _Requirements: 2.6, 3.1, 4.1_
|
||||
|
||||
- [x] 8.5 编写 Flow 层级过滤前端单元测试
|
||||
- **Property 21: Flow 层级与任务兼容性**
|
||||
- 使用 Vitest 测试过滤逻辑函数
|
||||
- **Validates: Requirements 2.2**
|
||||
|
||||
- [x] 9. 前端功能页面 — 任务管理
|
||||
- [x] 9.1 实现任务管理页面(`src/pages/TaskManager.tsx`)
|
||||
- Ant Design Tabs:队列 + 调度 + 历史
|
||||
- 队列 Tab:Table 展示当前队列,支持拖拽排序、删除、取消
|
||||
- 历史 Tab:Table 展示执行历史,点击行查看详情和日志
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
|
||||
|
||||
- [x] 9.2 实现调度管理 Tab
|
||||
- 调度任务列表 Table:名称、调度描述、启用状态 Switch、下次执行时间、执行次数
|
||||
- 创建/编辑调度任务 Modal:任务选择 + 调度配置(类型、间隔、时间等)
|
||||
- 删除确认
|
||||
- _Requirements: 5.1, 5.3, 5.4, 5.5, 5.6_
|
||||
|
||||
- [x] 9.3 实现状态栏任务执行指示
|
||||
- 在 Layout Footer 或 Sider 底部显示当前执行状态
|
||||
- 轮询 `/api/execution/queue` 检查是否有 running 状态的任务
|
||||
- 有任务执行时显示 Spin 动画和任务名称
|
||||
- _Requirements: 10.3, 10.4_
|
||||
|
||||
- [x] 10. 前端功能页面 — 辅助模块
|
||||
- [x] 10.1 实现环境配置页面(`src/pages/EnvConfig.tsx`)
|
||||
- 键值对编辑表格(Ant Design Table,editable cells)
|
||||
- 敏感值显示为 `****`,编辑时可输入新值
|
||||
- 保存按钮调用 `PUT /api/env-config`
|
||||
- 导出按钮调用 `GET /api/env-config/export` 下载文件
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4_
|
||||
|
||||
- [x] 10.2 实现数据库查看器页面(`src/pages/DBViewer.tsx`)
|
||||
- 左侧 Tree:Schema → Table 层级浏览
|
||||
- 右侧上方:SQL 编辑器(Ant Design Input.TextArea 或集成 CodeMirror)
|
||||
- 右侧下方:查询结果 Table
|
||||
- 选择表时自动展示列定义
|
||||
- 执行查询按钮,结果分页展示
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4_
|
||||
|
||||
- [x] 10.3 实现 ETL 状态页面(`src/pages/ETLStatus.tsx`)
|
||||
- 游标状态 Table:任务编码、最后抓取时间、记录数
|
||||
- 最近执行记录 Table:任务名称、状态 Tag、开始时间、执行时长
|
||||
- 刷新按钮
|
||||
- _Requirements: 8.1, 8.2, 8.3_
|
||||
|
||||
- [x] 10.4 实现日志查看器页面(`src/pages/LogViewer.tsx`)和 LogStream 组件
|
||||
- WebSocket 连接 `/ws/logs/{execution_id}`,实时追加日志行
|
||||
- 日志过滤输入框:按关键词过滤显示
|
||||
- 自动滚动到底部,可手动暂停滚动
|
||||
- 历史日志:从 `/api/execution/{id}/logs` 加载
|
||||
- _Requirements: 9.1, 9.2, 9.3, 9.4_
|
||||
|
||||
- [x] 10.5 编写日志过滤前端单元测试
|
||||
- **Property 19: 日志过滤正确性**
|
||||
- 使用 Vitest 测试过滤函数
|
||||
- **Validates: Requirements 9.2**
|
||||
|
||||
- [x] 11. 门店隔离集成验证
|
||||
- [x] 11.1 编写门店隔离属性测试
|
||||
- **Property 20: 门店隔离 — 队列和调度数据不跨站泄露**
|
||||
- 使用 pytest + hypothesis 生成随机 site_id 对,验证数据隔离
|
||||
- **Validates: Requirements 1.3**
|
||||
|
||||
- [x] 11.2 编写任务注册表分组属性测试
|
||||
- **Property 4: 任务注册表按业务域正确分组**
|
||||
- **Validates: Requirements 2.1**
|
||||
|
||||
- [x] 12. 最终检查点 — 确保所有测试通过 ✅
|
||||
- 后端 302 passed / 0 failed,前端 33 passed / 0 failed,全部通过。
|
||||
|
||||
## 说明
|
||||
|
||||
- 标记 `*` 的子任务为可选测试任务,可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号,确保可追溯
|
||||
- 检查点用于阶段性验证,确保增量正确
|
||||
- 属性测试验证通用正确性属性,单元测试覆盖具体示例和边界条件
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,224 +0,0 @@
|
||||
# 设计文档:助教废除(Abolish)全链路清理
|
||||
|
||||
## 概述
|
||||
|
||||
本设计描述如何安全地从 ETL 全链路中移除"助教废除"独立数据链路。
|
||||
核心思路:**删除独立的废除链路(API → ODS → DWD),保留服务记录中已有的 `is_trash` 字段作为唯一废除判断源。**
|
||||
|
||||
清理范围覆盖:
|
||||
- ETL 任务定义(ODS 任务 + 注册表)
|
||||
- DWD 加载映射(FACT_MAPPINGS + TABLE_MAP)
|
||||
- DWS 聚合逻辑(死代码移除)
|
||||
- DWD 验证器配置
|
||||
- 数据库 DDL 和迁移脚本
|
||||
- 属性测试
|
||||
- 运维脚本
|
||||
|
||||
## 架构
|
||||
|
||||
### 清理前数据流
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
API_Abolish["/AssistantPerformance/GetAbolitionAssistant"] --> ODS_ACR["ods.assistant_cancellation_records"]
|
||||
ODS_ACR --> DWD_ATE["dwd.dwd_assistant_trash_event"]
|
||||
ODS_ACR --> DWD_ATE_EX["dwd.dwd_assistant_trash_event_ex"]
|
||||
DWD_ATE --> |"_extract_trash_records (死代码)"| DWS_DAILY["dws.dws_assistant_daily_detail"]
|
||||
|
||||
API_Service["/AssistantPerformance/GetAssistantServiceRecords"] --> ODS_ASR["ods.assistant_service_records"]
|
||||
ODS_ASR --> DWD_SL_EX["dwd.dwd_assistant_service_log_ex"]
|
||||
DWD_SL_EX --> |"is_trash 字段 (实际使用)"| DWS_DAILY
|
||||
|
||||
style API_Abolish fill:#f99,stroke:#c00
|
||||
style ODS_ACR fill:#f99,stroke:#c00
|
||||
style DWD_ATE fill:#f99,stroke:#c00
|
||||
style DWD_ATE_EX fill:#f99,stroke:#c00
|
||||
```
|
||||
|
||||
### 清理后数据流
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
API_Service["/AssistantPerformance/GetAssistantServiceRecords"] --> ODS_ASR["ods.assistant_service_records"]
|
||||
ODS_ASR --> DWD_SL_EX["dwd.dwd_assistant_service_log_ex"]
|
||||
DWD_SL_EX --> |"is_trash 字段"| DWS_DAILY["dws.dws_assistant_daily_detail"]
|
||||
|
||||
style API_Service fill:#9f9,stroke:#090
|
||||
style DWD_SL_EX fill:#9f9,stroke:#090
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 需要修改的文件清单
|
||||
|
||||
| 层级 | 文件 | 操作 | 需求 |
|
||||
|------|------|------|------|
|
||||
| ODS 任务 | `tasks/ods/ods_tasks.py` | 删除 `OdsTaskSpec` 条目 + 从默认序列移除 | 1.2, 1.3 |
|
||||
| 任务注册 | `orchestration/task_registry.py` | 删除 `ODS_ASSISTANT_ABOLISH` 注册 | 1.1 |
|
||||
| DWD 加载 | `tasks/dwd/dwd_load_task.py` | 删除 FACT_MAPPINGS 和 TABLE_MAP 条目 | 2.1–2.4 |
|
||||
| DWS 日度 | `tasks/dws/assistant_daily_task.py` | 删除 `_extract_trash_records`、`_build_trash_index`,简化 `_aggregate_by_assistant_date` 签名 | 3.1–3.4 |
|
||||
| DWD 验证 | `tasks/verification/dwd_verifier.py` | 删除废除表的 ID 和时间字段映射 | 4.1–4.4 |
|
||||
| DDL | `db/etl_feiqiu/schemas/dwd.sql` | 删除 `dwd_assistant_trash_event` / `_ex` 的 CREATE TABLE + COMMENT | 6.1–6.2 |
|
||||
| DDL | `db/etl_feiqiu/schemas/ods.sql` | 删除 `assistant_cancellation_records` 的 CREATE TABLE + COMMENT | 6.3 |
|
||||
| DDL | `db/etl_feiqiu/schemas/schema_dwd_doc.sql` | 删除废除表的 CREATE TABLE + COMMENT | 6.4 |
|
||||
| DDL | `db/etl_feiqiu/schemas/schema_ODS_doc.sql` | 删除废除表的 CREATE TABLE + COMMENT | 6.5 |
|
||||
| DDL | `db/etl_feiqiu/schemas/dws.sql` | 更新 `dws_assistant_daily_detail` 注释 | 6.6 |
|
||||
| DDL | `db/etl_feiqiu/schemas/schema_dws.sql` | 更新 `dws_assistant_daily_detail` 注释 | 6.7 |
|
||||
| 迁移 | `db/etl_feiqiu/migrations/` | 新建 DROP TABLE 迁移脚本 | 5.1–5.5 |
|
||||
| 属性测试 | `tests/test_property_1_fact_mappings.py` | 删除 `_REQ3_EXPECTED` 和相关引用 | 7.1–7.3 |
|
||||
| 运维 | `scripts/ops/dataflow_analyzer.py` | 删除 `ODS_ASSISTANT_ABOLISH` spec 条目 | 8.1 |
|
||||
| 运维 | `scripts/ops/gen_full_dataflow_doc.py` | 删除 `ODS_ASSISTANT_ABOLISH` spec 条目 | 8.1 |
|
||||
| 运维 | `scripts/ops/etl_consistency_check.py` | 删除废除相关映射 | 8.1–8.2 |
|
||||
| 运维 | `scripts/ops/blackbox_test_report.py` | 删除废除相关映射 | 8.1–8.4 |
|
||||
| 运维 | `scripts/ops/field_audit.py` | 删除废除表审计条目 | 8.3–8.4 |
|
||||
| 运维 | `scripts/ops/gen_field_review_doc.py` | 删除废除表字段定义 | 8.3–8.4 |
|
||||
| 运维 | `scripts/ops/gen_api_field_mapping.py` | 从 ODS_TABLES 列表移除 | 8.3 |
|
||||
| 运维 | `scripts/ops/export_dwd_field_review.py` | 删除废除表条目 | 8.4 |
|
||||
| 运维 | `scripts/ops/check_ods_latest_indexes.py` | 删除废除表索引检查 | 8.3 |
|
||||
|
||||
### 不需要修改的文件(确认安全)
|
||||
|
||||
| 文件 | 原因 |
|
||||
|------|------|
|
||||
| `dwd_assistant_service_log_ex` 表 DDL | 保留 `is_trash` 等字段(需求 9) |
|
||||
| `ods.assistant_service_records` 表 DDL | 保留 `is_trash` 等字段(需求 9) |
|
||||
| `assistant_monthly_task.py` | 仅消费 `dws_assistant_daily_detail` 的 `trashed_seconds`/`trashed_count`,不直接引用废除表 |
|
||||
| `assistant_salary_task.py` | 仅消费 `dws_assistant_monthly_summary`,不直接引用废除表 |
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 被删除的表
|
||||
|
||||
```sql
|
||||
-- ODS 层
|
||||
ods.assistant_cancellation_records -- 78 条记录
|
||||
|
||||
-- DWD 层
|
||||
dwd.dwd_assistant_trash_event -- 主表
|
||||
dwd.dwd_assistant_trash_event_ex -- 扩展表
|
||||
```
|
||||
|
||||
### 保留的废除相关字段
|
||||
|
||||
```sql
|
||||
-- ods.assistant_service_records 中(保留)
|
||||
is_trash INT -- 废除标记
|
||||
trash_reason TEXT -- 废除原因
|
||||
trash_applicant_id BIGINT -- 废除申请人 ID
|
||||
trash_applicant_name TEXT -- 废除申请人姓名
|
||||
|
||||
-- dwd.dwd_assistant_service_log_ex 中(保留)
|
||||
is_trash INTEGER -- 废除标记
|
||||
trash_applicant_id BIGINT -- 废除申请人 ID
|
||||
trash_applicant_name VARCHAR(64) -- 废除申请人姓名
|
||||
trash_reason VARCHAR(255) -- 废除原因
|
||||
```
|
||||
|
||||
### DWS 层字段(保留,数据来源变更说明)
|
||||
|
||||
```sql
|
||||
-- dws.dws_assistant_daily_detail(保留,注释需更新)
|
||||
trashed_seconds INTEGER -- 数据来源:dwd_assistant_service_log_ex.is_trash + income_seconds
|
||||
trashed_count INTEGER -- 数据来源:dwd_assistant_service_log_ex.is_trash 计数
|
||||
|
||||
-- dws.dws_assistant_monthly_summary(保留)
|
||||
trashed_hours NUMERIC(10,2) -- 来自 daily_detail.trashed_seconds 汇总
|
||||
```
|
||||
|
||||
## DWS 代码重构细节
|
||||
|
||||
### assistant_daily_task.py 变更
|
||||
|
||||
**删除方法:**
|
||||
- `_extract_trash_records()` — 查询 `dwd.dwd_assistant_trash_event` 的 SQL,已无消费者
|
||||
- `_build_trash_index()` — 构建废除索引,已不参与判断逻辑
|
||||
|
||||
**修改方法:**
|
||||
- `extract()` — 移除对 `_extract_trash_records` 的调用,移除 `trash_records` 变量
|
||||
- `transform()` 或调用 `_aggregate_by_assistant_date` 的地方 — 移除 `trash_index` 参数传递
|
||||
- `_aggregate_by_assistant_date()` — 从签名中移除 `trash_index` 参数;`is_trash` 判断逻辑保持不变
|
||||
|
||||
**不变逻辑:**
|
||||
```python
|
||||
# 这段逻辑保持不变——通过 is_trash 字段判断废除
|
||||
is_trashed = bool(record.get('is_trash', 0))
|
||||
if is_trashed:
|
||||
agg['trashed_seconds'] += income_seconds
|
||||
agg['trashed_count'] += 1
|
||||
```
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*正确性属性是一种在系统所有有效执行中都应成立的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1:废除聚合逻辑正确性(is_trash 驱动)
|
||||
|
||||
*对任意*服务记录集合,其中每条记录包含 `is_trash` 标记和 `income_seconds` 值,聚合后:
|
||||
- `trashed_seconds` 应等于所有 `is_trash=1` 记录的 `income_seconds` 之和
|
||||
- `trashed_count` 应等于所有 `is_trash=1` 记录的数量
|
||||
- `total_service_count` 应等于所有 `is_trash=0` 记录的数量
|
||||
- `total_seconds` 应等于所有 `is_trash=0` 记录的 `income_seconds` 之和
|
||||
|
||||
**Validates: Requirements 3.4, 9.3**
|
||||
|
||||
### Property 2:FACT_MAPPINGS 一致性(已有属性测试的回归验证)
|
||||
|
||||
*对任意* FACT_MAPPINGS 中的表名,该表名应在 TABLE_MAP 中有对应的 ODS 源表映射,且映射的每个 DWD 列名应为合法的 SQL 标识符。
|
||||
|
||||
**Validates: Requirements 2.1–2.4, 7.3**
|
||||
|
||||
> 注:此属性已由 `tests/test_property_1_fact_mappings.py` 实现。清理后需确保该测试仍然通过。
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 迁移脚本安全性
|
||||
|
||||
- 所有 `DROP TABLE` 语句使用 `IF EXISTS`,确保幂等执行
|
||||
- 迁移脚本在单个事务中执行,失败时自动回滚
|
||||
- 迁移脚本包含注释说明移除原因,便于审计追溯
|
||||
|
||||
### 代码删除安全性
|
||||
|
||||
- 删除 `_extract_trash_records` 和 `_build_trash_index` 前,确认无其他调用者
|
||||
- `_aggregate_by_assistant_date` 移除 `trash_index` 参数后,确认所有调用点已同步更新
|
||||
- 保留 `is_trash` 判断逻辑不变,确保废除统计功能不受影响
|
||||
|
||||
### 回滚策略
|
||||
|
||||
- DDL 变更通过迁移脚本管理,可通过反向迁移(CREATE TABLE)回滚
|
||||
- 代码变更通过 Git 版本控制回滚
|
||||
- ODS 表数据在删除前可选择性备份(数据量小,仅 78 条)
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 属性测试
|
||||
|
||||
- 使用 `hypothesis` 库进行属性测试
|
||||
- 每个属性测试至少运行 100 次迭代
|
||||
- 每个测试用注释标注对应的设计属性编号
|
||||
|
||||
**Property 1 测试方案:**
|
||||
- 生成随机服务记录列表(包含随机 `is_trash` 标记和 `income_seconds` 值)
|
||||
- 调用 `_aggregate_by_assistant_date` 方法
|
||||
- 验证 `trashed_seconds`/`trashed_count` 与手动计算的期望值一致
|
||||
- 标签:`Feature: assistant-abolish-cleanup, Property 1: 废除聚合逻辑正确性`
|
||||
|
||||
**Property 2 测试方案:**
|
||||
- 已由 `tests/test_property_1_fact_mappings.py` 覆盖
|
||||
- 清理后运行 `pytest tests/ -v` 确认无回归
|
||||
- 标签:`Feature: assistant-abolish-cleanup, Property 2: FACT_MAPPINGS 一致性`
|
||||
|
||||
### 单元测试
|
||||
|
||||
- 验证 `AssistantDailyTask` 不再有 `_extract_trash_records` 和 `_build_trash_index` 方法
|
||||
- 验证 `_aggregate_by_assistant_date` 签名不包含 `trash_index` 参数
|
||||
- 验证 FACT_MAPPINGS 不包含废除表条目
|
||||
- 验证 TABLE_MAP 不包含废除表映射
|
||||
- 验证 DwdVerifier 配置不包含废除表
|
||||
|
||||
### 集成验证
|
||||
|
||||
- 运行现有属性测试套件:`pytest tests/ -v`
|
||||
- 运行 ETL 单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit`
|
||||
- 确认所有测试通过,无回归
|
||||
@@ -1,127 +0,0 @@
|
||||
# 需求文档:助教废除(Abolish)全链路清理
|
||||
|
||||
## 简介
|
||||
|
||||
上游 SaaS 系统提供了一个独立的"助教废除记录"API(`/AssistantPerformance/GetAbolitionAssistant`),
|
||||
ETL 系统为此建立了完整的 ODS → DWD → DWS 数据链路。但经排查发现:
|
||||
|
||||
1. **废除表 `dwd_assistant_trash_event` 无法与服务记录表 `dwd_assistant_service_log` 做 1:1 关联**——废除表没有 `assistant_service_id` 外键,两个 ID 不同源。
|
||||
2. **DWS 层已改用 `dwd_assistant_service_log_ex.is_trash` 字段**(来自 `assistant_service_records` API)直接判断服务是否被废除,不再依赖废除表做跨表匹配。
|
||||
3. 废除表的 `_extract_trash_records` 和 `_build_trash_index` 虽然仍被调用,但 `trash_index` 实际上不再参与废除判断逻辑(仅"备查"),属于死代码。
|
||||
4. `trashed_seconds` / `trashed_count` 等 DWS 字段的数据来源已从废除表切换为服务记录自身的 `income_seconds`,废除表数据不再被消费。
|
||||
|
||||
因此,整条 abolish 独立链路(API 抓取 → ODS 表 → DWD 表 → DWS 引用)可以安全移除,
|
||||
同时保留 `assistant_service_records` 中已有的 `is_trash` / `trash_reason` / `trash_applicant_*` 字段作为废除判断的唯一数据源。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **ETL_System**:飞球 ETL Connector(`apps/etl/connectors/feiqiu/`)
|
||||
- **ODS_Layer**:原始数据层(`ods` schema),存储从上游 API 抓取的原始记录
|
||||
- **DWD_Layer**:明细数据层(`dwd` schema),存储清洗后的事实表和维度表
|
||||
- **DWS_Layer**:汇总数据层(`dws` schema),存储按业务粒度聚合的汇总表
|
||||
- **Abolish_Chain**:助教废除独立链路,包括 `ODS_ASSISTANT_ABOLISH` 任务、`ods.assistant_cancellation_records` 表、`dwd.dwd_assistant_trash_event` / `_ex` 表,以及 DWS 层中引用这些表的代码
|
||||
- **Service_Trash_Fields**:`assistant_service_records` API 中自带的废除标记字段(`is_trash`、`trash_reason`、`trash_applicant_id`、`trash_applicant_name`),已映射到 `dwd_assistant_service_log_ex` 表
|
||||
- **FACT_MAPPINGS**:`dwd_load_task.py` 中定义的 ODS → DWD 字段映射字典
|
||||
- **Task_Registry**:`orchestration/task_registry.py` 中的任务注册表
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:移除 ODS 层废除任务
|
||||
|
||||
**用户故事:** 作为 ETL 维护者,我希望移除不再使用的 ODS 抓取任务,以减少无效 API 调用和维护负担。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 执行调度时,THE Task_Registry SHALL 不包含 `ODS_ASSISTANT_ABOLISH` 任务注册
|
||||
2. WHEN ETL_System 加载 ODS 任务定义时,THE ETL_System SHALL 不包含 `OdsAssistantAbolishTask` 的 `OdsTaskSpec` 定义
|
||||
3. WHEN ETL_System 构建默认执行序列时,THE ETL_System SHALL 不包含 `ODS_ASSISTANT_ABOLISH` 任务代码
|
||||
|
||||
### 需求 2:移除 DWD 层废除表映射
|
||||
|
||||
**用户故事:** 作为 ETL 维护者,我希望移除废除表的 FACT_MAPPINGS 和 DWD 加载配置,以消除死代码。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN DWD_Layer 执行装载时,THE FACT_MAPPINGS SHALL 不包含 `dwd.dwd_assistant_trash_event` 的映射条目
|
||||
2. WHEN DWD_Layer 执行装载时,THE FACT_MAPPINGS SHALL 不包含 `dwd.dwd_assistant_trash_event_ex` 的映射条目
|
||||
3. WHEN DWD_Layer 构建 ODS→DWD 表映射时,THE ETL_System SHALL 不包含 `dwd.dwd_assistant_trash_event` 到 `ods.assistant_cancellation_records` 的映射关系
|
||||
4. WHEN DWD_Layer 构建 ODS→DWD 表映射时,THE ETL_System SHALL 不包含 `dwd.dwd_assistant_trash_event_ex` 到 `ods.assistant_cancellation_records` 的映射关系
|
||||
|
||||
### 需求 3:清理 DWS 层废除表引用
|
||||
|
||||
**用户故事:** 作为 ETL 维护者,我希望移除 DWS 任务中对废除表的查询和索引构建代码,以消除死代码路径。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN DWS_Layer 执行 `DWS_ASSISTANT_DAILY` 任务时,THE AssistantDailyTask SHALL 不调用 `_extract_trash_records` 方法
|
||||
2. WHEN DWS_Layer 执行 `DWS_ASSISTANT_DAILY` 任务时,THE AssistantDailyTask SHALL 不调用 `_build_trash_index` 方法
|
||||
3. WHEN DWS_Layer 执行 `DWS_ASSISTANT_DAILY` 任务时,THE AssistantDailyTask SHALL 不向 `_aggregate_by_assistant_date` 传递 `trash_index` 参数
|
||||
4. WHEN DWS_Layer 聚合服务记录时,THE AssistantDailyTask SHALL 仅通过 `is_trash` 字段(来自 `dwd_assistant_service_log_ex` JOIN)判断服务是否被废除
|
||||
|
||||
### 需求 4:清理 DWD 验证器配置
|
||||
|
||||
**用户故事:** 作为 ETL 维护者,我希望移除验证器中对废除表的引用,以避免验证器尝试校验已不存在的表。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN DWD_Layer 执行数据验证时,THE DwdVerifier SHALL 不包含 `dwd_assistant_trash_event` 的 ID 映射配置
|
||||
2. WHEN DWD_Layer 执行数据验证时,THE DwdVerifier SHALL 不包含 `dwd_assistant_trash_event_ex` 的 ID 映射配置
|
||||
3. WHEN DWD_Layer 执行数据验证时,THE DwdVerifier SHALL 不包含 `dwd_assistant_trash_event` 的时间字段映射配置
|
||||
4. WHEN DWD_Layer 执行数据验证时,THE DwdVerifier SHALL 不包含 `dwd_assistant_trash_event_ex` 的时间字段映射配置
|
||||
|
||||
### 需求 5:创建数据库迁移脚本
|
||||
|
||||
**用户故事:** 作为数据库管理员,我希望通过迁移脚本安全地移除废除相关的数据库对象,以保持 schema 整洁。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 迁移脚本执行时,THE 迁移脚本 SHALL 删除 `dwd.dwd_assistant_trash_event` 表
|
||||
2. WHEN 迁移脚本执行时,THE 迁移脚本 SHALL 删除 `dwd.dwd_assistant_trash_event_ex` 表
|
||||
3. WHEN 迁移脚本执行时,THE 迁移脚本 SHALL 删除 `ods.assistant_cancellation_records` 表
|
||||
4. WHEN 迁移脚本执行时,THE 迁移脚本 SHALL 在 DROP 前使用 `IF EXISTS` 防止重复执行报错
|
||||
5. WHEN 迁移脚本执行时,THE 迁移脚本 SHALL 包含注释说明移除原因
|
||||
|
||||
### 需求 6:同步更新 DDL 文档
|
||||
|
||||
**用户故事:** 作为 ETL 维护者,我希望 DDL schema 文件与实际数据库结构保持一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN DDL 文件更新后,THE `db/etl_feiqiu/schemas/dwd.sql` SHALL 不包含 `dwd_assistant_trash_event` 表的 CREATE TABLE 语句
|
||||
2. WHEN DDL 文件更新后,THE `db/etl_feiqiu/schemas/dwd.sql` SHALL 不包含 `dwd_assistant_trash_event_ex` 表的 CREATE TABLE 语句
|
||||
3. WHEN DDL 文件更新后,THE `db/etl_feiqiu/schemas/ods.sql` SHALL 不包含 `assistant_cancellation_records` 表的 CREATE TABLE 语句
|
||||
4. WHEN DDL 文件更新后,THE `db/etl_feiqiu/schemas/schema_dwd_doc.sql` SHALL 不包含 `dwd_assistant_trash_event` 相关的 CREATE TABLE 和 COMMENT 语句
|
||||
5. WHEN DDL 文件更新后,THE `db/etl_feiqiu/schemas/schema_ODS_doc.sql` SHALL 不包含 `assistant_cancellation_records` 相关的 CREATE TABLE 和 COMMENT 语句
|
||||
6. WHEN DDL 文件更新后,THE `db/etl_feiqiu/schemas/dws.sql` 中 `dws_assistant_daily_detail` 的注释 SHALL 不再引用 `dwd_assistant_trash_event` 作为数据来源
|
||||
7. WHEN DDL 文件更新后,THE `db/etl_feiqiu/schemas/schema_dws.sql` 中 `dws_assistant_daily_detail` 的注释 SHALL 不再引用 `dwd_assistant_trash_event` 作为数据来源
|
||||
|
||||
### 需求 7:更新属性测试
|
||||
|
||||
**用户故事:** 作为开发者,我希望属性测试反映清理后的实际状态,以确保测试的准确性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 属性测试执行时,THE `test_property_1_fact_mappings.py` SHALL 不包含 `dwd.dwd_assistant_trash_event` 在 A 类表列表中
|
||||
2. WHEN 属性测试执行时,THE `test_property_1_fact_mappings.py` SHALL 不包含 `assistant_cancellation_records → dwd_assistant_trash_event` 的映射期望(`_REQ3_EXPECTED`)
|
||||
3. WHEN 属性测试执行后,THE 所有现有属性测试 SHALL 通过(无回归)
|
||||
|
||||
### 需求 8:更新运维脚本引用
|
||||
|
||||
**用户故事:** 作为运维人员,我希望运维脚本中不再引用已移除的表和任务,以避免脚本执行错误。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 运维脚本加载 ODS 任务映射时,THE 脚本 SHALL 不包含 `ODS_ASSISTANT_ABOLISH` 到 `assistant_cancellation_records` 的映射
|
||||
2. WHEN 运维脚本加载 DWD 表映射时,THE 脚本 SHALL 不包含 `dwd.dwd_assistant_trash_event` 到 `ods.assistant_cancellation_records` 的映射
|
||||
3. WHEN 运维脚本列举 ODS 表时,THE 脚本 SHALL 不包含 `assistant_cancellation_records`
|
||||
4. WHEN 运维脚本列举 DWD 表时,THE 脚本 SHALL 不包含 `dwd_assistant_trash_event` 和 `dwd_assistant_trash_event_ex`
|
||||
|
||||
### 需求 9:保留 Service_Trash_Fields 不受影响
|
||||
|
||||
**用户故事:** 作为 ETL 维护者,我希望确认清理操作不会影响 `assistant_service_records` 中已有的废除标记字段。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHILE 清理操作执行期间,THE `dwd_assistant_service_log_ex` 表 SHALL 保留 `is_trash`、`trash_reason`、`trash_applicant_id`、`trash_applicant_name` 字段不变
|
||||
2. WHILE 清理操作执行期间,THE `ods.assistant_service_records` 表 SHALL 保留 `is_trash`、`trash_reason`、`trash_applicant_id`、`trash_applicant_name` 字段不变
|
||||
3. WHILE 清理操作执行期间,THE AssistantDailyTask 中通过 `is_trash` 判断废除的逻辑 SHALL 保持正常工作
|
||||
@@ -1,99 +0,0 @@
|
||||
# 实施计划:助教废除(Abolish)全链路清理
|
||||
|
||||
## 概述
|
||||
|
||||
按 ETL 数据流的逆序(DWS → DWD → ODS)清理废除链路,确保每一步都可验证。先清理代码引用,再清理 DDL 和数据库对象,最后更新运维脚本和测试。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 清理 DWS 层死代码
|
||||
- [x] 1.1 从 `assistant_daily_task.py` 中删除 `_extract_trash_records` 和 `_build_trash_index` 方法,从 `extract()` 中移除对 `_extract_trash_records` 的调用和 `trash_records` 变量,从 `_aggregate_by_assistant_date` 签名中移除 `trash_index` 参数,同步更新所有调用点。保留 `is_trash` 判断逻辑不变。
|
||||
- 更新文件头部的 docstring,移除对 `dwd_assistant_trash_event` 的数据来源引用
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4_
|
||||
|
||||
- [x] 1.2 编写属性测试验证废除聚合逻辑正确性
|
||||
- **Property 1: 废除聚合逻辑正确性(is_trash 驱动)**
|
||||
- 生成随机服务记录列表,验证 `trashed_seconds`/`trashed_count` 与 `is_trash=1` 记录的手动计算一致
|
||||
- **Validates: Requirements 3.4, 9.3**
|
||||
|
||||
- [x] 2. 清理 DWD 层映射和验证器
|
||||
- [x] 2.1 从 `dwd_load_task.py` 的 `FACT_MAPPINGS` 中删除 `dwd.dwd_assistant_trash_event` 和 `dwd.dwd_assistant_trash_event_ex` 条目,从 `TABLE_MAP` 中删除对应的 ODS→DWD 映射
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
|
||||
- [x] 2.2 从 `dwd_verifier.py` 中删除 `dwd_assistant_trash_event` 和 `dwd_assistant_trash_event_ex` 的 ID 映射和时间字段映射配置
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4_
|
||||
|
||||
- [x] 3. 清理 ODS 层任务定义
|
||||
- [x] 3.1 从 `ods_tasks.py` 中删除 `ODS_ASSISTANT_ABOLISH` 的 `OdsTaskSpec` 定义,从默认执行序列中移除该任务代码
|
||||
- _Requirements: 1.2, 1.3_
|
||||
|
||||
- [x] 3.2 从 `task_registry.py` 中删除 `ODS_ASSISTANT_ABOLISH` 的注册语句(如果存在独立注册)
|
||||
- _Requirements: 1.1_
|
||||
|
||||
- [x] 4. Checkpoint — 确保 ETL 单元测试通过
|
||||
- 运行 `cd apps/etl/connectors/feiqiu && pytest tests/unit`,确保所有测试通过,如有问题请询问用户。
|
||||
|
||||
- [x] 5. 更新属性测试
|
||||
- [x] 5.1 从 `tests/test_property_1_fact_mappings.py` 中删除 `dwd.dwd_assistant_trash_event` 在 A 类表列表中的条目,删除 `_REQ3_EXPECTED` 映射期望及其在参数化测试中的引用
|
||||
- _Requirements: 7.1, 7.2_
|
||||
|
||||
- [x] 6. 创建数据库迁移脚本
|
||||
- [x] 6.1 在 `db/etl_feiqiu/migrations/` 下创建迁移脚本 `2026-02-22__drop_assistant_abolish_tables.sql`,包含 `DROP TABLE IF EXISTS` 语句删除 `ods.assistant_cancellation_records`、`dwd.dwd_assistant_trash_event`、`dwd.dwd_assistant_trash_event_ex`,以及删除相关索引(如 `idx_ods_assistant_cancellation_records_latest`),包含注释说明移除原因
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
|
||||
|
||||
- [x] 7. 更新 DDL schema 文件
|
||||
- [x] 7.1 从 `db/etl_feiqiu/schemas/dwd.sql` 中删除 `dwd_assistant_trash_event` 和 `dwd_assistant_trash_event_ex` 的 CREATE TABLE 和 COMMENT 语句
|
||||
- _Requirements: 6.1, 6.2_
|
||||
|
||||
- [x] 7.2 从 `db/etl_feiqiu/schemas/ods.sql` 中删除 `assistant_cancellation_records` 的 CREATE TABLE 和 COMMENT 语句
|
||||
- _Requirements: 6.3_
|
||||
|
||||
- [x] 7.3 从 `db/etl_feiqiu/schemas/schema_dwd_doc.sql` 中删除 `dwd_assistant_trash_event` 和 `dwd_assistant_trash_event_ex` 的 CREATE TABLE 和 COMMENT 语句
|
||||
- _Requirements: 6.4_
|
||||
|
||||
- [x] 7.4 从 `db/etl_feiqiu/schemas/schema_ODS_doc.sql` 中删除 `assistant_cancellation_records` 的 CREATE TABLE 和 COMMENT 语句
|
||||
- _Requirements: 6.5_
|
||||
|
||||
- [x] 7.5 更新 `db/etl_feiqiu/schemas/dws.sql` 和 `db/etl_feiqiu/schemas/schema_dws.sql` 中 `dws_assistant_daily_detail` 的注释,将数据来源从 `dwd_assistant_trash_event` 改为 `dwd_assistant_service_log_ex.is_trash`
|
||||
- _Requirements: 6.6, 6.7_
|
||||
|
||||
- [x] 8. 更新运维脚本
|
||||
- [x] 8.1 从 `scripts/ops/dataflow_analyzer.py` 中删除 `ODS_ASSISTANT_ABOLISH` spec 条目
|
||||
- _Requirements: 8.1_
|
||||
|
||||
- [x] 8.2 从 `scripts/ops/gen_full_dataflow_doc.py` 中删除 `ODS_ASSISTANT_ABOLISH` spec 条目
|
||||
- _Requirements: 8.1_
|
||||
|
||||
- [x] 8.3 从 `scripts/ops/etl_consistency_check.py` 中删除 `ODS_ASSISTANT_ABOLISH` 映射和 `dwd.dwd_assistant_trash_event` 映射
|
||||
- _Requirements: 8.1, 8.2_
|
||||
|
||||
- [x] 8.4 从 `scripts/ops/blackbox_test_report.py` 中删除 `assistant_cancellation_records` 在 ODS_TABLES 列表中的条目、`ODS_ASSISTANT_ABOLISH` 映射、`dwd.dwd_assistant_trash_event` 映射
|
||||
- _Requirements: 8.1, 8.2, 8.3, 8.4_
|
||||
|
||||
- [x] 8.5 从 `scripts/ops/field_audit.py` 中删除 `assistant_cancellation_records` 审计条目
|
||||
- _Requirements: 8.3, 8.4_
|
||||
|
||||
- [x] 8.6 从 `scripts/ops/gen_field_review_doc.py` 中删除 `assistant_cancellation_records` 相关的字段定义块
|
||||
- _Requirements: 8.3, 8.4_
|
||||
|
||||
- [x] 8.7 从 `scripts/ops/gen_api_field_mapping.py` 中删除 `assistant_cancellation_records` 在 ODS_TABLES 列表中的条目
|
||||
- _Requirements: 8.3_
|
||||
|
||||
- [x] 8.8 从 `scripts/ops/export_dwd_field_review.py` 中删除 `dwd_assistant_trash_event` 和 `dwd_assistant_trash_event_ex` 条目
|
||||
- _Requirements: 8.4_
|
||||
|
||||
- [x] 8.9 从 `scripts/ops/check_ods_latest_indexes.py` 中删除 `idx_ods_assistant_cancellation_records_latest` 索引检查
|
||||
- _Requirements: 8.3_
|
||||
|
||||
- [x] 9. 最终 Checkpoint — 确保所有测试通过
|
||||
- 运行 `cd apps/etl/connectors/feiqiu && pytest tests/unit` 和 `cd C:\NeoZQYY && pytest tests/ -v`
|
||||
- 确认所有测试通过,无回归,如有问题请询问用户。
|
||||
- _Requirements: 7.3, 9.1, 9.2, 9.3_
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的任务为可选,可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号,便于追溯
|
||||
- Checkpoint 确保增量验证
|
||||
- 属性测试验证通用正确性属性,单元测试验证具体示例和边界情况
|
||||
- 本次清理涉及高风险路径(`tasks/`、`orchestration/`、`db/`),完成后需运行 `/audit`
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "98a585de-82d9-4bbd-bed8-179208c12f8b", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -1,422 +0,0 @@
|
||||
# 设计文档:业务日分割点机制(Business Day Cutoff)
|
||||
|
||||
## 概述
|
||||
|
||||
本设计将全系统的统计时间口径从自然日切换为以可配置小时值(默认 08:00)为分割点的营业日。影响范围覆盖六个层面:
|
||||
|
||||
1. **配置层**:`.env` 新增 `BUSINESS_DAY_START_HOUR`,ETL `AppConfig` 和后端 `config.py` 同步加载
|
||||
2. **共享工具层**:`packages/shared` 的 `datetime_utils.py` 扩展 `business_day_range`、`business_week_range`、`business_month_range` 三个范围函数
|
||||
3. **ETL DWS 层**:`BaseDwsTask.iter_dwd_rows` 的 `DATE()` 替换为 `biz_date_sql_expr`,18 个具体 DWS 任务的 SQL 全面改造
|
||||
4. **后端 API 层**:新增 `/api/config/business-day` 端点,时间范围查询统一使用 `business_*_range` 函数
|
||||
5. **数据库层**:新增 PostgreSQL `biz_date()` 函数,物化视图迁移
|
||||
6. **前端展示层**:管理后台日期选择器标注营业日口径,小程序透传后端数据
|
||||
|
||||
### 设计决策
|
||||
|
||||
1. **单一配置源**:`BUSINESS_DAY_START_HOUR` 仅在根 `.env` 定义一次,ETL 通过 `AppConfig`、后端通过 `config.py`、数据库通过迁移脚本参数化读取,避免多处硬编码导致不一致。
|
||||
2. **共享包作为唯一逻辑实现**:所有营业日归属计算集中在 `packages/shared/datetime_utils.py`,ETL 和后端均从此导入,禁止各子系统重复实现。
|
||||
3. **SQL 表达式生成器模式**:`biz_date_sql_expr(col, hour)` 生成 `DATE(col - INTERVAL 'N hours')` 字符串,DWS 任务在 SQL 拼接时调用,避免在 Python 侧逐行转换。
|
||||
4. **BaseDwsTask 基类统一改造**:`iter_dwd_rows` 的日期过滤从 `DATE(col)` 改为 `biz_date_sql_expr(col)`,所有子类自动继承,减少逐任务修改量。
|
||||
5. **物化视图通过迁移脚本重建**:物化视图的时间过滤条件无法动态参数化,需通过 SQL 迁移脚本 DROP + CREATE 重建。
|
||||
6. **历史数据重算采用 CLI 批量模式**:提供独立重算脚本,复用正式 ETL 任务逻辑(相同 `Business_Day_Cutoff` 配置),按日期窗口分批执行。
|
||||
|
||||
## 架构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph 配置层
|
||||
ENV[".env<br/>BUSINESS_DAY_START_HOUR=8"]
|
||||
ENV --> AC[AppConfig<br/>app.business_day_start_hour]
|
||||
ENV --> BC[Backend config.py<br/>BUSINESS_DAY_START_HOUR]
|
||||
end
|
||||
|
||||
subgraph 共享工具层
|
||||
DU["datetime_utils.py<br/>business_date / business_*_range<br/>biz_date_sql_expr"]
|
||||
end
|
||||
|
||||
subgraph ETL DWS 层
|
||||
BDT["BaseDwsTask<br/>iter_dwd_rows(biz_date_sql_expr)<br/>get_time_window_range"]
|
||||
BDT --> FT["FinanceBaseTask / FinanceDailyTask<br/>FinanceRechargeTask / FinanceDiscountTask<br/>FinanceIncomeTask"]
|
||||
BDT --> AT["AssistantDailyTask / AssistantMonthlyTask<br/>AssistantFinanceTask / AssistantCustomerTask<br/>AssistantOrderContributionTask"]
|
||||
BDT --> MT["MemberVisitTask / MemberConsumptionTask"]
|
||||
BDT --> GT["GoodsStockDailyTask / WeeklyTask / MonthlyTask"]
|
||||
BDT --> IT["SpendingPowerIndexTask / MemberIndexBase"]
|
||||
BDT --> MV["MvRefreshTask"]
|
||||
end
|
||||
|
||||
subgraph 后端 API 层
|
||||
API["/api/config/business-day<br/>GET → business_day_start_hour"]
|
||||
TR["时间范围计算<br/>business_day_range / week_range / month_range"]
|
||||
end
|
||||
|
||||
subgraph 数据库层
|
||||
PGF["biz_date(timestamptz, int)<br/>PostgreSQL 函数"]
|
||||
MVR["物化视图重建<br/>迁移脚本"]
|
||||
end
|
||||
|
||||
subgraph 前端
|
||||
AW["Admin_Web<br/>日期选择器标注"]
|
||||
MP["Miniprogram<br/>透传后端数据"]
|
||||
end
|
||||
|
||||
AC --> BDT
|
||||
AC --> DU
|
||||
BC --> API
|
||||
BC --> TR
|
||||
DU --> BDT
|
||||
DU --> TR
|
||||
API --> AW
|
||||
API --> MP
|
||||
PGF --> MVR
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 1. 共享时间工具(`packages/shared/src/neozqyy_shared/datetime_utils.py`)
|
||||
|
||||
现有函数(已实现):
|
||||
- `business_date(dt, day_start_hour) -> date`
|
||||
- `business_month(dt, day_start_hour) -> date`
|
||||
- `business_week_monday(dt, day_start_hour) -> date`
|
||||
- `biz_date_sql_expr(col, day_start_hour) -> str`
|
||||
|
||||
新增函数:
|
||||
|
||||
```python
|
||||
def business_day_range(biz_date: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
|
||||
"""返回给定营业日的精确时间戳范围 [start, end)。
|
||||
|
||||
start = biz_date 当天 day_start_hour:00
|
||||
end = biz_date 次日 day_start_hour:00
|
||||
"""
|
||||
|
||||
def business_week_range(week_monday: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
|
||||
"""返回给定营业周(周一)的精确时间戳范围 [start, end)。
|
||||
|
||||
start = week_monday 当天 day_start_hour:00
|
||||
end = week_monday + 7天 day_start_hour:00
|
||||
"""
|
||||
|
||||
def business_month_range(month_first: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
|
||||
"""返回给定营业月(首日)的精确时间戳范围 [start, end)。
|
||||
|
||||
start = month_first 当天 day_start_hour:00
|
||||
end = 次月1日 day_start_hour:00
|
||||
"""
|
||||
```
|
||||
|
||||
所有 `*_range` 函数返回的时间戳带 `Asia/Shanghai` 时区信息(使用 `SHANGHAI_TZ`)。
|
||||
|
||||
### 2. ETL 配置层(`apps/etl/connectors/feiqiu/config/`)
|
||||
|
||||
**已完成**(代码库中已存在):
|
||||
- `defaults.py`:`"app": {"business_day_start_hour": 8}`
|
||||
- `env_parser.py`:`"BUSINESS_DAY_START_HOUR": ("app.business_day_start_hour",)`
|
||||
|
||||
**需新增**:`settings.py` 的 `_validate` 方法增加范围校验:
|
||||
|
||||
```python
|
||||
# 在 _validate 中新增
|
||||
hour = cfg["app"].get("business_day_start_hour", 8)
|
||||
if not isinstance(hour, int) or not (0 <= hour <= 23):
|
||||
raise SystemExit("app.business_day_start_hour 必须为 0–23 的整数")
|
||||
```
|
||||
|
||||
### 3. 后端配置层(`apps/backend/app/config.py`)
|
||||
|
||||
新增模块级常量:
|
||||
|
||||
```python
|
||||
BUSINESS_DAY_START_HOUR: int = int(get("BUSINESS_DAY_START_HOUR", "8"))
|
||||
```
|
||||
|
||||
### 4. 后端配置查询 API(`apps/backend/app/routers/business_day.py`)
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/api/config", tags=["业务配置"])
|
||||
|
||||
@router.get("/business-day")
|
||||
async def get_business_day_config():
|
||||
"""返回当前营业日分割点配置。"""
|
||||
return {"business_day_start_hour": config.BUSINESS_DAY_START_HOUR}
|
||||
```
|
||||
|
||||
无需认证(公开配置),前端启动时调用一次缓存。
|
||||
|
||||
### 5. BaseDwsTask 基类改造
|
||||
|
||||
**`iter_dwd_rows` 改造**:
|
||||
|
||||
```python
|
||||
def iter_dwd_rows(self, table_name, columns, start_date, end_date,
|
||||
date_col="created_at", ...):
|
||||
cutoff = self.config.get("app.business_day_start_hour", 8)
|
||||
biz_expr = biz_date_sql_expr(date_col, cutoff)
|
||||
where_parts = [f"{biz_expr} >= %s", f"{biz_expr} <= %s"]
|
||||
# ... 其余逻辑不变
|
||||
```
|
||||
|
||||
**`get_time_window_range` 改造**:
|
||||
|
||||
当前方法返回 `TimeRange(start=date, end=date)`,改造后语义不变(仍返回 `date` 范围),但内部使用 `business_date` 计算 `base_date` 的营业日归属:
|
||||
|
||||
```python
|
||||
def get_time_window_range(self, window, base_date=None):
|
||||
if base_date is None:
|
||||
from neozqyy_shared.datetime_utils import now_shanghai, business_date
|
||||
cutoff = self.config.get("app.business_day_start_hour", 8)
|
||||
base_date = business_date(now_shanghai(), cutoff)
|
||||
# ... 其余逻辑使用 base_date(已是营业日)
|
||||
```
|
||||
|
||||
### 6. 各 DWS 任务 SQL 改造模式
|
||||
|
||||
所有任务的 SQL 改造遵循统一模式:
|
||||
|
||||
```sql
|
||||
-- 改造前
|
||||
DATE(pay_time) AS stat_date
|
||||
WHERE DATE(pay_time) >= %s AND DATE(pay_time) <= %s
|
||||
GROUP BY DATE(pay_time)
|
||||
|
||||
-- 改造后(cutoff_hour=8 时)
|
||||
DATE(pay_time - INTERVAL '8 hours') AS stat_date
|
||||
WHERE DATE(pay_time - INTERVAL '8 hours') >= %s AND DATE(pay_time - INTERVAL '8 hours') <= %s
|
||||
GROUP BY DATE(pay_time - INTERVAL '8 hours')
|
||||
```
|
||||
|
||||
任务从 `self.config.get("app.business_day_start_hour", 8)` 读取 cutoff 值,调用 `biz_date_sql_expr(col, cutoff)` 生成表达式。
|
||||
|
||||
**受影响任务清单**(18 个):
|
||||
|
||||
| 任务 | 主要时间列 | 聚合粒度 |
|
||||
|------|-----------|---------|
|
||||
| FinanceBaseTask | pay_time | 日 |
|
||||
| FinanceDailyTask | pay_time | 日 |
|
||||
| FinanceRechargeTask | pay_time | 日 |
|
||||
| FinanceDiscountTask | pay_time | 日 |
|
||||
| FinanceIncomeTask | pay_time | 日 |
|
||||
| AssistantDailyTask | start_use_time | 日 |
|
||||
| AssistantOrderContributionTask | pay_time, start_use_time | 日 |
|
||||
| AssistantCustomerTask | start_use_time | 日 |
|
||||
| AssistantMonthlyTask | (基于日度数据) | 月 |
|
||||
| AssistantFinanceTask | start_use_time | 日 |
|
||||
| MemberVisitTask | pay_time, start_use_time, ledger_end_time | 日 |
|
||||
| MemberConsumptionTask | pay_time, create_time | 日 |
|
||||
| GoodsStockDailyTask | fetched_at | 日 |
|
||||
| GoodsStockWeeklyTask | fetched_at | 周 |
|
||||
| GoodsStockMonthlyTask | fetched_at | 月 |
|
||||
| SpendingPowerIndexTask | pay_time | 日 |
|
||||
| MemberIndexBase | pay_time | 日 |
|
||||
| MvRefreshTask | (物化视图刷新) | - |
|
||||
|
||||
### 7. 数据库层
|
||||
|
||||
**新增 PostgreSQL 函数**(迁移脚本 `db/etl_feiqiu/migrations/2026-03-XX__add_biz_date_function.sql`):
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8)
|
||||
RETURNS date AS $$
|
||||
SELECT (ts - make_interval(hours => cutoff_hour))::date;
|
||||
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
|
||||
```
|
||||
|
||||
**物化视图重建**(迁移脚本 `db/etl_feiqiu/migrations/2026-03-XX__rebuild_mv_with_biz_date.sql`):
|
||||
|
||||
物化视图 `mv_dws_finance_daily_summary_l1..l4` 和 `mv_dws_assistant_daily_detail_l1..l4` 的时间过滤条件从 `CURRENT_DATE` 改为 `dws.biz_date(NOW())` 或等效表达式。
|
||||
|
||||
### 8. 前端适配
|
||||
|
||||
**Admin_Web**:
|
||||
- 日期选择器组件旁增加 Tooltip 或文字标注:`营业日:{HH}:00 起`
|
||||
- 通过 `/api/config/business-day` 获取 `business_day_start_hour`,启动时请求一次存入全局状态
|
||||
- 降级策略:API 不可用时使用默认值 8,`console.warn` 输出警告
|
||||
|
||||
**Miniprogram**:
|
||||
- 不直接使用 cutoff 值,所有统计数据由后端 API 按营业日口径返回
|
||||
- 无需前端改造,仅确认后端 API 返回的数据已是营业日口径
|
||||
|
||||
### 9. 历史数据重算
|
||||
|
||||
提供 `scripts/ops/rebuild_dws_biz_date.py` 脚本:
|
||||
|
||||
```python
|
||||
# 伪代码
|
||||
for task_cls in ALL_DWS_TASKS:
|
||||
for date_window in split_by_month(history_start, history_end):
|
||||
task = task_cls(config)
|
||||
task.run(window_start=date_window.start, window_end=date_window.end)
|
||||
```
|
||||
|
||||
- 复用正式 ETL 任务逻辑,确保与正式运行使用相同的 `Business_Day_Cutoff`
|
||||
- 按月分窗口执行,避免单次事务过大
|
||||
- 执行前后记录行数对比到日志
|
||||
- 支持 `--dry-run` 模式预览影响范围
|
||||
|
||||
### 10. 运维脚本排查
|
||||
|
||||
| 脚本 | 涉及的 DATE() 调用 | 处理方式 |
|
||||
|------|-------------------|---------|
|
||||
| `scripts/ops/export_bug_report.py` | `DATE(trash_time)`, `DATE(create_time)`, `DATE(start_use_time)` | 替换为 `biz_date_sql_expr` 生成的表达式 |
|
||||
| `scripts/ops/etl_consistency_check.py` | 日期比较逻辑 | 评估后按需替换 |
|
||||
| `apps/etl/.../debug_blackbox.py` | `::date` 类型转换 | 替换为 `biz_date()` 函数调用 |
|
||||
| `apps/etl/.../run_update.py` | `.date()` 和 `datetime.combine` | 替换为 `business_date()` + `business_day_range()` |
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 配置数据
|
||||
|
||||
```
|
||||
BUSINESS_DAY_START_HOUR: int (0–23, 默认 8)
|
||||
```
|
||||
|
||||
存储位置:
|
||||
- 根 `.env`:`BUSINESS_DAY_START_HOUR=8`
|
||||
- ETL:`AppConfig.config["app"]["business_day_start_hour"]`
|
||||
- 后端:`config.BUSINESS_DAY_START_HOUR`
|
||||
|
||||
### 时间工具函数签名
|
||||
|
||||
```python
|
||||
# 输入/输出类型
|
||||
business_date(dt: datetime, day_start_hour: int = 8) -> date
|
||||
business_month(dt: datetime, day_start_hour: int = 8) -> date
|
||||
business_week_monday(dt: datetime, day_start_hour: int = 8) -> date
|
||||
business_day_range(biz_date: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
|
||||
business_week_range(week_monday: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
|
||||
business_month_range(month_first: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
|
||||
biz_date_sql_expr(col: str, day_start_hour: int = 8) -> str
|
||||
```
|
||||
|
||||
### 数据库函数
|
||||
|
||||
```sql
|
||||
dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8) RETURNS date
|
||||
-- 等价于 Python 的 business_date,用于 SQL 查询和物化视图
|
||||
```
|
||||
|
||||
### API 响应模型
|
||||
|
||||
```json
|
||||
// GET /api/config/business-day
|
||||
{
|
||||
"business_day_start_hour": 8
|
||||
}
|
||||
```
|
||||
|
||||
### DWS 表影响
|
||||
|
||||
所有 DWS 表的 `stat_date` 字段语义从"自然日"变为"营业日"。表结构不变,仅数据内容因重算而变化。
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是在系统所有合法执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: 营业日归属往返一致性(Round-Trip)
|
||||
|
||||
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h(0–23),`business_day_range(business_date(dt, h), h)` 返回的范围 `[start, end)` 应满足 `start <= dt < end`。
|
||||
|
||||
**Validates: Requirements 2.9, 11.1**
|
||||
|
||||
### Property 2: 营业月与营业日一致性
|
||||
|
||||
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h(0–23),`business_month(dt, h)` 应等于 `business_date(dt, h).replace(day=1)`。
|
||||
|
||||
**Validates: Requirements 2.10, 11.2**
|
||||
|
||||
### Property 3: 营业周与营业日一致性
|
||||
|
||||
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h(0–23),`business_week_monday(dt, h)` 应等于 `business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`,且结果的 `weekday()` 始终为 0(周一)。
|
||||
|
||||
**Validates: Requirements 2.11, 11.3**
|
||||
|
||||
### Property 4: 营业日归属单调性
|
||||
|
||||
*对任意* 两个 datetime `dt1 < dt2` 和任意合法的 `day_start_hour` h(0–23),若 `dt1` 和 `dt2` 都在同一个 `business_day_range(d, h)` 范围内,则 `business_date(dt1, h) == business_date(dt2, h)`。等价表述:`business_date(dt, h)` 关于 `dt` 是单调非递减的。
|
||||
|
||||
**Validates: Requirements 11.9**
|
||||
|
||||
### Property 5: 时间范围长度不变量
|
||||
|
||||
*对任意* date `d` 和任意合法的 `day_start_hour` h(0–23):
|
||||
- `business_day_range(d, h)` 返回的 `(start, end)` 满足 `end - start == timedelta(hours=24)`
|
||||
- `business_week_range(monday, h)` 返回的 `(start, end)` 满足 `end - start == timedelta(days=7)`
|
||||
|
||||
**Validates: Requirements 11.6, 11.7**
|
||||
|
||||
### Property 6: SQL 表达式生成幂等性
|
||||
|
||||
*对任意* 列名 `col` 和任意合法的 `day_start_hour` h(0–23),`biz_date_sql_expr(col, h)` 多次调用应返回完全相同的字符串。
|
||||
|
||||
**Validates: Requirements 11.4**
|
||||
|
||||
### Property 7: 非法配置值拒绝
|
||||
|
||||
*对任意* 不在 0–23 范围内的整数值 `v`,当 `BUSINESS_DAY_START_HOUR` 设为 `v` 时,`AppConfig.load()` 应抛出 `SystemExit`。
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 8: 合法配置值正确加载
|
||||
|
||||
*对任意* 0–23 范围内的整数值 `v`,当 `BUSINESS_DAY_START_HOUR` 环境变量设为 `v` 时,`AppConfig.load()` 后 `cfg.get("app.business_day_start_hour")` 应返回 `v`。
|
||||
|
||||
**Validates: Requirements 3.4**
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| `BUSINESS_DAY_START_HOUR` 值超出 0–23 | `AppConfig._validate` 抛出 `SystemExit`,明确提示合法范围 |
|
||||
| `BUSINESS_DAY_START_HOUR` 环境变量缺失 | 使用默认值 8,不报错 |
|
||||
| `BUSINESS_DAY_START_HOUR` 值为非整数字符串 | `env_parser._coerce_env` 保持字符串,`_validate` 阶段类型检查失败抛出 `SystemExit` |
|
||||
| 后端 `/api/config/business-day` 不可用 | Admin_Web 使用默认值 8,`console.warn` 输出警告 |
|
||||
| 历史数据重算脚本执行失败 | 按月窗口回滚当前批次,记录错误日志,继续下一窗口或中止(由 `--fail-fast` 参数控制) |
|
||||
| 物化视图迁移脚本执行失败 | 标准 PostgreSQL 事务回滚,迁移脚本幂等设计(`CREATE OR REPLACE`) |
|
||||
| `business_day_range` 等函数收到非法 `day_start_hour` | 函数内部不做校验(调用方负责),依赖 AppConfig 加载阶段的前置校验 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
使用 `hypothesis` 库,测试文件位于 `tests/test_property_business_day_cutoff.py`。
|
||||
|
||||
每个属性测试最少运行 100 次迭代,使用 `@settings(max_examples=200)` 配置。
|
||||
|
||||
生成策略:
|
||||
- `day_start_hour`:`st.integers(min_value=0, max_value=23)`
|
||||
- `dt`:`st.datetimes(min_value=datetime(2020, 1, 1), max_value=datetime(2030, 12, 31))`(避免极端日期)
|
||||
- `biz_date`:`st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31))`
|
||||
|
||||
每个测试函数以注释标注对应的设计属性:
|
||||
|
||||
```python
|
||||
# Feature: business-day-cutoff, Property 1: 营业日归属往返一致性
|
||||
@given(dt=st.datetimes(...), h=st.integers(0, 23))
|
||||
@settings(max_examples=200)
|
||||
def test_business_date_round_trip(dt, h):
|
||||
...
|
||||
|
||||
# Feature: business-day-cutoff, Property 2: 营业月与营业日一致性
|
||||
# Feature: business-day-cutoff, Property 3: 营业周与营业日一致性
|
||||
# Feature: business-day-cutoff, Property 4: 营业日归属单调性
|
||||
# Feature: business-day-cutoff, Property 5: 时间范围长度不变量
|
||||
# Feature: business-day-cutoff, Property 6: SQL 表达式生成幂等性
|
||||
# Feature: business-day-cutoff, Property 7: 非法配置值拒绝
|
||||
# Feature: business-day-cutoff, Property 8: 合法配置值正确加载
|
||||
```
|
||||
|
||||
### 单元测试
|
||||
|
||||
单元测试覆盖属性测试不适合的场景:
|
||||
|
||||
- **边界示例**:`day_start_hour=8` 时,07:59:59 归属前一天,08:00:00 归属当天
|
||||
- **默认值行为**:`BUSINESS_DAY_START_HOUR` 缺失时 AppConfig 返回 8
|
||||
- **API 端点**:`/api/config/business-day` 返回正确 JSON 格式
|
||||
- **SQL 表达式格式**:`biz_date_sql_expr("pay_time", 8)` 返回 `DATE(pay_time - INTERVAL '8 hours')`
|
||||
- **月末边界**:1月31日 07:00 归属1月30日(营业日),`business_month` 返回1月1日
|
||||
|
||||
### 测试配置
|
||||
|
||||
- 属性测试库:`hypothesis`(已在项目 `pyproject.toml` 中声明)
|
||||
- 每个属性测试对应设计文档中的一个 Property,由单个 `@given` 装饰的测试函数实现
|
||||
- 运行命令:`cd C:\NeoZQYY && pytest tests/test_property_business_day_cutoff.py -v`
|
||||
@@ -1,186 +0,0 @@
|
||||
# 需求文档:业务日分割点机制(Business Day Cutoff)
|
||||
|
||||
## 简介
|
||||
|
||||
引入"业务日分割点"机制,将全系统的统计时间口径从自然日/自然周/自然月切换为以可配置的小时值(默认 08:00)为分割点的营业日/营业周/营业月。影响范围覆盖配置层、共享包、ETL 层(ODS→DWD→DWS)、后端 API 层、前端展示层(管理后台、小程序)及数据库层。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Business_Day_Cutoff**:营业日分割点,一个整数小时值(0–23),定义一个"业务日"的起始时刻。默认值为 8(即 08:00)。
|
||||
- **Business_Date**:营业日,从当天 `Business_Day_Cutoff` 时刻到次日 `Business_Day_Cutoff` 时刻的时间段所归属的日期。`Business_Day_Cutoff` 之前的时间戳归属前一天。
|
||||
- **Business_Week**:营业周,从周一 `Business_Day_Cutoff` 到次周一 `Business_Day_Cutoff` 的时间段。
|
||||
- **Business_Month**:营业月,从当月1日 `Business_Day_Cutoff` 到次月1日 `Business_Day_Cutoff` 的时间段。
|
||||
- **Shared_DateTime_Utils**:`packages/shared/src/neozqyy_shared/datetime_utils.py`,跨子系统共享的时间工具模块。
|
||||
- **AppConfig**:ETL 配置管理器(`apps/etl/connectors/feiqiu/config/settings.py`),通过 `AppConfig.load()` 加载配置。
|
||||
- **Backend_Config**:后端配置模块(`apps/backend/app/config.py`),从 `.env` 加载环境变量。
|
||||
- **DWS_Task**:DWS 层聚合任务,从 DWD 事实表读取数据并按时间维度聚合写入 DWS 汇总表。
|
||||
- **biz_date_sql_expr**:`Shared_DateTime_Utils` 中生成 PostgreSQL 营业日归属 SQL 表达式的函数。
|
||||
- **stat_date**:DWS 汇总表中的统计日期字段,存储的是 Business_Date 而非自然日期。
|
||||
- **Admin_Web**:管理后台前端(`apps/admin-web/`,React + Vite + Ant Design)。
|
||||
- **Miniprogram**:微信小程序前端(`apps/miniprogram/`)。
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:环境变量配置
|
||||
|
||||
**用户故事:** 作为运维人员,我希望通过 `.env` 环境变量配置营业日分割点小时值,以便在不修改代码的情况下调整统计时间口径。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Root_Env SHALL 定义 `BUSINESS_DAY_START_HOUR` 环境变量,值为 0–23 的整数,默认值为 8
|
||||
2. THE Env_Template SHALL 同步包含 `BUSINESS_DAY_START_HOUR` 的定义及注释说明(日/周/月统计的分割语义)
|
||||
3. WHEN `BUSINESS_DAY_START_HOUR` 的值不在 0–23 范围内时, THEN THE AppConfig SHALL 在加载阶段抛出 `SystemExit` 错误并给出明确提示
|
||||
4. WHEN `BUSINESS_DAY_START_HOUR` 环境变量缺失时, THE AppConfig SHALL 使用默认值 8
|
||||
|
||||
### 需求 2:共享时间工具函数
|
||||
|
||||
**用户故事:** 作为开发者,我希望有一组经过充分测试的共享时间工具函数,以便所有子系统使用统一的营业日归属逻辑。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Shared_DateTime_Utils SHALL 提供 `business_date(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Date
|
||||
2. THE Shared_DateTime_Utils SHALL 提供 `business_month(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Month 首日
|
||||
3. THE Shared_DateTime_Utils SHALL 提供 `business_week_monday(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Week 的周一日期
|
||||
4. THE Shared_DateTime_Utils SHALL 提供 `biz_date_sql_expr(col, day_start_hour)` 函数,生成 PostgreSQL 营业日归属 SQL 表达式(形如 `DATE(col - INTERVAL 'N hours')`)
|
||||
5. WHEN `day_start_hour` 参数未传入时, THE Shared_DateTime_Utils SHALL 使用 `DEFAULT_BUSINESS_DAY_START_HOUR`(值为 8)作为默认值
|
||||
6. THE Shared_DateTime_Utils SHALL 提供 `business_day_range(biz_date, day_start_hour)` 函数,返回给定 Business_Date 对应的精确时间戳范围 `(start_dt, end_dt)`,即 `(biz_date 当天 day_start_hour:00, biz_date 次日 day_start_hour:00)`
|
||||
7. THE Shared_DateTime_Utils SHALL 提供 `business_week_range(week_monday, day_start_hour)` 函数,返回给定 Business_Week 周一对应的精确时间戳范围
|
||||
8. THE Shared_DateTime_Utils SHALL 提供 `business_month_range(month_first, day_start_hour)` 函数,返回给定 Business_Month 首日对应的精确时间戳范围
|
||||
9. FOR ALL 合法的 datetime 输入, `business_date` 的输出 SHALL 满足:`business_day_range(business_date(dt, h), h)[0] <= dt < business_day_range(business_date(dt, h), h)[1]`(往返一致性)
|
||||
10. FOR ALL 合法的 datetime 输入, `business_month(dt, h)` SHALL 等于 `business_date(dt, h).replace(day=1)`(月归属与日归属一致性)
|
||||
11. FOR ALL 合法的 datetime 输入, `business_week_monday(dt, h)` SHALL 等于 `business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`(周归属与日归属一致性)
|
||||
|
||||
### 需求 3:ETL 配置层集成
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望 `AppConfig` 正确加载并传播 `BUSINESS_DAY_START_HOUR`,以便 ETL 任务能获取到配置的分割点值。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE AppConfig SHALL 在 `app.business_day_start_hour` 路径下存储 `BUSINESS_DAY_START_HOUR` 的整数值
|
||||
2. THE Env_Parser SHALL 将环境变量 `BUSINESS_DAY_START_HOUR` 映射到 `app.business_day_start_hour` 配置路径
|
||||
3. THE AppConfig_Defaults SHALL 将 `app.business_day_start_hour` 的默认值设为 8
|
||||
4. WHEN AppConfig 加载完成后, THE AppConfig SHALL 通过 `cfg.get("app.business_day_start_hour")` 返回正确的整数值
|
||||
|
||||
### 需求 4:ETL DWS 层聚合逻辑
|
||||
|
||||
**用户故事:** 作为数据分析师,我希望 DWS 层的所有日度/周度/月度聚合统计都基于营业日口径,以便统计结果与门店实际营业周期一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN DWS_Task 从 DWD 表提取数据时, THE DWS_Task SHALL 使用 `biz_date_sql_expr` 替代 `DATE()` 进行日期归属计算
|
||||
2. WHEN DWS_Task 按日聚合时, THE DWS_Task SHALL 使用 `DATE(timestamp_col - INTERVAL 'N hours')` 作为 `stat_date` 的分组依据,其中 N 为 `Business_Day_Cutoff`
|
||||
3. WHEN DWS_Task 按月聚合时, THE DWS_Task SHALL 使用 Business_Month 口径(当月1日 cutoff 到次月1日 cutoff)
|
||||
4. WHEN DWS_Task 按周聚合时, THE DWS_Task SHALL 使用 Business_Week 口径(周一 cutoff 到次周一 cutoff)
|
||||
5. THE BaseDwsTask.iter_dwd_rows SHALL 使用 `biz_date_sql_expr` 替代 `DATE()` 进行日期过滤
|
||||
6. THE BaseDwsTask.get_time_window_range SHALL 返回基于 Business_Date 口径的时间范围
|
||||
7. WHILE ETL 任务运行期间, THE DWS_Task SHALL 从 `AppConfig` 读取 `app.business_day_start_hour` 值,禁止硬编码
|
||||
|
||||
|
||||
### 需求 5:受影响的 DWS 任务全面排查
|
||||
|
||||
**用户故事:** 作为项目负责人,我希望所有使用 `DATE()` 进行时间归属的 DWS 任务都被排查并改造,确保无遗漏。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE FinanceBaseTask SHALL 将所有 `DATE(pay_time)` 替换为 `biz_date_sql_expr("pay_time", cutoff_hour)` 生成的表达式
|
||||
2. THE FinanceDailyTask SHALL 使用 Business_Date 口径提取和聚合结账单、团购核销、充值等数据
|
||||
3. THE FinanceRechargeTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
|
||||
4. THE FinanceDiscountTask SHALL 使用 Business_Date 口径聚合优惠明细
|
||||
5. THE FinanceIncomeTask SHALL 使用 Business_Date 口径聚合收入结构
|
||||
6. THE AssistantDailyTask SHALL 使用 Business_Date 口径聚合助教日度明细
|
||||
7. THE AssistantOrderContributionTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
|
||||
8. THE AssistantCustomerTask SHALL 将 `DATE(start_use_time)` 替换为营业日归属表达式
|
||||
9. THE AssistantMonthlyTask SHALL 使用 Business_Month 口径聚合助教月度汇总
|
||||
10. THE AssistantFinanceTask SHALL 使用 Business_Date 口径聚合助教财务分析
|
||||
11. THE MemberVisitTask SHALL 将 `DATE(pay_time)`、`DATE(start_use_time)`、`DATE(ledger_end_time)` 替换为营业日归属表达式
|
||||
12. THE MemberConsumptionTask SHALL 将 `DATE(pay_time)` 和 `DATE(create_time)` 替换为营业日归属表达式
|
||||
13. THE GoodsStockDailyTask SHALL 将 `DATE(fetched_at)` 替换为营业日归属表达式
|
||||
14. THE GoodsStockWeeklyTask SHALL 使用 Business_Week 口径聚合库存周报
|
||||
15. THE GoodsStockMonthlyTask SHALL 使用 Business_Month 口径聚合库存月报
|
||||
16. THE SpendingPowerIndexTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
|
||||
17. THE MemberIndexBase SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
|
||||
18. THE MvRefreshTask SHALL 确保物化视图刷新的时间过滤条件使用 Business_Date 口径
|
||||
|
||||
### 需求 6:后端 API 层时间范围计算
|
||||
|
||||
**用户故事:** 作为后端开发者,我希望后端 API 在处理"今日/本周/本月"等时间范围查询时使用营业日口径,以便前端展示的数据与 DWS 统计一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Backend_Config SHALL 加载 `BUSINESS_DAY_START_HOUR` 环境变量并暴露为模块级常量
|
||||
2. WHEN 后端 API 需要计算"今日"时间范围时, THE Backend SHALL 使用 `business_day_range` 函数计算从当天 cutoff 到次日 cutoff 的时间戳范围
|
||||
3. WHEN 后端 API 需要计算"本周"时间范围时, THE Backend SHALL 使用 `business_week_range` 函数计算从本周一 cutoff 到次周一 cutoff 的时间戳范围
|
||||
4. WHEN 后端 API 需要计算"本月"时间范围时, THE Backend SHALL 使用 `business_month_range` 函数计算从本月1日 cutoff 到次月1日 cutoff 的时间戳范围
|
||||
5. THE Backend SHALL 从 `Shared_DateTime_Utils` 导入时间工具函数,禁止在后端重复实现营业日逻辑
|
||||
|
||||
### 需求 7:前端展示层适配
|
||||
|
||||
**用户故事:** 作为前端开发者,我希望管理后台和小程序在展示日期选择器和统计数据时,能正确反映营业日口径,避免用户困惑。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Admin_Web 展示日期选择器时, THE Admin_Web SHALL 在日期选择器旁标注营业日口径说明(如"营业日:08:00 起")
|
||||
2. WHEN Admin_Web 展示"今日统计"时, THE Admin_Web SHALL 显示的时间范围为当天 cutoff 到次日 cutoff
|
||||
3. WHEN Miniprogram 展示统计数据时, THE Miniprogram SHALL 使用后端 API 返回的基于营业日口径的数据
|
||||
4. THE Admin_Web SHALL 通过后端 API 获取 `BUSINESS_DAY_START_HOUR` 配置值,禁止前端硬编码
|
||||
5. IF 后端 API 返回的 `BUSINESS_DAY_START_HOUR` 配置值不可用, THEN THE Admin_Web SHALL 使用默认值 8 并在控制台输出警告
|
||||
|
||||
### 需求 8:后端配置查询 API
|
||||
|
||||
**用户故事:** 作为前端开发者,我希望有一个 API 端点能返回当前的营业日分割点配置,以便前端动态获取并展示。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Backend SHALL 提供一个 API 端点返回当前 `BUSINESS_DAY_START_HOUR` 的值
|
||||
2. WHEN 前端请求该端点时, THE Backend SHALL 返回包含 `business_day_start_hour` 字段的 JSON 响应
|
||||
3. THE Backend SHALL 确保该端点的响应值与 ETL 层使用的 `BUSINESS_DAY_START_HOUR` 值一致(均来源于同一 `.env` 配置)
|
||||
|
||||
### 需求 9:数据库层适配
|
||||
|
||||
**用户故事:** 作为 DBA,我希望数据库中的物化视图和 SQL 函数使用营业日口径,以便直接查询数据库时也能获得正确的统计结果。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 物化视图使用 `date_trunc` 或 `CURRENT_DATE` 进行时间过滤时, THE Migration_Script SHALL 将其替换为基于 `Business_Day_Cutoff` 的表达式
|
||||
2. THE Migration_Script SHALL 提供一个 PostgreSQL 函数 `biz_date(timestamptz, int)` 用于在 SQL 中直接计算营业日归属
|
||||
3. WHEN 迁移脚本执行后, THE Database SHALL 确保所有物化视图的时间过滤条件使用营业日口径
|
||||
4. THE Migration_Script SHALL 使用日期前缀命名(如 `2026-03-XX__add_biz_date_function.sql`),遵循项目迁移脚本规范
|
||||
|
||||
### 需求 10:数据迁移与历史数据兼容
|
||||
|
||||
**用户故事:** 作为运维人员,我希望引入营业日机制后,历史数据能被正确重算,确保统计连续性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Migration_Plan SHALL 提供 DWS 历史数据重算脚本,按营业日口径重新聚合所有受影响的 DWS 表
|
||||
2. WHEN 历史数据重算执行时, THE Rebuild_Script SHALL 使用与正式 ETL 任务相同的 `Business_Day_Cutoff` 配置值
|
||||
3. THE Migration_Plan SHALL 记录重算前后的数据行数对比,用于验证重算正确性
|
||||
4. IF 重算过程中发生错误, THEN THE Rebuild_Script SHALL 回滚到重算前的状态并记录错误日志
|
||||
|
||||
### 需求 11:属性测试覆盖
|
||||
|
||||
**用户故事:** 作为测试工程师,我希望营业日归属逻辑有完整的属性测试覆盖,确保边界条件和不变量得到验证。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Property_Test SHALL 验证 `business_date` 的往返一致性:对任意 datetime dt,`business_day_range(business_date(dt, h), h)` 的范围包含 dt
|
||||
2. THE Property_Test SHALL 验证 `business_month` 与 `business_date` 的一致性:`business_month(dt, h) == business_date(dt, h).replace(day=1)`
|
||||
3. THE Property_Test SHALL 验证 `business_week_monday` 与 `business_date` 的一致性:`business_week_monday(dt, h).weekday() == 0`(结果始终为周一)
|
||||
4. THE Property_Test SHALL 验证 `biz_date_sql_expr` 的幂等性:对同一输入参数多次调用返回相同结果
|
||||
5. THE Property_Test SHALL 验证边界条件:cutoff 时刻恰好在分割点上的时间戳归属当天,分割点前一秒归属前一天
|
||||
6. THE Property_Test SHALL 验证 `business_day_range` 返回的范围恰好为 24 小时
|
||||
7. THE Property_Test SHALL 验证 `business_week_range` 返回的范围恰好为 7 天(168 小时)
|
||||
8. THE Property_Test SHALL 使用 hypothesis 库生成随机 datetime 和 day_start_hour(0–23)进行测试
|
||||
9. FOR ALL `day_start_hour` 值(0–23), THE Property_Test SHALL 验证 `business_date` 函数的单调性:若 dt1 < dt2 且两者在同一 Business_Date 范围内,则 `business_date(dt1, h) == business_date(dt2, h)`
|
||||
|
||||
### 需求 12:运维脚本中的 DATE() 排查
|
||||
|
||||
**用户故事:** 作为运维人员,我希望 `scripts/ops/` 和 ETL `scripts/` 中使用 `DATE()` 的运维脚本也被排查,确保调试和排查工具与正式统计口径一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Ops_Scripts SHALL 排查 `scripts/ops/export_bug_report.py` 中的 `DATE(trash_time)`、`DATE(create_time)`、`DATE(start_use_time)` 调用,评估是否需要替换为营业日归属表达式
|
||||
2. THE Ops_Scripts SHALL 排查 `scripts/ops/etl_consistency_check.py` 中的日期比较逻辑
|
||||
3. THE ETL_Scripts SHALL 排查 `apps/etl/connectors/feiqiu/scripts/debug/debug_blackbox.py` 中的 `::date` 类型转换
|
||||
4. THE ETL_Scripts SHALL 排查 `apps/etl/connectors/feiqiu/scripts/run_update.py` 中的 `.date()` 调用和 `datetime.combine` 逻辑
|
||||
5. WHEN 运维脚本用于与 DWS 数据对比验证时, THE Ops_Scripts SHALL 使用与 DWS 任务相同的营业日归属逻辑
|
||||
@@ -1,260 +0,0 @@
|
||||
# 实现计划:业务日分割点机制(Business Day Cutoff)
|
||||
|
||||
## 概述
|
||||
|
||||
将全系统统计时间口径从自然日切换为以可配置小时值(默认 08:00)为分割点的营业日。实现顺序:配置层 → 共享工具层 → ETL 层 → 后端 API 层 → 数据库层 → 前端适配 → 历史数据重算 → 运维脚本排查。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 配置层:环境变量与配置加载
|
||||
- [x] 1.1 在根 `.env` 和 `.env.template` 中新增 `BUSINESS_DAY_START_HOUR=8`
|
||||
- `.env` 新增 `BUSINESS_DAY_START_HOUR=8`
|
||||
- `.env.template` 新增带注释的定义,说明日/周/月统计分割语义
|
||||
- _Requirements: 1.1, 1.2_
|
||||
|
||||
- [x] 1.2 在 `AppConfig._validate` 中新增 `business_day_start_hour` 范围校验
|
||||
- 在 `apps/etl/connectors/feiqiu/config/settings.py` 的 `_validate` 方法中增加校验
|
||||
- 值不在 0–23 范围内或非整数时抛出 `SystemExit`
|
||||
- 环境变量缺失时使用默认值 8(已由 `defaults.py` 保证)
|
||||
- _Requirements: 1.3, 1.4, 3.1, 3.2, 3.3, 3.4_
|
||||
|
||||
- [x] 1.3 在后端 `apps/backend/app/config.py` 中新增 `BUSINESS_DAY_START_HOUR` 常量
|
||||
- 新增 `BUSINESS_DAY_START_HOUR: int = int(get("BUSINESS_DAY_START_HOUR", "8"))`
|
||||
- _Requirements: 6.1_
|
||||
|
||||
- [x] 2. 共享时间工具层:新增 range 函数
|
||||
- [x] 2.1 在 `packages/shared/src/neozqyy_shared/datetime_utils.py` 中实现三个 range 函数
|
||||
- 实现 `business_day_range(biz_date, day_start_hour)` → `tuple[datetime, datetime]`
|
||||
- 实现 `business_week_range(week_monday, day_start_hour)` → `tuple[datetime, datetime]`
|
||||
- 实现 `business_month_range(month_first, day_start_hour)` → `tuple[datetime, datetime]`
|
||||
- 所有返回值带 `Asia/Shanghai` 时区(使用 `SHANGHAI_TZ`)
|
||||
- 默认 `day_start_hour=8`
|
||||
- _Requirements: 2.6, 2.7, 2.8, 2.5_
|
||||
|
||||
- [x] 2.2 编写属性测试:营业日归属往返一致性(Property 1)
|
||||
- **Property 1: 营业日归属往返一致性(Round-Trip)**
|
||||
- `business_day_range(business_date(dt, h), h)` 的范围 `[start, end)` 应满足 `start <= dt < end`
|
||||
- 使用 `hypothesis`,`@given(dt=st.datetimes(...), h=st.integers(0, 23))`,`@settings(max_examples=200)`
|
||||
- 测试文件:`tests/test_property_business_day_cutoff.py`
|
||||
- **Validates: Requirements 2.9, 11.1**
|
||||
|
||||
- [x] 2.3 编写属性测试:营业月与营业日一致性(Property 2)
|
||||
- **Property 2: 营业月与营业日一致性**
|
||||
- `business_month(dt, h) == business_date(dt, h).replace(day=1)`
|
||||
- **Validates: Requirements 2.10, 11.2**
|
||||
|
||||
- [x] 2.4 编写属性测试:营业周与营业日一致性(Property 3)
|
||||
- **Property 3: 营业周与营业日一致性**
|
||||
- `business_week_monday(dt, h) == business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`,且结果 `weekday() == 0`
|
||||
- **Validates: Requirements 2.11, 11.3**
|
||||
|
||||
- [x] 2.5 编写属性测试:营业日归属单调性(Property 4)
|
||||
- **Property 4: 营业日归属单调性**
|
||||
- 若 `dt1 < dt2` 且两者在同一 `business_day_range` 范围内,则 `business_date(dt1, h) == business_date(dt2, h)`
|
||||
- **Validates: Requirements 11.9**
|
||||
|
||||
- [x] 2.6 编写属性测试:时间范围长度不变量(Property 5)
|
||||
- **Property 5: 时间范围长度不变量**
|
||||
- `business_day_range(d, h)` 的 `end - start == timedelta(hours=24)`
|
||||
- `business_week_range(monday, h)` 的 `end - start == timedelta(days=7)`
|
||||
- **Validates: Requirements 11.6, 11.7**
|
||||
|
||||
- [x] 2.7 编写属性测试:SQL 表达式生成幂等性(Property 6)
|
||||
- **Property 6: SQL 表达式生成幂等性**
|
||||
- `biz_date_sql_expr(col, h)` 多次调用返回完全相同的字符串
|
||||
- **Validates: Requirements 11.4**
|
||||
|
||||
- [x] 2.8 编写属性测试:边界条件验证(Property 1 补充)
|
||||
- cutoff 时刻恰好在分割点上的时间戳归属当天,分割点前一秒归属前一天
|
||||
- 使用 hypothesis 生成 `day_start_hour`,构造边界时间戳验证
|
||||
- **Validates: Requirements 11.5**
|
||||
|
||||
- [x] 2.9 编写单元测试:共享时间工具函数
|
||||
- 测试 `day_start_hour=8` 时 07:59:59 归属前一天、08:00:00 归属当天
|
||||
- 测试 `biz_date_sql_expr("pay_time", 8)` 返回 `DATE(pay_time - INTERVAL '8 hours')`
|
||||
- 测试月末边界:1月31日 07:00 归属1月30日,`business_month` 返回1月1日
|
||||
- 测试默认值行为:不传 `day_start_hour` 时使用 8
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8_
|
||||
|
||||
- [x] 3. Checkpoint — 共享工具层验证
|
||||
- 确保所有属性测试和单元测试通过,运行 `pytest tests/test_property_business_day_cutoff.py -v`,如有问题请向用户确认。
|
||||
|
||||
- [x] 4. ETL 配置层集成与 BaseDwsTask 基类改造
|
||||
- [x] 4.1 在 `AppConfig._validate` 中新增 `business_day_start_hour` 范围校验(与 1.2 合并实现)
|
||||
- 确认 `defaults.py` 和 `env_parser.py` 已有配置映射(设计文档标注"已完成")
|
||||
- 仅需在 `settings.py` 的 `_validate` 中增加校验逻辑
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4_
|
||||
|
||||
- [x] 4.2 编写属性测试:非法配置值拒绝(Property 7)
|
||||
- **Property 7: 非法配置值拒绝**
|
||||
- 对任意不在 0–23 范围内的整数值,`AppConfig.load()` 应抛出 `SystemExit`
|
||||
- **Validates: Requirements 1.3**
|
||||
|
||||
- [x] 4.3 编写属性测试:合法配置值正确加载(Property 8)
|
||||
- **Property 8: 合法配置值正确加载**
|
||||
- 对任意 0–23 范围内的整数值 `v`,`AppConfig.load()` 后 `cfg.get("app.business_day_start_hour")` 应返回 `v`
|
||||
- **Validates: Requirements 3.4**
|
||||
|
||||
- [x] 4.4 改造 `BaseDwsTask.iter_dwd_rows`,将 `DATE()` 替换为 `biz_date_sql_expr`
|
||||
- 从 `self.config.get("app.business_day_start_hour", 8)` 读取 cutoff 值
|
||||
- 调用 `biz_date_sql_expr(date_col, cutoff)` 生成 SQL 表达式
|
||||
- 替换 WHERE 子句中的 `DATE(col)` 为 `biz_date_sql_expr` 生成的表达式
|
||||
- _Requirements: 4.1, 4.5, 4.7_
|
||||
|
||||
- [x] 4.5 改造 `BaseDwsTask.get_time_window_range`,使用 `business_date` 计算营业日归属
|
||||
- `base_date` 为 None 时使用 `business_date(now_shanghai(), cutoff)` 计算
|
||||
- 确保返回的 `TimeRange` 基于营业日口径
|
||||
- _Requirements: 4.6_
|
||||
|
||||
- [x] 5. DWS 任务 SQL 改造(财务类)
|
||||
- [x] 5.1 改造 FinanceBaseTask:`DATE(pay_time)` → `biz_date_sql_expr("pay_time", cutoff)`
|
||||
- 从 config 读取 cutoff,替换所有 `DATE(pay_time)` 为营业日表达式
|
||||
- 包括 SELECT、WHERE、GROUP BY 中的所有出现
|
||||
- _Requirements: 5.1_
|
||||
|
||||
- [x] 5.2 改造 FinanceDailyTask:使用 Business_Date 口径
|
||||
- 替换结账单、团购核销、充值等数据的日期归属
|
||||
- _Requirements: 5.2_
|
||||
|
||||
- [x] 5.3 改造 FinanceRechargeTask:`DATE(pay_time)` → 营业日归属表达式
|
||||
- _Requirements: 5.3_
|
||||
|
||||
- [x] 5.4 改造 FinanceDiscountTask:使用 Business_Date 口径聚合优惠明细
|
||||
- _Requirements: 5.4_
|
||||
|
||||
- [x] 5.5 改造 FinanceIncomeTask:使用 Business_Date 口径聚合收入结构
|
||||
- _Requirements: 5.5_
|
||||
|
||||
- [x] 6. DWS 任务 SQL 改造(助教类)
|
||||
- [x] 6.1 改造 AssistantDailyTask:`DATE(start_use_time)` → 营业日归属表达式
|
||||
- _Requirements: 5.6_
|
||||
|
||||
- [x] 6.2 改造 AssistantOrderContributionTask:`DATE(pay_time)` → 营业日归属表达式
|
||||
- _Requirements: 5.7_
|
||||
|
||||
- [x] 6.3 改造 AssistantCustomerTask:`DATE(start_use_time)` → 营业日归属表达式
|
||||
- _Requirements: 5.8_
|
||||
|
||||
- [x] 6.4 改造 AssistantMonthlyTask:使用 Business_Month 口径聚合月度汇总
|
||||
- _Requirements: 5.9_
|
||||
|
||||
- [x] 6.5 改造 AssistantFinanceTask:使用 Business_Date 口径聚合助教财务分析
|
||||
- _Requirements: 5.10_
|
||||
|
||||
- [x] 7. DWS 任务 SQL 改造(会员类 + 商品库存类 + 指标类)
|
||||
- [x] 7.1 改造 MemberVisitTask:`DATE(pay_time)`、`DATE(start_use_time)`、`DATE(ledger_end_time)` → 营业日归属表达式
|
||||
- _Requirements: 5.11_
|
||||
|
||||
- [x] 7.2 改造 MemberConsumptionTask:`DATE(pay_time)` 和 `DATE(create_time)` → 营业日归属表达式
|
||||
- _Requirements: 5.12_
|
||||
|
||||
- [x] 7.3 改造 GoodsStockDailyTask:`DATE(fetched_at)` → 营业日归属表达式
|
||||
- _Requirements: 5.13_
|
||||
|
||||
- [x] 7.4 改造 GoodsStockWeeklyTask:使用 Business_Week 口径聚合库存周报
|
||||
- _Requirements: 5.14_
|
||||
|
||||
- [x] 7.5 改造 GoodsStockMonthlyTask:使用 Business_Month 口径聚合库存月报
|
||||
- _Requirements: 5.15_
|
||||
|
||||
- [x] 7.6 改造 SpendingPowerIndexTask:`DATE(pay_time)` → 营业日归属表达式
|
||||
- _Requirements: 5.16_
|
||||
|
||||
- [x] 7.7 改造 MemberIndexBase:`DATE(pay_time)` → 营业日归属表达式
|
||||
- _Requirements: 5.17_
|
||||
|
||||
- [x] 7.8 改造 MvRefreshTask:确保物化视图刷新的时间过滤条件使用 Business_Date 口径
|
||||
- _Requirements: 5.18_
|
||||
|
||||
- [x] 8. Checkpoint — ETL DWS 层改造验证
|
||||
- 确保所有 18 个 DWS 任务的 SQL 改造完成,`DATE()` 调用已全部替换为 `biz_date_sql_expr` 生成的表达式。运行 ETL 单元测试 `cd apps/etl/connectors/feiqiu && pytest tests/unit -v`,如有问题请向用户确认。
|
||||
|
||||
- [x] 9. 后端 API 层
|
||||
- [x] 9.1 创建 `apps/backend/app/routers/business_day.py`,实现 `/api/config/business-day` 端点
|
||||
- 创建 `APIRouter(prefix="/api/config", tags=["业务配置"])`
|
||||
- 实现 `GET /business-day` 返回 `{"business_day_start_hour": config.BUSINESS_DAY_START_HOUR}`
|
||||
- 无需认证(公开配置)
|
||||
- _Requirements: 8.1, 8.2, 8.3_
|
||||
|
||||
- [x] 9.2 在后端主路由中注册 `business_day` 路由
|
||||
- 在 `apps/backend/app/main.py` 或路由注册文件中 `include_router`
|
||||
- _Requirements: 8.1_
|
||||
|
||||
- [x] 9.3 后端时间范围查询统一使用 `business_*_range` 函数
|
||||
- 在后端需要计算"今日/本周/本月"时间范围的 API 中,导入并使用 `business_day_range`、`business_week_range`、`business_month_range`
|
||||
- 从 `Shared_DateTime_Utils` 导入,禁止重复实现
|
||||
- _Requirements: 6.2, 6.3, 6.4, 6.5_
|
||||
|
||||
- [x] 9.4 编写单元测试:后端配置查询 API
|
||||
- 测试 `/api/config/business-day` 返回正确 JSON 格式
|
||||
- 测试返回值与 `config.BUSINESS_DAY_START_HOUR` 一致
|
||||
- _Requirements: 8.1, 8.2, 8.3_
|
||||
|
||||
- [x] 10. 数据库层:PostgreSQL 函数与物化视图迁移
|
||||
- [x] 10.1 创建迁移脚本:新增 `dws.biz_date()` PostgreSQL 函数
|
||||
- 文件:`db/etl_feiqiu/migrations/2026-03-XX__add_biz_date_function.sql`
|
||||
- `CREATE OR REPLACE FUNCTION dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8) RETURNS date`
|
||||
- 标记为 `IMMUTABLE PARALLEL SAFE`
|
||||
- _Requirements: 9.2, 9.4_
|
||||
|
||||
- [x] 10.2 创建迁移脚本:重建物化视图使用 `biz_date()` 函数
|
||||
- 文件:`db/etl_feiqiu/migrations/2026-03-XX__rebuild_mv_with_biz_date.sql`
|
||||
- 将 `mv_dws_finance_daily_summary_l1..l4` 和 `mv_dws_assistant_daily_detail_l1..l4` 的 `CURRENT_DATE` / `date_trunc` 替换为 `dws.biz_date(NOW())`
|
||||
- 使用 `DROP MATERIALIZED VIEW IF EXISTS` + `CREATE MATERIALIZED VIEW` 重建
|
||||
- _Requirements: 9.1, 9.3_
|
||||
|
||||
- [x] 11. 前端适配
|
||||
- [x] 11.1 Admin_Web:日期选择器旁标注营业日口径说明
|
||||
- 在日期选择器组件旁增加 Tooltip 或文字标注:`营业日:{HH}:00 起`
|
||||
- 通过 `/api/config/business-day` 获取 `business_day_start_hour`,启动时请求一次存入全局状态
|
||||
- 降级策略:API 不可用时使用默认值 8,`console.warn` 输出警告
|
||||
- _Requirements: 7.1, 7.2, 7.4, 7.5_
|
||||
|
||||
- [x] 11.2 Miniprogram:确认后端 API 返回的数据已是营业日口径
|
||||
- 小程序不直接使用 cutoff 值,所有统计数据由后端 API 按营业日口径返回
|
||||
- 确认无需前端改造,仅验证后端 API 数据正确性
|
||||
- _Requirements: 7.3_
|
||||
|
||||
- [x] 12. Checkpoint — 后端 + 数据库 + 前端验证
|
||||
- 确保后端 API 端点可用、迁移脚本语法正确、前端标注正常显示。如有问题请向用户确认。
|
||||
|
||||
- [x] 13. 历史数据重算脚本
|
||||
- [x] 13.1 创建 `scripts/ops/rebuild_dws_biz_date.py` 历史数据重算脚本
|
||||
- 复用正式 ETL 任务逻辑,使用相同的 `Business_Day_Cutoff` 配置
|
||||
- 按月分窗口执行,避免单次事务过大
|
||||
- 执行前后记录行数对比到日志
|
||||
- 支持 `--dry-run` 模式预览影响范围
|
||||
- 支持 `--fail-fast` 参数控制错误时中止或继续
|
||||
- 错误时回滚当前批次并记录错误日志
|
||||
- _Requirements: 10.1, 10.2, 10.3, 10.4_
|
||||
|
||||
- [x] 14. 运维脚本排查与改造
|
||||
- [x] 14.1 排查并改造 `scripts/ops/export_bug_report.py`
|
||||
- 将 `DATE(trash_time)`、`DATE(create_time)`、`DATE(start_use_time)` 替换为 `biz_date_sql_expr` 生成的表达式
|
||||
- _Requirements: 12.1, 12.5_
|
||||
|
||||
- [x] 14.2 排查并改造 `scripts/ops/etl_consistency_check.py`
|
||||
- 评估日期比较逻辑,按需替换为营业日归属表达式
|
||||
- _Requirements: 12.2, 12.5_
|
||||
|
||||
- [x] 14.3 排查并改造 `apps/etl/connectors/feiqiu/scripts/debug/debug_blackbox.py`
|
||||
- 将 `::date` 类型转换替换为 `biz_date()` 函数调用
|
||||
- _Requirements: 12.3_
|
||||
|
||||
- [x] 14.4 排查并改造 `apps/etl/connectors/feiqiu/scripts/run_update.py`
|
||||
- 将 `.date()` 和 `datetime.combine` 替换为 `business_date()` + `business_day_range()`
|
||||
- _Requirements: 12.4, 12.5_
|
||||
|
||||
- [x] 15. Final Checkpoint — 全量验证
|
||||
- 确保所有属性测试通过:`cd C:\NeoZQYY && pytest tests/test_property_business_day_cutoff.py -v`
|
||||
- 确保 ETL 单元测试通过:`cd apps/etl/connectors/feiqiu && pytest tests/unit -v`
|
||||
- 确认所有 12 项需求的验收标准均有对应任务覆盖
|
||||
- 如有问题请向用户确认。
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
|
||||
- 每个任务引用了具体的需求编号,确保可追溯性
|
||||
- 属性测试验证设计文档中的 8 个正确性属性
|
||||
- Checkpoint 任务确保增量验证,及时发现问题
|
||||
- 设计文档标注 `defaults.py` 和 `env_parser.py` 已完成,任务 4.1 仅需增加校验逻辑
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,447 +0,0 @@
|
||||
# 设计文档:数据流字段补全与前后端联调
|
||||
|
||||
## 概述
|
||||
|
||||
本设计基于 `dataflow_2026-02-19_190440.md` 数据流分析报告,覆盖两大任务:
|
||||
|
||||
1. **字段补全**:对 11 张 ODS/DWD 表执行字段映射补全,包括 DDL 更新、ETL loader/task 代码同步、文档精化
|
||||
2. **DWS 库存汇总**:在 DWS 层新建日/周/月三个粒度的库存汇总表,基于 DWD goods_stock_summary 数据构建
|
||||
3. **前后端联调**:确保 admin-web 前端与 FastAPI 后端的 ETL 执行流程完整可用,含计时和黑盒测试
|
||||
|
||||
核心设计原则:
|
||||
- **执行依据**:字段补全部分基于排查结论文档 `export/SYSTEM/REPORTS/field_audit/field_review_for_user.md`(由 `FIELD_AUDIT_ROOT` 环境变量配置路径)
|
||||
- **先确认再新增**:对每个疑似缺失字段,必须先排查是否已存在(可能是命名差异、已映射到其他列、或已在 FACT_MAPPINGS 中以不同名称配置),确认确实缺失后才执行新增
|
||||
- 所有字段映射变更通过 `DwdLoadTask.FACT_MAPPINGS` 声明式配置,不修改核心合并逻辑
|
||||
- 新建 DWD 表遵循现有 main/ex 分表模式(核心字段 → main 表,扩展字段 → ex 表)
|
||||
- DDL 变更通过迁移脚本(`db/etl_feiqiu/migrations/`)执行,同步更新 schema 文件
|
||||
- 控制无效字段新增:仅在确认字段确实缺失且有业务价值时才新增
|
||||
|
||||
## 架构
|
||||
|
||||
### 现有 ETL 数据流架构
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
API[上游 SaaS API] -->|JSON| ODS_Loader[GenericODSLoader]
|
||||
ODS_Loader -->|UPSERT| ODS[(ODS 表)]
|
||||
ODS -->|SELECT| DWD_Task[DwdLoadTask]
|
||||
DWD_Task -->|SCD2 合并| DIM[(DWD 维度表)]
|
||||
DWD_Task -->|增量插入| FACT[(DWD 事实表)]
|
||||
```
|
||||
|
||||
### 字段映射机制
|
||||
|
||||
`DwdLoadTask` 使用两层映射策略:
|
||||
1. **自动映射**:ODS 列名与 DWD 列名相同时自动匹配
|
||||
2. **显式映射**:通过 `FACT_MAPPINGS` 字典声明 `(dwd_col, ods_expr, cast_type)` 三元组
|
||||
|
||||
本次变更主要操作 `FACT_MAPPINGS` 和 `TABLE_MAP`,以及对应的 DDL。
|
||||
|
||||
### 前后端联调架构
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
AdminWeb[Admin Web<br/>React + Ant Design] -->|HTTP/WS| Backend[FastAPI 后端]
|
||||
Backend -->|subprocess| ETL[ETL CLI]
|
||||
ETL -->|SQL| DB[(PostgreSQL)]
|
||||
Backend -->|WebSocket| AdminWeb
|
||||
```
|
||||
|
||||
## 字段排查结论(已完成)
|
||||
|
||||
排查工作已完成,详细结论见 `export/SYSTEM/REPORTS/field_audit/field_review_for_user.md`。
|
||||
|
||||
排查方法包括:查 DWD 表现有列、查 FACT_MAPPINGS、查 ODS 表现有列、查自动映射、查 API JSON 样本、数据库实际数据验证。排查发现 4 个映射错误、21 个待新增字段、2 张需新建 DWD 表、6 个跳过字段。
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 任务 1:字段补全涉及的组件
|
||||
|
||||
| 组件 | 文件路径 | 变更类型 |
|
||||
|------|---------|---------|
|
||||
| DWD 加载任务 | `tasks/dwd/dwd_load_task.py` | 修改 `FACT_MAPPINGS`、`TABLE_MAP` |
|
||||
| ODS DDL | `db/etl_feiqiu/schemas/ods.sql` | 新增列(store_goods_master 嵌套展开) |
|
||||
| DWD DDL | `db/etl_feiqiu/schemas/dwd.sql` | 新增列、新建表 |
|
||||
| 迁移脚本 | `db/etl_feiqiu/migrations/` | 新增 ALTER TABLE / CREATE TABLE |
|
||||
| ODS 加载器 | `loaders/ods/generic.py` | 可能需要扩展 columns 列表 |
|
||||
| BD_Manual 文档 | `docs/database/` | 更新字段说明 |
|
||||
|
||||
### 任务 2:前后端联调涉及的组件
|
||||
|
||||
| 组件 | 文件路径 | 变更类型 |
|
||||
|------|---------|---------|
|
||||
| 执行 API | `apps/backend/app/routers/` | 调试/修复参数传递 |
|
||||
| 执行页面 | `apps/admin-web/src/pages/TaskManager.tsx` | 调试/修复前端逻辑 |
|
||||
| 计时模块 | `apps/etl/connectors/feiqiu/utils/` | 新增计时器工具 |
|
||||
| 黑盒测试 | `apps/etl/connectors/feiqiu/quality/` | 新增数据一致性检查 |
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 字段补全分类
|
||||
|
||||
根据 `field_review_for_user.md` 排查结论,将变更分为四类:
|
||||
|
||||
#### 🔴 映射错误修复(高优先级)
|
||||
|
||||
| 表 | 问题 | 修正方案 |
|
||||
|----|------|---------|
|
||||
| assistant_service_records | DWD `site_assistant_id` 错误映射自 ODS `order_assistant_id` | 修正映射源 + 新增 `order_assistant_id` 列 |
|
||||
| store_goods_sales_records | DWD `discount_price` 实际映射自 ODS `discount_money`(列名误导) | 重命名 DWD 列 + 新增真正的 `discount_price` |
|
||||
| store_goods_master | `batch_stock_qty` 映射自 `stock`(错误),`provisional_total_cost` 映射自 `total_purchase_cost`(错误) | 修正 FACT_MAPPINGS 源列 |
|
||||
|
||||
#### A 类:新增 DWD 列 + FACT_MAPPINGS
|
||||
|
||||
| 表 | 新增字段数 | DWD 目标 |
|
||||
|----|----------|---------|
|
||||
| assistant_accounts_master | 4 | dim_assistant_ex |
|
||||
| assistant_service_records | 2 | dwd_assistant_service_log_ex |
|
||||
| assistant_cancellation_records | 0(仅更新映射) | dwd_assistant_trash_event |
|
||||
| member_balance_changes | 1 | dwd_member_balance_change_ex |
|
||||
| site_tables_master | 14 | dim_table_ex |
|
||||
|
||||
#### B 类:仅补 FACT_MAPPINGS(DWD 列已存在)
|
||||
|
||||
| 表 | 说明 |
|
||||
|----|------|
|
||||
| recharge_settlements | 5 个字段,DWD 列已存在,ODS/DWD 两侧数据全为 0(业务未启用) |
|
||||
|
||||
#### 跳过(无需变更)
|
||||
|
||||
| 表 | 原因 |
|
||||
|----|------|
|
||||
| tenant_goods_master | `commoditycode` 与 `commodity_code` 100% 冗余(花括号包裹格式),跳过 |
|
||||
| store_goods_master(time_slot_sale) | ODS 列不存在,跳过 |
|
||||
|
||||
#### C 类:需新建 DWD 表
|
||||
|
||||
| 表 | ODS 字段数 | DWD 新表 | 备注 |
|
||||
|----|----------|---------|------|
|
||||
| goods_stock_summary | 14 | dwd_goods_stock_summary | 需先修改 ODS 配置 `requires_window=True` 并重新采集 |
|
||||
| goods_stock_movements | 19 | dwd_goods_stock_movement | 事实表,按 createtime 增量加载 |
|
||||
|
||||
#### C 类:疑似需新建 DWD 表(需排查是否有替代方案)
|
||||
|
||||
| 表 | ODS 字段数 | 疑似新建 DWD 表 | 排查重点 |
|
||||
|----|----------|---------------|---------|
|
||||
| goods_stock_summary | 14 | dwd_goods_stock_summary | 确认是否有意不建 DWD 表(如数据直接在 ODS 层使用) |
|
||||
| goods_stock_movements | 19 | dwd_goods_stock_movement | 同上 |
|
||||
|
||||
### 已确认的映射关系(排查结论)
|
||||
|
||||
以下映射关系已通过数据库实际数据验证确认:
|
||||
|
||||
| 字段 | 排查结论 | 所在表 |
|
||||
|------|---------|-------|
|
||||
| discount_price (store_goods_sales) | 🔴 DWD `discount_price` 实际映射自 ODS `discount_money`,需重命名 + 新增 | store_goods_sales_records |
|
||||
| commoditycode (tenant_goods) | ⏭️ 与 `commodity_code` 100% 冗余,跳过 | tenant_goods_master |
|
||||
| site_assistant_id (assistant_service) | 🔴 DWD 错误映射自 ODS `order_assistant_id`,需修正 | assistant_service_records |
|
||||
| recharge 电费/券字段 | ✅ DWD 列已存在,仅需补 FACT_MAPPINGS(数据全为 0) | recharge_settlements |
|
||||
| batch_stock_qty (store_goods) | 🔴 错误映射自 `stock`,应映射自 `batch_stock_quantity` | store_goods_master |
|
||||
| provisional_total_cost (store_goods) | 🔴 错误映射自 `total_purchase_cost`,应映射自 `provisional_total_cost` | store_goods_master |
|
||||
|
||||
### 新建 DWD 表设计
|
||||
|
||||
#### dwd_goods_stock_summary
|
||||
|
||||
```sql
|
||||
CREATE TABLE dwd.dwd_goods_stock_summary (
|
||||
site_goods_id bigint NOT NULL,
|
||||
goods_name text,
|
||||
goods_unit text,
|
||||
goods_category_id bigint,
|
||||
goods_category_second_id bigint,
|
||||
category_name text,
|
||||
range_start_stock numeric,
|
||||
range_end_stock numeric,
|
||||
range_in numeric,
|
||||
range_out numeric,
|
||||
range_sale numeric,
|
||||
range_sale_money numeric(12,2),
|
||||
range_inventory numeric,
|
||||
current_stock numeric,
|
||||
site_id bigint,
|
||||
tenant_id bigint,
|
||||
fetched_at timestamptz,
|
||||
PRIMARY KEY (site_goods_id)
|
||||
);
|
||||
```
|
||||
|
||||
#### dwd_goods_stock_movement
|
||||
|
||||
```sql
|
||||
CREATE TABLE dwd.dwd_goods_stock_movement (
|
||||
site_goods_stock_id bigint NOT NULL,
|
||||
tenant_id bigint,
|
||||
site_id bigint,
|
||||
site_goods_id bigint,
|
||||
goods_name text,
|
||||
goods_category_id bigint,
|
||||
goods_second_category_id bigint,
|
||||
unit text,
|
||||
price numeric(12,2),
|
||||
stock_type integer,
|
||||
change_num numeric,
|
||||
start_num numeric,
|
||||
end_num numeric,
|
||||
change_num_a numeric,
|
||||
start_num_a numeric,
|
||||
end_num_a numeric,
|
||||
remark text,
|
||||
operator_name text,
|
||||
create_time timestamptz,
|
||||
fetched_at timestamptz,
|
||||
PRIMARY KEY (site_goods_stock_id)
|
||||
);
|
||||
```
|
||||
|
||||
### recharge_settlements 映射关系
|
||||
|
||||
ODS 列与 DWD 列的对应关系(命名转换):
|
||||
|
||||
| ODS 列(驼峰) | DWD 列(蛇形) |
|
||||
|---------------|--------------|
|
||||
| plcouponsaleamount | pl_coupon_sale_amount |
|
||||
| mervousalesamount | mervou_sales_amount |
|
||||
| electricitymoney | electricity_money |
|
||||
| realelectricitymoney | real_electricity_money |
|
||||
| electricityadjustmoney | electricity_adjust_money |
|
||||
|
||||
这 5 个字段在 `dwd_recharge_order` 中已有列定义但缺少 FACT_MAPPINGS 条目,需要补充映射。
|
||||
|
||||
### store_goods_master 映射修正
|
||||
|
||||
根据排查结论,该表存在两个映射错误(非新增字段):
|
||||
|
||||
| DWD 列 | 当前错误映射 ODS 列 | 正确 ODS 列 | 验证结果 |
|
||||
|--------|-------------------|------------|---------|
|
||||
| `batch_stock_qty` | `stock`(当前库存) | `batch_stock_quantity`(批次库存) | 仅 7.3% 行相等 |
|
||||
| `provisional_total_cost` | `total_purchase_cost`(实际采购成本) | `provisional_total_cost`(暂估成本) | 93.5% 行相等但 113 行不同 |
|
||||
|
||||
`time_slot_sale` ODS 列不存在,跳过。`goodsStockWarningInfo` 嵌套展开不在本次范围内。
|
||||
|
||||
### DWS 库存汇总表设计(日/周/月)
|
||||
|
||||
基于 `field_review_for_user.md` 第 10 章发现,goods_stock_summary API 支持 `startTime`/`endTime` 参数返回时间范围内的库存汇总数据。在 ODS 任务配置修改(`requires_window=True` + `time_fields=("startTime", "endTime")`)并重新采集后,DWD 层 `dwd_goods_stock_summary` 将拥有带时间范围的真实数据,可在此基础上构建 DWS 层汇总。
|
||||
|
||||
#### 三张 DWS 表
|
||||
|
||||
| 表名 | 粒度 | 任务代码 | stat_period |
|
||||
|------|------|---------|-------------|
|
||||
| `dws.dws_goods_stock_daily_summary` | 日 | `DWS_GOODS_STOCK_DAILY` | `'daily'` |
|
||||
| `dws.dws_goods_stock_weekly_summary` | 周 | `DWS_GOODS_STOCK_WEEKLY` | `'weekly'` |
|
||||
| `dws.dws_goods_stock_monthly_summary` | 月 | `DWS_GOODS_STOCK_MONTHLY` | `'monthly'` |
|
||||
|
||||
#### DDL 设计(三张表结构相同)
|
||||
|
||||
```sql
|
||||
CREATE TABLE dws.dws_goods_stock_daily_summary (
|
||||
site_id bigint NOT NULL,
|
||||
tenant_id bigint,
|
||||
stat_date date NOT NULL,
|
||||
site_goods_id bigint NOT NULL,
|
||||
goods_name text,
|
||||
goods_unit text,
|
||||
goods_category_id bigint,
|
||||
goods_category_second_id bigint,
|
||||
category_name text,
|
||||
range_start_stock numeric,
|
||||
range_end_stock numeric,
|
||||
range_in numeric,
|
||||
range_out numeric,
|
||||
range_sale numeric,
|
||||
range_sale_money numeric(12,2),
|
||||
range_inventory numeric,
|
||||
current_stock numeric,
|
||||
stat_period text NOT NULL DEFAULT 'daily',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (site_id, stat_date, site_goods_id)
|
||||
);
|
||||
```
|
||||
|
||||
周度表和月度表结构相同,仅表名和 `stat_period` 默认值不同(`'weekly'` / `'monthly'`)。
|
||||
|
||||
#### 任务实现模式
|
||||
|
||||
继承 `BaseDwsTask`,实现 `extract` / `transform` / `load` 三阶段:
|
||||
|
||||
- **extract**:从 `dwd.dwd_goods_stock_summary` 按时间范围查询数据
|
||||
- **transform**:按粒度(日/周/月)对 `stat_date` 进行分组聚合,计算各库存指标
|
||||
- 日度:直接取 DWD 数据(`stat_date` = 采集日期)
|
||||
- 周度:按 ISO 周分组,`stat_date` = 周一日期
|
||||
- 月度:按自然月分组,`stat_date` = 月首日期
|
||||
- **load**:使用 `upsert` 写入目标表,主键冲突时更新
|
||||
|
||||
#### 前置依赖
|
||||
|
||||
- 需求 7(goods_stock_summary 新建 DWD 表)必须先完成
|
||||
- ODS 任务配置修改(`requires_window=True`)必须先完成并重新采集数据
|
||||
|
||||
#### 文件位置
|
||||
|
||||
- DDL:`db/etl_feiqiu/schemas/dws.sql`
|
||||
- 迁移脚本:`db/etl_feiqiu/migrations/{date}__create_dws_goods_stock_summary.sql`
|
||||
- 任务代码:`apps/etl/connectors/feiqiu/tasks/dws/goods_stock_daily_task.py`、`goods_stock_weekly_task.py`、`goods_stock_monthly_task.py`
|
||||
|
||||
### settlement_ticket_details 彻底移除设计
|
||||
|
||||
从项目中完整移除 `settlement_ticket_details`(结账小票详情)相关的所有代码、DDL、配置、文档和数据。
|
||||
|
||||
#### 需要移除的文件/代码位置
|
||||
|
||||
| 层级 | 文件路径 | 移除内容 |
|
||||
|------|---------|---------|
|
||||
| ETL 任务定义 | `tasks/ods/ods_tasks.py` | `OdsTaskSpec("ODS_SETTLEMENT_TICKET", ...)`、`OdsSettlementTicketTask` 类、`ENABLED_ODS_CODES` 中的条目、`ODS_TASK_CLASSES` 覆盖 |
|
||||
| ETL 校验 | `tasks/verification/dwd_verifier.py` | `settlement_ticket_details` 主键映射条目 |
|
||||
| ETL 校验 | `tasks/verification/ods_verifier.py` | 相关注释和特殊处理逻辑 |
|
||||
| ETL 手动导入 | `tasks/utility/manual_ingest_task.py` | `settlement_ticket_details` 的表映射和配置 |
|
||||
| JSON 存储 | `utils/json_store.py` | `/order/getordersettleticketnew` 的路径映射 |
|
||||
| ODS 间隙检查 | `scripts/check/check_ods_gaps.py` | `_check_settlement_tickets` 函数及调用 |
|
||||
| 黑盒调试 | `scripts/debug/debug_blackbox.py` | `ODS_SETTLEMENT_TICKET` 跳过逻辑 |
|
||||
| DDL | `db/etl_feiqiu/schemas/ods.sql`、`schema_ODS_doc.sql` | `settlement_ticket_details` 建表语句和注释 |
|
||||
| 种子数据 | `db/etl_feiqiu/seeds/seed_ods_tasks.sql` | `ODS_SETTLEMENT_TICKET` 条目 |
|
||||
| 索引检查 | `scripts/ops/check_ods_latest_indexes.py` | `idx_ods_settlement_ticket_details_latest` |
|
||||
| 分析脚本 | `scripts/ops/gen_full_dataflow_doc.py` | ODS spec 条目和特殊跳过逻辑 |
|
||||
| 分析脚本 | `scripts/ops/gen_field_review_doc.py` | 第 12 章 settlement_ticket_details 配置 |
|
||||
| 分析脚本 | `scripts/ops/gen_api_field_mapping.py` | 表名列表中的条目 |
|
||||
| 分析脚本 | `scripts/ops/field_audit.py` | 排查配置和特殊处理 |
|
||||
| 分析脚本 | `scripts/ops/export_dwd_field_review.py` | 字段列表配置 |
|
||||
| 分析脚本 | `scripts/ops/dataflow_analyzer.py` | ODS spec 条目和跳过逻辑 |
|
||||
| 文档 | `docs/database/etl_feiqiu_schema_migration.md` | 索引条目 |
|
||||
| ETL 文档 | `apps/etl/connectors/feiqiu/docs/etl_tasks/` | 任务表格条目 |
|
||||
| 单元测试 | `tests/unit/test_ods_tasks.py` | `test_ods_settlement_ticket_by_payment_relate_ids` |
|
||||
|
||||
#### 迁移脚本
|
||||
|
||||
```sql
|
||||
-- 移除 settlement_ticket_details 表和索引
|
||||
DROP INDEX IF EXISTS ods.idx_ods_settlement_ticket_details_latest;
|
||||
DROP TABLE IF EXISTS ods.settlement_ticket_details;
|
||||
|
||||
-- 移除 meta.ods_task_registry 中的任务注册
|
||||
DELETE FROM meta.ods_task_registry WHERE task_code = 'ODS_SETTLEMENT_TICKET';
|
||||
```
|
||||
|
||||
#### 注意事项
|
||||
|
||||
- `export/` 下的报告文件(`field_audit_report.md`、`dataflow_api_ods_dwd.md` 等)为历史产物,不需要手动清理,下次重新生成时自然不再包含
|
||||
- `docs/audit/` 下的审计日志为历史记录,保留不动
|
||||
- `tmp/` 下的临时文件不需要处理
|
||||
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*正确性属性是一种在系统所有合法执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1:FACT_MAPPINGS 字段映射正确性
|
||||
|
||||
*对于任意* ODS 表行和任意已配置的 `FACT_MAPPINGS` 条目 `(dwd_col, ods_expr, cast_type)`,当 DWD 加载任务执行后,DWD 目标行中 `dwd_col` 列的值应等于从 ODS 行中按 `ods_expr` 提取并按 `cast_type` 转换后的值。
|
||||
|
||||
**Validates: Requirements 1.1, 1.2, 2.1, 3.1, 4.1, 5.1, 6.1, 6.2, 7.2, 8.2, 9.1, 10.3, 11.1**
|
||||
|
||||
### Property 2:FACT_MAPPINGS 引用完整性
|
||||
|
||||
*对于任意* `FACT_MAPPINGS` 中的映射条目,其 DWD 目标列名必须存在于对应 DWD 表的列定义中,其 ODS 源表达式引用的列名必须存在于对应 ODS 表的列定义中(或为合法的 SQL 表达式)。
|
||||
|
||||
**Validates: Requirements 6.3**
|
||||
|
||||
### Property 3:TABLE_MAP 覆盖完整性
|
||||
|
||||
*对于任意* 在 `TABLE_MAP` 中注册的 DWD 表,该表的所有非 SCD2 列要么在 `FACT_MAPPINGS` 中有显式映射,要么在对应 ODS 表中存在同名列(自动映射)。
|
||||
|
||||
**Validates: Requirements 7.2, 8.2**
|
||||
|
||||
### Property 4:映射错误修正后数据一致性
|
||||
|
||||
*对于任意* 已修正映射的字段(assistant_service_records.site_assistant_id、store_goods_sales_records.discount_price、store_goods_master.batch_stock_qty、store_goods_master.provisional_total_cost),修正后 DWD 目标列的值应等于从正确的 ODS 源列提取的值,而非修正前的错误源列。
|
||||
|
||||
**Validates: Requirements 2.1, 4.1, 10.3**
|
||||
|
||||
### Property 5:ETL 参数解析与 CLI 命令构建正确性
|
||||
|
||||
*对于任意* 合法的 ETL 执行参数组合(门店列表、数据源模式、校验模式、时间范围、窗口切分、force-full 标志、任务选择),Backend 构建的 CLI 命令字符串应包含所有指定参数,且参数值与输入一致。
|
||||
|
||||
**Validates: Requirements 14.1, 14.2**
|
||||
|
||||
### Property 6:数据一致性检查正确性
|
||||
|
||||
*对于任意* ODS 行和对应的 DWD 行,黑盒测试检查器应能正确识别:(a) ODS 中存在但 DWD 中缺失的字段,(b) ODS 与 DWD 之间值不一致的字段。
|
||||
|
||||
**Validates: Requirements 16.2, 16.3**
|
||||
|
||||
### Property 7:计时器记录完整性
|
||||
|
||||
*对于任意* ETL 步骤序列,计时器输出应包含每个步骤的名称、开始时间、结束时间和耗时,且耗时等于结束时间减去开始时间。
|
||||
|
||||
**Validates: Requirements 15.2**
|
||||
|
||||
### Property 8:DWS 库存汇总粒度聚合正确性
|
||||
|
||||
*对于任意* DWD 库存汇总数据集和任意汇总粒度(日/周/月),DWS 汇总任务的 transform 输出应满足:(a) 每条记录的 `stat_period` 与任务粒度一致,(b) 同一 `(site_id, stat_date, site_goods_id)` 组合不重复,(c) 日度汇总的记录数不少于周度和月度汇总的记录数。
|
||||
|
||||
**Validates: Requirements 12.2, 12.3, 12.4, 12.5, 12.6**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 字段补全错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| DDL 迁移失败 | 回滚事务,记录错误日志,不影响其他表 |
|
||||
| ODS 列不存在 | 跳过该映射条目,记录 WARNING 日志 |
|
||||
| 类型转换失败 | 使用 NULLIF + CAST 兜底,转换失败写入 NULL |
|
||||
| 新建 DWD 表主键冲突 | 使用 ON CONFLICT DO UPDATE 策略 |
|
||||
|
||||
### DWS 库存汇总错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| DWD 源表无数据 | 跳过汇总,记录 WARNING 日志 |
|
||||
| 跨周/跨月边界数据不完整 | 按已有数据汇总,不补零 |
|
||||
| upsert 主键冲突 | 使用 ON CONFLICT DO UPDATE 更新已有记录 |
|
||||
| DWD 表尚未创建(前置依赖未完成) | 抛出明确错误,提示需先完成需求 7 |
|
||||
|
||||
### 前后端联调错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| 参数校验失败 | 返回 422 状态码,附带详细错误信息 |
|
||||
| ETL 子进程超时 | 设置超时阈值,超时后终止进程并返回错误 |
|
||||
| WebSocket 断连 | 前端自动重连,后端缓存最近日志 |
|
||||
| 黑盒测试发现不一致 | 记录差异明细到报告,不中断流程 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 属性测试
|
||||
|
||||
使用 `hypothesis` 库进行属性测试,每个属性至少运行 100 次迭代。
|
||||
|
||||
- **Property 1-3**:通过 FakeDB 模拟 ODS/DWD 表结构,生成随机 ODS 行数据,验证 FACT_MAPPINGS 映射逻辑
|
||||
- **Property 4**:对修正后的映射字段,验证 DWD 值来自正确的 ODS 源列
|
||||
- **Property 5**:生成随机参数组合,验证 CLI 命令构建
|
||||
- **Property 6**:生成随机 ODS/DWD 行对,验证一致性检查逻辑
|
||||
- **Property 7**:生成随机步骤序列,验证计时器输出
|
||||
- **Property 8**:生成随机 DWD 库存数据,验证日/周/月三个粒度的聚合逻辑正确性
|
||||
|
||||
测试标签格式:`Feature: dataflow-field-completion, Property N: {property_text}`
|
||||
|
||||
### 单元测试
|
||||
|
||||
- DDL 迁移脚本语法正确性(SQL 解析)
|
||||
- 各表 FACT_MAPPINGS 条目的具体映射值验证
|
||||
- DWS 库存汇总任务的边界值测试(跨周/跨月数据、空数据集)
|
||||
- 前端参数表单的边界值测试
|
||||
- 计时器精度测试
|
||||
|
||||
### 集成测试
|
||||
|
||||
- 端到端 ETL 执行:从 API JSON 到 DWD 落库的完整流程
|
||||
- 前后端联调:从 Admin Web 触发到 ETL 完成的完整流程
|
||||
- 黑盒测试:全量数据一致性验证
|
||||
|
||||
### 测试工具
|
||||
|
||||
- ETL 单元测试使用 `tests/unit/task_test_utils.py` 提供的 FakeDB/FakeAPI
|
||||
- 属性测试使用 `hypothesis` 库
|
||||
- 后端测试使用 `pytest` + FastAPI TestClient
|
||||
@@ -1,228 +0,0 @@
|
||||
# 需求文档:数据流字段补全与前后端联调
|
||||
|
||||
## 简介
|
||||
|
||||
本特性基于 `dataflow_2026-02-19_190440.md` 数据流分析报告,完成三大任务:
|
||||
1. 补全 11 张 ODS/DWD 表中缺失的字段映射(含 DDL 更新、ETL loader/task 代码同步、文档精化)
|
||||
2. 在 DWS 层新建库存汇总表,支持日/周/月三个粒度的库存数据汇总
|
||||
3. 管理后台(admin-web)前后端联调,确保 ETL 全流程可通过 Web 界面正确触发和执行
|
||||
|
||||
## 术语表
|
||||
|
||||
- **ETL_System**:飞球连接器 ETL 系统(`apps/etl/connectors/feiqiu/`),负责从上游 API 抽取数据并经 ODS→DWD→DWS 三层处理
|
||||
- **ODS**:Operational Data Store,原始数据层,保留 API 返回的原始字段
|
||||
- **DWD**:Data Warehouse Detail,明细数据层,经清洗、标准化后的业务字段
|
||||
- **DDL**:Data Definition Language,数据库表结构定义(位于 `db/etl_feiqiu/schemas/`)
|
||||
- **Loader**:ETL 加载器(`loaders/`),负责将 ODS 数据清洗映射到 DWD 表
|
||||
- **Task**:ETL 任务(`tasks/`),编排 loader 的执行逻辑
|
||||
- **Admin_Web**:管理后台(`apps/admin-web/`),React + Vite + Ant Design 前端
|
||||
- **Backend**:FastAPI 后端(`apps/backend/`),提供 ETL 调度和数据查询 API
|
||||
- **SCD2**:缓慢变化维度类型 2,用于维度表历史版本追踪
|
||||
- **BD_Manual**:业务数据字典文档(`docs/database/`),记录字段含义和映射关系
|
||||
- **Field_Mapping**:字段映射关系,描述 API JSON → ODS 列 → DWD 列的对应关系
|
||||
- **DWS**:Data Warehouse Summary,汇总数据层,按业务维度聚合的统计数据
|
||||
- **BaseDwsTask**:DWS 任务基类(`tasks/dws/base_dws_task.py`),提供 extract/transform/load 三阶段框架
|
||||
|
||||
## 执行依据
|
||||
|
||||
本需求文档的字段补全部分基于以下排查结论文档:
|
||||
- `export/SYSTEM/REPORTS/field_audit/field_review_for_user.md` — 逐表逐字段的排查结论与操作建议
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:assistant_accounts_master 字段补全
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望将助教账号档案表中 4 个未映射的 ODS 字段(system_role_id、job_num、cx_unit_price、pd_unit_price)补全到 DWD 层,以便下游分析可以使用完整的助教档案数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 执行 assistant_accounts_master 的 DWD 加载任务, THE Loader SHALL 将 ODS 列 `system_role_id` 映射到 DWD 目标表 `dim_assistant_ex` 的对应列
|
||||
2. WHEN ETL_System 执行 assistant_accounts_master 的 DWD 加载任务, THE Loader SHALL 将 ODS 列 `job_num`、`cx_unit_price`、`pd_unit_price` 映射到 DWD 目标表 `dim_assistant_ex` 的对应列
|
||||
3. WHEN 新字段被添加到 DWD 表, THE DDL SHALL 在 `db/etl_feiqiu/schemas/dwd.sql` 中包含对应的 ALTER TABLE 或 CREATE 语句
|
||||
4. WHEN 字段映射完成后, THE BD_Manual SHALL 更新对应的字段说明文档,消除"待补充""待分析"等模糊描述
|
||||
|
||||
### 需求 2:assistant_service_records 字段补全
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望将助教服务流水表中 3 个未映射的 ODS 字段(site_assistant_id、operator_id、operator_name)补全到 DWD 层,以便追踪服务操作员信息。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 执行 assistant_service_records 的 DWD 加载任务, THE Loader SHALL 将 ODS 列 `site_assistant_id`、`operator_id`、`operator_name` 映射到 DWD 目标表 `dwd_assistant_service_log_ex` 的对应列
|
||||
2. WHEN 新字段被添加到 DWD 表, THE DDL SHALL 在 `dwd.sql` 中包含对应的列定义
|
||||
3. WHEN 字段映射完成后, THE BD_Manual SHALL 更新对应的字段说明文档
|
||||
|
||||
### 需求 3:assistant_cancellation_records 字段补全
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望将助教废除记录表中 1 个未映射的 ODS 字段(assistanton)补全到 DWD 层。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 执行 assistant_cancellation_records 的 DWD 加载任务, THE Loader SHALL 将 ODS 列 `assistanton` 映射到 DWD 目标表 `dwd_assistant_trash_event_ex` 的对应列
|
||||
2. WHEN 新字段被添加到 DWD 表, THE DDL SHALL 在 `dwd.sql` 中包含对应的列定义
|
||||
3. WHEN 字段映射完成后, THE BD_Manual SHALL 对 `assistanton` 字段进行语义分析并补充精确说明
|
||||
|
||||
### 需求 4:store_goods_sales_records 字段补全
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望将门店商品销售流水表中 1 个未映射的 ODS 字段(discount_price)补全到 DWD 层,以便分析折后单价。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 执行 store_goods_sales_records 的 DWD 加载任务, THE Loader SHALL 将 ODS 列 `discount_price` 映射到 DWD 目标表 `dwd_store_goods_sale_ex` 的对应列
|
||||
2. WHEN 新字段被添加到 DWD 表, THE DDL SHALL 在 `dwd.sql` 中包含对应的列定义,类型为 `numeric`(金额精度)
|
||||
3. WHEN 字段映射完成后, THE BD_Manual SHALL 更新对应的字段说明文档
|
||||
|
||||
### 需求 5:member_balance_changes 字段补全
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望将会员余额变动表中 1 个未映射的 ODS 字段(relate_id)补全到 DWD 层,以便关联充值记录或订单。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 执行 member_balance_changes 的 DWD 加载任务, THE Loader SHALL 将 ODS 列 `relate_id` 映射到 DWD 目标表 `dwd_member_balance_change_ex` 的对应列
|
||||
2. WHEN 新字段被添加到 DWD 表, THE DDL SHALL 在 `dwd.sql` 中包含对应的列定义
|
||||
3. WHEN 字段映射完成后, THE BD_Manual SHALL 更新对应的字段说明文档
|
||||
|
||||
|
||||
### 需求 6:recharge_settlements 字段补全与映射建立
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望将充值结算表中 5 个 ODS→DWD 未映射字段补全,并为 5 个 DWD 无 ODS 源字段建立正确的映射关系,以便电费和券销售额数据完整流转。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 执行 recharge_settlements 的 DWD 加载任务, THE Loader SHALL 将 ODS 列 `electricityadjustmoney`、`electricitymoney`、`mervousalesamount`、`plcouponsaleamount`、`realelectricitymoney` 映射到 DWD 目标表 `dwd_recharge_order` 的对应列
|
||||
2. WHEN DWD 表存在无 ODS 源的列(`pl_coupon_sale_amount`、`mervou_sales_amount`、`electricity_money`、`real_electricity_money`、`electricity_adjust_money`), THE Loader SHALL 建立从 ODS 对应列到这些 DWD 列的映射关系
|
||||
3. WHEN 映射建立后, THE ETL_System SHALL 确保 ODS 列名(驼峰式)与 DWD 列名(蛇形式)之间的命名转换正确
|
||||
4. WHEN 字段映射完成后, THE DDL SHALL 同步更新,THE BD_Manual SHALL 更新对应的字段说明文档
|
||||
|
||||
### 需求 7:goods_stock_summary 新建 DWD 表与字段映射
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望为库存汇总表新建 DWD 目标表,并将 14 个 ODS 字段完整映射,以便库存数据可在 DWD 层使用。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 需要加载 goods_stock_summary 数据到 DWD 层, THE DDL SHALL 在 `dwd.sql` 中创建新的 DWD 目标表(如 `dwd_goods_stock_summary`)
|
||||
2. WHEN DWD 目标表创建后, THE Loader SHALL 将全部 14 个 ODS 列(sitegoodsid、goodsname、goodsunit、goodscategoryid、goodscategorysecondid、categoryname、rangestartstock、rangeendstock、rangein、rangeout、rangesale、rangesalemoney、rangeinventory、currentstock)映射到 DWD 目标表
|
||||
3. WHEN 新表创建后, THE ETL_System SHALL 创建对应的 DWD loader 和 task 代码
|
||||
4. WHEN 新表创建后, THE BD_Manual SHALL 为新表编写完整的字段说明文档
|
||||
|
||||
### 需求 8:goods_stock_movements 新建 DWD 表与字段映射
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望为库存变化记录表新建 DWD 目标表,并将 19 个 ODS 字段完整映射,以便库存变动明细可在 DWD 层使用。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 需要加载 goods_stock_movements 数据到 DWD 层, THE DDL SHALL 在 `dwd.sql` 中创建新的 DWD 目标表(如 `dwd_goods_stock_movement`)
|
||||
2. WHEN DWD 目标表创建后, THE Loader SHALL 将全部 19 个 ODS 列映射到 DWD 目标表
|
||||
3. WHEN 新表创建后, THE ETL_System SHALL 创建对应的 DWD loader 和 task 代码
|
||||
4. WHEN 新表创建后, THE BD_Manual SHALL 为新表编写完整的字段说明文档
|
||||
|
||||
### 需求 9:site_tables_master 字段补全
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望将台桌维表中 14 个未映射的 ODS 字段补全到 DWD 层,以便台桌配置信息完整可用。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 执行 site_tables_master 的 DWD 加载任务, THE Loader SHALL 将 14 个 ODS 列(sitename、appletQrCodeUrl、audit_status、charge_free、create_time、delay_lights_time、is_rest_area、light_status、only_allow_groupon、order_delay_time、self_table、tablestatusname、temporary_light_second、virtual_table)映射到 DWD 目标表 `dim_table_ex` 的对应列
|
||||
2. WHEN 新字段被添加到 DWD 表, THE DDL SHALL 在 `dwd.sql` 中包含对应的列定义
|
||||
3. WHEN 字段映射完成后, THE BD_Manual SHALL 更新对应的字段说明文档,消除"待补充""待分析"等模糊描述
|
||||
|
||||
### 需求 10:store_goods_master 字段补全与嵌套展开
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望将门店商品档案表中的平层未映射字段、嵌套对象字段、ODS→DWD 未映射字段全部补全,以便商品档案数据完整。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 执行 store_goods_master 的 ODS 加载任务, THE Loader SHALL 将 API 平层字段 `time_slot_sale` 映射到 ODS 表的对应列
|
||||
2. WHEN ETL_System 执行 store_goods_master 的 ODS 加载任务, THE Loader SHALL 将嵌套对象 `goodsStockWarningInfo` 的 4 个子字段(site_goods_id、sales_day、warning_day_max、warning_day_min)展开并映射到 ODS 表的对应列
|
||||
3. WHEN ETL_System 执行 store_goods_master 的 DWD 加载任务, THE Loader SHALL 将 ODS 列 `batch_stock_quantity`、`provisional_total_cost` 以及展开后的库存预警字段映射到 DWD 目标表(根据字段用途自动分配到 `dim_store_goods` 或 `dim_store_goods_ex`)
|
||||
4. WHEN 新字段被添加, THE DDL SHALL 同步更新 `ods.sql` 和 `dwd.sql`
|
||||
5. WHEN 字段映射完成后, THE BD_Manual SHALL 更新对应的字段说明文档
|
||||
|
||||
### 需求 11:tenant_goods_master 字段补全
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望将租户商品档案表中 1 个未映射的 ODS 字段(commoditycode)补全到 DWD 层。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL_System 执行 tenant_goods_master 的 DWD 加载任务, THE Loader SHALL 将 ODS 列 `commoditycode` 映射到 DWD 目标表 `dim_tenant_goods_ex` 的对应列
|
||||
2. WHEN 新字段被添加到 DWD 表, THE DDL SHALL 在 `dwd.sql` 中包含对应的列定义
|
||||
3. WHEN 字段映射完成后, THE BD_Manual SHALL 更新对应的字段说明文档
|
||||
|
||||
### 需求 12:DWS 库存汇总(日/周/月)
|
||||
|
||||
**用户故事:** 作为数据分析师,我希望在 DWS 层拥有日度、周度、月度三个粒度的库存汇总表,以便按不同时间维度分析商品库存变化趋势。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 需求 7(goods_stock_summary 新建 DWD 表)完成且 ODS 任务配置已修改(`requires_window=True` + `time_fields=("startTime", "endTime")`)并重新采集数据后, THE ETL_System SHALL 具备构建 DWS 库存汇总的数据基础
|
||||
2. WHEN ETL_System 执行 DWS_GOODS_STOCK_DAILY 任务, THE ETL_System SHALL 从 DWD 层 `dwd_goods_stock_summary` 提取数据,按日粒度汇总并写入 `dws.dws_goods_stock_daily_summary`
|
||||
3. WHEN ETL_System 执行 DWS_GOODS_STOCK_WEEKLY 任务, THE ETL_System SHALL 从 DWD 层提取数据,按周粒度汇总并写入 `dws.dws_goods_stock_weekly_summary`
|
||||
4. WHEN ETL_System 执行 DWS_GOODS_STOCK_MONTHLY 任务, THE ETL_System SHALL 从 DWD 层提取数据,按月粒度汇总并写入 `dws.dws_goods_stock_monthly_summary`
|
||||
5. THE DWS 库存汇总表 SHALL 包含以下字段:site_id、tenant_id、stat_date(汇总日期)、site_goods_id、goods_name、goods_unit、goods_category_id、goods_category_second_id、category_name(商品维度)、range_start_stock、range_end_stock、range_in、range_out、range_sale、range_sale_money、range_inventory、current_stock(库存指标)、stat_period(汇总粒度标识:'daily'/'weekly'/'monthly')
|
||||
6. THE DWS 库存汇总表 SHALL 以 `(site_id, stat_date, site_goods_id)` 为主键,支持按门店、日期、商品维度的唯一性约束
|
||||
7. WHEN DWS 库存汇总任务执行时, THE ETL_System SHALL 继承 `BaseDwsTask`,实现 `extract` / `transform` / `load` 三阶段
|
||||
8. WHEN DWS 库存汇总表创建后, THE DDL SHALL 在 `db/etl_feiqiu/schemas/dws.sql` 中包含建表语句,迁移脚本放在 `db/etl_feiqiu/migrations/`
|
||||
9. WHEN DWS 库存汇总任务代码创建后, THE ETL_System SHALL 将任务代码放在 `apps/etl/connectors/feiqiu/tasks/dws/` 目录下
|
||||
|
||||
### 需求 17:彻底移除 settlement_ticket_details
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望从项目中彻底移除 settlement_ticket_details(结账小票详情)相关的所有代码、DDL、配置、文档和数据,以便简化系统维护并消除无用的数据流。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 移除任务完成后, THE ETL_System SHALL 不再包含 `ODS_SETTLEMENT_TICKET` 任务代码(从 `ods_tasks.py` 的 `ENABLED_ODS_CODES`、`ODS_TASK_CLASSES`、`OdsSettlementTicketTask` 类中移除)
|
||||
2. WHEN 移除任务完成后, THE DDL SHALL 不再包含 `ods.settlement_ticket_details` 表定义(从 `ods.sql` / `schema_ODS_doc.sql` 中移除建表语句和注释)
|
||||
3. WHEN 移除任务完成后, THE ETL_System SHALL 从以下位置移除所有 settlement_ticket_details 引用:
|
||||
- `tasks/ods/ods_tasks.py`(OdsTaskSpec、OdsSettlementTicketTask 类、ENABLED_ODS_CODES)
|
||||
- `tasks/verification/dwd_verifier.py`、`tasks/verification/ods_verifier.py`
|
||||
- `tasks/utility/manual_ingest_task.py`
|
||||
- `utils/json_store.py`
|
||||
- `scripts/check/check_ods_gaps.py`
|
||||
- `scripts/debug/debug_blackbox.py`
|
||||
4. WHEN 移除任务完成后, THE ETL_System SHALL 从 `db/etl_feiqiu/seeds/seed_ods_tasks.sql` 中移除 `ODS_SETTLEMENT_TICKET`
|
||||
5. WHEN 移除任务完成后, THE BD_Manual SHALL 从 `docs/database/etl_feiqiu_schema_migration.md` 和 ETL 任务文档中移除相关条目
|
||||
6. WHEN 移除任务完成后, THE ETL_System SHALL 编写迁移脚本 `DROP TABLE IF EXISTS ods.settlement_ticket_details` 和 `DROP INDEX IF EXISTS ods.idx_ods_settlement_ticket_details_latest`
|
||||
7. WHEN 移除任务完成后, THE ETL_System SHALL 从 `scripts/ops/` 下的分析脚本(`gen_full_dataflow_doc.py`、`gen_field_review_doc.py`、`gen_api_field_mapping.py`、`field_audit.py`、`export_dwd_field_review.py`、`dataflow_analyzer.py`、`check_ods_latest_indexes.py`)中移除相关引用
|
||||
8. WHEN 移除任务完成后, THE ETL_System SHALL 从单元测试 `tests/unit/test_ods_tasks.py` 中移除 `test_ods_settlement_ticket_by_payment_relate_ids` 测试
|
||||
|
||||
### 需求 13:文档精化
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望对所有涉及的 BD_Manual 文档进行精细化更新,消除所有模糊描述,以便团队成员可以准确理解每个字段的含义。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 文档精化任务执行时, THE BD_Manual SHALL 逐个文档、逐项排查所有"待补充""待处理""未确定""未定义"等缺失内容
|
||||
2. WHEN 文档精化任务执行时, THE BD_Manual SHALL 将"金额字段""XX 相关""XXX 类"等粗略说明替换为精确的字段语义描述
|
||||
3. WHEN 字段说明需要精化时, THE ETL_System SHALL 通过手动字段名称分析、上下文推测、遍历值/枚举值分析、代码取用情况分析来确定字段含义
|
||||
4. WHEN 文档更新完成后, THE BD_Manual SHALL 确保每个字段说明包含:字段类型、业务含义、取值范围或枚举值、在代码中的使用位置
|
||||
|
||||
### 需求 14:Admin-Web 前后端联调
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望通过管理后台 Web 界面触发 ETL 全流程执行,以便可视化管理数据处理任务。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 管理员在 Admin_Web 中配置 ETL 参数(全部门店、api_full、仅校验修复且校验前从 API 获取、自定义范围 2025-11-01 至 2026-02-20、窗口切分 10 天、force-full、全选常用功能), THE Backend SHALL 正确接收并解析这些参数
|
||||
2. WHEN Backend 接收到 ETL 执行请求, THE Backend SHALL 将参数转换为 ETL_System 可识别的命令并触发执行
|
||||
3. WHEN ETL 任务执行时, THE Admin_Web SHALL 实时展示任务执行状态和进度
|
||||
4. WHEN 所有选中的任务执行完成后, THE ETL_System SHALL 确保数据处理结果正确(源数据与落库数据/字段一致)
|
||||
|
||||
### 需求 15:ETL 执行计时机制
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望 ETL 执行过程中有详细的计时记录,以便分析各步骤的性能瓶颈。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL 任务开始执行, THE ETL_System SHALL 启动计时器,记录每个步骤和分步骤的开始时间
|
||||
2. WHEN 每个步骤完成时, THE ETL_System SHALL 记录该步骤的耗时(精确到毫秒)
|
||||
3. WHEN 全部任务执行完成后, THE ETL_System SHALL 输出详细颗粒度的计时结果文档,包含每个步骤名称、开始时间、结束时间、耗时
|
||||
|
||||
### 需求 16:黑盒测试机制
|
||||
|
||||
**用户故事:** 作为质量保证工程师,我希望在 ETL 全流程完成后执行黑盒测试,验证数据源与落库数据的一致性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 所有 ETL 步骤顺利完成后, THE ETL_System SHALL 以黑盒测试者角度检查数据源和落库数据/字段情况是否一致
|
||||
2. WHEN 黑盒测试执行时, THE ETL_System SHALL 对比 API 源数据与 ODS 落库数据的字段完整性
|
||||
3. WHEN 黑盒测试执行时, THE ETL_System SHALL 对比 ODS 数据与 DWD 落库数据的映射正确性
|
||||
4. WHEN 黑盒测试完成后, THE ETL_System SHALL 输出黑盒测试报告,包含每张表的检查结果、差异明细、通过/失败状态
|
||||
@@ -1,304 +0,0 @@
|
||||
# 实现计划:数据流字段补全与前后端联调
|
||||
|
||||
## 概述
|
||||
|
||||
按"先排查确认 → 再 DDL 变更 → 再代码映射 → 再移除废弃表 → 再 DWS 汇总 → 再文档精化 → 最后联调"的顺序,分阶段推进。每个表的字段补全遵循"先确认再新增"原则。
|
||||
|
||||
执行依据:`export/SYSTEM/REPORTS/field_audit/field_review_for_user.md`(逐表逐字段排查结论)
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 字段排查脚本与基础设施
|
||||
- [x] 1.1 编写字段排查脚本 `scripts/ops/field_audit.py`
|
||||
- 连接数据库,对每张目标表执行排查流程:查 DWD 现有列、查 FACT_MAPPINGS 现状、查 ODS 列、查自动映射
|
||||
- 输出排查记录表(markdown 格式),标注每个字段的排查结论和建议操作
|
||||
- 覆盖 11 张表的所有疑似缺失字段
|
||||
- _Requirements: 1.1-1.4, 2.1-2.3, 3.1-3.3, 4.1-4.3, 5.1-5.3, 6.1-6.4, 7.1-7.4, 8.1-8.4, 9.1-9.3, 10.1-10.5, 11.1-11.3_
|
||||
|
||||
- [x] 1.2 执行排查脚本,生成排查报告
|
||||
- 运行脚本,审查输出结果
|
||||
- _Requirements: 1.1-1.4_
|
||||
|
||||
- [x] 1.3 逐字段调查与推测,确认排查结论(由 Kiro 执行)
|
||||
- 结论文档:`export/SYSTEM/REPORTS/field_audit/field_review_for_user.md`
|
||||
- 脚本输出仅为线索,不能直接作为最终结论
|
||||
- 对脚本标记为"缺失"或"对不齐"的每个字段,Kiro 需逐一执行以下调查:
|
||||
- 查阅 FACT_MAPPINGS 源码,确认是否已以其他名称/表达式映射
|
||||
- 查阅 DWD DDL,确认是否已有同语义列(命名差异)
|
||||
- 查阅 ODS loader 代码,确认 ODS 列是否真实写入
|
||||
- 结合字段命名规律、上下文语义、业务逻辑进行推测
|
||||
- 必要时查询数据库实际数据(SELECT DISTINCT / 采样)辅助判断
|
||||
- 对每个字段标注最终决策:无需变更 / 仅补映射 / 新增列+映射 / 跳过(附理由)
|
||||
- 将调查过程和推测依据记录到排查报告中,确保可追溯
|
||||
- _Requirements: 1.1-1.4, 2.1-2.3, 3.1-3.3, 4.1-4.3, 5.1-5.3, 6.1-6.4, 7.1-7.4, 8.1-8.4, 9.1-9.3, 10.1-10.5, 11.1-11.3_
|
||||
|
||||
- [x] 2. Checkpoint - 排查结果确认
|
||||
- 此项已完成,最终文档为:`export/SYSTEM/REPORTS/field_audit/field_review_for_user.md`
|
||||
|
||||
- [x] 3. 🔴 映射错误修复(高优先级)
|
||||
- 依据:`field_review_for_user.md` 映射错误修复章节
|
||||
|
||||
- [x] 3.1 assistant_service_records — site_assistant_id 映射错误修正
|
||||
- 当前问题:DWD `dwd_assistant_service_log.site_assistant_id` 错误映射自 ODS `order_assistant_id`(订单级 ID),应映射自 ODS `site_assistant_id`(助教档案 ID)
|
||||
- 修正 FACT_MAPPINGS:将 DWD `site_assistant_id` 的 ODS 源从 `order_assistant_id` 改为 `site_assistant_id`
|
||||
- 新增 DWD 列 `order_assistant_id`(bigint)到 `dwd_assistant_service_log`,映射 ODS `order_assistant_id`
|
||||
- 编写迁移脚本
|
||||
- 需重新加载历史数据
|
||||
- _Requirements: 2.1, 2.2_
|
||||
|
||||
- [x] 3.2 store_goods_sales_records — discount_price 列名误导修正
|
||||
- 当前问题:DWD `discount_price` 实际映射自 ODS `discount_money`(折扣金额),而非 ODS `discount_price`(折后单价)
|
||||
- 将 DWD 列名 `discount_price` 重命名为 `discount_money`
|
||||
- 新增 DWD 列 `discount_price`(numeric),映射 ODS `discount_price`(折后单价)
|
||||
- 编写迁移脚本
|
||||
- _Requirements: 4.1, 4.2_
|
||||
|
||||
- [x] 3.3 store_goods_master — batch_stock_qty 和 provisional_total_cost 映射源修正
|
||||
- `batch_stock_qty` 的 FACT_MAPPINGS 从 `stock`(当前库存)改为 `batch_stock_quantity`(批次库存)
|
||||
- `provisional_total_cost` 的 FACT_MAPPINGS 从 `total_purchase_cost`(实际采购成本)改为 `provisional_total_cost`(暂估成本)
|
||||
- 需重新加载历史数据
|
||||
- _Requirements: 10.3_
|
||||
|
||||
- [x] 4. A 类表:新增 DWD 列 + FACT_MAPPINGS
|
||||
- 依据:`field_review_for_user.md` 各表"待新增/补映射字段"
|
||||
|
||||
- [x] 4.1 assistant_accounts_master — 新增 4 个字段到 dim_assistant_ex
|
||||
- 新增列:`system_role_id`(bigint)、`job_num`(text)、`cx_unit_price`(numeric(18,2))、`pd_unit_price`(numeric(18,2))
|
||||
- 更新 `dwd.sql`,添加 FACT_MAPPINGS 条目
|
||||
- 编写迁移脚本
|
||||
- _Requirements: 1.1, 1.2, 1.3_
|
||||
|
||||
- [x] 4.2 assistant_service_records — 新增 2 个字段到 dwd_assistant_service_log_ex
|
||||
- 新增列:`operator_id`(bigint)、`operator_name`(text)
|
||||
- 跳过 `siteprofile`(jsonb 嵌套列)
|
||||
- 更新 `dwd.sql`,添加 FACT_MAPPINGS 条目
|
||||
- 编写迁移脚本
|
||||
- _Requirements: 2.1, 2.2_
|
||||
|
||||
- [x] 4.3 assistant_cancellation_records — 更新 FACT_MAPPINGS
|
||||
- 更新映射:ODS `assistanton` → DWD `dwd_assistant_trash_event.assistant_no`
|
||||
- 跳过 `siteprofile`(jsonb 嵌套列)
|
||||
- _Requirements: 3.1_
|
||||
|
||||
- [x] 4.4 member_balance_changes — 新增 1 个字段到 dwd_member_balance_change_ex
|
||||
- 新增列:`relate_id`(bigint)— 关联业务单据 ID
|
||||
- 更新 `dwd.sql`,添加 FACT_MAPPINGS 条目
|
||||
- 编写迁移脚本
|
||||
- _Requirements: 5.1, 5.2_
|
||||
|
||||
- [x] 4.5 site_tables_master — 新增 14 个字段到 dim_table_ex
|
||||
- 新增列:`create_time`(timestamptz)、`light_status`(integer)、`tablestatusname`(text)、`sitename`(text)、`appletQrCodeUrl`(text)、`audit_status`(integer)、`charge_free`(integer)、`delay_lights_time`(integer)、`is_rest_area`(integer)、`only_allow_groupon`(integer)、`order_delay_time`(integer)、`self_table`(integer)、`temporary_light_second`(integer)、`virtual_table`(integer)
|
||||
- 更新 `dwd.sql`,添加 FACT_MAPPINGS 条目
|
||||
- 编写迁移脚本
|
||||
- _Requirements: 9.1, 9.2_
|
||||
|
||||
- [x] 4.6 tenant_goods_master — 无需变更(跳过)
|
||||
- `commoditycode` 与 `commodity_code` 100% 冗余(花括号包裹格式),已确认跳过
|
||||
- _Requirements: 11.1(已确认无需操作)_
|
||||
|
||||
- [x] 4.7 编写 A 类表字段映射属性测试
|
||||
- **Property 1: FACT_MAPPINGS 字段映射正确性**
|
||||
- **Validates: Requirements 1.1, 1.2, 2.1, 3.1, 4.1, 5.1, 9.1**
|
||||
|
||||
- [x] 5. B 类表:仅补 FACT_MAPPINGS / 修正映射
|
||||
- 依据:`field_review_for_user.md` B 类表章节
|
||||
|
||||
- [x] 5.1 recharge_settlements — 补充 5 个 FACT_MAPPINGS 条目
|
||||
- 仅补映射(DWD 列已存在,ODS/DWD 两侧数据全为 0,业务未启用):
|
||||
- `plcouponsaleamount → pl_coupon_sale_amount`
|
||||
- `mervousalesamount → mervou_sales_amount`
|
||||
- `electricitymoney → electricity_money`
|
||||
- `realelectricitymoney → real_electricity_money`
|
||||
- `electricityadjustmoney → electricity_adjust_money`
|
||||
- 无需 DDL 变更,无需迁移脚本
|
||||
- _Requirements: 6.1, 6.2, 6.3_
|
||||
|
||||
- [x] 5.2 编写 B 类表属性测试
|
||||
- **Property 2: FACT_MAPPINGS 引用完整性**
|
||||
- **Validates: Requirements 6.3**
|
||||
|
||||
- [x] 5.5. Checkpoint - 映射修复与 A/B 类表完成确认
|
||||
- 确保所有映射错误已修正、A/B 类表的 FACT_MAPPINGS、DDL、迁移脚本都已更新
|
||||
- Ask the user if questions arise.
|
||||
|
||||
- [x] 6. C 类表:新建 DWD 表与完整映射
|
||||
- 依据:`field_review_for_user.md` C 类表章节
|
||||
|
||||
- [x] 6.1 goods_stock_summary — 修改 ODS 配置 + 新建 DWD 表
|
||||
- 步骤 1:修改 ODS 任务配置 `requires_window=True` + `time_fields=("startTime", "endTime")`
|
||||
- 步骤 2:重新采集历史数据(按时间窗口分批)
|
||||
- 步骤 3:编写 DDL 创建 `dwd.dwd_goods_stock_summary`(14 个字段)
|
||||
- 步骤 4:在 `TABLE_MAP` 中注册,在 `FACT_MAPPINGS` 中添加映射
|
||||
- 步骤 5:创建 DWD loader 和 task 代码
|
||||
- 编写迁移脚本
|
||||
- _Requirements: 7.1, 7.2, 7.3_
|
||||
|
||||
- [x] 6.2 goods_stock_movements — 新建 DWD 表
|
||||
- 编写 DDL 创建 `dwd.dwd_goods_stock_movement`(19 个字段,事实表,按 createtime 增量加载)
|
||||
- 在 `TABLE_MAP` 中注册,在 `FACT_MAPPINGS` 中添加映射(驼峰 → 蛇形命名)
|
||||
- 创建 DWD loader 和 task 代码
|
||||
- 编写迁移脚本
|
||||
- _Requirements: 8.1, 8.2, 8.3_
|
||||
|
||||
- [x] 6.3 编写 C 类表属性测试
|
||||
- **Property 3: TABLE_MAP 覆盖完整性**
|
||||
- **Validates: Requirements 7.2, 8.2**
|
||||
|
||||
- [x] 7. Checkpoint - 全部字段补全完成
|
||||
- 确保所有 11 张表的字段补全工作完成,所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 7.3. 彻底移除 settlement_ticket_details
|
||||
- [x] 7.3.1 移除 ETL 核心代码中的 settlement_ticket_details
|
||||
- 从 `tasks/ods/ods_tasks.py` 中移除:`OdsTaskSpec("ODS_SETTLEMENT_TICKET", ...)`、`OdsSettlementTicketTask` 类、`ENABLED_ODS_CODES` 中的条目、`ODS_TASK_CLASSES` 覆盖
|
||||
- 从 `tasks/verification/dwd_verifier.py` 中移除主键映射条目
|
||||
- 从 `tasks/verification/ods_verifier.py` 中移除相关注释和特殊处理
|
||||
- 从 `tasks/utility/manual_ingest_task.py` 中移除表映射和配置
|
||||
- 从 `utils/json_store.py` 中移除 `/order/getordersettleticketnew` 路径映射
|
||||
- _Requirements: 17.1, 17.3_
|
||||
|
||||
- [x] 7.3.2 移除 DDL、种子数据和迁移脚本
|
||||
- 从 `db/etl_feiqiu/schemas/ods.sql` 和 `schema_ODS_doc.sql` 中移除建表语句和注释
|
||||
- 从 `db/etl_feiqiu/seeds/seed_ods_tasks.sql` 中移除 `ODS_SETTLEMENT_TICKET`
|
||||
- 编写迁移脚本:`DROP TABLE IF EXISTS ods.settlement_ticket_details`、`DROP INDEX`、`DELETE FROM meta.ods_task_registry`
|
||||
- _Requirements: 17.2, 17.4, 17.6_
|
||||
|
||||
- [x] 7.3.3 移除分析脚本和工具中的引用
|
||||
- 从 `scripts/ops/` 下移除:`gen_full_dataflow_doc.py`、`gen_field_review_doc.py`、`gen_api_field_mapping.py`、`field_audit.py`、`export_dwd_field_review.py`、`dataflow_analyzer.py`、`check_ods_latest_indexes.py` 中的相关引用
|
||||
- 从 `scripts/check/check_ods_gaps.py` 中移除 `_check_settlement_tickets` 函数及调用
|
||||
- 从 `scripts/debug/debug_blackbox.py` 中移除跳过逻辑
|
||||
- _Requirements: 17.7_
|
||||
|
||||
- [x] 7.3.4 移除文档和测试中的引用
|
||||
- 从 `docs/database/etl_feiqiu_schema_migration.md` 中移除索引条目
|
||||
- 从 `apps/etl/connectors/feiqiu/docs/etl_tasks/` 中移除任务表格条目
|
||||
- 从 `tests/unit/test_ods_tasks.py` 中移除 `test_ods_settlement_ticket_by_payment_relate_ids`
|
||||
- _Requirements: 17.5, 17.8_
|
||||
|
||||
- [x] 7.5. DWS 库存汇总(日/周/月)
|
||||
- 前置依赖:任务 6.1(goods_stock_summary DWD 表)完成、ODS 任务配置修改(`requires_window=True`)完成并重新采集数据
|
||||
|
||||
- [x] 7.5.1 编写 DWS 库存汇总 DDL 与迁移脚本
|
||||
- 在 `db/etl_feiqiu/schemas/dws.sql` 中添加三张表的建表语句:`dws_goods_stock_daily_summary`、`dws_goods_stock_weekly_summary`、`dws_goods_stock_monthly_summary`
|
||||
- 编写迁移脚本 `db/etl_feiqiu/migrations/{date}__create_dws_goods_stock_summary.sql`
|
||||
- 主键:`(site_id, stat_date, site_goods_id)`
|
||||
- 字段:site_id, tenant_id, stat_date, site_goods_id, goods_name, goods_unit, goods_category_id, goods_category_second_id, category_name, range_start_stock, range_end_stock, range_in, range_out, range_sale, range_sale_money, range_inventory, current_stock, stat_period, created_at, updated_at
|
||||
- _Requirements: 12.5, 12.6, 12.8_
|
||||
|
||||
- [x] 7.5.2 实现 DWS_GOODS_STOCK_DAILY 任务
|
||||
- 在 `apps/etl/connectors/feiqiu/tasks/dws/` 下创建 `goods_stock_daily_task.py`
|
||||
- 继承 `BaseDwsTask`,实现 `extract` / `transform` / `load` 三阶段
|
||||
- extract:从 `dwd.dwd_goods_stock_summary` 按时间范围查询
|
||||
- transform:按日粒度汇总,`stat_period='daily'`
|
||||
- load:upsert 写入 `dws.dws_goods_stock_daily_summary`
|
||||
- _Requirements: 12.2, 12.7_
|
||||
|
||||
- [x] 7.5.3 实现 DWS_GOODS_STOCK_WEEKLY 任务
|
||||
- 创建 `goods_stock_weekly_task.py`,按 ISO 周分组,`stat_date` = 周一日期,`stat_period='weekly'`
|
||||
- _Requirements: 12.3, 12.7_
|
||||
|
||||
- [x] 7.5.4 实现 DWS_GOODS_STOCK_MONTHLY 任务
|
||||
- 创建 `goods_stock_monthly_task.py`,按自然月分组,`stat_date` = 月首日期,`stat_period='monthly'`
|
||||
- _Requirements: 12.4, 12.7_
|
||||
|
||||
- [x] 7.5.5 注册 DWS 库存汇总任务到任务调度
|
||||
- 在任务注册表中添加 `DWS_GOODS_STOCK_DAILY`、`DWS_GOODS_STOCK_WEEKLY`、`DWS_GOODS_STOCK_MONTHLY`
|
||||
- 确保任务依赖关系正确(依赖 DWD goods_stock_summary 加载完成)
|
||||
- _Requirements: 12.9_
|
||||
|
||||
- [x] 7.5.6 编写 DWS 库存汇总属性测试
|
||||
- **Property 8: DWS 库存汇总粒度聚合正确性**
|
||||
- **Validates: Requirements 12.2, 12.3, 12.4, 12.5, 12.6**
|
||||
|
||||
|
||||
- [x] 8. 文档精化
|
||||
- [x] 8.1 精化 A/B/C 类表涉及的 BD_Manual 文档
|
||||
- 逐个文档、逐项排查所有"待补充""待处理""未确定""未定义"等缺失内容
|
||||
- 将"金额字段""XX 相关""XXX 类"等粗略说明替换为精确的字段语义描述
|
||||
- 通过字段名称分析、上下文推测、遍历值/枚举值分析、代码取用情况分析确定字段含义
|
||||
- 确保每个字段说明包含:字段类型、业务含义、取值范围或枚举值、在代码中的使用位置
|
||||
- _Requirements: 13.1, 13.2, 13.3, 13.4_
|
||||
|
||||
- [x] 8.2 更新 dataflow 分析报告中涉及表的字段说明
|
||||
- 同步更新 `docs/database/` 下对应的文档
|
||||
- _Requirements: 1.4, 2.3, 3.3, 4.3, 5.3, 6.4, 7.4, 8.4, 9.3, 10.5, 11.3_
|
||||
|
||||
- [x] 9. Checkpoint - 文档精化完成
|
||||
- 确保所有文档更新完毕,无遗留的"待补充"标记,ask the user if questions arise.
|
||||
|
||||
- [x] 10. Admin-Web 前后端联调
|
||||
- [x] 10.1 排查并修复后端 ETL 执行 API
|
||||
- 检查 `apps/backend/app/routers/` 中 ETL 执行相关路由
|
||||
- 确保参数解析正确:全部门店、api_full、仅校验修复且校验前从 API 获取、自定义范围 2025-11-01 至 2026-02-20、窗口切分 10 天、force-full、全选常用功能
|
||||
- 确保参数正确转换为 ETL CLI 命令
|
||||
- _Requirements: 14.1, 14.2_
|
||||
|
||||
- [x] 10.2 排查并修复前端 TaskManager 页面
|
||||
- 检查 `apps/admin-web/src/pages/TaskManager.tsx` 中的参数配置表单
|
||||
- 确保所有参数选项可正确选择和提交
|
||||
- 确保任务执行状态实时展示(WebSocket 日志流)
|
||||
- _Requirements: 14.3_
|
||||
|
||||
- [x] 10.3 实现 ETL 执行计时器模块
|
||||
- 在 `apps/etl/connectors/feiqiu/utils/` 中新增计时器工具
|
||||
- 记录每个步骤和分步骤的开始时间、结束时间、耗时(精确到毫秒)
|
||||
- 全部任务完成后输出计时结果文档
|
||||
- _Requirements: 15.1, 15.2, 15.3_
|
||||
|
||||
- [x] 10.4 编写计时器属性测试
|
||||
- **Property 7: 计时器记录完整性**
|
||||
- **Validates: Requirements 15.2**
|
||||
|
||||
- [x] 10.5 编写 ETL 参数解析属性测试
|
||||
- **Property 5: ETL 参数解析与 CLI 命令构建正确性**
|
||||
- **Validates: Requirements 14.1, 14.2**
|
||||
|
||||
- [x] 11. 黑盒测试机制
|
||||
- [x] 11.1 实现数据一致性检查器
|
||||
- 在 `apps/etl/connectors/feiqiu/quality/` 中新增一致性检查模块
|
||||
- 实现 API 源数据 vs ODS 落库数据的字段完整性对比
|
||||
- 实现 ODS 数据 vs DWD 落库数据的映射正确性对比
|
||||
- 输出黑盒测试报告(每张表的检查结果、差异明细、通过/失败状态)
|
||||
- _Requirements: 16.1, 16.2, 16.3, 16.4_
|
||||
|
||||
- [x] 11.2 编写数据一致性检查属性测试
|
||||
- **Property 6: 数据一致性检查正确性**
|
||||
- **Validates: Requirements 16.2, 16.3**
|
||||
|
||||
- [x] 12. 端到端联调验证
|
||||
- [x] 12.1 执行完整 ETL 流程并验证数据正确性
|
||||
- 将 `EtlTimer` 集成到 `orchestration/flow_runner.py` 的 `FlowRunner.run()` 方法中
|
||||
- 增量 ETL 和校验分支均包裹计时步骤(`INCREMENT_ETL`、`VERIFICATION`、`FETCH_BEFORE_VERIFY`)
|
||||
- `timer.finish(write_report=True)` 在 Flow 结束时自动输出计时报告到 `ETL_REPORT_ROOT`
|
||||
- 产出物:`export/ETL-Connectors/feiqiu/REPORTS/etl_timing_*.md`
|
||||
- _Requirements: 14.4, 15.3_
|
||||
|
||||
- [x] 12.2 执行黑盒测试并生成报告
|
||||
- 将 `ConsistencyChecker` 集成到 `FlowRunner.run()` 的 `_run_post_consistency_check()` 方法中
|
||||
- ETL Flow 完成后自动运行一致性检查(API vs ODS + ODS vs DWD),输出报告到 `ETL_REPORT_ROOT`
|
||||
- 独立验证脚本 `scripts/ops/run_post_etl_reports.py` 确认报告生成正常
|
||||
- 实际执行结果:API vs ODS 22/22 通过,ODS vs DWD 38/42 通过
|
||||
- 产出物:`export/ETL-Connectors/feiqiu/REPORTS/consistency_report_*.md`
|
||||
- _Requirements: 16.1, 16.4_
|
||||
|
||||
- [x] 12.3 前后端浏览器联调验证(2026-02-20)
|
||||
- 启动后端 `uvicorn app.main:app --reload`(localhost:8000)+ 前端 `pnpm dev`(localhost:5173)
|
||||
- 浏览器登录管理后台,进入"任务配置"页面
|
||||
- 配置:Flow=ods_dwd, 处理模式=仅增量, dry-run=✓, 本地JSON=✓, 回溯24h
|
||||
- 点击"直接执行"→ 自动跳转"任务管理 > 历史"tab
|
||||
- 验证结果:status=success, duration=22.5s, exit_code=0
|
||||
- CLI 命令正确构建:`python -m cli.main --flow ods_dwd --processing-mode increment_only --tasks DWD_LOAD_FROM_ODS --lookback-hours 24 --overlap-seconds 600 --dry-run --data-source offline --store-id 2790685415443269`
|
||||
- 执行日志实时推送到前端 Modal(WebSocket /ws/logs/{id})✅
|
||||
- 计时报告自动生成:`etl_timing_20260220_073610.md`(2步骤,总耗时20.78s)✅
|
||||
- 一致性检查报告自动生成:`consistency_report_20260220_073610.md` ✅
|
||||
- _Requirements: 14.1, 14.2, 14.3, 14.4, 15.3, 16.4_
|
||||
|
||||
- [x] 13. Final checkpoint - 全部完成
|
||||
- 确保所有字段补全、文档精化、前后端联调、黑盒测试均已完成,ask the user if questions arise.
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的任务为可选,可跳过以加速 MVP
|
||||
- 每个任务引用具体需求编号以确保可追溯
|
||||
- Checkpoint 确保增量验证
|
||||
- 属性测试验证通用正确性属性,单元测试验证具体示例和边界情况
|
||||
- 所有涉及 `loaders/`、`tasks/`、`db/` 的变更属于高风险路径,完成后需触发 `/audit`
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,569 +0,0 @@
|
||||
# 设计文档
|
||||
|
||||
## 概览
|
||||
|
||||
对 `scripts/ops/gen_full_dataflow_doc.py` 进行重构,将其从单体运维脚本升级为"Python 数据采集 + Kiro Agent 语义分析"的双层架构。
|
||||
|
||||
核心设计原则:**Python 脚本负责机械性数据准备(API 调用、JSON 展开、DB 查询),Kiro Agent 负责需要理解代码和业务上下文的语义工作(映射计算、字段作用推断、统计总结编排、报告组装)。**
|
||||
|
||||
改进要点:
|
||||
|
||||
1. **JSON 层级完整展开**:递归遍历 JSON 树,用 `.` 路径和 `[]` 数组标记展示完整层级,遍历所有记录拼合最全字段集
|
||||
2. **数据库驱动的表结构**:ODS/DWD 表结构从 `information_schema` + `pg_description` 查询,不依赖 DDL 文件
|
||||
3. **字段作用说明**:由 Kiro Agent 结合 DDL COMMENT、数据样本、ETL 源码和映射关系推断
|
||||
4. **详细统计总结**:由 Kiro Agent 编排有业务语义的字段统计和上下游映射总结
|
||||
5. **CLI 任务化**:支持 `--date-from`、`--date-to`、`--limit`、`--tables` 参数,落盘到 `SYSTEM_ANALYZE_ROOT`
|
||||
6. **Kiro Hook 集成**:`userTriggered` 类型 Hook 手动触发,先执行数据采集脚本,再由 Agent 完成语义分析
|
||||
|
||||
## 架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "触发方式"
|
||||
CLI["CLI 命令行<br/>python scripts/ops/analyze_dataflow.py"]
|
||||
HOOK["Kiro Hook<br/>userTriggered 手动触发"]
|
||||
end
|
||||
|
||||
subgraph "第一阶段:Python 数据采集"
|
||||
ANALYZER["dataflow_analyzer.py<br/>核心采集模块"]
|
||||
CLI_ENTRY["analyze_dataflow.py<br/>CLI 入口 (argparse)"]
|
||||
end
|
||||
|
||||
subgraph "数据源"
|
||||
FEIQIU_API["飞球 SaaS API"]
|
||||
PG_INFO["PostgreSQL<br/>information_schema.columns"]
|
||||
PG_COMMENT["PostgreSQL<br/>pg_catalog.pg_description"]
|
||||
end
|
||||
|
||||
subgraph "中间产物 (SYSTEM_ANALYZE_ROOT/)"
|
||||
JSON_DUMP["api_samples/<br/>各表 JSON 原始数据"]
|
||||
JSON_TREE["json_trees/<br/>各表展开后的字段结构 (JSON)"]
|
||||
DB_SCHEMA["db_schemas/<br/>ODS/DWD 表结构 (JSON)"]
|
||||
end
|
||||
|
||||
subgraph "第二阶段:Kiro Agent 语义分析"
|
||||
MAPPING["映射计算<br/>JSON→ODS, ODS→DWD"]
|
||||
PURPOSE["字段作用推断<br/>COMMENT + 源码 + 样本"]
|
||||
SUMMARY["统计总结编排"]
|
||||
REPORT["Markdown 报告组装"]
|
||||
end
|
||||
|
||||
subgraph "参考源码"
|
||||
ETL_SRC["ETL 源码<br/>loaders/ tasks/ models/ scd/"]
|
||||
end
|
||||
|
||||
subgraph "最终输出"
|
||||
MD_FILE["Markdown 报告<br/>SYSTEM_ANALYZE_ROOT/dataflow_YYYY-MM-DD_HHMMSS.md"]
|
||||
end
|
||||
|
||||
CLI --> CLI_ENTRY
|
||||
HOOK --> CLI_ENTRY
|
||||
CLI_ENTRY --> ANALYZER
|
||||
ANALYZER --> FEIQIU_API
|
||||
ANALYZER --> PG_INFO
|
||||
ANALYZER --> PG_COMMENT
|
||||
ANALYZER --> JSON_DUMP
|
||||
ANALYZER --> JSON_TREE
|
||||
ANALYZER --> DB_SCHEMA
|
||||
|
||||
JSON_TREE --> MAPPING
|
||||
DB_SCHEMA --> MAPPING
|
||||
DB_SCHEMA --> PURPOSE
|
||||
JSON_DUMP --> PURPOSE
|
||||
ETL_SRC --> PURPOSE
|
||||
ETL_SRC --> MAPPING
|
||||
MAPPING --> SUMMARY
|
||||
PURPOSE --> SUMMARY
|
||||
SUMMARY --> REPORT
|
||||
REPORT --> MD_FILE
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 1. 核心采集模块 `scripts/ops/dataflow_analyzer.py`
|
||||
|
||||
从 `gen_full_dataflow_doc.py` 重构提取,专注于机械性数据采集,不做语义推断。
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyzerConfig:
|
||||
"""采集配置,由 CLI 参数或 Hook 构造"""
|
||||
date_from: date | None = None
|
||||
date_to: date | None = None
|
||||
limit: int = 200 # 每端点最大记录数
|
||||
tables: list[str] | None = None # 指定表名,None=全部
|
||||
output_dir: Path = Path("docs/reports") # 落盘目录
|
||||
pg_dsn: str = ""
|
||||
api_base: str = ""
|
||||
api_token: str = ""
|
||||
store_id: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldInfo:
|
||||
"""JSON 字段信息(递归展开后)"""
|
||||
path: str # 完整路径,如 "data.settleList[].amount"
|
||||
json_type: str # "string" | "integer" | "number" | "boolean" | "object" | "array" | "null"
|
||||
sample: str # 样本值(截断到 60 字符)
|
||||
depth: int # 层级深度(0 为顶层)
|
||||
occurrence: int # 在所有记录中出现的次数
|
||||
total_records: int # 总记录数
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColumnInfo:
|
||||
"""数据库列信息"""
|
||||
name: str
|
||||
data_type: str
|
||||
is_nullable: bool
|
||||
column_default: str | None
|
||||
comment: str | None # DDL COMMENT 注释
|
||||
ordinal_position: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableCollectionResult:
|
||||
"""单张表的采集结果"""
|
||||
table_name: str
|
||||
task_code: str
|
||||
description: str
|
||||
endpoint: str
|
||||
record_count: int
|
||||
json_fields: OrderedDict[str, FieldInfo] # path -> FieldInfo
|
||||
ods_columns: list[ColumnInfo]
|
||||
dwd_columns: list[ColumnInfo]
|
||||
raw_records_path: Path | None # JSON 原始数据文件路径
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def flatten_json_tree(
|
||||
records: list[dict],
|
||||
) -> OrderedDict[str, FieldInfo]:
|
||||
"""
|
||||
递归展开 JSON 记录的完整层级结构。
|
||||
|
||||
算法:
|
||||
1. 对每条记录递归遍历所有嵌套层级
|
||||
2. 用 '.' 分隔符拼接路径,数组用 '[]' 标记
|
||||
3. 遍历所有记录拼合最全字段集
|
||||
4. 统计每个字段的出现频率
|
||||
|
||||
返回 path -> FieldInfo 的有序字典(按首次出现顺序)。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def _recurse_json(
|
||||
obj: Any,
|
||||
prefix: str,
|
||||
depth: int,
|
||||
field_map: dict[str, FieldInfo],
|
||||
total_records: int,
|
||||
):
|
||||
"""
|
||||
递归遍历 JSON 值,填充 field_map。
|
||||
|
||||
- dict: 遍历每个 key,路径追加 ".key"
|
||||
- list: 路径追加 "[]",遍历每个元素
|
||||
- 标量: 记录类型、样本值、出现次数
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def query_table_columns(
|
||||
conn, schema: str, table: str,
|
||||
) -> list[ColumnInfo]:
|
||||
"""
|
||||
从 information_schema.columns + pg_description 查询表结构。
|
||||
|
||||
SQL:
|
||||
SELECT c.column_name, c.data_type, c.is_nullable,
|
||||
c.column_default, c.ordinal_position,
|
||||
pgd.description AS column_comment
|
||||
FROM information_schema.columns c
|
||||
LEFT JOIN pg_catalog.pg_statio_all_tables st
|
||||
ON st.schemaname = c.table_schema
|
||||
AND st.relname = c.table_name
|
||||
LEFT JOIN pg_catalog.pg_description pgd
|
||||
ON pgd.objoid = st.relid
|
||||
AND pgd.objsubid = c.ordinal_position
|
||||
WHERE c.table_schema = %s AND c.table_name = %s
|
||||
ORDER BY c.ordinal_position;
|
||||
|
||||
返回所有列(含版本控制列如 valid_from, valid_to, is_current, fetched_at)。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def collect_all_tables(
|
||||
config: AnalyzerConfig,
|
||||
) -> list[TableCollectionResult]:
|
||||
"""
|
||||
执行完整数据采集流程:
|
||||
1. 根据 config.tables 过滤要分析的表
|
||||
2. 对每张表:调用 API 获取 JSON → flatten_json_tree 展开
|
||||
3. 对每张表:query_table_columns 查询 ODS 和 DWD 表结构
|
||||
4. 将原始 JSON 和结构化结果落盘到 config.output_dir
|
||||
5. 返回所有表的采集结果
|
||||
|
||||
错误处理:单表失败不中断,记录 error 字段继续处理其余表。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def dump_collection_results(
|
||||
results: list[TableCollectionResult],
|
||||
output_dir: Path,
|
||||
) -> dict[str, Path]:
|
||||
"""
|
||||
将采集结果序列化为 JSON 文件落盘。
|
||||
|
||||
输出结构:
|
||||
{output_dir}/
|
||||
api_samples/{table}.json — API 原始记录
|
||||
json_trees/{table}.json — 展开后的字段结构
|
||||
db_schemas/ods_{table}.json — ODS 表结构
|
||||
db_schemas/dwd_{table}.json — DWD 表结构
|
||||
collection_manifest.json — 采集清单(表名、记录数、时间戳)
|
||||
|
||||
返回 {类别: 目录路径} 的字典。
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
### 2. CLI 入口 `scripts/ops/analyze_dataflow.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
数据流结构分析 — CLI 入口
|
||||
|
||||
用法:
|
||||
python scripts/ops/analyze_dataflow.py
|
||||
python scripts/ops/analyze_dataflow.py --date-from 2025-01-01 --date-to 2025-01-15
|
||||
python scripts/ops/analyze_dataflow.py --limit 100 --tables settlement_records,payment_transactions
|
||||
"""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""
|
||||
构造 CLI 参数解析器。
|
||||
|
||||
参数:
|
||||
--date-from 数据获取起始日期 (YYYY-MM-DD)
|
||||
--date-to 数据获取截止日期 (YYYY-MM-DD)
|
||||
--limit 每端点最大记录数 (默认 200)
|
||||
--tables 要分析的表名列表 (逗号分隔,缺省=全部)
|
||||
"""
|
||||
...
|
||||
|
||||
def resolve_output_dir() -> Path:
|
||||
"""
|
||||
确定输出目录:
|
||||
1. 优先读取环境变量 SYSTEM_ANALYZE_ROOT
|
||||
2. 回退到 docs/reports/
|
||||
3. 在根目录下创建按日期组织的子目录
|
||||
"""
|
||||
...
|
||||
|
||||
def main():
|
||||
"""
|
||||
1. 解析 CLI 参数
|
||||
2. 加载环境变量(.env 分层叠加)
|
||||
3. 构造 AnalyzerConfig
|
||||
4. 调用 collect_all_tables() 执行采集
|
||||
5. 调用 dump_collection_results() 落盘
|
||||
6. 输出采集摘要到 stdout
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
### 3. Kiro Hook `.kiro/hooks/dataflow-analyze.kiro.hook`
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "数据流结构分析",
|
||||
"description": "手动触发数据流结构分析:先执行 Python 脚本采集 API JSON 和 DB 表结构,再由 Kiro Agent 基于采集数据执行语义分析(映射计算、字段作用推断、统计总结),生成 Markdown 报告。",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "userTriggered"
|
||||
},
|
||||
"then": {
|
||||
"type": "prompt",
|
||||
"prompt": "执行数据流结构分析:\n1. 先运行 `python scripts/ops/analyze_dataflow.py` 完成数据采集\n2. 读取采集结果(SYSTEM_ANALYZE_ROOT 或 docs/reports/ 下的 JSON 文件)\n3. 读取 ETL 源码(loaders/、tasks/、models/、scd/ 等模块)理解数据流转逻辑\n4. 为每个字段推断作用说明(优先使用 DDL COMMENT,结合源码和样本)\n5. 计算 JSON→ODS 和 ODS→DWD 映射关系\n6. 编排统计总结(字段覆盖率、类型分布、上下游映射)\n7. 组装最终 Markdown 报告并保存"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hook 执行流程说明:
|
||||
- `type: "userTriggered"` — 开发者在 Kiro 中手动触发
|
||||
- `type: "prompt"` — 触发后由 Kiro Agent 执行 prompt 中描述的完整流程
|
||||
- Agent 先调用 Python 脚本完成数据采集,再基于采集数据执行语义分析
|
||||
- 当前仅覆盖飞球连接器;架构预留多连接器扩展(通过 `--tables` 参数或连接器发现机制)
|
||||
|
||||
### 4. 中间数据格式
|
||||
|
||||
Python 脚本采集后落盘的 JSON 文件,供 Kiro Agent 消费。
|
||||
|
||||
**`json_trees/{table}.json`** — 展开后的字段结构:
|
||||
```json
|
||||
{
|
||||
"table": "settlement_records",
|
||||
"total_records": 200,
|
||||
"fields": [
|
||||
{
|
||||
"path": "data.settleList[].amount",
|
||||
"json_type": "number",
|
||||
"sample": "128.50",
|
||||
"depth": 2,
|
||||
"occurrence": 198,
|
||||
"total_records": 200
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`db_schemas/ods_{table}.json`** — ODS 表结构:
|
||||
```json
|
||||
{
|
||||
"schema": "ods",
|
||||
"table": "settlement_records",
|
||||
"columns": [
|
||||
{
|
||||
"name": "amount",
|
||||
"data_type": "numeric",
|
||||
"is_nullable": true,
|
||||
"column_default": null,
|
||||
"comment": "结算金额",
|
||||
"ordinal_position": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`collection_manifest.json`** — 采集清单:
|
||||
```json
|
||||
{
|
||||
"timestamp": "2025-01-15T14:30:22+08:00",
|
||||
"config": {
|
||||
"date_from": "2025-01-01",
|
||||
"date_to": "2025-01-15",
|
||||
"limit": 200,
|
||||
"tables": null
|
||||
},
|
||||
"tables": [
|
||||
{
|
||||
"table": "settlement_records",
|
||||
"task_code": "ODS_SETTLEMENT_RECORDS",
|
||||
"record_count": 200,
|
||||
"ods_column_count": 25,
|
||||
"dwd_column_count": 18,
|
||||
"error": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## 数据模型
|
||||
|
||||
### FieldInfo(JSON 字段信息)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| path | str | 完整层级路径,如 `data.settleList[].amount` |
|
||||
| json_type | str | JSON 类型:string / integer / number / boolean / object / array / null |
|
||||
| sample | str | 样本值(截断到 60 字符) |
|
||||
| depth | int | 层级深度(0 为顶层) |
|
||||
| occurrence | int | 在所有记录中出现的次数 |
|
||||
| total_records | int | 总记录数 |
|
||||
|
||||
### ColumnInfo(数据库列信息)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| name | str | 列名 |
|
||||
| data_type | str | PostgreSQL 数据类型 |
|
||||
| is_nullable | bool | 是否可空 |
|
||||
| column_default | str \| None | 默认值 |
|
||||
| comment | str \| None | DDL COMMENT 注释(来自 pg_description) |
|
||||
| ordinal_position | int | 列序号 |
|
||||
|
||||
### TableCollectionResult(单表采集结果)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| table_name | str | 表名 |
|
||||
| task_code | str | ETL 任务编码,如 `ODS_SETTLEMENT_RECORDS` |
|
||||
| description | str | 中文描述 |
|
||||
| endpoint | str | API 端点路径 |
|
||||
| record_count | int | 获取的记录数 |
|
||||
| json_fields | OrderedDict[str, FieldInfo] | 展开后的 JSON 字段结构 |
|
||||
| ods_columns | list[ColumnInfo] | ODS 表结构 |
|
||||
| dwd_columns | list[ColumnInfo] | DWD 表结构 |
|
||||
| raw_records_path | Path \| None | 原始 JSON 文件路径 |
|
||||
| error | str \| None | 错误信息(None 表示成功) |
|
||||
|
||||
### AnalyzerConfig(采集配置)
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| date_from | date \| None | None | 数据获取起始日期 |
|
||||
| date_to | date \| None | None | 数据获取截止日期 |
|
||||
| limit | int | 200 | 每端点最大记录数 |
|
||||
| tables | list[str] \| None | None | 指定表名列表,None=全部 |
|
||||
| output_dir | Path | docs/reports/ | 落盘目录 |
|
||||
| pg_dsn | str | "" | PostgreSQL 连接串 |
|
||||
| api_base | str | "" | API 基础 URL |
|
||||
| api_token | str | "" | API 认证令牌 |
|
||||
| store_id | str | "" | 门店 ID |
|
||||
|
||||
### 数据库查询 SQL
|
||||
|
||||
```sql
|
||||
-- 查询表结构(含 COMMENT 注释)
|
||||
SELECT
|
||||
c.column_name,
|
||||
c.data_type,
|
||||
c.is_nullable,
|
||||
c.column_default,
|
||||
c.ordinal_position,
|
||||
pgd.description AS column_comment
|
||||
FROM information_schema.columns c
|
||||
LEFT JOIN pg_catalog.pg_statio_all_tables st
|
||||
ON st.schemaname = c.table_schema AND st.relname = c.table_name
|
||||
LEFT JOIN pg_catalog.pg_description pgd
|
||||
ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position
|
||||
WHERE c.table_schema = %s AND c.table_name = %s
|
||||
ORDER BY c.ordinal_position;
|
||||
```
|
||||
|
||||
### Kiro Agent 语义分析输出格式
|
||||
|
||||
Agent 在消费中间数据后,生成的最终 Markdown 报告遵循以下表格结构:
|
||||
|
||||
**API 源字段表格**(每张表一个):
|
||||
|
||||
| # | JSON 路径 | 类型 | 层级 | 出现率 | 示例值 | → ODS 列 | 字段作用 | 处理 |
|
||||
|---|----------|------|------|--------|--------|----------|----------|------|
|
||||
|
||||
**ODS 表格**(每张表一个):
|
||||
|
||||
| # | 列名 | 数据类型 | 可空 | COMMENT | ← JSON 来源 | → DWD 列 | 字段作用 |
|
||||
|---|------|----------|------|---------|-------------|----------|----------|
|
||||
|
||||
**DWD 表格**(每张表一个):
|
||||
|
||||
| # | 列名 | 数据类型 | 可空 | COMMENT | ← ODS 来源 | 字段作用 | 来源类型 |
|
||||
|---|------|----------|------|---------|-------------|----------|----------|
|
||||
|
||||
> "来源类型"列标注:直接映射 / ETL 派生 / SCD2 版本控制 / 元数据
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*正确性属性是一种在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
以下属性基于需求文档中的验收标准推导,聚焦于 Python 数据采集模块(`dataflow_analyzer.py`)中可自动化测试的核心逻辑。
|
||||
|
||||
> 注:需求 3(字段作用说明)、需求 4(统计总结)的验收标准由 Kiro Agent 执行语义分析,不属于可自动化测试的代码逻辑,因此不生成正确性属性。需求 6(Kiro Hook)为平台集成配置,同样不生成属性。
|
||||
|
||||
### Property 1: JSON 递归展开路径正确性
|
||||
|
||||
*对于任意*嵌套 JSON 对象(含 dict、list、标量的任意组合),`flatten_json_tree` 的输出应满足:
|
||||
- 每个叶子节点的路径使用 `.` 分隔层级
|
||||
- 数组层级使用 `[]` 标记
|
||||
- 每个 FieldInfo 的 `depth` 等于路径中 `.` 分隔符的数量(即实际嵌套深度)
|
||||
- 输出中不遗漏任何叶子节点
|
||||
|
||||
**Validates: Requirements 1.1, 1.3, 1.4**
|
||||
|
||||
### Property 2: 多记录字段合并完整性与出现频率准确性
|
||||
|
||||
*对于任意*一组 JSON 记录列表,`flatten_json_tree` 的输出应满足:
|
||||
- 合并后的字段路径集合是所有单条记录字段路径集合的并集(不遗漏任何记录中出现过的字段)
|
||||
- 每个字段的 `occurrence` 等于实际包含该字段的记录数
|
||||
- 每个字段的 `occurrence` ≤ `total_records`
|
||||
- `total_records` 等于输入记录列表的长度
|
||||
|
||||
**Validates: Requirements 1.2, 1.5**
|
||||
|
||||
### Property 3: 输出文件名格式正确性
|
||||
|
||||
*对于任意*合法的日期时间值,生成的输出文件名应匹配模式 `dataflow_YYYY-MM-DD_HHMMSS.md`,且文件名中的日期时间与输入时间一致。
|
||||
|
||||
**Validates: Requirements 5.6**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 数据采集阶段(Python 脚本)
|
||||
|
||||
| 错误场景 | 处理策略 | 对应需求 |
|
||||
|----------|----------|----------|
|
||||
| API 请求失败(超时/HTTP 错误) | 记录错误到 `TableCollectionResult.error`,跳过该表继续处理其余表 | 2.4 |
|
||||
| 数据库连接失败 | 记录错误信息,ODS/DWD 列信息置空,不中断其余表分析 | 2.4 |
|
||||
| 表不存在(information_schema 查询返回空) | 记录警告,该表的 ods_columns/dwd_columns 为空列表 | 2.4 |
|
||||
| `SYSTEM_ANALYZE_ROOT` 未配置 | 回退到 `docs/reports/` 默认目录 | 5.5 |
|
||||
| `SYSTEM_ANALYZE_ROOT` 目录不存在 | 自动创建目录(`mkdir -p`) | 5.4 |
|
||||
| JSON 记录为空列表 | json_fields 为空 OrderedDict,record_count=0,正常落盘 | 1.2 |
|
||||
| API 返回非 JSON 响应 | 捕获 `json.JSONDecodeError`,记录错误,跳过该表 | — |
|
||||
| CLI 参数格式错误(如日期格式不合法) | argparse 自动报错退出,显示用法帮助 | 5.1 |
|
||||
|
||||
### Kiro Agent 语义分析阶段
|
||||
|
||||
| 错误场景 | 处理策略 |
|
||||
|----------|----------|
|
||||
| 采集数据文件缺失或损坏 | Agent 报告错误,建议重新运行数据采集脚本 |
|
||||
| ETL 源码结构变更导致映射推断失败 | Agent 在报告中标注"映射待确认",不阻塞其余字段分析 |
|
||||
| 某张表采集失败(error 非空) | Agent 在报告中标注该表采集失败原因,继续处理其余表 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试框架
|
||||
|
||||
- 单元测试:`pytest`
|
||||
- 属性测试:`hypothesis`(pytest 插件模式)
|
||||
- 测试位置:`tests/test_dataflow_analyzer.py`(Monorepo 级属性测试目录)
|
||||
|
||||
### 属性测试
|
||||
|
||||
使用 `hypothesis` 生成随机嵌套 JSON 结构,验证 `flatten_json_tree` 的核心正确性属性。
|
||||
|
||||
每个属性测试至少运行 100 次迭代。
|
||||
|
||||
| 属性 | 测试方法 | 生成器 |
|
||||
|------|----------|--------|
|
||||
| Property 1: JSON 递归展开路径正确性 | 生成任意嵌套 JSON(dict/list/标量组合),调用 `flatten_json_tree`,验证路径格式、`[]` 标记、depth 一致性 | `hypothesis.strategies` 递归策略生成嵌套 JSON |
|
||||
| Property 2: 多记录字段合并完整性 | 生成随机记录列表(部分字段随机缺失),调用 `flatten_json_tree`,验证并集完整性和 occurrence 准确性 | `st.lists(st.dictionaries(...))` |
|
||||
| Property 3: 输出文件名格式 | 生成随机 datetime,调用文件名生成函数,验证格式匹配 | `st.datetimes()` |
|
||||
|
||||
标注格式:
|
||||
```python
|
||||
# Feature: dataflow-structure-audit, Property 1: JSON 递归展开路径正确性
|
||||
# Validates: Requirements 1.1, 1.3, 1.4
|
||||
@given(...)
|
||||
def test_flatten_json_tree_path_correctness(...):
|
||||
...
|
||||
```
|
||||
|
||||
### 单元测试
|
||||
|
||||
针对具体示例和边界情况:
|
||||
|
||||
| 测试场景 | 验证内容 | 对应需求 |
|
||||
|----------|----------|----------|
|
||||
| 空 JSON 记录列表 | `flatten_json_tree([])` 返回空 OrderedDict | 1.2 边界 |
|
||||
| 单层 JSON(无嵌套) | 路径无 `.` 分隔,depth=0 | 1.1 |
|
||||
| 深层嵌套(5+ 层) | 路径正确拼接,depth 正确 | 1.1, 1.4 |
|
||||
| 数组内嵌套对象 | `items[].name` 格式正确 | 1.3 |
|
||||
| `SYSTEM_ANALYZE_ROOT` 环境变量存在/缺失 | 输出目录正确回退 | 5.4, 5.5 |
|
||||
| CLI 参数解析 | `--date-from`、`--date-to`、`--limit`、`--tables` 正确解析 | 5.1, 5.2, 5.3 |
|
||||
| 数据库连接失败时的错误隔离 | 单表失败不影响其余表 | 2.4 |
|
||||
| Hook 配置文件格式 | JSON 结构正确,type 为 userTriggered | 6.1 |
|
||||
@@ -1,110 +0,0 @@
|
||||
# 需求文档
|
||||
|
||||
## 简介
|
||||
|
||||
对现有 `scripts/ops/gen_full_dataflow_doc.py` 全链路数据流文档生成器进行大幅改进,使其能够:
|
||||
1. 以真实 JSON 数据(API 获取)和数据库实际表结构(`information_schema` 查询)为唯一数据源,不依赖 DDL 文件
|
||||
2. 完整展开 JSON 层级结构,遍历所有记录拼合最全字段集
|
||||
3. 为每个字段增加"字段作用"说明列,由 Kiro Agent 结合 ETL 源码和业务上下文推断
|
||||
4. 改进统计总结,由 Kiro Agent 编排有业务语义的字段统计和上下游映射总结
|
||||
5. 将数据采集脚本任务化,支持 CLI 参数(日期范围、条数),落盘到 `SYSTEM_ANALYZE_ROOT` 目录
|
||||
6. 全流程通过 Kiro Hook 手动触发,Python 脚本负责机械性数据准备,Kiro Agent 负责语义分析和报告编排
|
||||
|
||||
现有脚本输出样本参见 `export/SYSTEM/REPORTS/full_dataflow_doc/dataflow_api_ods_dwd.md`。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Analyzer**:数据采集脚本(Python 模块),负责 JSON 采集和数据库表结构查询,输出结构化中间数据供 Kiro Agent 消费
|
||||
- **Kiro Agent**:Kiro 的 AI 能力,负责映射计算、字段作用推断、统计总结编排等需要理解代码和业务上下文的工作
|
||||
- **JSON_Tree**:API 返回 JSON 数据的完整层级结构,用 `.` 分隔路径表示(如 `data.settleList[].amount`)
|
||||
- **Field_Purpose**:字段作用说明,由 Kiro Agent 结合 DDL COMMENT、JSON 数据样本、ETL 源码和 ODS/DWD 映射关系推断得出
|
||||
- **SYSTEM_ANALYZE_ROOT**:环境变量,定义分析结果落盘的根目录路径
|
||||
- **ODS_Schema**:PostgreSQL 中存储原始数据的 schema(`ods` 或 `billiards_ods`)
|
||||
- **DWD_Schema**:PostgreSQL 中存储明细数据的 schema(`dwd`)
|
||||
- **information_schema**:PostgreSQL 系统目录,提供表结构、列信息、注释等元数据
|
||||
|
||||
## 职责分工
|
||||
|
||||
| 职责 | 执行者 | 说明 |
|
||||
|------|--------|------|
|
||||
| API 调用获取 JSON 数据 | Python 脚本 | 机械性数据采集,可固化 |
|
||||
| JSON 结构递归展开 | Python 脚本 | 递归遍历、路径拼接,可固化 |
|
||||
| 数据库表结构查询 | Python 脚本 | `information_schema` + `pg_description` 查询,可固化 |
|
||||
| JSON → ODS 映射计算 | Kiro Agent | 需要理解字段语义,纯字符串匹配不可靠 |
|
||||
| ODS → DWD 映射计算 | Kiro Agent | 需要读 ETL loader/task 源码理解数据流转 |
|
||||
| 字段作用推断 | Kiro Agent | 需要结合代码上下文、业务语义、DDL COMMENT 综合判断 |
|
||||
| 统计总结编排 | Kiro Agent | 需要理解业务含义,生成有意义的总结而非纯数字 |
|
||||
| 报告 Markdown 生成 | Kiro Agent | 基于上述分析结果组装最终文档 |
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:JSON 层级结构完整展开
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望看到 API 返回 JSON 的完整层级结构,以便准确理解每个嵌套字段的位置和含义。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 1.1 WHEN Analyzer 处理 API 返回的 JSON 记录时,THEN Analyzer SHALL 递归遍历所有嵌套层级,使用 `.` 分隔符表示层级路径(如 `data.settleList[].amount`)
|
||||
- 1.2 WHEN Analyzer 分析多条 JSON 记录时,THEN Analyzer SHALL 遍历所有记录并拼合出最全字段结构,确保任何记录中出现过的字段都被包含
|
||||
- 1.3 WHEN JSON 字段为数组类型时,THEN Analyzer SHALL 展开数组内元素的结构,使用 `[]` 标记数组层级(如 `items[].name`)
|
||||
- 1.4 WHEN JSON 字段为嵌套对象时,THEN Analyzer SHALL 在 JSON 字段列中明确展示出层级缩进或路径前缀,使层级关系一目了然
|
||||
- 1.5 IF JSON 记录中某字段在部分记录中缺失,THEN Analyzer SHALL 仍将该字段纳入最全字段结构,并标注其出现频率(出现次数/总记录数)
|
||||
|
||||
### 需求 2:ODS/DWD 表结构从数据库查询
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望 ODS 和 DWD 的表结构直接从数据库查询获得,以便反映数据库的真实状态而非 DDL 文件的定义。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 2.1 WHEN Analyzer 获取 ODS 表结构时,THEN Analyzer SHALL 通过 `information_schema.columns` 联合 `pg_catalog.pg_description` 查询数据库获取列名、数据类型和列注释
|
||||
- 2.2 WHEN Analyzer 获取 DWD 表结构时,THEN Analyzer SHALL 通过 `information_schema.columns` 联合 `pg_catalog.pg_description` 查询数据库获取列名、数据类型和列注释
|
||||
- 2.3 WHEN Analyzer 列出表结构时,THEN Analyzer SHALL 列出表中所有列,包括业务字段和版本控制字段(如 `valid_from`、`valid_to`、`is_current`、`fetched_at` 等)
|
||||
- 2.4 IF 数据库连接失败或表不存在,THEN Analyzer SHALL 记录错误信息并在文档中标注该表结构获取失败,不中断其余表的分析
|
||||
|
||||
### 需求 3:字段作用说明列
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望每个字段都有作用说明,以便快速理解字段的业务含义和在数据流中的角色。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 3.1 WHEN Kiro Agent 生成 API 源字段表格、ODS 表格或 DWD 表格时,THEN Agent SHALL 为每个字段增加"字段作用"列
|
||||
- 3.2 WHEN Kiro Agent 推断字段作用时,THEN Agent SHALL 读取相关 ETL 源码(loader、task、model),结合 DDL COMMENT、JSON 数据样本和映射关系综合判断
|
||||
- 3.3 WHEN API 源字段未被 ODS 处理时,THEN Agent SHALL 在"处理"列(原"说明"列改名)中明确标注该字段被忽略且未处理
|
||||
- 3.4 WHEN ODS 或 DWD 字段有 DDL COMMENT 注释时,THEN Agent SHALL 优先使用该注释作为字段作用说明
|
||||
|
||||
### 需求 4:改进统计总结
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望每个表格结束后有详细的统计总结,以便快速掌握字段覆盖情况和上下游映射关系。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 4.1 WHEN API 源字段表格结束时,THEN Kiro Agent SHALL 生成详细统计总结,包括:总字段数、已映射到 ODS 的字段数、仅存于 payload 的字段数、被忽略的嵌套对象数、各 JSON 类型字段分布
|
||||
- 4.2 WHEN ODS 表格结束时,THEN Kiro Agent SHALL 生成统计总结,包括:总列数、业务列数、元数据列数、有上游 JSON 映射的列数、有下游 DWD 映射的列数、上下游覆盖率
|
||||
- 4.3 WHEN DWD 表格结束时,THEN Kiro Agent SHALL 生成统计总结,包括:总列数、有 ODS 来源的列数、ETL 派生列数、SCD2 版本控制列数、上游 ODS 表名和映射类型(维度/事实)
|
||||
|
||||
### 需求 5:数据采集脚本 CLI 化与落盘
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望通过 CLI 参数控制数据采集范围和输出位置,以便为 Kiro Agent 提供新鲜的结构化数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 5.1 WHEN 用户通过 CLI 执行 Analyzer 时,THEN Analyzer SHALL 支持 `--date-from` 和 `--date-to` 参数定义数据获取的日期范围
|
||||
- 5.2 WHEN 用户通过 CLI 执行 Analyzer 时,THEN Analyzer SHALL 支持 `--limit` 参数定义每个端点获取的最大记录条数(默认 200)
|
||||
- 5.3 WHEN 用户通过 CLI 执行 Analyzer 时,THEN Analyzer SHALL 支持 `--tables` 参数指定要分析的表名列表(逗号分隔),缺省时分析全部表
|
||||
- 5.4 WHEN Analyzer 确定输出目录时,THEN Analyzer SHALL 从环境变量 `SYSTEM_ANALYZE_ROOT` 读取落盘根目录,并将采集数据和最终报告保存到该目录下按日期组织的子目录中
|
||||
- 5.5 IF `SYSTEM_ANALYZE_ROOT` 未配置,THEN Analyzer SHALL 回退到默认目录 `docs/reports/`
|
||||
- 5.6 WHEN Analyzer 生成文档时,THEN Analyzer SHALL 使用包含日期和时间戳的文件名(如 `dataflow_2025-01-15_143022.md`),避免覆盖历史文件
|
||||
|
||||
### 需求 6:Kiro Hook 集成
|
||||
|
||||
**用户故事:** 作为开发者,我希望通过 Kiro Hook 手动触发完整的数据流分析流程,一键完成数据采集和语义分析。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 6.1 WHEN Hook 被配置时,THEN Hook SHALL 以 `userTriggered` 类型注册,允许开发者在 Kiro 中手动触发
|
||||
- 6.2 WHEN Hook 被触发时,THEN Hook SHALL 先调用 Python 脚本完成数据采集(API JSON + DB 表结构),再由 Kiro Agent 基于采集数据执行语义分析(映射计算、字段作用推断、总结编排)
|
||||
- 6.3 WHEN Kiro Agent 执行语义分析时,THEN Agent SHALL 读取 ETL 源码(loader、task、model、scd 等模块)理解数据流转逻辑
|
||||
- 6.4 WHEN 分析完成时,THEN Hook SHALL 将最终 Markdown 报告保存到 `SYSTEM_ANALYZE_ROOT` 目录,并输出文件路径和关键统计摘要
|
||||
- 6.5 WHEN 有 2 个及以上 ETL 连接器完成并启用时,THEN Hook SHALL 涵盖所有已启用的连接器,对每个连接器的 API → ODS → DWD 链路分别执行分析
|
||||
|
||||
> 注:当前仅有飞球(feiqiu)连接器,但架构设计需预留多连接器扩展能力。未来新增连接器时,Hook 应自动发现并纳入分析范围。
|
||||
@@ -1,90 +0,0 @@
|
||||
# 实现计划:数据流结构分析重构
|
||||
|
||||
## 概览
|
||||
|
||||
将 `scripts/ops/gen_full_dataflow_doc.py` 重构为"Python 数据采集 + Kiro Agent 语义分析"双层架构。Python 脚本负责 API JSON 采集、JSON 递归展开、DB 表结构查询和结构化落盘;Kiro Agent 负责映射计算、字段作用推断、统计总结和报告组装。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 创建核心采集模块 `scripts/ops/dataflow_analyzer.py`
|
||||
- [x] 1.1 实现数据模型(AnalyzerConfig、FieldInfo、ColumnInfo、TableCollectionResult)
|
||||
- 使用 `@dataclass` 定义四个核心数据类
|
||||
- FieldInfo 包含 path、json_type、sample、depth、occurrence、total_records
|
||||
- ColumnInfo 包含 name、data_type、is_nullable、column_default、comment、ordinal_position
|
||||
- _Requirements: 1.1, 1.5, 2.1, 2.2_
|
||||
- [x] 1.2 实现 `flatten_json_tree()` 和 `_recurse_json()` — JSON 递归展开
|
||||
- 递归遍历 dict(路径追加 `.key`)、list(路径追加 `[]`)、标量(记录类型和样本)
|
||||
- 遍历所有记录拼合最全字段集,统计每个字段的出现频率
|
||||
- 返回 OrderedDict[str, FieldInfo],按首次出现顺序排列
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||
- [x] 1.3 编写属性测试:JSON 递归展开路径正确性
|
||||
- **Property 1: JSON 递归展开路径正确性**
|
||||
- 使用 hypothesis 递归策略生成任意嵌套 JSON
|
||||
- 验证:`.` 分隔路径、`[]` 数组标记、depth 等于路径深度、不遗漏叶子节点
|
||||
- **Validates: Requirements 1.1, 1.3, 1.4**
|
||||
- [x] 1.4 编写属性测试:多记录字段合并完整性
|
||||
- **Property 2: 多记录字段合并完整性与出现频率准确性**
|
||||
- 生成随机记录列表(部分字段随机缺失),验证并集完整性和 occurrence 准确性
|
||||
- **Validates: Requirements 1.2, 1.5**
|
||||
- [x] 1.5 编写单元测试:flatten_json_tree 边界情况
|
||||
- 空记录列表、单层 JSON、深层嵌套(5+ 层)、数组内嵌套对象
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4_
|
||||
|
||||
- [x] 2. 实现数据库表结构查询
|
||||
- [x] 2.1 实现 `query_table_columns()` — 从 information_schema 查询表结构
|
||||
- 使用 `information_schema.columns` 联合 `pg_catalog.pg_description` 查询
|
||||
- 返回所有列(含版本控制字段 valid_from、valid_to、is_current、fetched_at)
|
||||
- 错误处理:连接失败或表不存在时返回空列表并记录错误
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
- [x] 2.2 实现 `collect_all_tables()` — 完整采集流程编排
|
||||
- 根据 config.tables 过滤表,逐表调用 API + flatten + query_table_columns
|
||||
- 单表失败不中断,记录 error 字段继续处理其余表
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
- [x] 2.3 实现 `dump_collection_results()` — 结构化 JSON 落盘
|
||||
- 输出 api_samples/、json_trees/、db_schemas/ 和 collection_manifest.json
|
||||
- _Requirements: 5.4, 5.6_
|
||||
|
||||
- [x] 3. 检查点 — 确保核心采集逻辑测试通过
|
||||
- 运行 `pytest tests/test_dataflow_analyzer.py -v`,确保所有测试通过,有问题请询问用户。
|
||||
|
||||
- [x] 4. 实现 CLI 入口 `scripts/ops/analyze_dataflow.py`
|
||||
- [x] 4.1 实现 `build_parser()` 和 `resolve_output_dir()`
|
||||
- argparse 解析 `--date-from`、`--date-to`、`--limit`、`--tables` 参数
|
||||
- `resolve_output_dir()` 优先读取 `SYSTEM_ANALYZE_ROOT`,回退到 `docs/reports/`
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
|
||||
- [x] 4.2 实现 `main()` — 串联采集流程
|
||||
- 加载环境变量(分层叠加)、构造 AnalyzerConfig、调用 collect_all_tables + dump_collection_results
|
||||
- 输出文件名使用 `dataflow_YYYY-MM-DD_HHMMSS.md` 格式
|
||||
- _Requirements: 5.4, 5.6_
|
||||
- [x] 4.3 编写属性测试:输出文件名格式
|
||||
- **Property 3: 输出文件名格式正确性**
|
||||
- 生成随机 datetime,验证文件名匹配 `dataflow_YYYY-MM-DD_HHMMSS.md` 模式
|
||||
- **Validates: Requirements 5.6**
|
||||
- [x] 4.4 编写单元测试:CLI 参数解析和输出目录回退
|
||||
- 测试各参数组合、默认值、SYSTEM_ANALYZE_ROOT 存在/缺失
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
|
||||
|
||||
- [x] 5. 从现有脚本迁移 API 调用和 ODS_SPECS 配置
|
||||
- [x] 5.1 将 `gen_full_dataflow_doc.py` 中的 ODS_SPECS、api_post、fetch_records 迁移到 dataflow_analyzer.py
|
||||
- 保留现有 API 调用逻辑和缓存机制
|
||||
- 适配新的 AnalyzerConfig 配置结构
|
||||
- _Requirements: 1.1, 1.2, 5.1, 5.2_
|
||||
|
||||
- [x] 6. 创建 Kiro Hook 配置
|
||||
- [x] 6.1 创建 `.kiro/hooks/dataflow-analyze.kiro.hook`
|
||||
- type: userTriggered,then.type: prompt
|
||||
- prompt 描述完整的数据采集 + 语义分析流程
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4_
|
||||
- [x] 6.2 编写单元测试:Hook 配置文件格式验证
|
||||
- 验证 JSON 结构正确、type 为 userTriggered
|
||||
- _Requirements: 6.1_
|
||||
|
||||
- [x] 7. 最终检查点 — 确保所有测试通过
|
||||
- 运行 `pytest tests/test_dataflow_analyzer.py -v`,确保所有测试通过,有问题请询问用户。
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选测试任务,可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号,确保可追溯
|
||||
- 属性测试验证通用正确性属性,单元测试验证具体示例和边界情况
|
||||
- 需求 3(字段作用推断)和需求 4(统计总结)由 Kiro Agent 在 Hook 触发时执行,不在本实现计划中(非代码任务)
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "7e1dc63d-3dbd-4462-a43c-9ecaa9b1dd07", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -1,440 +0,0 @@
|
||||
# 设计文档:DWD 业务全景梳理
|
||||
|
||||
## 概述
|
||||
|
||||
本设计文档描述如何系统梳理飞球 ETL 的 DWD 层全部业务数据,产出 5 份全景分析文档。这是一个纯文档梳理任务,不涉及代码改动,核心挑战在于:如何在"数据本源优先"的强制准则下,高效、准确地完成 7 个业务域、约 30 张表(含扩展表)的字段语义验证和跨表关联分析。
|
||||
|
||||
### 设计目标
|
||||
|
||||
1. 定义可重复执行的梳理方法论,确保每张表的分析过程一致
|
||||
2. 设计每份文档的结构模板,确保产出物格式统一
|
||||
3. 规划数据验证的技术方案,确保结论可追溯
|
||||
4. 设计文档间的引用和关联机制,避免信息重复
|
||||
|
||||
### 范围
|
||||
|
||||
- 输入:`test_etl_feiqiu` 数据库 DWD schema 的全部表和数据
|
||||
- 参考:现有 BD 手册(`apps/etl/connectors/feiqiu/docs/database/DWD/`)、已有分析报告(`docs/reports/`)
|
||||
- 产出:5 份全景文档,输出到 `docs/reports/`
|
||||
|
||||
### DWD 表全景
|
||||
|
||||
根据现有 BD 手册,DWD 层包含以下表:
|
||||
|
||||
| 类型 | 表数量 | 表名 |
|
||||
|------|--------|------|
|
||||
| 维度表(主表) | 9 | `dim_site`, `dim_table`, `dim_assistant`, `dim_member`, `dim_member_card_account`, `dim_tenant_goods`, `dim_store_goods`, `dim_goods_category`, `dim_groupbuy_package` |
|
||||
| 维度表(扩展表) | 8 | 上述除 `dim_goods_category` 外均有 `_ex` 扩展表 |
|
||||
| 事实表(主表) | 13 | `dwd_settlement_head`, `dwd_payment`, `dwd_table_fee_log`, `dwd_table_fee_adjust`, `dwd_assistant_service_log`, `dwd_member_balance_change`, `dwd_recharge_order`, `dwd_refund`, `dwd_groupbuy_redemption`, `dwd_platform_coupon_redemption`, `dwd_store_goods_sale`, `dwd_goods_stock_summary`, `dwd_goods_stock_movement` |
|
||||
| 事实表(扩展表) | 11 | 除 `dwd_payment`, `dwd_goods_stock_summary`, `dwd_goods_stock_movement` 外均有 `_ex` 扩展表 |
|
||||
|
||||
共计约 43 张表。
|
||||
|
||||
|
||||
## 架构
|
||||
|
||||
### 整体梳理流程
|
||||
|
||||
梳理工作分为两个阶段:基础层(表结构与字段语义)和全景层(跨表关联分析)。基础层产出为全景层的输入。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Phase1["阶段一:基础层梳理"]
|
||||
A[枚举 DWD 全部表] --> B[按业务域分组]
|
||||
B --> C[逐表执行智能聚焦分析]
|
||||
C --> D[字段分类筛选]
|
||||
D --> E[业务关键字段倒推验证]
|
||||
E --> F[产出:DWD 表结构与字段语义总览]
|
||||
end
|
||||
|
||||
subgraph Phase2["阶段二:全景层梳理"]
|
||||
F --> G[业务全景:消费产生机制]
|
||||
F --> H[账务全景:结算与支付]
|
||||
F --> I[财务全景:收入与对账]
|
||||
F --> J[维度表与主数据全景]
|
||||
end
|
||||
|
||||
subgraph Validation["贯穿:数据验证"]
|
||||
K[information_schema 查表结构]
|
||||
L[SQL 查询验证值域分布]
|
||||
M[交叉查询验证关联关系]
|
||||
N[对账公式全量验证]
|
||||
end
|
||||
|
||||
C -.-> K
|
||||
E -.-> L
|
||||
E -.-> M
|
||||
H -.-> N
|
||||
```
|
||||
|
||||
### 信息流向
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 输入源
|
||||
DB[(test_etl_feiqiu<br/>DWD schema)]
|
||||
BD[现有 BD 手册<br/>宏观参考]
|
||||
RPT[已有分析报告<br/>待验证假设]
|
||||
end
|
||||
|
||||
subgraph 梳理过程
|
||||
DB -->|information_schema| META[表结构元数据]
|
||||
DB -->|SQL 查询| DATA[实际数据验证]
|
||||
BD -->|宏观层可参考| REF[参考起点]
|
||||
RPT -->|字段级需验证| HYP[待验证假设]
|
||||
end
|
||||
|
||||
subgraph 产出
|
||||
META --> DOC1[文档1: 表结构总览]
|
||||
DATA --> DOC1
|
||||
REF --> DOC1
|
||||
HYP --> DOC1
|
||||
DOC1 --> DOC2[文档2: 业务全景]
|
||||
DOC1 --> DOC3[文档3: 账务全景]
|
||||
DOC1 --> DOC4[文档4: 财务全景]
|
||||
DOC1 --> DOC5[文档5: 维度表全景]
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 组件一:单表智能聚焦分析器
|
||||
|
||||
对每张 DWD 表执行标准化的分析流程,产出该表的字段语义报告。
|
||||
|
||||
#### 分析流程(每张表)
|
||||
|
||||
```
|
||||
步骤 1: 表结构获取
|
||||
→ 查询 information_schema.columns 获取列名、类型、nullable
|
||||
→ 禁止参考 db/ 目录下的 DDL .sql 文件
|
||||
|
||||
步骤 2: 字段分类筛选
|
||||
→ 查询全表空字段(SELECT column WHERE ALL NULL),标记为"空字段-跳过"
|
||||
→ 识别 ETL 管理字段(_etl_loaded_at, _etl_batch_id 等),简要标注
|
||||
→ 识别含义透明字段(id, site_id, created_at 等),仅列出
|
||||
→ 剩余为"业务关键字段",进入深度验证
|
||||
|
||||
步骤 3: 业务关键字段倒推验证
|
||||
→ 从含义明确的字段出发(如 id, site_id, total_amount)
|
||||
→ 通过 JOIN / 聚合对比 / 值域交叉推断不确定字段
|
||||
→ 金额字段:MIN/MAX/AVG/中位数/NULL占比 + 交叉验证
|
||||
→ 枚举字段:DISTINCT 值 + 频次分布
|
||||
→ 关联 ID:JOIN 验证关联完整性
|
||||
|
||||
步骤 4: 偏差检测
|
||||
→ 对比现有 BD 手册的字段描述
|
||||
→ 标注一致/偏差/错误
|
||||
```
|
||||
|
||||
#### 输出格式(每张表)
|
||||
|
||||
```markdown
|
||||
### {table_name}
|
||||
|
||||
**业务职责**:一句话描述
|
||||
**数据状态**:{行数} 行,时间范围 {min_date} ~ {max_date}
|
||||
**主键**:{pk_fields}
|
||||
**关联表**:{related_tables with join fields}
|
||||
|
||||
#### 业务关键字段
|
||||
|
||||
| 字段名 | 类型 | 验证状态 | 语义说明 | 值域/分布 |
|
||||
|--------|------|----------|----------|-----------|
|
||||
| ... | ... | ✅/⚠️/❌ | ... | ... |
|
||||
|
||||
#### 空字段(附录)
|
||||
{列出全 NULL 的字段名}
|
||||
|
||||
#### 偏差记录
|
||||
{与现有文档不一致的地方}
|
||||
```
|
||||
|
||||
### 组件二:全景文档生成器
|
||||
|
||||
基于单表分析结果,按业务视角组织跨表关联分析。
|
||||
|
||||
#### 全景文档通用模板
|
||||
|
||||
```markdown
|
||||
# {全景文档标题}
|
||||
|
||||
> 数据来源:test_etl_feiqiu (DWD schema)
|
||||
> 验证日期:{date}
|
||||
> 数据时间范围:{min_date} ~ {max_date}
|
||||
|
||||
## 目录
|
||||
{自动生成}
|
||||
|
||||
## 正文
|
||||
{按业务逻辑组织的分析内容}
|
||||
{每个关键结论标注验证状态:✅ 已验证 / ⚠️ 部分验证 / ❌ 未验证}
|
||||
|
||||
## 附录
|
||||
### 验证 SQL
|
||||
{关键验证查询}
|
||||
### 数据样例
|
||||
{来自测试库的真实数据}
|
||||
```
|
||||
|
||||
### 组件三:数据验证引擎
|
||||
|
||||
贯穿整个梳理过程的验证机制。
|
||||
|
||||
#### 验证类型
|
||||
|
||||
| 验证类型 | 方法 | 适用场景 |
|
||||
|----------|------|----------|
|
||||
| 值域验证 | MIN/MAX/AVG/MEDIAN/NULL% | 金额字段、数值字段 |
|
||||
| 枚举验证 | DISTINCT + COUNT | 状态字段、类型字段 |
|
||||
| 关联验证 | LEFT JOIN + NULL 检查 | 外键关联完整性 |
|
||||
| 等式验证 | SUM 对比 | 对账公式(F1~F6 等) |
|
||||
| 交叉验证 | 多表 JOIN + 聚合对比 | 跨表金额一致性 |
|
||||
| 边界验证 | WHERE = 0 / < 0 / IS NULL | 异常值业务含义 |
|
||||
|
||||
#### 验证结果标注规范
|
||||
|
||||
- ✅ 已验证:附验证 SQL 摘要或结果统计
|
||||
- ⚠️ 部分验证:附已知例外数量和分类
|
||||
- ❌ 未验证:附原因(数据不足/无法关联/逻辑不明)
|
||||
- ⚠️ 警告:经多次交叉验证仍无法对齐的数据关系
|
||||
|
||||
|
||||
## 数据模型
|
||||
|
||||
### DWD 表按业务域分组
|
||||
|
||||
本次梳理将 DWD 层全部表按 7 个业务域组织,每个域内的表构成一个分析单元。
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
%% 结算域
|
||||
dwd_settlement_head ||--o{ dwd_payment : "order_settle_id"
|
||||
dwd_settlement_head ||--o{ dwd_table_fee_log : "order_settle_id"
|
||||
dwd_settlement_head ||--o{ dwd_store_goods_sale : "order_settle_id"
|
||||
dwd_settlement_head ||--o{ dwd_assistant_service_log : "order_settle_id"
|
||||
dwd_settlement_head ||--o{ dwd_platform_coupon_redemption : "order_settle_id"
|
||||
dwd_settlement_head ||--o{ dwd_refund : "order_settle_id"
|
||||
|
||||
%% 台桌域
|
||||
dim_table ||--o{ dwd_table_fee_log : "table_id"
|
||||
dwd_table_fee_log ||--o{ dwd_table_fee_adjust : "table_fee_log_id"
|
||||
|
||||
%% 助教域(作废判断已内聚到 dwd_assistant_service_log_ex.is_trash)
|
||||
dim_assistant ||--o{ dwd_assistant_service_log : "assistant_id"
|
||||
|
||||
%% 会员域
|
||||
dim_member ||--o{ dim_member_card_account : "member_id"
|
||||
dim_member ||--o{ dwd_member_balance_change : "member_id"
|
||||
dim_member_card_account ||--o{ dwd_recharge_order : "tenant_member_card_id"
|
||||
|
||||
%% 团购域
|
||||
dim_groupbuy_package ||--o{ dwd_groupbuy_redemption : "groupbuy_package_id"
|
||||
|
||||
%% 商品域
|
||||
dim_goods_category ||--o{ dim_tenant_goods : "category_id"
|
||||
dim_tenant_goods ||--o{ dim_store_goods : "tenant_goods_id"
|
||||
dim_store_goods ||--o{ dwd_store_goods_sale : "site_goods_id"
|
||||
dim_store_goods ||--o{ dwd_goods_stock_summary : "site_goods_id"
|
||||
dim_store_goods ||--o{ dwd_goods_stock_movement : "site_goods_id"
|
||||
|
||||
%% 门店维度
|
||||
dim_site ||--o{ dwd_settlement_head : "site_id"
|
||||
```
|
||||
|
||||
### 业务域与表映射
|
||||
|
||||
| 业务域 | 事实表 | 维度表 | 核心关联 |
|
||||
|--------|--------|--------|----------|
|
||||
| 结算 | `dwd_settlement_head`(+ex), `dwd_payment`, `dwd_refund`(+ex) | — | 结算单是所有消费的汇总入口 |
|
||||
| 台桌 | `dwd_table_fee_log`(+ex), `dwd_table_fee_adjust`(+ex) | `dim_table`(+ex) | 台费计费流水 → 台费调整 |
|
||||
| 助教 | `dwd_assistant_service_log`(+ex) | `dim_assistant`(+ex) | 助教服务流水(作废通过 `_ex.is_trash` 判断) |
|
||||
| 会员 | `dwd_member_balance_change`(+ex), `dwd_recharge_order`(+ex) | `dim_member`(+ex), `dim_member_card_account`(+ex) | 充值 → 余额变动 |
|
||||
| 团购 | `dwd_groupbuy_redemption`(+ex), `dwd_platform_coupon_redemption`(+ex) | `dim_groupbuy_package`(+ex) | 团购核销 → 平台券核销 |
|
||||
| 商品 | `dwd_store_goods_sale`(+ex) | `dim_tenant_goods`(+ex), `dim_store_goods`(+ex), `dim_goods_category` | 商品销售流水 |
|
||||
| 库存 | `dwd_goods_stock_summary`, `dwd_goods_stock_movement` | (复用商品域维度表) | 库存汇总 + 变动流水 |
|
||||
|
||||
### 5 份文档的数据依赖关系
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
DOC1["文档1: DWD 表结构与字段语义总览<br/>覆盖全部 43 张表"]
|
||||
|
||||
DOC1 --> DOC2["文档2: 业务全景<br/>消费产生机制"]
|
||||
DOC1 --> DOC3["文档3: 账务全景<br/>结算与支付流水"]
|
||||
DOC1 --> DOC4["文档4: 财务全景<br/>收入确认与对账"]
|
||||
DOC1 --> DOC5["文档5: 维度表与主数据全景<br/>全部维度表"]
|
||||
|
||||
DOC2 -->|消费构成| DOC3
|
||||
DOC2 -->|消费金额| DOC4
|
||||
DOC3 -->|支付渠道| DOC4
|
||||
DOC5 -->|维度关联| DOC2
|
||||
DOC5 -->|维度关联| DOC3
|
||||
```
|
||||
|
||||
### 文档产出路径与文件名
|
||||
|
||||
| 序号 | 文档 | 文件名 | 路径 |
|
||||
|------|------|--------|------|
|
||||
| 1 | DWD 表结构与字段语义总览 | `dwd-table-structure-overview.md` | `docs/reports/` |
|
||||
| 2 | 业务全景:消费产生机制 | `dwd-business-panorama.md` | `docs/reports/` |
|
||||
| 3 | 账务全景:结算与支付流水 | `dwd-accounting-panorama.md` | `docs/reports/` |
|
||||
| 4 | 财务全景:收入确认与对账 | `dwd-financial-panorama.md` | `docs/reports/` |
|
||||
| 5 | 维度表与主数据全景 | `dwd-dimension-panorama.md` | `docs/reports/` |
|
||||
|
||||
### 文档间引用规范
|
||||
|
||||
- 文档间使用相对路径引用:`[表结构总览](./dwd-table-structure-overview.md#table_name)`
|
||||
- 引用已有分析报告:`[消费金额口径分析](./consume-money-caliber-deep-analysis.md#章节名)`
|
||||
- 引用 BD 手册:`[BD 手册](../../apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_xxx.md)`
|
||||
- 引用结论时标注验证状态和来源文档
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是一种在系统所有合法执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: DWD 表覆盖完整性
|
||||
|
||||
*对于* DWD schema(`test_etl_feiqiu.dwd`)中 `information_schema.tables` 返回的任意表,产出的文档集合中必须包含对该表的描述段落(表名出现在某份文档的标题或表格中)。
|
||||
|
||||
**Validates: Requirements 1.1, 5.1**
|
||||
|
||||
### Property 2: 主键标注准确性
|
||||
|
||||
*对于* DWD schema 中的任意表,文档中记录的主键字段集合必须与 `information_schema.table_constraints` + `key_column_usage` 查询返回的实际主键约束一致。
|
||||
|
||||
**Validates: Requirements 1.5**
|
||||
|
||||
### Property 3: 业务环节数据佐证
|
||||
|
||||
*对于* 业务全景文档(文档2)中描述的任意业务环节段落,该段落必须包含至少一个来自测试库的数据样例(以代码块或表格形式呈现的查询结果)。
|
||||
|
||||
**Validates: Requirements 2.6**
|
||||
|
||||
### Property 4: 对账公式验证一致性
|
||||
|
||||
*对于* 账务全景文档(文档3)中列出的任意对账公式,文档中标注的成立率和例外数量必须与在 `test_etl_feiqiu` 全量数据上执行该公式验证 SQL 的实际结果一致。
|
||||
|
||||
**Validates: Requirements 3.5, 6.2**
|
||||
|
||||
### Property 5: 文档元数据完整性
|
||||
|
||||
*对于* 产出的任意全景文档,文档开头必须包含:(a) 数据来源标注(`test_etl_feiqiu`)、(b) 验证日期、(c) 数据时间范围(最早和最晚记录的时间)。
|
||||
|
||||
**Validates: Requirements 6.4**
|
||||
|
||||
### Property 6: 文档输出路径正确性
|
||||
|
||||
*对于* 本次梳理产出的任意文档文件,其路径必须位于 `docs/reports/` 目录下。
|
||||
|
||||
**Validates: Requirements 7.1**
|
||||
|
||||
### Property 7: 文档模板一致性
|
||||
|
||||
*对于* 产出的任意全景文档,其结构必须包含以下模板元素:标题、数据来源与验证日期块、目录、正文、附录(含验证 SQL 或数据样例)。
|
||||
|
||||
**Validates: Requirements 7.3**
|
||||
|
||||
### Property 8: 内部链接格式
|
||||
|
||||
*对于* 产出文档中的任意内部链接(指向本项目其他 markdown 文件的链接),链接必须使用相对路径格式(以 `./` 或 `../` 开头),且目标文件实际存在。
|
||||
|
||||
**Validates: Requirements 7.5**
|
||||
|
||||
|
||||
## 错误处理
|
||||
|
||||
本任务是纯文档梳理,不涉及运行时代码。"错误"主要指梳理过程中遇到的数据异常和验证失败。
|
||||
|
||||
### 数据异常处理策略
|
||||
|
||||
| 异常场景 | 处理方式 | 文档标注 |
|
||||
|----------|----------|----------|
|
||||
| 表无数据(0 行) | 跳过字段语义验证,仅记录表结构 | `❌ 未验证:表无数据` |
|
||||
| 数据量不足(<10 行) | 执行有限验证,标注样本量不足 | `⚠️ 部分验证:仅 N 行数据` |
|
||||
| 字段全 NULL | 标记为空字段,不展开分析 | 附录中列出字段名 |
|
||||
| 对账公式不成立 | 分析例外案例,量化影响范围 | `⚠️ 成立率 X%,例外 N 笔` |
|
||||
| 交叉验证矛盾 | 记录矛盾细节,标注为不确定 | `⚠️ 警告:无法对齐` |
|
||||
| 现有文档与数据不一致 | 以数据为准,记录偏差 | `偏差记录` 段落 |
|
||||
|
||||
### 不确定性升级机制
|
||||
|
||||
当遇到以下情况时,必须在文档中以 `⚠️ 警告` 醒目标记:
|
||||
|
||||
1. 经过 ≥3 次不同角度的交叉验证仍无法确认的字段含义
|
||||
2. 对账公式成立率 < 95% 且无法归因的例外
|
||||
3. 金额字段的计算关系无法通过任何已知公式解释
|
||||
4. 枚举值在现有文档中未记录且无法通过数据推断含义
|
||||
|
||||
警告内容须包含:已尝试的验证方法、无法确认的具体原因、建议的后续验证方向。
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试方法说明
|
||||
|
||||
本任务的产出物是 markdown 文档而非代码,因此传统的单元测试和属性测试需要适配为"文档正确性验证"。
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
使用 Python + `hypothesis` 库,针对设计文档中定义的正确性属性编写验证脚本。
|
||||
|
||||
**测试库**:`hypothesis`(项目已有,见 `tests/` 目录)
|
||||
**最低迭代次数**:100 次(对于涉及随机采样的属性)
|
||||
**测试位置**:`tests/` 目录(Monorepo 级属性测试)
|
||||
|
||||
#### 属性测试实现方案
|
||||
|
||||
| Property | 测试方法 | 实现思路 |
|
||||
|----------|----------|----------|
|
||||
| P1: 表覆盖完整性 | 查询 information_schema → 解析文档 → 比对 | 从数据库获取全部 DWD 表名,解析 5 份文档提取提及的表名,验证覆盖率 = 100% |
|
||||
| P2: 主键标注准确性 | 查询 PK 约束 → 解析文档 → 比对 | 对于随机采样的 N 张表,比对文档中的主键与数据库实际主键 |
|
||||
| P3: 业务环节数据佐证 | 解析文档段落 → 检查数据样例 | 解析业务全景文档的每个业务环节段落,验证包含代码块或数据表格 |
|
||||
| P4: 对账公式验证一致性 | 提取公式 → 执行 SQL → 比对成立率 | 对于文档中的每个对账公式,重新执行验证 SQL,比对成立率 |
|
||||
| P5: 文档元数据完整性 | 解析文档头部 → 检查必要字段 | 对于每份文档,检查开头是否包含数据来源、验证日期、时间范围 |
|
||||
| P6: 文档路径正确性 | 列出产出文件 → 检查路径 | 验证所有产出文件位于 `docs/reports/` |
|
||||
| P7: 文档模板一致性 | 解析文档结构 → 检查模板元素 | 对于每份文档,检查是否包含标题、元数据块、目录、正文、附录 |
|
||||
| P8: 内部链接格式 | 正则提取链接 → 检查格式和目标 | 提取所有 markdown 链接,验证使用相对路径且目标文件存在 |
|
||||
|
||||
#### 属性测试标签格式
|
||||
|
||||
每个属性测试必须包含注释标签:
|
||||
|
||||
```python
|
||||
# Feature: dwd-business-panorama, Property 1: DWD 表覆盖完整性
|
||||
# Feature: dwd-business-panorama, Property 2: 主键标注准确性
|
||||
# ...
|
||||
```
|
||||
|
||||
### 单元测试(示例测试)
|
||||
|
||||
针对 prework 中标记为 `yes - example` 的验收标准:
|
||||
|
||||
| 验收标准 | 测试内容 |
|
||||
|----------|----------|
|
||||
| 1.6 SCD2 字段标注 | 检查 `dim_member` 文档中是否标注了 `scd2_start_time`, `scd2_end_time`, `scd2_is_current`, `scd2_version` |
|
||||
| 2.2 消费类目覆盖 | 检查业务全景文档中是否包含"台费"、"商品消费"、"助教服务"、"灯控电费"四个关键词 |
|
||||
| 2.5 团购三层价格 | 检查文档中是否提及 `sale_price`、`pl_coupon_sale_amount`、`coupon_amount` |
|
||||
| 2.7 Mermaid 流程图 | 检查业务全景文档中是否包含 ` ```mermaid` 代码块 |
|
||||
| 3.7 consume_money 三种口径 | 检查文档中是否包含口径 A、B、C 的描述 |
|
||||
| 4.5 对账矩阵 | 检查财务全景文档中是否包含矩阵格式的表格 |
|
||||
| 7.2 5 份文档产出 | 检查 `docs/reports/` 下是否存在 5 个指定文件名 |
|
||||
| 7.4 Mermaid 图表 | 检查每份文档中是否包含至少一个 Mermaid 代码块 |
|
||||
|
||||
### 边界条件测试
|
||||
|
||||
| 验收标准 | 边界场景 |
|
||||
|----------|----------|
|
||||
| 1.7 表无数据 | 验证无数据表在文档中有"数据不足"标注 |
|
||||
|
||||
### 测试执行
|
||||
|
||||
```bash
|
||||
# 属性测试(Monorepo 级)
|
||||
cd C:\NeoZQYY && pytest tests/test_dwd_panorama_properties.py -v
|
||||
|
||||
# 单元测试
|
||||
cd C:\NeoZQYY && pytest tests/test_dwd_panorama_examples.py -v
|
||||
```
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
# 需求文档:DWD 业务全景梳理
|
||||
|
||||
## 简介
|
||||
|
||||
从 DWD 层出发,系统梳理飞球 ETL 对台球厅所有业务数据的记录方式。不仅覆盖每个字段的含义,还要搞清楚字段间的关联性,最终产出覆盖业务全景、账务全景、财务全景三个维度的分析文档。
|
||||
|
||||
现有的 `consume-money-caliber-deep-analysis.md` 和 `consumption-cases-analysis.md` 已深入分析了结算/支付相关的 DWD 表,但仅从支付订单金额角度无法搞清楚整个球房的业务、财务、账务全貌。本 SPEC 旨在补全剩余的业务域(台桌、助教、会员、团购、商品、库存),并将所有域串联成三个全景视图。
|
||||
|
||||
## ⚠️ 核心准则:数据本源优先(强制)
|
||||
|
||||
**启动本 SPEC 的根本原因**:项目中现有的所有文档(包括 `consume-money-caliber-deep-analysis.md`、`consumption-cases-analysis.md`、BD 手册、ETL 代码注释等)都可能存在纰漏、过期、遗漏或与数据库现实不符的情况。因此本次梳理必须遵循以下强制准则:
|
||||
|
||||
### 信息可信度分层
|
||||
|
||||
| 层级 | 可信度 | 说明 | 使用方式 |
|
||||
|------|--------|------|----------|
|
||||
| 宏观/直观层 | ✅ 可参考 | 表的业务归属、业务域划分、主要关联方向、流程大框架等直观明显的信息 | 直接参考作为起点,无需逐一验证 |
|
||||
| 字段级/数据关联层 | ⚠️ 必须验证 | 字段语义、金额计算规则、枚举值含义、跨表关联关系等细节 | 慎之又慎,必须通过测试库数据关联做推理验证,不可直接采信 |
|
||||
|
||||
### 强制准则
|
||||
|
||||
1. **数据库是唯一真相源(字段级)**:字段级别的结论必须以测试库(`test_etl_feiqiu`)的实际表结构和数据为准。表结构信息必须通过查询 `information_schema` 或 `pg_catalog` 获取,禁止参考 `db/` 目录下的 DDL `.sql` 文件。宏观层面的信息(表的职责、业务域归属、流程大框架)可参考现有文档作为起点
|
||||
2. **先查后写,禁止臆断**:描述任何字段语义、业务规则、金额关系之前,必须先通过 SQL 查询验证实际数据分布和值域。未经查询验证的结论禁止写入文档
|
||||
3. **刨根问底,通过数据关联做推理**:对每个金额字段、状态枚举、关联关系,不能停留在"看起来是什么",必须通过交叉查询、边界案例、异常值分析确认其真实业务含义。从含义明确的字段出发,通过数据关联倒推不确定的字段
|
||||
4. **现有文档作为假设而非事实**:引用现有文档的字段级结论时,必须标注为"待验证假设",并在验证后标注实际结果(一致/偏差/错误)
|
||||
5. **偏差必须显式记录**:当验证发现现有文档与数据库现实不一致时,必须在新文档中明确记录偏差内容、偏差原因(如果能推断)、以及修正后的正确描述
|
||||
6. **无数据不下结论**:如果测试库中某张表无数据或数据量不足以支撑结论,必须明确标注"数据不足,无法验证",禁止基于推测填充内容
|
||||
7. **不确定性必须显式警告**:对于以下情况,必须在文档中以醒目的 `⚠️ 警告` 标记:(a) 经过长时间推理和多次交叉验证仍无法对齐的数据关系;(b) 根据现有数据和依据无法确认的字段含义或业务规则。警告内容须说明:已尝试的验证方法、无法确认的具体原因、建议的后续验证方向
|
||||
|
||||
## 术语表
|
||||
|
||||
- **DWD_层**:Data Warehouse Detail 层,ETL 管道中的明细数据层,存储经过清洗和标准化的业务事实和维度数据
|
||||
- **全景文档**:按特定视角(业务/账务/财务)组织的、覆盖所有相关 DWD 表及其字段关联的分析文档
|
||||
- **梳理器**:执行本 SPEC 任务的分析人员或脚本,负责读取 DWD 表结构和数据并产出文档
|
||||
- **测试库**:PostgreSQL 数据库 `test_etl_feiqiu`,包含 DWD 层的实际数据,用于验证文档描述的准确性
|
||||
- **业务域**:DWD 表按业务功能的分组,包括:结算、台桌、助教、会员、团购、商品、库存
|
||||
- **主表/扩展表**:DWD 层的表设计模式,主表存核心字段,`_ex` 扩展表存补充字段,通过主键 1:1 关联
|
||||
- **维度表**:以 `dim_` 前缀命名的 DWD 表,存储缓慢变化的主数据(SCD2 模式)
|
||||
- **事实表**:以 `dwd_` 前缀命名的 DWD 表,存储业务事件流水(增量写入)
|
||||
- **字段语义**:字段在实际业务中的真实含义,可能与 DDL 注释不一致(如 `point_amount` 实际是线上收款而非积分)
|
||||
- **对账公式**:用于校验数据一致性的等式关系,如 F1(消费构成)、F2(收支平衡)
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:DWD 表结构与字段语义梳理(智能聚焦策略)
|
||||
|
||||
**用户故事:** 作为数据分析人员,我希望获得每张 DWD 表中有业务意义的字段的准确语义说明,以便理解数据如何记录业务事实。
|
||||
|
||||
**梳理策略说明:** `apps/etl/connectors/feiqiu/docs/database/DWD/` 下已有各表的文档,宏观层面(表的职责、业务域归属、主要关联关系)可作为参考起点。但字段级别的语义必须通过数据关联倒推验证,不可直接采信。梳理时采用"智能聚焦"策略,而非逐字段全量罗列。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 梳理器 SHALL 覆盖 DWD 层全部 7 个业务域(结算、台桌、助教、会员、团购、商品、库存)的所有主表和扩展表,以现有 DWD 文档为宏观参考起点
|
||||
2. THE 梳理器 SHALL 对每张表先执行字段分类筛选:(a) 查询全表空字段(全部为 NULL 的列),标记为"空字段-跳过";(b) 识别含义明确的基础字段(如 `id`、`site_id`、`created_at`),简要标注即可;(c) 聚焦于业务关键字段(金额、状态、类型、关联 ID),进行深度验证
|
||||
3. WHEN 梳理业务关键字段时,THE 梳理器 SHALL 采用"倒推法":先从含义明确的字段出发,通过数据关联(JOIN、聚合对比、值域交叉)推断不确定字段的真实含义
|
||||
4. WHEN 发现字段的实际业务含义与现有 DWD 文档或 DDL 注释不一致时,THE 梳理器 SHALL 明确标注偏差内容并给出基于测试库数据验证的修正说明
|
||||
5. THE 梳理器 SHALL 标注每张表的主键、外键关联、以及与其他 DWD 表的关联方式(关联字段和关联类型如 1:1、1:N)
|
||||
6. THE 梳理器 SHALL 标注每张维度表的 SCD2 生效/失效字段和当前记录标识字段
|
||||
7. IF 测试库中某张表无数据或数据量不足以验证字段语义,THEN THE 梳理器 SHALL 在文档中标注该表的数据状态并说明无法验证的字段
|
||||
8. THE 梳理器 SHALL 主动忽略以下字段类别,不在文档中详细展开:(a) 全表 NULL 的空字段(仅在附录中列出字段名);(b) ETL 管理字段(如 `_etl_loaded_at`、`_etl_batch_id`),仅简要说明用途;(c) 含义完全透明且无歧义的字段(如 `id`、`created_at`、`updated_at`),仅在表结构概览中列出
|
||||
|
||||
### 需求 2:业务全景文档——消费是怎么产生的
|
||||
|
||||
**用户故事:** 作为门店经营者,我希望搞清楚台球厅的消费是怎么来的、价格怎么报的、优惠怎么产生的,以便理解整个消费链路。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 全景文档 SHALL 描述从顾客开台到结算的完整业务流程,标注每个环节涉及的 DWD 表和关键字段
|
||||
2. THE 全景文档 SHALL 覆盖以下消费类目的产生机制:台费(含多台桌合并)、商品消费、助教服务(陪打/超休)、灯控电费
|
||||
3. THE 全景文档 SHALL 描述台费的计价规则,包括:台桌类型与单价的关系、计时方式、台费折扣(`dwd_table_fee_adjust`)的触发条件和计算方式
|
||||
4. THE 全景文档 SHALL 描述优惠的产生机制,包括:平台团购券(美团/抖音)的核销流程、会员折扣的计算方式、台费调整(`adjust_amount`)的业务场景
|
||||
5. THE 全景文档 SHALL 描述团购券的三层价格体系(顾客支付给平台的 `sale_price`、平台结算给门店的 `pl_coupon_sale_amount`、门店实际抵扣的 `coupon_amount`),并标注每层价格对应的 DWD 表和字段
|
||||
6. WHEN 描述某个业务环节时,THE 全景文档 SHALL 提供至少一个来自测试库的真实数据样例作为佐证
|
||||
7. THE 全景文档 SHALL 以 Mermaid 流程图展示从开台到结算的完整数据流向
|
||||
|
||||
### 需求 3:账务全景文档——客户怎么结算的
|
||||
|
||||
**用户故事:** 作为门店财务人员,我希望搞清楚客户的每一笔消费是通过什么方式结算的、支付流水怎么记录的,以便进行对账和核销。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 全景文档 SHALL 描述所有支付渠道及其在 DWD 层的记录方式,包括:线上收款(微信/支付宝)、现金、储值卡余额(含礼品卡/充值卡)、平台团购券
|
||||
2. THE 全景文档 SHALL 描述 `dwd_payment` 表与 `dwd_settlement_head` 的关联方式,以及 `payment_method` 枚举值与实际支付渠道的对应关系
|
||||
3. THE 全景文档 SHALL 描述会员储值卡体系,包括:充值流程(`dwd_recharge_order`)、余额变动记录(`dwd_member_balance_change`)、本金/赠送金额的分账逻辑
|
||||
4. THE 全景文档 SHALL 描述退款流程,包括:结算退款、充值退款、转账退款的触发场景和在 DWD 层的记录方式
|
||||
5. THE 全景文档 SHALL 列出所有已验证的对账公式(F1~F6、R1~R3、RF1~RF2、B1~B4),标注每个公式的适用范围、成立率、以及已知的例外情况
|
||||
6. WHEN 描述支付方式推断逻辑时,THE 全景文档 SHALL 提供完整的推断规则(因 `settlement_head_ex.payment_method` 全部为 0 不可用)
|
||||
7. THE 全景文档 SHALL 描述 `consume_money` 字段的三种历史口径(A/B/C)及其时间线,标注当前生效的口径
|
||||
|
||||
### 需求 4:财务全景文档——收入确认与对账
|
||||
|
||||
**用户故事:** 作为门店管理者,我希望搞清楚门店的收入如何确认、各渠道的资金如何对账,以便进行财务分析和经营决策。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 全景文档 SHALL 描述门店收入的构成,按收入来源分类:台费收入、商品收入、助教服务收入、充值收入
|
||||
2. THE 全景文档 SHALL 描述每种收入来源对应的 DWD 表、关键金额字段、以及从 DWD 到 DWS 的聚合路径
|
||||
3. THE 全景文档 SHALL 描述平台团购券场景下的收入确认逻辑:门店实际收入 = `pl_coupon_sale_amount`(平台结算额),而非 `coupon_amount`(券面值抵扣额),差额为门店补贴
|
||||
4. THE 全景文档 SHALL 描述储值卡充值场景的资金流向:充值收款 → 余额入账(本金+赠送)→ 消费扣款 → 退款(如有),标注每个环节的 DWD 表和金额字段
|
||||
5. THE 全景文档 SHALL 提供按支付渠道的对账矩阵,列出每种支付渠道在 DWD 层涉及的表和字段,以及跨表校验的公式
|
||||
6. THE 全景文档 SHALL 标注已知的数据质量问题和对账例外(如助教券的支付缺口、商品消费未覆盖等),并给出影响范围的量化评估
|
||||
|
||||
### 需求 5:维度表与主数据全景
|
||||
|
||||
**用户故事:** 作为数据开发人员,我希望搞清楚 DWD 层所有维度表的结构和业务含义,以便在 DWS 聚合时正确关联维度。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 全景文档 SHALL 覆盖所有 DWD 维度表(`dim_site`、`dim_table`、`dim_assistant`、`dim_member`、`dim_member_card_account`、`dim_tenant_goods`、`dim_store_goods`、`dim_goods_category`、`dim_groupbuy_package`),包括主表和扩展表
|
||||
2. THE 全景文档 SHALL 描述每张维度表与事实表的关联方式,标注关联字段和关联基数
|
||||
3. THE 全景文档 SHALL 描述会员体系的数据结构,包括:会员档案(`dim_member`)、储值卡账户(`dim_member_card_account`)、会员等级/标签的记录方式
|
||||
4. THE 全景文档 SHALL 描述商品体系的数据结构,包括:商品分类树(`dim_goods_category`)、租户商品(`dim_tenant_goods`)与门店商品(`dim_store_goods`)的关系、库存相关表(`dwd_goods_stock_summary`、`dwd_goods_stock_movement`)的结构
|
||||
5. THE 全景文档 SHALL 描述团购套餐维度(`dim_groupbuy_package`)的结构,包括:套餐与券种的关系、价格体系(面值/售价/门店结算价)
|
||||
|
||||
### 需求 6:数据验证与文档可信度保障
|
||||
|
||||
**用户故事:** 作为数据分析人员,我希望文档中的每个结论都经过测试库数据验证,以避免使用过期或错误的信息。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 梳理任何 DWD 表的字段语义时,THE 梳理器 SHALL 通过查询测试库(`test_etl_feiqiu`)验证字段的实际值分布,确认语义描述的准确性。禁止仅凭 DDL 注释或现有文档描述字段含义
|
||||
2. WHEN 文档引用对账公式时,THE 梳理器 SHALL 提供该公式在测试库全量数据上的验证结果(成立率、例外数量和分类)
|
||||
3. IF 验证过程中发现现有文档(`consume-money-caliber-deep-analysis.md`、`consumption-cases-analysis.md`、BD 手册、ETL 代码注释等)的结论与测试库数据不一致,THEN THE 梳理器 SHALL 在新文档中明确标注修正内容,包括:原文档的描述、实际数据的表现、偏差原因分析
|
||||
4. THE 梳理器 SHALL 在每份全景文档的开头标注数据验证日期和测试库数据的时间范围
|
||||
5. THE 全景文档 SHALL 对每个关键结论标注验证状态:✅ 已验证(附验证 SQL 或结果摘要)、⚠️ 部分验证(附已知例外)、❌ 未验证(附原因)
|
||||
6. THE 梳理器 SHALL 对每个金额字段执行以下深度验证流程:(a) 查询值域分布(MIN/MAX/AVG/中位数/NULL 占比);(b) 与关联字段交叉验证(如 `total_amount` 是否等于各子项之和);(c) 检查边界案例(零值、负值、极端值)的业务含义
|
||||
7. WHEN 引用现有文档的任何结论时,THE 梳理器 SHALL 将其标注为"待验证假设",并在验证后更新为实际结果(一致 ✅ / 偏差 ⚠️ / 错误 ❌),附偏差说明
|
||||
|
||||
### 需求 7:文档产出与组织
|
||||
|
||||
**用户故事:** 作为项目成员,我希望全景文档按统一格式组织并落在正确的路径下,以便团队查阅和后续维护。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 梳理器 SHALL 将所有全景文档输出到 `docs/reports/` 目录下
|
||||
2. THE 梳理器 SHALL 产出以下文档(文件名待确认):
|
||||
- DWD 表结构与字段语义总览
|
||||
- 业务全景:消费产生机制
|
||||
- 账务全景:结算与支付流水
|
||||
- 财务全景:收入确认与对账
|
||||
- 维度表与主数据全景
|
||||
3. THE 全景文档 SHALL 使用统一的文档模板,包含:标题、数据来源与验证日期、目录、正文、附录(验证 SQL、数据样例)
|
||||
4. THE 全景文档 SHALL 在适当位置使用 Mermaid 图表(ER 图、流程图、时序图)辅助说明数据关系和业务流程
|
||||
5. WHEN 全景文档引用其他文档的结论时,THE 梳理器 SHALL 使用相对路径链接到源文档,并标注引用的具体章节
|
||||
@@ -1,277 +0,0 @@
|
||||
# 实施计划:DWD 业务全景梳理
|
||||
|
||||
## 概述
|
||||
|
||||
将 DWD 业务全景梳理的设计方案转化为可执行的任务序列。任务按两阶段组织:基础层(文档1:表结构总览)→ 全景层(文档2~5),数据验证贯穿全程。属性测试使用 Python + hypothesis,放置在 `tests/` 目录。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. 阶段一:DWD 表结构与字段语义总览(文档1)
|
||||
- [x] 1.1 枚举 DWD 全部表并按业务域分组
|
||||
- 查询 `test_etl_feiqiu.dwd` 的 `information_schema.tables` 获取全部表名
|
||||
- 按 7 个业务域(结算、台桌、助教、会员、团购、商品、库存)分组
|
||||
- 查询每张表的行数和时间范围,确认数据状态
|
||||
- 创建 `docs/reports/dwd-table-structure-overview.md` 文件骨架(含模板元素:标题、元数据块、目录、正文、附录)
|
||||
- _Requirements: 1.1, 7.1, 7.2, 7.3_
|
||||
|
||||
- [x] 1.2 结算域表梳理(dwd_settlement_head/ex, dwd_payment, dwd_refund/ex)
|
||||
- 对每张表执行单表智能聚焦分析:information_schema 获取列信息 → 字段分类筛选(空字段/ETL字段/透明字段/业务关键字段)→ 业务关键字段倒推验证
|
||||
- 金额字段执行深度验证:值域分布(MIN/MAX/AVG/中位数/NULL占比)+ 交叉验证
|
||||
- 枚举字段执行 DISTINCT + 频次分布
|
||||
- 对比现有 BD 手册标注偏差
|
||||
- 将结果写入文档1的结算域章节
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.8, 6.1, 6.6_
|
||||
|
||||
- [x] 1.3 台桌域表梳理(dim_table/ex, dwd_table_fee_log/ex, dwd_table_fee_adjust/ex)
|
||||
- 同 1.2 的分析流程
|
||||
- 重点验证台费计价相关字段、台费调整的触发条件
|
||||
- 标注 dim_table 的 SCD2 字段
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
|
||||
|
||||
- [x] 1.4 助教域表梳理(dim_assistant/ex, dwd_assistant_service_log/ex)
|
||||
- 同 1.2 的分析流程
|
||||
- 重点验证助教服务类型枚举、`_ex.is_trash` 作废标记的业务含义
|
||||
- 标注 dim_assistant 的 SCD2 字段
|
||||
- 注意:`dwd_assistant_trash_event` 已于 2026-02-22 DROP,作废判断改用 `dwd_assistant_service_log_ex.is_trash`
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
|
||||
|
||||
- [x] 1.5 会员域表梳理(dim_member/ex, dim_member_card_account/ex, dwd_member_balance_change/ex, dwd_recharge_order/ex)
|
||||
- 同 1.2 的分析流程
|
||||
- 重点验证储值卡本金/赠送金额分账、余额变动类型枚举
|
||||
- 标注 dim_member 和 dim_member_card_account 的 SCD2 字段
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
|
||||
|
||||
- [x] 1.6 团购域表梳理(dim_groupbuy_package/ex, dwd_groupbuy_redemption/ex, dwd_platform_coupon_redemption/ex)
|
||||
- 同 1.2 的分析流程
|
||||
- 重点验证团购三层价格体系(sale_price / pl_coupon_sale_amount / coupon_amount)
|
||||
- 标注 dim_groupbuy_package 的 SCD2 字段
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
|
||||
|
||||
- [x] 1.7 商品域与库存域表梳理(dim_tenant_goods/ex, dim_store_goods/ex, dim_goods_category, dwd_store_goods_sale/ex, dwd_goods_stock_summary, dwd_goods_stock_movement)
|
||||
- 同 1.2 的分析流程
|
||||
- 重点验证商品分类树结构、租户商品与门店商品的关系
|
||||
- 标注维度表的 SCD2 字段(dim_goods_category 无扩展表)
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 6.1, 6.6_
|
||||
|
||||
- [x] 1.8 完善文档1:跨表关联关系汇总与文档收尾
|
||||
- 汇总所有表的主键、外键关联、关联类型(1:1, 1:N)
|
||||
- 补充 Mermaid ER 图展示跨域关联
|
||||
- 补充附录:空字段汇总、验证 SQL 汇总
|
||||
- 添加文档间引用链接(相对路径格式)
|
||||
- 确认文档模板完整性(标题、元数据块、目录、正文、附录)
|
||||
- _Requirements: 1.5, 6.4, 7.3, 7.4, 7.5_
|
||||
|
||||
- [x] 2. 检查点 - 文档1 完成确认
|
||||
- 确认文档1(`docs/reports/dwd-table-structure-overview.md`)已覆盖全部 42 张表(含新发现的 dim_staff/dim_staff_ex)
|
||||
- 确认每张表的业务关键字段均已通过测试库数据验证
|
||||
- 确认偏差记录完整(point_amount 等偏差已标注)
|
||||
- 第9章门店维度有占位符待后续补充,不阻塞全景文档
|
||||
|
||||
- [x] 3. 阶段二A:业务全景文档(文档2)
|
||||
- [x] 3.1 创建文档2骨架并梳理消费产生链路
|
||||
- 创建 `docs/reports/dwd-business-panorama.md`,使用全景文档通用模板
|
||||
- 描述从开台到结算的完整业务流程,标注每个环节涉及的 DWD 表和关键字段
|
||||
- 以 Mermaid 流程图展示从开台到结算的完整数据流向
|
||||
- 基于文档1的字段语义,引用而非重复描述
|
||||
- _Requirements: 2.1, 2.7, 6.4, 7.3_
|
||||
|
||||
- [x] 3.2 梳理各消费类目的产生机制
|
||||
- 台费(含多台桌合并):计价规则、台桌类型与单价关系、计时方式
|
||||
- 台费折扣(dwd_table_fee_adjust):触发条件和计算方式
|
||||
- 商品消费:商品销售流水的记录方式
|
||||
- 助教服务(陪打/超休):服务类型和计费方式
|
||||
- 灯控电费:记录方式
|
||||
- 每个环节提供至少一个测试库真实数据样例
|
||||
- _Requirements: 2.2, 2.3, 2.6_
|
||||
|
||||
- [x] 3.3 梳理优惠与团购机制
|
||||
- 平台团购券(美团/抖音)核销流程
|
||||
- 会员折扣计算方式
|
||||
- 台费调整(adjust_amount)的业务场景
|
||||
- 团购券三层价格体系(sale_price / pl_coupon_sale_amount / coupon_amount),标注每层价格对应的 DWD 表和字段
|
||||
- 每个环节提供测试库数据样例
|
||||
- _Requirements: 2.4, 2.5, 2.6_
|
||||
|
||||
- [x] 3.4 文档2收尾:附录与引用
|
||||
- 补充附录(验证 SQL、数据样例)
|
||||
- 添加文档间引用链接
|
||||
- 确认文档模板完整性
|
||||
- _Requirements: 7.3, 7.4, 7.5_
|
||||
|
||||
- [x] 4. 阶段二B:账务全景文档(文档3)
|
||||
- [x] 4.1 创建文档3骨架并梳理支付渠道
|
||||
- 创建 `docs/reports/dwd-accounting-panorama.md`,使用全景文档通用模板
|
||||
- 描述所有支付渠道及其在 DWD 层的记录方式(线上收款、现金、储值卡余额、平台团购券)
|
||||
- 描述 dwd_payment 与 dwd_settlement_head 的关联方式
|
||||
- 描述 payment_method 枚举值与实际支付渠道的对应关系
|
||||
- 描述支付方式推断逻辑(因 settlement_head_ex.payment_method 全部为 0 不可用)
|
||||
- _Requirements: 3.1, 3.2, 3.6, 6.4, 7.3_
|
||||
|
||||
- [x] 4.2 梳理会员储值卡体系与退款流程
|
||||
- 充值流程(dwd_recharge_order)
|
||||
- 余额变动记录(dwd_member_balance_change)
|
||||
- 本金/赠送金额分账逻辑
|
||||
- 退款流程:结算退款、充值退款、转账退款的触发场景和 DWD 记录方式
|
||||
- _Requirements: 3.3, 3.4_
|
||||
|
||||
- [x] 4.3 梳理对账公式与 consume_money 口径
|
||||
- 列出所有已验证的对账公式(F1~F6、R1~R3、RF1~RF2、B1~B4)
|
||||
- 对每个公式在 test_etl_feiqiu 全量数据上执行验证,标注成立率和例外情况
|
||||
- 描述 consume_money 字段的三种历史口径(A/B/C)及时间线,标注当前生效口径
|
||||
- _Requirements: 3.5, 3.7, 6.2_
|
||||
|
||||
- [x] 4.4 文档3收尾:附录与引用
|
||||
- 补充附录(验证 SQL、对账公式验证结果)
|
||||
- 添加文档间引用链接
|
||||
- 确认文档模板完整性
|
||||
- _Requirements: 7.3, 7.4, 7.5_
|
||||
|
||||
- [x] 5. 检查点 - 文档2和文档3完成确认
|
||||
- 确认文档2覆盖全部消费类目和优惠机制(台费/商品/助教/灯控/团购券/会员折扣)
|
||||
- 确认文档3覆盖全部支付渠道和对账公式(F1/F2/B1-B3/consume_money三种口径)
|
||||
- 确认所有关键结论标注了验证状态
|
||||
- 确认文档3覆盖全部支付渠道和对账公式
|
||||
- 确认所有关键结论标注了验证状态
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 6. 阶段二C:财务全景文档(文档4)
|
||||
- [x] 6.1 创建文档4骨架并梳理收入构成
|
||||
- 创建 `docs/reports/dwd-financial-panorama.md`,使用全景文档通用模板
|
||||
- 按收入来源分类描述门店收入构成:台费收入、商品收入、助教服务收入、充值收入
|
||||
- 描述每种收入来源对应的 DWD 表、关键金额字段、从 DWD 到 DWS 的聚合路径
|
||||
- _Requirements: 4.1, 4.2, 6.4, 7.3_
|
||||
|
||||
- [x] 6.2 梳理团购收入确认与储值卡资金流向
|
||||
- 团购券场景收入确认逻辑:门店实际收入 = pl_coupon_sale_amount,差额为门店补贴
|
||||
- 储值卡充值资金流向:充值收款 → 余额入账(本金+赠送)→ 消费扣款 → 退款
|
||||
- 标注每个环节的 DWD 表和金额字段
|
||||
- _Requirements: 4.3, 4.4_
|
||||
|
||||
- [x] 6.3 构建对账矩阵与数据质量评估
|
||||
- 按支付渠道构建对账矩阵:每种支付渠道涉及的 DWD 表和字段、跨表校验公式
|
||||
- 标注已知数据质量问题和对账例外(助教券支付缺口、商品消费未覆盖等)
|
||||
- 给出影响范围的量化评估
|
||||
- _Requirements: 4.5, 4.6_
|
||||
|
||||
- [x] 6.4 文档4收尾:附录与引用
|
||||
- 补充附录(验证 SQL、对账矩阵详细数据)
|
||||
- 添加文档间引用链接(引用文档2的消费构成、文档3的支付渠道)
|
||||
- 确认文档模板完整性
|
||||
- _Requirements: 7.3, 7.4, 7.5_
|
||||
|
||||
- [x] 7. 阶段二D:维度表与主数据全景(文档5)
|
||||
- [x] 7.1 创建文档5骨架并梳理门店与台桌维度
|
||||
- 创建 `docs/reports/dwd-dimension-panorama.md`,使用全景文档通用模板
|
||||
- 梳理 dim_site/ex:门店维度结构、SCD2 字段、与事实表的关联
|
||||
- 梳理 dim_table/ex:台桌维度结构、台桌类型枚举、与台费流水的关联
|
||||
- _Requirements: 5.1, 5.2, 6.4, 7.3_
|
||||
|
||||
- [x] 7.2 梳理会员体系与助教维度
|
||||
- 会员档案(dim_member/ex):会员等级/标签记录方式
|
||||
- 储值卡账户(dim_member_card_account/ex):账户类型、余额字段
|
||||
- 助教维度(dim_assistant/ex):助教类型、服务能力
|
||||
- 描述维度表与事实表的关联方式,标注关联字段和基数
|
||||
- _Requirements: 5.2, 5.3_
|
||||
|
||||
- [x] 7.3 梳理商品体系与团购维度
|
||||
- 商品分类树(dim_goods_category):分类层级结构
|
||||
- 租户商品(dim_tenant_goods/ex)与门店商品(dim_store_goods/ex)的关系
|
||||
- 库存相关表(dwd_goods_stock_summary, dwd_goods_stock_movement)的结构
|
||||
- 团购套餐维度(dim_groupbuy_package/ex):套餐与券种关系、价格体系
|
||||
- _Requirements: 5.4, 5.5_
|
||||
|
||||
- [x] 7.4 文档5收尾:Mermaid ER 图与附录
|
||||
- 补充 Mermaid ER 图展示全部维度表与事实表的关联关系
|
||||
- 补充附录(验证 SQL、SCD2 字段汇总)
|
||||
- 添加文档间引用链接
|
||||
- 确认文档模板完整性
|
||||
- _Requirements: 5.1, 5.2, 7.3, 7.4, 7.5_
|
||||
|
||||
- [x] 8. 检查点 - 全部5份文档完成确认
|
||||
- 确认 5 份文档均已创建在 `docs/reports/` 目录下
|
||||
- 确认文档间引用链接格式正确(相对路径)且目标文件存在
|
||||
- 确认每份文档包含完整模板元素(标题、元数据块、目录、正文、附录)
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 9. 属性测试与示例测试
|
||||
- [x] 9.1 创建属性测试文件骨架与公共工具
|
||||
- 创建 `tests/test_dwd_panorama_properties.py`
|
||||
- 实现公共工具函数:读取文档内容、解析 markdown 结构、提取表名、提取链接
|
||||
- 配置 hypothesis settings(min_examples=100)
|
||||
- 使用 `load_dotenv` 加载根 `.env`,通过 `TEST_DB_DSN` 连接测试库
|
||||
- _Requirements: 6.1_
|
||||
|
||||
- [x] 9.2 编写 Property 1: DWD 表覆盖完整性
|
||||
- **Property 1: DWD 表覆盖完整性**
|
||||
- 查询 information_schema.tables 获取 DWD schema 全部表名
|
||||
- 解析 5 份文档提取提及的表名
|
||||
- 验证覆盖率 = 100%
|
||||
- **Validates: Requirements 1.1, 5.1**
|
||||
|
||||
- [x] 9.3 编写 Property 2: 主键标注准确性
|
||||
- **Property 2: 主键标注准确性**
|
||||
- 对随机采样的表,查询 information_schema.table_constraints + key_column_usage 获取实际主键
|
||||
- 解析文档1中该表的主键标注
|
||||
- 验证一致性
|
||||
- **Validates: Requirements 1.5**
|
||||
|
||||
- [x] 9.4 编写 Property 3: 业务环节数据佐证
|
||||
- **Property 3: 业务环节数据佐证**
|
||||
- 解析业务全景文档(文档2)的每个业务环节段落
|
||||
- 验证每个段落包含至少一个代码块或数据表格
|
||||
- **Validates: Requirements 2.6**
|
||||
|
||||
- [x] 9.5 编写 Property 4: 对账公式验证一致性
|
||||
- **Property 4: 对账公式验证一致性**
|
||||
- 提取账务全景文档(文档3)中的对账公式和标注的成立率
|
||||
- 重新执行验证 SQL,比对成立率
|
||||
- **Validates: Requirements 3.5, 6.2**
|
||||
|
||||
- [x] 9.6 编写 Property 5: 文档元数据完整性
|
||||
- **Property 5: 文档元数据完整性**
|
||||
- 对每份全景文档,检查开头是否包含:数据来源(test_etl_feiqiu)、验证日期、数据时间范围
|
||||
- **Validates: Requirements 6.4**
|
||||
|
||||
- [x] 9.7 编写 Property 6: 文档输出路径正确性
|
||||
- **Property 6: 文档输出路径正确性**
|
||||
- 验证所有产出文件位于 `docs/reports/` 目录下
|
||||
- **Validates: Requirements 7.1**
|
||||
|
||||
- [x] 9.8 编写 Property 7: 文档模板一致性
|
||||
- **Property 7: 文档模板一致性**
|
||||
- 对每份文档,检查是否包含标题、元数据块、目录、正文、附录
|
||||
- **Validates: Requirements 7.3**
|
||||
|
||||
- [x] 9.9 编写 Property 8: 内部链接格式
|
||||
- **Property 8: 内部链接格式**
|
||||
- 正则提取所有 markdown 内部链接
|
||||
- 验证使用相对路径格式(以 `./` 或 `../` 开头)且目标文件存在
|
||||
- **Validates: Requirements 7.5**
|
||||
|
||||
- [x] 9.10 编写示例测试
|
||||
- 创建 `tests/test_dwd_panorama_examples.py`
|
||||
- SCD2 字段标注检查:dim_member 文档中包含 scd2_start_time 等字段
|
||||
- 消费类目覆盖检查:业务全景文档包含"台费"、"商品消费"、"助教服务"、"灯控电费"
|
||||
- 团购三层价格检查:文档包含 sale_price、pl_coupon_sale_amount、coupon_amount
|
||||
- Mermaid 流程图检查:业务全景文档包含 mermaid 代码块
|
||||
- consume_money 三种口径检查:文档包含口径 A、B、C
|
||||
- 对账矩阵检查:财务全景文档包含矩阵格式表格
|
||||
- 5 份文档产出检查:docs/reports/ 下存在 5 个指定文件名
|
||||
- 每份文档 Mermaid 图表检查
|
||||
- 无数据表标注检查(边界条件)
|
||||
- _Requirements: 1.6, 1.7, 2.2, 2.5, 2.7, 3.5, 3.7, 4.5, 7.2, 7.4_
|
||||
|
||||
- [x] 10. 最终检查点 - 全部完成确认
|
||||
- 运行全部属性测试:`cd C:\NeoZQYY && pytest tests/test_dwd_panorama_properties.py -v`
|
||||
- 运行全部示例测试:`cd C:\NeoZQYY && pytest tests/test_dwd_panorama_examples.py -v`
|
||||
- 确认 5 份文档内容完整、验证状态标注齐全
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- 标记 `*` 的任务为可选,可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号,确保可追溯
|
||||
- 检查点任务确保增量验证
|
||||
- 属性测试验证文档的结构正确性,示例测试验证具体内容覆盖
|
||||
- 所有数据库查询必须使用测试库 `test_etl_feiqiu`,通过 `TEST_DB_DSN` 连接
|
||||
- 文档1是其他4份文档的基础,必须先完成阶段一再进入阶段二
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,245 +0,0 @@
|
||||
# 设计文档:DWD 第一阶段重构
|
||||
|
||||
## 概述
|
||||
|
||||
本次重构针对 `DwdLoadTask`(`tasks/dwd/dwd_load_task.py`),目标是:
|
||||
|
||||
1. 统一事实表增量窗口模式,消除水位线与窗口两套并行的范围过滤
|
||||
2. 删除回补机制(`_insert_missing_by_pk`),简化事实表写入路径
|
||||
3. 清理已确认的死代码、未使用常量和方法
|
||||
4. 修复 `_build_column_mapping()` 的参数 bug
|
||||
|
||||
所有改动集中在 `dwd_load_task.py` 一个文件(加上删除 `base_dwd_task.py`、更新两个外部引用),属于低风险重构。重构后代码路径更清晰,为第二阶段架构重构(E/T/L → `process_segment` 钩子)做好准备。
|
||||
|
||||
## 架构
|
||||
|
||||
### 当前架构(重构前)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[DwdLoadTask.load] --> B{事实表?}
|
||||
B -->|是| C{use_window?}
|
||||
C -->|是| D["_merge_fact_increment(window_start, window_end)"]
|
||||
C -->|否| E["_get_fact_watermark() → watermark"]
|
||||
E --> F["_merge_fact_increment(watermark)"]
|
||||
D --> G["_insert_missing_by_pk(回补)"]
|
||||
F --> G
|
||||
B -->|否| H{scd_cols_present?}
|
||||
H -->|是| I[_merge_dim_scd2]
|
||||
H -->|否| J[_merge_dim_type1_upsert]
|
||||
```
|
||||
|
||||
### 目标架构(重构后)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[DwdLoadTask.load] --> B{事实表?}
|
||||
B -->|是| D["_merge_fact_increment(window_start, window_end)"]
|
||||
D --> R[返回计数]
|
||||
B -->|否| I[_merge_dim_scd2]
|
||||
```
|
||||
|
||||
关键变化:
|
||||
- 事实表路径:消除 `use_window` 分支判断,始终传 `context.window_start/window_end`;删除水位线获取和回补步骤
|
||||
- 维度表路径:消除 `scd_cols_present` 条件判断,直接调用 `_merge_dim_scd2`(所有 17 张维度表都有 SCD2 列)
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 变更组件清单
|
||||
|
||||
| 组件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `DwdLoadTask.load()` | 修改 | 删除 `use_window` 判断,始终传 `context.window_start/window_end` |
|
||||
| `DwdLoadTask._merge_fact_increment()` | 修改 | 删除 `watermark` 分支和回补调用;`window_start`/`window_end` 改为必填参数 |
|
||||
| `DwdLoadTask._merge_dim()` | 修改 | 删除 Type1 分支,直接调用 `_merge_dim_scd2` |
|
||||
| `DwdLoadTask._build_column_mapping()` | 修改 | 将 `ods_table` 和 `cur` 加入方法签名 |
|
||||
| `DwdLoadTask._get_fact_watermark()` | 删除 | 水位线机制不再需要 |
|
||||
| `DwdLoadTask._insert_missing_by_pk()` | 删除 | 回补机制不再需要 |
|
||||
| `DwdLoadTask._pick_order_column()` | 删除 | 从未被调用的死代码 |
|
||||
| `DwdLoadTask._merge_dim_type1_upsert()` | 删除 | Type1 分支永远不触发 |
|
||||
| `DwdLoadTask._upsert_scd2_row()` | 删除 | 已被批量方法替代 |
|
||||
| `DwdLoadTask._close_current_dim()` | 删除 | 已被 `_close_current_dim_bulk` 替代 |
|
||||
| `DwdLoadTask._insert_dim_row()` | 删除 | 已被 `_insert_dim_rows_bulk` 替代 |
|
||||
| `FACT_ORDER_CANDIDATES` 常量 | 删除 | 配合 `_pick_order_column` 删除 |
|
||||
| `FACT_MISSING_FILL_TABLES` 常量 | 删除 | 配合回补机制删除 |
|
||||
| `base_dwd_task.py` | 删除文件 | 死代码,从未被使用 |
|
||||
| `debug_dwd.py` | 修改 | 内联时间列候选列表,替代对 `FACT_ORDER_CANDIDATES` 的引用 |
|
||||
| `integrity_checker.py` | 修改 | 内联时间列候选列表,替代对 `FACT_ORDER_CANDIDATES` 的引用 |
|
||||
|
||||
### 接口变更详情
|
||||
|
||||
#### `_merge_fact_increment()` 签名变更
|
||||
|
||||
```python
|
||||
# 重构前
|
||||
def _merge_fact_increment(
|
||||
self, cur, dwd_table, ods_table, dwd_cols, ods_cols,
|
||||
dwd_types, ods_types,
|
||||
window_start: datetime | None = None, # 可选
|
||||
window_end: datetime | None = None, # 可选
|
||||
) -> Dict[str, int]:
|
||||
|
||||
# 重构后
|
||||
def _merge_fact_increment(
|
||||
self, cur, dwd_table, ods_table, dwd_cols, ods_cols,
|
||||
dwd_types, ods_types,
|
||||
window_start: datetime, # 必填
|
||||
window_end: datetime, # 必填
|
||||
) -> Dict[str, int]:
|
||||
```
|
||||
|
||||
#### `_build_column_mapping()` 签名变更
|
||||
|
||||
```python
|
||||
# 重构前(bug:引用了外部作用域的 ods_table 和 cur)
|
||||
def _build_column_mapping(
|
||||
self, dwd_table: str, pk_cols: Sequence[str], ods_cols: Sequence[str]
|
||||
) -> Dict[str, tuple[str, str | None]]:
|
||||
|
||||
# 重构后
|
||||
def _build_column_mapping(
|
||||
self, cur, dwd_table: str, ods_table: str,
|
||||
pk_cols: Sequence[str], ods_cols: Sequence[str]
|
||||
) -> Dict[str, tuple[str, str | None]]:
|
||||
```
|
||||
|
||||
#### `_merge_dim()` 简化
|
||||
|
||||
```python
|
||||
# 重构前
|
||||
def _merge_dim(self, cur, dwd_table, ods_table, dwd_cols, ods_cols, now):
|
||||
pk_cols = self._get_primary_keys(cur, dwd_table)
|
||||
scd_cols_present = any(c.lower() in self.SCD_COLS for c in dwd_cols)
|
||||
if scd_cols_present:
|
||||
return self._merge_dim_scd2(...)
|
||||
return self._merge_dim_type1_upsert(...)
|
||||
|
||||
# 重构后
|
||||
def _merge_dim(self, cur, dwd_table, ods_table, dwd_cols, ods_cols, now):
|
||||
return self._merge_dim_scd2(cur, dwd_table, ods_table, dwd_cols, ods_cols, now)
|
||||
```
|
||||
|
||||
#### `load()` 事实表分支简化
|
||||
|
||||
```python
|
||||
# 重构前
|
||||
use_window = bool(
|
||||
self.config.get("run.window_override.start")
|
||||
and self.config.get("run.window_override.end")
|
||||
)
|
||||
fact_counts = self._merge_fact_increment(
|
||||
...,
|
||||
window_start=context.window_start if use_window else None,
|
||||
window_end=context.window_end if use_window else None,
|
||||
)
|
||||
|
||||
# 重构后
|
||||
fact_counts = self._merge_fact_increment(
|
||||
...,
|
||||
window_start=context.window_start,
|
||||
window_end=context.window_end,
|
||||
)
|
||||
```
|
||||
|
||||
### 外部引用处理
|
||||
|
||||
`FACT_ORDER_CANDIDATES` 被两个外部模块引用,删除后需要同步处理:
|
||||
|
||||
1. **`scripts/debug/debug_dwd.py`**:将候选列表内联为模块级常量 `_TIME_COLUMN_CANDIDATES`
|
||||
2. **`quality/integrity_checker.py`**:将候选列表内联为模块级常量 `_TIME_COLUMN_CANDIDATES`
|
||||
|
||||
两处内联的列表内容与原 `FACT_ORDER_CANDIDATES` 相同:`["pay_time", "create_time", "update_time", "occur_time", "settle_time", "start_use_time", "fetched_at"]`。这些模块的用途是调试和完整性检查,与 DWD 装载的核心逻辑无关,内联不会引入耦合问题。
|
||||
|
||||
## 数据模型
|
||||
|
||||
本次重构不涉及数据库 schema 变更。所有 DWD 表结构、ODS 表结构、`FACT_MAPPINGS` 和 `TABLE_MAP` 保持不变。
|
||||
|
||||
变更仅影响运行时行为:
|
||||
- 事实表增量 SQL 的 WHERE 条件从"水位线或窗口"统一为"窗口"
|
||||
- 回补 LEFT JOIN SQL 不再执行
|
||||
- 维度表合并路径从"SCD2 或 Type1"统一为"SCD2"
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是一种在系统所有有效执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
基于验收标准的前置分析,以下属性覆盖了本次重构的核心正确性要求。静态代码结构检查(方法/常量/文件是否存在)通过单元测试覆盖,不作为属性列出。
|
||||
|
||||
### Property 1: 事实表增量 SQL 始终使用窗口范围条件
|
||||
|
||||
*For any* 有效的事实表映射(`TABLE_MAP` 中 `dwd_` 前缀的表)和任意的 `window_start`/`window_end` 时间对(`window_start < window_end`),`_merge_fact_increment()` 生成的主增量 SQL 的 WHERE 子句 SHALL 包含 `fetched_at >= window_start AND fetched_at < window_end` 条件,且不包含单边水位线条件(`fetched_at > watermark`)。
|
||||
|
||||
**Validates: Requirements 1.1, 1.2**
|
||||
|
||||
### Property 2: 事实表增量不执行回补 SQL
|
||||
|
||||
*For any* 有效的事实表映射和任意的窗口参数,`_merge_fact_increment()` 执行的 SQL 语句列表中 SHALL 不包含 LEFT JOIN 回补查询(即不包含 `LEFT JOIN ... WHERE ... IS NULL` 模式的 SQL)。
|
||||
|
||||
**Validates: Requirements 2.3**
|
||||
|
||||
### Property 3: 事实表主增量 SQL 结构等价性
|
||||
|
||||
*For any* 有效的事实表映射、列集合和窗口参数,重构后 `_merge_fact_increment()` 生成的主增量 `INSERT INTO ... SELECT ... ON CONFLICT` SQL 的结构(列列表、SELECT 表达式、ON CONFLICT 子句)SHALL 与重构前窗口模式(`use_window=True`)生成的 SQL 结构相同。
|
||||
|
||||
**Validates: Requirements 5.2, 5.5**
|
||||
|
||||
### Property 4: 维度表始终走 SCD2 路径
|
||||
|
||||
*For any* 有效的维度表映射(`TABLE_MAP` 中 `dim_` 前缀的表),调用 `_merge_dim()` SHALL 始终委托给 `_merge_dim_scd2()`,不经过任何条件分支判断。
|
||||
|
||||
**Validates: Requirements 3.8, 5.1**
|
||||
|
||||
## 错误处理
|
||||
|
||||
本次重构不引入新的错误处理逻辑,保留现有机制:
|
||||
|
||||
| 场景 | 处理方式 | 变更 |
|
||||
|------|----------|------|
|
||||
| 单表事实增量 SQL 执行失败(含类型转换错误) | `load()` 中 `try/except` 捕获,回滚该表事务,记录错误日志,继续处理下一张表 | 无变更(需求 2.4:异常自然传播到 load() 层面) |
|
||||
| 维度表 SCD2 合并失败 | 同上 | 无变更 |
|
||||
| ODS 表缺少 `fetched_at` 列 | `_merge_fact_increment` 记录 error 日志并返回零计数 | 无变更 |
|
||||
| DWD 表无法获取列信息 | `load()` 记录 warning 并跳过该表 | 无变更 |
|
||||
|
||||
删除回补机制后,原本由回补兜底的"部分行丢失"场景不再存在——类型转换失败会导致整条 SQL 报错、整张表事务回滚,这是正确的行为(需求 2.4)。
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试框架
|
||||
|
||||
- 单元测试:`pytest`,使用 `FakeDB`/`FakeCursor`(`tests/unit/task_test_utils.py`)
|
||||
- 属性测试:`hypothesis`,最少 100 次迭代
|
||||
- 测试位置:
|
||||
- 单元测试:`apps/etl/pipelines/feiqiu/tests/unit/test_dwd_phase1_refactor.py`
|
||||
- 属性测试:`tests/test_dwd_phase1_properties.py`(monorepo 根目录)
|
||||
|
||||
### 双轨测试方法
|
||||
|
||||
**单元测试**覆盖:
|
||||
- 静态代码结构验证(死代码/常量/方法已删除)
|
||||
- `_build_column_mapping()` bug 修复后的调用正确性
|
||||
- `_merge_dim()` 直接调用 SCD2 的行为
|
||||
- 外部模块(`debug_dwd.py`、`integrity_checker.py`)导入不报错
|
||||
- 表过滤功能(`dwd.only_tables`)回归
|
||||
|
||||
**属性测试**覆盖:
|
||||
- Property 1: 事实表 SQL 窗口条件(通过 FakeCursor 捕获 SQL,验证 WHERE 子句)
|
||||
- Property 2: 无回补 SQL(通过 FakeCursor 捕获所有执行的 SQL,验证无 LEFT JOIN)
|
||||
- Property 3: SQL 结构等价性(对比重构前后生成的 SQL)
|
||||
- Property 4: 维度表 SCD2 路径(通过 mock 验证调用链)
|
||||
|
||||
### 属性测试配置
|
||||
|
||||
- 库:`hypothesis`
|
||||
- 每个属性最少 100 次迭代
|
||||
- 每个属性测试标注对应的设计属性编号
|
||||
- 标注格式:`# Feature: dwd-phase1-refactor, Property N: {property_text}`
|
||||
|
||||
### 测试数据生成策略
|
||||
|
||||
使用 `hypothesis` 的 `@st.composite` 策略生成:
|
||||
- 随机的 `window_start`/`window_end` 时间对(确保 `start < end`)
|
||||
- 随机的列名集合(确保包含 `fetched_at` 和至少一个主键列)
|
||||
- 随机的 `FACT_MAPPINGS` 条目(列名 → 源列 + 可选类型转换)
|
||||
|
||||
使用 `FakeCursor` 捕获执行的 SQL 语句,而非实际连接数据库。
|
||||
@@ -1,81 +0,0 @@
|
||||
# 需求文档:DWD 第一阶段重构
|
||||
|
||||
## 简介
|
||||
|
||||
对 `DwdLoadTask`(`tasks/dwd/dwd_load_task.py`)进行低风险重构,统一事实表增量窗口模式、删除回补机制、清理死代码和未使用常量、修复已知 bug。改动集中在单个文件(及删除一个死代码文件),目标是简化代码路径、消除冗余分支,为后续架构重构(第二阶段)打好基础。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **DwdLoadTask**:DWD 层数据装载任务类,负责从 ODS 表读取数据并合并到 DWD 维度表和事实表
|
||||
- **ODS**:Operational Data Store,原始数据存储层
|
||||
- **DWD**:Data Warehouse Detail,明细数据层
|
||||
- **SCD2**:Slowly Changing Dimension Type 2,缓慢变化维度第二类,通过版本号和时间戳追踪历史变更
|
||||
- **水位线(Watermark)**:基于 DWD 表中已有数据的最大时间戳来确定增量起点的机制
|
||||
- **窗口模式(Window Mode)**:通过 `context.window_start` / `context.window_end` 明确指定时间范围的增量机制
|
||||
- **回补机制(Backfill)**:`_insert_missing_by_pk()` 方法,在主增量写入后通过 LEFT JOIN 补齐缺失主键记录
|
||||
- **BaseDwdTask**:`base_dwd_task.py` 中定义的死代码基类,从未被任何子类使用
|
||||
- **FACT_MAPPINGS**:事实表和维度表的列映射字典,定义 ODS 列到 DWD 列的转换规则
|
||||
- **TaskContext**:运行期上下文数据类,包含 `store_id`、`window_start`、`window_end`、`window_minutes` 等字段
|
||||
- **overlap_seconds**:自动模式下窗口起点的回退秒数,用于覆盖可能的数据延迟
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:统一事实表增量窗口模式
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望事实表增量加载统一使用窗口模式(`window_start` / `window_end`),以消除水位线与窗口两套并行的范围过滤逻辑,降低代码复杂度。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN `DwdLoadTask.load()` 处理事实表时,THE DwdLoadTask SHALL 始终将 `context.window_start` 和 `context.window_end` 传递给 `_merge_fact_increment()`,不再根据 `run.window_override` 配置判断是否传递
|
||||
2. WHEN `_merge_fact_increment()` 构建增量 SQL 时,THE DwdLoadTask SHALL 统一使用 `WHERE fetched_at >= %s AND fetched_at < %s` 条件过滤,参数为 `window_start` 和 `window_end`
|
||||
3. WHEN 重构完成后,THE DwdLoadTask SHALL 不再包含 `_get_fact_watermark()` 方法
|
||||
4. WHEN `_merge_fact_increment()` 被调用时,THE DwdLoadTask SHALL 不再接受 `watermark` 参数,方法签名中 `window_start` 和 `window_end` 为必填参数
|
||||
5. WHEN 自动模式(无 `window_override`)运行时,THE DwdLoadTask SHALL 通过 `BaseTask._get_time_window()` 计算的 `context.window_start`(含 `overlap_seconds` 回退)和 `context.window_end` 作为窗口范围
|
||||
|
||||
### 需求 2:删除回补机制
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望删除事实表的回补机制(`_insert_missing_by_pk`),因为统一窗口后回补的 LEFT JOIN 结果集几乎一定为空,且类型转换失败应直接报错而非静默丢失数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 重构完成后,THE DwdLoadTask SHALL 不再包含 `_insert_missing_by_pk()` 方法
|
||||
2. WHEN 重构完成后,THE DwdLoadTask SHALL 不再包含 `FACT_MISSING_FILL_TABLES` 常量
|
||||
3. WHEN `_merge_fact_increment()` 执行完主增量 INSERT 后,THE DwdLoadTask SHALL 直接返回插入和更新计数,不再调用回补逻辑
|
||||
4. WHEN 事实表增量 SQL 执行过程中发生类型转换错误时,THE DwdLoadTask SHALL 让异常自然传播,触发该表的事务回滚和错误日志记录
|
||||
|
||||
### 需求 3:清理死代码和未使用常量
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望清理所有已确认的死代码和未使用常量,以减少代码体积、消除维护负担和潜在的误导。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 重构完成后,THE 代码库 SHALL 不再包含 `tasks/dwd/base_dwd_task.py` 文件
|
||||
2. WHEN 重构完成后,THE DwdLoadTask SHALL 不再包含 `_pick_order_column()` 方法
|
||||
3. WHEN 重构完成后,THE DwdLoadTask SHALL 不再包含 `FACT_ORDER_CANDIDATES` 常量
|
||||
4. WHEN 重构完成后,THE DwdLoadTask SHALL 不再包含 `_upsert_scd2_row()` 方法
|
||||
5. WHEN 重构完成后,THE DwdLoadTask SHALL 不再包含 `_close_current_dim()` 方法
|
||||
6. WHEN 重构完成后,THE DwdLoadTask SHALL 不再包含 `_insert_dim_row()` 方法
|
||||
7. WHEN 重构完成后,THE DwdLoadTask SHALL 不再包含 `_merge_dim_type1_upsert()` 方法
|
||||
8. WHEN `_merge_dim()` 被调用时,THE DwdLoadTask SHALL 直接调用 `_merge_dim_scd2()`,不再检查 `scd_cols_present` 条件分支
|
||||
9. WHEN 外部模块(`debug_dwd.py`、`integrity_checker.py`)引用 `FACT_ORDER_CANDIDATES` 时,THE 重构 SHALL 同步更新这些引用,将候选列列表内联到各自模块中或提取为共享常量
|
||||
|
||||
### 需求 4:修复 `_build_column_mapping()` 参数 bug
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望修复 `_build_column_mapping()` 中引用未定义变量 `ods_table` 和 `cur` 的 bug,以防止未来条件变化时触发 `NameError`。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN `_build_column_mapping()` 被调用时,THE DwdLoadTask SHALL 通过方法参数接收 `ods_table` 和 `cur`,而非依赖外部作用域的未定义变量
|
||||
2. WHEN `_build_column_mapping()` 的方法签名变更后,THE DwdLoadTask SHALL 同步更新所有调用点,传入正确的 `ods_table` 和 `cur` 参数
|
||||
|
||||
### 需求 5:保持现有功能行为不变
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望重构后的代码在功能行为上与重构前保持一致(除了移除水位线和回补),以确保生产环境的数据处理不受影响。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 维度表执行 SCD2 合并时,THE DwdLoadTask SHALL 保持与重构前相同的关闭旧版本和插入新版本行为
|
||||
2. WHEN 事实表执行增量插入时,THE DwdLoadTask SHALL 保持与重构前相同的 UPSERT 逻辑(含 `ON CONFLICT` 和 `IS DISTINCT FROM` 变更检测)
|
||||
3. WHEN `overlap_seconds` 配置存在时,THE DwdLoadTask SHALL 保留自动模式下窗口起点的回退机制
|
||||
4. WHEN `dwd.only_tables` 或 `DWD_ONLY_TABLES` 配置存在时,THE DwdLoadTask SHALL 保留表过滤功能
|
||||
5. FOR ALL 有效的 `FACT_MAPPINGS` 配置和 ODS 源数据,重构后的事实表增量插入 SHALL 产生与重构前窗口模式相同的 DWD 写入结果
|
||||
@@ -1,96 +0,0 @@
|
||||
# 实施计划:DWD 第一阶段重构
|
||||
|
||||
## 概述
|
||||
|
||||
按照 confirmed_changes.md 中 2.1-2.4 的顺序,对 `DwdLoadTask` 进行低风险重构。改动集中在 `dwd_load_task.py`,辅以删除 `base_dwd_task.py` 和更新两个外部引用。每个步骤递增构建,确保无悬空代码。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 统一窗口模式,去掉水位线(需求 1)
|
||||
- [x] 1.1 修改 `_merge_fact_increment()` 签名:`window_start` 和 `window_end` 改为必填参数(去掉 `| None` 和默认值 `None`);删除方法体内的 watermark 分支(`elif order_col:` 块及 `watermark = None` 赋值),统一使用 `WHERE fetched_at >= %s AND fetched_at < %s`
|
||||
- 文件:`tasks/dwd/dwd_load_task.py`,方法 `_merge_fact_increment`
|
||||
- _Requirements: 1.2, 1.4_
|
||||
- [x] 1.2 修改 `load()` 中事实表分支:删除 `use_window` 变量及条件判断,始终传 `window_start=context.window_start, window_end=context.window_end`
|
||||
- 文件:`tasks/dwd/dwd_load_task.py`,方法 `load`
|
||||
- _Requirements: 1.1_
|
||||
- [x] 1.3 删除 `_get_fact_watermark()` 方法(约 30 行)
|
||||
- 文件:`tasks/dwd/dwd_load_task.py`
|
||||
- _Requirements: 1.3_
|
||||
- [x] 1.4 编写属性测试:事实表增量 SQL 始终使用窗口范围条件
|
||||
- **Property 1: 事实表增量 SQL 始终使用窗口范围条件**
|
||||
- **Validates: Requirements 1.1, 1.2**
|
||||
- 使用 `hypothesis` 生成随机 `window_start`/`window_end`,通过 `FakeCursor` 捕获 SQL,验证 WHERE 子句包含 `>= %s AND < %s`,不包含单边水位线条件
|
||||
- 文件:`tests/test_dwd_phase1_properties.py`
|
||||
|
||||
- [x] 2. 删除回补机制(需求 2)
|
||||
- [x] 2.1 删除 `_merge_fact_increment()` 末尾的回补调用块:移除对 `_insert_missing_by_pk()` 的调用及 `missing_inserted` 相关代码
|
||||
- 文件:`tasks/dwd/dwd_load_task.py`,方法 `_merge_fact_increment`
|
||||
- _Requirements: 2.3_
|
||||
- [x] 2.2 删除 `_insert_missing_by_pk()` 方法(约 100 行)
|
||||
- 文件:`tasks/dwd/dwd_load_task.py`
|
||||
- _Requirements: 2.1_
|
||||
- [x] 2.3 删除 `FACT_MISSING_FILL_TABLES` 常量
|
||||
- 文件:`tasks/dwd/dwd_load_task.py`
|
||||
- _Requirements: 2.2_
|
||||
- [x] 2.4 编写属性测试:事实表增量不执行回补 SQL
|
||||
- **Property 2: 事实表增量不执行回补 SQL**
|
||||
- **Validates: Requirements 2.3**
|
||||
- 通过 `FakeCursor` 捕获所有执行的 SQL,验证无 `LEFT JOIN ... IS NULL` 模式
|
||||
- 文件:`tests/test_dwd_phase1_properties.py`
|
||||
|
||||
- [x] 3. 检查点 - 确保窗口统一和回补删除后测试通过
|
||||
- 运行 `cd apps/etl/pipelines/feiqiu && pytest tests/unit` 和 `cd C:\NeoZQYY && pytest tests/ -v`,确保所有测试通过,如有问题请询问用户。
|
||||
|
||||
- [x] 4. 清理死代码和未使用常量(需求 3)
|
||||
- [x] 4.1 删除 `_pick_order_column()` 方法和 `FACT_ORDER_CANDIDATES` 常量
|
||||
- 文件:`tasks/dwd/dwd_load_task.py`
|
||||
- _Requirements: 3.2, 3.3_
|
||||
- [x] 4.2 更新外部引用:在 `debug_dwd.py` 和 `integrity_checker.py` 中将 `DwdLoadTask.FACT_ORDER_CANDIDATES` 替换为模块级常量 `_TIME_COLUMN_CANDIDATES`(内联相同列表)
|
||||
- 文件:`scripts/debug/debug_dwd.py`、`quality/integrity_checker.py`
|
||||
- _Requirements: 3.9_
|
||||
- [x] 4.3 删除逐行 SCD2 方法:`_upsert_scd2_row()`、`_close_current_dim()`、`_insert_dim_row()`
|
||||
- 文件:`tasks/dwd/dwd_load_task.py`
|
||||
- _Requirements: 3.4, 3.5, 3.6_
|
||||
- [x] 4.4 删除 `_merge_dim_type1_upsert()` 方法;简化 `_merge_dim()` 直接调用 `_merge_dim_scd2()`
|
||||
- 文件:`tasks/dwd/dwd_load_task.py`
|
||||
- _Requirements: 3.7, 3.8_
|
||||
- [x] 4.5 删除 `base_dwd_task.py` 文件
|
||||
- 文件:`tasks/dwd/base_dwd_task.py`
|
||||
- _Requirements: 3.1_
|
||||
- [x] 4.6 编写属性测试:维度表始终走 SCD2 路径
|
||||
- **Property 4: 维度表始终走 SCD2 路径**
|
||||
- **Validates: Requirements 3.8, 5.1**
|
||||
- 通过 mock `_merge_dim_scd2` 验证 `_merge_dim()` 始终委托给它
|
||||
- 文件:`tests/test_dwd_phase1_properties.py`
|
||||
|
||||
- [x] 5. 修复 `_build_column_mapping()` 参数 bug(需求 4)
|
||||
- [x] 5.1 修改 `_build_column_mapping()` 签名:添加 `cur` 和 `ods_table` 参数;更新所有调用点(`_merge_dim_type1_upsert` 已删除,剩余调用点为 `_merge_dim_scd2`)传入正确参数
|
||||
- 文件:`tasks/dwd/dwd_load_task.py`
|
||||
- _Requirements: 4.1, 4.2_
|
||||
- [x] 5.2 编写单元测试:验证 `_build_column_mapping()` 在 `fetched_at` 缺失时正确使用 `ods_table` 和 `cur` 参数记录日志
|
||||
- 文件:`apps/etl/pipelines/feiqiu/tests/unit/test_dwd_phase1_refactor.py`
|
||||
- _Requirements: 4.1_
|
||||
|
||||
- [x] 6. 编写回归单元测试(需求 5)
|
||||
- [x] 6.1 编写单元测试:验证死代码已清理(`hasattr` 检查所有已删除的方法和常量)
|
||||
- 文件:`apps/etl/pipelines/feiqiu/tests/unit/test_dwd_phase1_refactor.py`
|
||||
- _Requirements: 1.3, 2.1, 2.2, 3.1-3.7_
|
||||
- [x] 6.2 编写单元测试:验证外部模块导入正常(`debug_dwd.py`、`integrity_checker.py` 无 ImportError)
|
||||
- 文件:`apps/etl/pipelines/feiqiu/tests/unit/test_dwd_phase1_refactor.py`
|
||||
- _Requirements: 3.9_
|
||||
- [x] 6.3 编写属性测试:事实表主增量 SQL 结构等价性
|
||||
- **Property 3: 事实表主增量 SQL 结构等价性**
|
||||
- **Validates: Requirements 5.2, 5.5**
|
||||
- 使用 `hypothesis` 生成随机列集合和窗口参数,通过 `FakeCursor` 捕获 SQL,验证 INSERT INTO ... ON CONFLICT 结构与预期一致
|
||||
- 文件:`tests/test_dwd_phase1_properties.py`
|
||||
|
||||
- [x] 7. 最终检查点 - 确保所有测试通过
|
||||
- 运行 `cd apps/etl/pipelines/feiqiu && pytest tests/unit` 和 `cd C:\NeoZQYY && pytest tests/ -v`,确保所有测试通过,如有问题请询问用户。
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选测试任务,可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号,确保可追溯
|
||||
- 检查点确保增量验证
|
||||
- 属性测试验证通用正确性属性,单元测试验证具体示例和边界情况
|
||||
- 本次重构涉及 `tasks/` 高风险路径,完成后需运行 `/audit`
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "cd79656c-9c23-4470-a147-d402b5f4b50b", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -1,357 +0,0 @@
|
||||
# 技术设计:团购详情接口整合 ETL 数据流
|
||||
|
||||
> 对应需求文档:#[[file:.kiro/specs/etl-coupon-detail/requirements.md]]
|
||||
|
||||
## 1. 架构概览
|
||||
|
||||
```
|
||||
ODS_GROUP_PACKAGE 任务(BaseOdsTask + OdsTaskSpec)
|
||||
│
|
||||
│ 阶段 1:列表拉取(UnifiedPipeline #1)
|
||||
│ QueryPackageCouponList → ods.group_buy_packages
|
||||
│
|
||||
▼
|
||||
ods.group_buy_packages ──写入完成──▶ 阶段 2:详情拉取自动启动
|
||||
│ │
|
||||
│ ┌───────┴───────┐
|
||||
│ │ UnifiedPipeline│
|
||||
│ │ #2 │
|
||||
│ │ (detail_mode) │
|
||||
│ └───────┬───────┘
|
||||
│ │
|
||||
│ ┌───────▼───────┐
|
||||
│ │ 串行请求线程 │
|
||||
│ │ (主线程) │
|
||||
│ │ RateLimiter │
|
||||
│ └───────┬───────┘
|
||||
│ │ 响应提交
|
||||
│ ┌───────▼───────┐
|
||||
│ │ 处理队列 │
|
||||
│ │ N 个工作线程 │
|
||||
│ └───────┬───────┘
|
||||
│ │ 处理完成
|
||||
│ ┌───────▼───────┐
|
||||
│ │ 写入队列 │
|
||||
│ │ 单线程写库 │
|
||||
│ └───────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
DWD dim_groupbuy_package ods.group_buy_package_details
|
||||
DWD dim_groupbuy_package_ex ◀── SCD2 合并 ──┘
|
||||
```
|
||||
|
||||
> 不再新建独立的 `DetailFetcher` 类。详情拉取完全复用 `BaseOdsTask` 已有的 `detail_endpoint` 二级拉取模式,通过 `OdsTaskSpec` 声明式配置即可驱动。
|
||||
|
||||
## 2. 调研结论与设计决策
|
||||
|
||||
### 2.1 ODS 层表方案 → 选项 A:新建独立表
|
||||
|
||||
决策依据:
|
||||
- 现有 `ods.group_buy_packages` 使用 `OdsTaskSpec` 驱动,payload 存储列表接口的原始 JSON,PK 为 `id + content_hash`
|
||||
- 详情接口返回嵌套结构(子数组 `packageCouponAssistants`、`grouponSiteInfos` 等),与列表接口的扁平结构差异大
|
||||
- 两个接口的写入时机不同(列表先写完,详情后写),混在同一表会导致 `SnapshotMode.FULL_TABLE` 的软删除逻辑冲突
|
||||
- 独立表可以独立演进字段,不影响现有列表数据的稳定性
|
||||
|
||||
新表:`ods.group_buy_package_details`,PK = `coupon_id`(BIGINT),全量快照覆盖
|
||||
|
||||
### 2.2 DWD 层表方案 → 选项 A:在现有扩展表新增字段
|
||||
|
||||
决策依据:
|
||||
- `dim_groupbuy_package_ex` 当前 21 个业务字段(不含 SCD2),密度适中
|
||||
- 详情接口的增量价值字段仅 4 个 JSONB 列(`table_area_ids`、`table_area_names`、`assistant_services`、`groupon_site_infos`)
|
||||
- 新建独立表会增加 SCD2 版本同步复杂度,且下游查询需要额外 JOIN
|
||||
- 扩展表的 SCD2 合并已与主表同步,新增字段自动纳入变更检测
|
||||
|
||||
### 2.3 取消信号 → 复用现有 `CancellationToken`
|
||||
|
||||
`CancellationToken`(封装 `threading.Event`)已在 etl-unified-pipeline 中实现。详情阶段的 `UnifiedPipeline` 实例共享同一个 `cancel_token`,在请求循环和 `RateLimiter.wait()` 中检查取消状态。
|
||||
|
||||
## 3. 组件设计
|
||||
|
||||
### 3.1 OdsTaskSpec 详情配置(声明式,无需新建类)
|
||||
|
||||
> `RateLimiter`(`api/rate_limiter.py`)、`CancellationToken`(`utils/cancellation.py`)和 `UnifiedPipeline`(`pipeline/unified_pipeline.py`)已在 etl-unified-pipeline 中实现并上线。`BaseOdsTask.execute()` 内置了 `detail_endpoint` 二级详情拉取模式,本 spec 通过 `OdsTaskSpec` 声明式配置驱动,不新建独立类。
|
||||
|
||||
在 `tasks/ods/ods_tasks.py` 的 `ODS_GROUP_PACKAGE` 任务 spec 中添加以下配置:
|
||||
|
||||
```python
|
||||
OdsTaskSpec(
|
||||
code="ODS_GROUP_PACKAGE",
|
||||
# ... 现有列表拉取配置 ...
|
||||
|
||||
# ── Detail_Mode 配置 ──
|
||||
detail_endpoint="/PackageCoupon/QueryPackageCouponInfo",
|
||||
detail_param_builder=lambda rec: {"couponId": rec["id"]},
|
||||
detail_target_table="ods.group_buy_package_details",
|
||||
detail_data_path=("data",),
|
||||
detail_id_column="id",
|
||||
)
|
||||
```
|
||||
|
||||
配置字段说明:
|
||||
|
||||
| 字段 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| `detail_endpoint` | `/PackageCoupon/QueryPackageCouponInfo` | 详情接口 endpoint |
|
||||
| `detail_param_builder` | `lambda rec: {"couponId": rec["id"]}` | 将列表表的 `id` 映射为详情接口的 `couponId` 参数 |
|
||||
| `detail_target_table` | `ods.group_buy_package_details` | 详情数据写入的目标表 |
|
||||
| `detail_data_path` | `("data",)` | 详情响应的数据路径 |
|
||||
| `detail_id_column` | `id` | 从 `ods.group_buy_packages` 提取 couponId 列表的列名 |
|
||||
|
||||
### 3.2 执行流程(BaseOdsTask.execute() 内置)
|
||||
|
||||
`BaseOdsTask.execute()` 在列表拉取全部完成后,自动检测 `spec.detail_endpoint` 是否配置,若已配置则启动详情拉取阶段:
|
||||
|
||||
```python
|
||||
# BaseOdsTask.execute() 内置逻辑(已实现,无需修改):
|
||||
if spec.detail_endpoint:
|
||||
# 1. 创建独立的 UnifiedPipeline 实例(共享 cancel_token)
|
||||
detail_pipeline = UnifiedPipeline(
|
||||
api_client=self.api,
|
||||
db_connection=self.db,
|
||||
logger=self.logger,
|
||||
config=pipeline_config, # PipelineConfig.from_app_config()
|
||||
cancel_token=cancel_token, # 与列表阶段共享
|
||||
)
|
||||
|
||||
# 2. 从 ODS 目标表查询 ID 列表,生成详情请求序列
|
||||
detail_requests = self._build_detail_requests(spec)
|
||||
# → SELECT DISTINCT {detail_id_column} FROM {table_name}
|
||||
# → 对每个 ID 调用 detail_param_builder 构造参数
|
||||
# → yield PipelineRequest(is_detail=True, detail_id=record_id)
|
||||
|
||||
# 3. 构建处理和写入函数
|
||||
detail_process_fn = self._build_detail_process_fn(spec)
|
||||
detail_write_fn = self._build_detail_write_fn(spec, source_file)
|
||||
# → 写入 detail_target_table,使用 _insert_records_schema_aware()
|
||||
|
||||
# 4. 执行管道
|
||||
detail_result = detail_pipeline.run(
|
||||
detail_requests, detail_process_fn, detail_write_fn,
|
||||
)
|
||||
self.db.commit()
|
||||
```
|
||||
|
||||
### 3.3 详情响应处理(需自定义 process_fn)
|
||||
|
||||
默认的 `_build_detail_process_fn` 从 `response.get("records", [])` 提取记录。对于团购详情接口,需要自定义字段提取逻辑:
|
||||
|
||||
- 从 `data.groupPurchasePackage` 提取结构化字段(`package_name`、`duration`、`start_time`、`end_time` 等)
|
||||
- 从 `data.groupPurchasePackage.tableAreaId` / `tableAreaNameList` 提取台区数组为 JSONB
|
||||
- 从 `data.packageCouponAssistants` 提取助教服务关联数组为 JSONB
|
||||
- 从 `data.grouponSiteInfos` 提取关联门店数组为 JSONB
|
||||
- 从 `data.packagePackageService` 和 `data.packageCouponDetailsList` 提取为 JSONB
|
||||
- 计算 `content_hash`,保留完整原始响应为 `payload`
|
||||
|
||||
实现方式:在 `ODS_GROUP_PACKAGE` 任务中覆盖 `_build_detail_process_fn`,或在 `OdsTaskSpec` 中扩展 `detail_process_fn` 回调。
|
||||
|
||||
### 3.4 详情数据写入(复用 _insert_records_schema_aware)
|
||||
|
||||
`_build_detail_write_fn` 已内置调用 `_insert_records_schema_aware()`,按目标表结构动态写入,支持 ON CONFLICT UPSERT。写入目标为 `ods.group_buy_package_details`,PK = `coupon_id`。
|
||||
|
||||
### 3.5 DWD 加载扩展
|
||||
|
||||
文件:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
|
||||
|
||||
在 `_merge_dim_scd2()` 处理 `dim_groupbuy_package_ex` 时,需要额外从 `ods.group_buy_package_details` 读取详情字段并合并到 ODS 快照中:
|
||||
|
||||
```python
|
||||
# 伪代码:在 DWD 加载 dim_groupbuy_package_ex 时
|
||||
def _load_groupbuy_package_ex(self, cur, now):
|
||||
# 1. 从 ods.group_buy_packages 读取列表数据(现有逻辑)
|
||||
# 2. 从 ods.group_buy_package_details 读取详情数据
|
||||
# 3. 通过 coupon_id = id 关联,将详情字段合并到 ODS 快照
|
||||
# 4. 执行 SCD2 合并(现有 _merge_dim_scd2 逻辑)
|
||||
```
|
||||
|
||||
新增字段映射(`ods.group_buy_package_details` → `dwd.dim_groupbuy_package_ex`):
|
||||
|
||||
| ODS 详情字段 | DWD 扩展表字段 | 类型 |
|
||||
|-------------|---------------|------|
|
||||
| `table_area_ids` | `table_area_ids` | JSONB |
|
||||
| `table_area_names` | `table_area_names` | JSONB |
|
||||
| `assistant_services` | `assistant_services` | JSONB |
|
||||
| `groupon_site_infos` | `groupon_site_infos` | JSONB |
|
||||
|
||||
## 4. 数据库变更
|
||||
|
||||
### 4.1 新建 ODS 表
|
||||
|
||||
```sql
|
||||
-- db/etl_feiqiu/ods/group_buy_package_details.sql
|
||||
CREATE TABLE IF NOT EXISTS ods.group_buy_package_details (
|
||||
coupon_id BIGINT NOT NULL,
|
||||
package_name TEXT,
|
||||
duration INTEGER, -- 台费计时时长(秒)
|
||||
start_time TIMESTAMPTZ, -- 可用日期开始
|
||||
end_time TIMESTAMPTZ, -- 可用日期结束
|
||||
add_start_clock TEXT, -- 可用时段开始
|
||||
add_end_clock TEXT, -- 可用时段结束
|
||||
is_enabled INTEGER,
|
||||
is_delete INTEGER,
|
||||
site_id BIGINT,
|
||||
tenant_id BIGINT,
|
||||
create_time TIMESTAMPTZ,
|
||||
creator_name TEXT,
|
||||
-- JSONB 数组字段
|
||||
table_area_ids JSONB, -- [2791960001957765, ...]
|
||||
table_area_names JSONB, -- ["A区", ...]
|
||||
assistant_services JSONB, -- [{skillId, assistantLevel, assistantDuration}, ...]
|
||||
groupon_site_infos JSONB, -- [{siteId, siteName}, ...]
|
||||
package_services JSONB, -- 待调研,可能为空
|
||||
coupon_details_list JSONB, -- 待调研,可能为空
|
||||
-- ETL 元数据
|
||||
content_hash TEXT,
|
||||
payload JSONB, -- 完整原始响应
|
||||
fetched_at TIMESTAMPTZ DEFAULT now(),
|
||||
-- 主键
|
||||
CONSTRAINT pk_group_buy_package_details PRIMARY KEY (coupon_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ods.group_buy_package_details IS '团购套餐详情 ODS:QueryPackageCouponInfo 原始数据';
|
||||
```
|
||||
|
||||
### 4.2 DWD 扩展表 ALTER
|
||||
|
||||
```sql
|
||||
-- db/etl_feiqiu/migrations/2026-03-05__add_detail_fields_to_dim_groupbuy_package_ex.sql
|
||||
ALTER TABLE dwd.dim_groupbuy_package_ex
|
||||
ADD COLUMN IF NOT EXISTS table_area_ids JSONB,
|
||||
ADD COLUMN IF NOT EXISTS table_area_names JSONB,
|
||||
ADD COLUMN IF NOT EXISTS assistant_services JSONB,
|
||||
ADD COLUMN IF NOT EXISTS groupon_site_infos JSONB;
|
||||
|
||||
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.table_area_ids IS '可用台区 ID 列表(来自详情接口 tableAreaId)';
|
||||
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.table_area_names IS '可用台区名称列表(来自详情接口 tableAreaNameList)';
|
||||
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.assistant_services IS '助教服务关联(来自详情接口 packageCouponAssistants)';
|
||||
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.groupon_site_infos IS '关联门店信息(来自详情接口 grouponSiteInfos)';
|
||||
```
|
||||
|
||||
## 5. 线程模型详细设计
|
||||
|
||||
详情阶段复用 `UnifiedPipeline` 的三层并发架构,与列表阶段完全一致:
|
||||
|
||||
```
|
||||
UnifiedPipeline #2(detail_mode)
|
||||
│
|
||||
│ 主线程(_request_loop)
|
||||
│ ┌─────────────────────────────────────────┐
|
||||
│ │ for req in _build_detail_requests(spec): │
|
||||
│ │ if cancel_token.is_cancelled: break │
|
||||
│ │ resp = api.post(req.endpoint, params) │
|
||||
│ │ processing_queue.put((req, resp)) │
|
||||
│ │ rate_limiter.wait(cancel_token.event) │
|
||||
│ └─────────────────┬───────────────────────┘
|
||||
│ │
|
||||
│ processing_queue.put(SENTINEL × worker_count)
|
||||
│ 等待所有 worker 完成
|
||||
│ write_queue.put(SENTINEL)
|
||||
│ 等待 writer 完成
|
||||
│
|
||||
├──▶ Worker Thread 1 ──┐
|
||||
├──▶ Worker Thread 2 ──┤
|
||||
│ │
|
||||
│ processing_queue │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ (req, resp) │───▶ detail_process_fn(resp)
|
||||
│ │ (req, resp) │ → 提取字段、计算 content_hash
|
||||
│ │ SENTINEL │ write_queue.put(records)
|
||||
│ └─────────────┘ │
|
||||
│ │
|
||||
│ ▼
|
||||
│ Write Thread
|
||||
│ ┌──────────────────┐
|
||||
│ │ write_queue │
|
||||
│ │ batch=batch_size │──▶ _insert_records_schema_aware()
|
||||
│ │ timeout=5s │ → UPSERT ods.group_buy_package_details
|
||||
│ │ SENTINEL │
|
||||
│ └──────────────────┘
|
||||
│
|
||||
▼
|
||||
PipelineResult(detail_success / detail_failure / detail_skipped)
|
||||
```
|
||||
|
||||
关键设计点(均由 `PipelineConfig` 统一控制,支持任务级覆盖):
|
||||
- `processing_queue`:`queue.Queue(maxsize=queue_size)`,满时阻塞主线程(背压机制)
|
||||
- `write_queue`:`queue.Queue(maxsize=queue_size * 2)`
|
||||
- Worker 数量:`PipelineConfig.workers`(默认 2)
|
||||
- Writer 批量写入:累积 `batch_size` 条或超时 `batch_timeout` 秒后执行
|
||||
- SENTINEL:`None` 对象,用于通知线程退出
|
||||
- 取消信号:主线程检查 `cancel_token.is_cancelled`,`RateLimiter.wait()` 轮询 `cancel_token.event`
|
||||
|
||||
## 6. 取消信号
|
||||
|
||||
详情阶段的 `UnifiedPipeline` 实例与列表阶段共享同一个 `CancellationToken`。取消信号的上游传递链(Admin-web → Backend → Orchestration → CancellationToken)属于 etl-unified-pipeline 的架构范畴,本 spec 仅关注详情阶段收到取消信号后的行为:
|
||||
|
||||
1. `_request_loop` 检测到 `cancel_token.is_cancelled`,停止发送新请求
|
||||
2. `RateLimiter.wait()` 在 0.5s 轮询周期内检测到取消,立即返回 `False`
|
||||
3. 主线程发送 SENTINEL 到处理队列,等待已入队数据处理完成
|
||||
4. `PipelineResult.cancelled = True`,`BaseOdsTask` 据此设置任务状态
|
||||
|
||||
## 7. 错误处理策略
|
||||
|
||||
错误处理由 `UnifiedPipeline` 统一管理,各阶段行为如下:
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| 单个 couponId 请求超时/HTTP 错误 | `_request_loop` 捕获异常,`request_failures++`,继续下一个 |
|
||||
| 单个 couponId 返回 `code != 0` | 同上(API 层异常) |
|
||||
| 连续失败超过阈值 | `_request_loop` 中断,`PipelineResult.status = "FAILED"` |
|
||||
| Worker 线程处理异常 | `_process_worker` 捕获异常,`processing_failures++`,继续消费队列 |
|
||||
| Writer 线程写入失败 | `_write_worker` 捕获异常,`write_failures++`,继续消费队列 |
|
||||
| 取消信号到达 | 停止新请求,等待已入队数据处理完成,`cancelled = True` |
|
||||
|
||||
`BaseOdsTask.execute()` 在详情阶段完成后,将 `detail_result` 的统计信息合并到任务结果中,并记录每个失败项的错误日志。
|
||||
|
||||
连续失败阈值:`PipelineConfig.max_consecutive_failures`(默认 10,支持 `pipeline.ods_group_package.max_consecutive_failures` 任务级覆盖)。
|
||||
|
||||
## 8. 配置参数
|
||||
|
||||
详情阶段复用 `PipelineConfig` 统一配置体系,支持三级回退:`pipeline.ods_group_package.<key>` → `pipeline.<key>` → 硬编码默认值。
|
||||
|
||||
| 配置键 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `pipeline.workers` | 2 | 处理线程数 |
|
||||
| `pipeline.queue_size` | 100 | 处理队列容量 |
|
||||
| `pipeline.batch_size` | 100 | 写入批量阈值 |
|
||||
| `pipeline.batch_timeout` | 5.0 | 写入等待超时(秒) |
|
||||
| `pipeline.rate_min` | 5.0 | RateLimiter 最小间隔(秒) |
|
||||
| `pipeline.rate_max` | 20.0 | RateLimiter 最大间隔(秒) |
|
||||
| `pipeline.max_consecutive_failures` | 10 | 连续失败中断阈值 |
|
||||
|
||||
如需为详情阶段单独调参,可通过 `pipeline.ods_group_package.*` 任务级覆盖(列表和详情阶段共享同一 `PipelineConfig` 实例)。
|
||||
|
||||
> 不再需要独立的 `DETAIL_FETCH_*` 配置参数。
|
||||
|
||||
## 9. 实施任务清单
|
||||
|
||||
### Task 1:新建 ODS 详情表 DDL
|
||||
- 创建 `db/etl_feiqiu/ods/group_buy_package_details.sql`
|
||||
- 执行 DDL 到测试库
|
||||
- 需求覆盖:需求 3 验收标准 1-4
|
||||
|
||||
### Task 2:扩展 ODS_GROUP_PACKAGE 任务 — 配置详情拉取
|
||||
- 在 `tasks/ods/ods_tasks.py` 的 `ODS_GROUP_PACKAGE` OdsTaskSpec 中添加 `detail_endpoint` 等配置
|
||||
- 实现自定义的 `_build_detail_process_fn` 字段提取逻辑
|
||||
- 实现自定义的 `_build_detail_write_fn` 写入逻辑
|
||||
- 复用 `BaseOdsTask.execute()` 已有的详情拉取流程(`UnifiedPipeline` + `RateLimiter` + `CancellationToken`)
|
||||
- 需求覆盖:需求 1 验收标准 1-8,需求 2 验收标准 1-6,需求 5 验收标准 1-4
|
||||
|
||||
### Task 3:DWD 扩展表 ALTER + 加载逻辑
|
||||
- 执行 ALTER TABLE 到测试库
|
||||
- 修改 DWD 加载逻辑,从详情 ODS 表读取并合并到扩展表
|
||||
- 需求覆盖:需求 4 验收标准 1-5
|
||||
|
||||
### Task 4:数据调研 — 获取全部团购详情并分析未标注字段
|
||||
- 编写一次性脚本调用详情接口获取全部数据
|
||||
- 分析未标注字段的值分布
|
||||
- 确认 `packagePackageService` 和 `packageCouponDetailsList` 是否有数据
|
||||
- 根据分析结果调整 ODS/DWD 字段定义
|
||||
- 需求覆盖:需求 3 验收标准 6,附录 B 调研 3、4
|
||||
|
||||
### Task 5:文档同步更新
|
||||
- 更新 ODS DDL 文档、字段映射文档
|
||||
- 更新 BD Manual
|
||||
- 更新 DWD 全景文档
|
||||
- 更新 README 任务清单
|
||||
- 需求覆盖:需求 6 验收标准 1-4
|
||||
@@ -1,256 +0,0 @@
|
||||
# 需求文档:团购详情接口整合 ETL 数据流
|
||||
|
||||
## 简介
|
||||
|
||||
将飞球 SaaS 的团购详情接口(`QueryPackageCouponInfo`)整合到现有 ETL 的 API → ODS → DWD 三层数据流中。当前 ETL 仅拉取团购列表(`QueryPackageCouponList`),缺少每个团购套餐的详情数据(助教服务关联、可用台区列表、关联门店等)。本次改造在现有团购列表拉取任务完成后,串行遍历所有 `couponId` 调用详情接口,将详情数据落入 ODS 层并向下传导至 DWD 层,丰富团购维度表的分析能力。采用"串行请求 + 异步处理 + 单线程写库"架构,在控制请求频率(5-20 秒随机间隔)的同时最大化数据处理吞吐。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **ETL_System**:飞球 Connector ETL 系统(`apps/etl/connectors/feiqiu/`),负责从飞球 SaaS API 拉取数据并加载到 PostgreSQL 的 ODS → DWD → DWS 各层
|
||||
- **API_Client**:ETL 系统中的 HTTP 客户端模块(`api/client.py`),封装 POST 请求、重试、分页逻辑
|
||||
- **Rate_Limiter**:请求间隔控制器,在串行请求模式下控制相邻两次 API 请求之间的随机等待时间(5-20 秒),防止触发上游风控
|
||||
- **ODS_Group_Package_Task**:现有 ODS 层团购套餐拉取任务(任务代码 `ODS_GROUP_PACKAGE`),调用 `QueryPackageCouponList` 获取团购列表并写入 `ods.group_buy_packages`
|
||||
- **Detail_Fetcher**:团购详情拉取子流程,在列表拉取完成后遍历所有 `couponId` 调用 `QueryPackageCouponInfo` 获取详情
|
||||
- **ODS_Detail_Table**:ODS 层团购详情表(`ods.group_buy_package_details`),存储详情接口返回的原始数据
|
||||
- **DWD_Groupbuy_Package**:DWD 层团购套餐维度主表(`dwd.dim_groupbuy_package`)
|
||||
- **DWD_Groupbuy_Package_Ex**:DWD 层团购套餐维度扩展表(`dwd.dim_groupbuy_package_ex`)
|
||||
- **SCD2**:缓慢变化维度 Type 2,通过版本号和生效/失效时间追踪维度历史变化
|
||||
- **couponId**:团购套餐的唯一标识 ID,对应 `ods.group_buy_packages.id` 字段
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:DetailFetcher 串行请求与异步处理
|
||||
|
||||
> RateLimiter 和 CancellationToken 已在 etl-unified-pipeline 中实现并上线,本 spec 直接复用。
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望团购详情拉取采用"串行请求 + 异步处理 + 单线程写库"的架构,复用现有限流和取消机制,在控制请求频率的同时最大化数据处理吞吐。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Detail_Fetcher SHALL 采用严格串行请求模式:等待上一个 API 请求的响应完整返回后,再等待 5 至 20 秒之间的随机间隔(均匀分布),然后发送下一个请求
|
||||
2. THE Detail_Fetcher SHALL 在每个请求的响应返回后,立即将响应数据提交到异步处理队列,不阻塞下一次请求的等待计时
|
||||
3. THE Detail_Fetcher SHALL 支持多个工作线程并行消费处理队列中的响应数据(字段提取、数据清洗、content_hash 计算等)
|
||||
4. THE Detail_Fetcher SHALL 使用单独的写入线程汇总所有处理完成的结果,统一执行数据库写入操作,避免并发写入冲突
|
||||
5. THE Detail_Fetcher SHALL 复用现有 RateLimiter(`api/rate_limiter.py`)控制请求间隔,通过构造参数配置最小/最大间隔
|
||||
6. WHEN 所有请求发送完毕后,THE Detail_Fetcher SHALL 等待处理队列和写入线程全部完成后再返回最终结果
|
||||
7. THE Detail_Fetcher SHALL 通过 CancellationToken(`utils/cancellation.py`)支持外部取消信号,收到信号后立即停止发送新请求、等待当前已提交到处理队列的数据处理完成并写入数据库、然后返回部分完成的统计结果
|
||||
8. WHEN 取消信号到达时,THE Detail_Fetcher SHALL 在当前请求的等待间隔期或响应等待期中断,不再发起后续请求,已进入处理队列的数据不丢弃、正常完成处理和写入
|
||||
|
||||
### 需求 2:团购详情 API 拉取
|
||||
|
||||
**用户故事:** 作为数据分析师,我希望 ETL 系统能拉取每个团购套餐的详情数据(助教服务关联、可用台区、关联门店等),以便进行团购套餐的深度分析。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ODS_Group_Package_Task 完成团购列表拉取后,THE Detail_Fetcher SHALL 遍历所有已拉取的 `couponId`(包含 `is_enabled=0` 和 `is_delete=1` 的记录)调用 `QueryPackageCouponInfo` 接口
|
||||
2. THE Detail_Fetcher SHALL 向 `https://pc.ficoo.vip/apiprod/admin/v1/PackageCoupon/QueryPackageCouponInfo` 发送 POST 请求,请求体为 `{"couponId": <id>}`
|
||||
3. THE Detail_Fetcher SHALL 严格串行发送请求,等待上一个请求响应返回后,经过 5-20 秒随机间隔再发送下一个请求
|
||||
4. WHEN 详情接口返回成功响应时,THE Detail_Fetcher SHALL 将响应数据提交到异步处理队列,由工作线程提取 `data` 层级下的 `groupPurchasePackage`、`tableAreaNameList`、`packageCouponAssistants`、`grouponSiteInfos`、`packagePackageService`、`packageCouponDetailsList` 等字段
|
||||
5. IF 详情接口对某个 `couponId` 返回错误或超时,THEN THE Detail_Fetcher SHALL 记录错误日志(含 `couponId` 和错误信息)并继续处理下一个 `couponId`
|
||||
6. WHEN 所有 `couponId` 遍历完成后,THE Detail_Fetcher SHALL 等待异步处理和写入线程全部完成,然后返回拉取统计信息(成功数、失败数、跳过数)
|
||||
|
||||
### 需求 3:ODS 层团购详情数据存储
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望团购详情数据以结构化方式存储在 ODS 层,以便下游 DWD 层消费。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE ETL_System SHALL 将团购详情数据存储到 ODS 层,具体方案(独立新表 `ods.group_buy_package_details` 还是在现有 `ods.group_buy_packages` 中扩展字段)在技术设计阶段通过实际数据调研后确定(见附录 B:待调研项)
|
||||
2. THE ODS_Detail_Table SHALL 包含以下结构化字段:`coupon_id`(BIGINT, PK)、`package_name`(TEXT)、`duration`(INT,秒)、`start_time`(TIMESTAMPTZ)、`end_time`(TIMESTAMPTZ)、`add_start_clock`(TEXT)、`add_end_clock`(TEXT)、`is_enabled`(INT)、`is_delete`(INT)、`site_id`(BIGINT)、`tenant_id`(BIGINT)、`create_time`(TIMESTAMPTZ)、`creator_name`(TEXT)
|
||||
3. THE ODS_Detail_Table SHALL 将数组类型字段以 JSONB 格式存储:`table_area_ids`(台区 ID 列表)、`table_area_names`(台区名称列表)、`assistant_services`(助教服务关联数组,含 `skillId`、`assistantLevel`、`assistantDuration`)、`groupon_site_infos`(关联门店数组)、`package_services`(套餐服务数组)、`coupon_details_list`(券明细数组)
|
||||
4. THE ODS_Detail_Table SHALL 包含 ETL 元数据字段:`content_hash`(TEXT)、`payload`(JSONB,完整原始响应)、`fetched_at`(TIMESTAMPTZ)
|
||||
5. THE ETL_System SHALL 采用全量快照模式(`SnapshotMode.FULL_TABLE`)写入 ODS_Detail_Table,每次运行覆盖全部记录
|
||||
6. WHEN 详情接口返回的字段中存在未标注字段且所有记录的值均相同时,THE ETL_System SHALL 忽略该字段不写入 ODS 表
|
||||
|
||||
### 需求 4:DWD 层团购维度表扩展
|
||||
|
||||
**用户故事:** 作为数据分析师,我希望 DWD 层的团购维度表能包含详情数据中的关键字段,以便在结算分析和财务审计中使用团购套餐的完整信息。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE ETL_System SHALL 在 DWD 层扩展团购维度数据,具体方案(在现有 `dwd.dim_groupbuy_package_ex` 扩展表中新增字段,还是新建独立详情维度表)在技术设计阶段通过实际数据调研后确定(见附录 B:待调研项)
|
||||
2. THE ETL_System SHALL 在 DWD 加载任务中建立 ODS 详情数据到 DWD 团购维度表的字段映射,通过 `coupon_id` = `groupbuy_package_id` 关联
|
||||
3. WHEN ODS 详情表中存在 `assistant_services` 数据时,THE DWD_Groupbuy_Package_Ex SHALL 保留完整的助教服务关联信息(`skillId`、`assistantLevel`、`assistantDuration`),`assistantDuration` 单位为秒
|
||||
4. THE ETL_System SHALL 通过 SCD2 机制追踪团购详情字段的历史变化,与现有 DWD_Groupbuy_Package_Ex 的 SCD2 版本保持同步
|
||||
5. WHEN `ods.group_buy_package_details` 中某个 `coupon_id` 在 `ods.group_buy_packages` 中不存在时,THE ETL_System SHALL 记录警告日志并跳过该记录的 DWD 加载
|
||||
|
||||
### 需求 5:任务编排与依赖管理
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望团购详情拉取任务能正确嵌入现有调度流程,不影响其他任务的执行顺序。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Detail_Fetcher SHALL 作为 ODS_Group_Package_Task 的内部子流程执行,在列表数据写入 ODS 完成后串行启动,不注册为独立的调度任务
|
||||
2. WHEN ODS_Group_Package_Task 执行时,THE ETL_System SHALL 先完成 `QueryPackageCouponList` 的全量拉取和 ODS 写入,再启动 Detail_Fetcher 遍历详情
|
||||
3. THE ODS_Group_Package_Task SHALL 在执行结果中包含详情拉取的统计信息(详情成功数、详情失败数),与列表拉取统计合并返回
|
||||
4. IF Detail_Fetcher 执行过程中发生不可恢复的错误(如网络完全不可达),THEN THE ODS_Group_Package_Task SHALL 将任务状态标记为部分成功,并在结果中记录已完成的列表拉取数据和详情拉取的中断点
|
||||
|
||||
### 需求 6:文档同步更新
|
||||
|
||||
**用户故事:** 作为开发者,我希望所有相关文档在功能交付时同步更新,以保持文档与代码的一致性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 团购详情 ETL 功能开发完成后,THE ETL_System 的文档 SHALL 更新以下内容:ODS 层 DDL 文档(新增 `ods.group_buy_package_details` 表定义)、ODS 字段映射文档(新增 `QueryPackageCouponInfo` 映射)、数据库手册(BD Manual 中新增详情表说明)
|
||||
2. WHEN DWD 层扩展字段添加完成后,THE ETL_System 的文档 SHALL 更新:DWD 全景文档(`docs/reports/dwd-panorama/dwd-dimension-panorama.md` 中 dim_groupbuy_package_ex 章节)、DWD 表结构概览文档
|
||||
3. THE ETL_System 的 README 文档 SHALL 更新任务清单,反映 ODS_GROUP_PACKAGE 任务新增的详情拉取子流程
|
||||
4. WHEN 全局限流机制实现后,THE ETL_System 的架构文档 SHALL 新增限流机制的说明,包含配置参数和使用方式
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:API 请求与响应示例
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
POST https://pc.ficoo.vip/apiprod/admin/v1/PackageCoupon/QueryPackageCouponInfo
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <token>
|
||||
|
||||
{"couponId": 3030873437310021}
|
||||
```
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"groupPurchasePackage": {
|
||||
"count": 1,
|
||||
"tableAreaId": [2791960001957765],
|
||||
"tableAreaNameList": ["A区"],
|
||||
"id": 3030873437310021,
|
||||
"add_end_clock": "1.00:00:00",
|
||||
"add_start_clock": "00:00:00",
|
||||
"area_tag_type": 1,
|
||||
"card_type_ids": "0",
|
||||
"coupon_money": 0.00,
|
||||
"create_time": "2025-12-31 12:23:56",
|
||||
"creator_name": "店长:郑丽珊",
|
||||
"date_info": "",
|
||||
"date_type": 1,
|
||||
"duration": 7200,
|
||||
"end_clock": "1.00:00:00",
|
||||
"end_time": "2027-01-01 00:00:00",
|
||||
"group_type": 1,
|
||||
"is_delete": 0,
|
||||
"is_enabled": 1,
|
||||
"is_first_limit": 1,
|
||||
"max_selectable_categories": 0,
|
||||
"package_id": 1173128804,
|
||||
"package_name": "助理教练竞技教学两小时",
|
||||
"selling_price": 0.00,
|
||||
"site_id": 2790685415443269,
|
||||
"sort": 100,
|
||||
"start_clock": "00:00:00",
|
||||
"start_time": "2025-07-21 00:00:00",
|
||||
"system_group_type": 1,
|
||||
"table_area_id": "0",
|
||||
"table_area_id_list": "",
|
||||
"table_area_name": "",
|
||||
"tenant_id": 2790683160709957,
|
||||
"tenant_table_area_id": "0",
|
||||
"tenant_table_area_id_list": "",
|
||||
"type": 1,
|
||||
"usable_count": 0,
|
||||
"usable_range": ""
|
||||
},
|
||||
"couponCode": "",
|
||||
"grouponSiteInfos": [
|
||||
{
|
||||
"siteId": 2790685415443269,
|
||||
"siteName": "朗朗桌球"
|
||||
}
|
||||
],
|
||||
"packageCouponAssistants": [
|
||||
{
|
||||
"couponAssistantId": 3030873437310023,
|
||||
"skillId": 0,
|
||||
"assistantLevel": "",
|
||||
"assistantDuration": 7200
|
||||
}
|
||||
],
|
||||
"packagePackageService": [],
|
||||
"packageCouponDetailsList": []
|
||||
},
|
||||
"code": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 字段标注
|
||||
|
||||
| 字段路径 | 含义 | 已标注 |
|
||||
|----------|------|--------|
|
||||
| `groupPurchasePackage.id` | 团购 ID(= couponId) | ✅ |
|
||||
| `groupPurchasePackage.package_name` | 团购名称 | ✅ |
|
||||
| `groupPurchasePackage.duration` | 台费计时时长(秒) | ✅ |
|
||||
| `groupPurchasePackage.start_time` / `end_time` | 可用日期范围 | ✅ |
|
||||
| `groupPurchasePackage.add_start_clock` / `add_end_clock` | 可用时段范围 | ✅ |
|
||||
| `groupPurchasePackage.is_enabled` | 是否启用 | ✅ |
|
||||
| `groupPurchasePackage.is_delete` | 是否已删除 | ✅ |
|
||||
| `groupPurchasePackage.site_id` | 店铺 ID | ✅ |
|
||||
| `groupPurchasePackage.tenant_id` | 租户 ID | ✅ |
|
||||
| `groupPurchasePackage.create_time` | 创建时间 | ✅ |
|
||||
| `groupPurchasePackage.creator_name` | 创建人 | ✅ |
|
||||
| `groupPurchasePackage.tableAreaId` / `tableAreaNameList` | 可用台桌区域 | ✅ |
|
||||
| `packageCouponAssistants[].skillId` | 可用课程 ID | ✅ |
|
||||
| `packageCouponAssistants[].assistantLevel` | 可用助教等级 | ✅ |
|
||||
| `packageCouponAssistants[].assistantDuration` | 助教时长(秒) | ✅ |
|
||||
| `grouponSiteInfos[].siteId` / `siteName` | 关联门店 | ✅ |
|
||||
| `groupPurchasePackage.count` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.area_tag_type` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.card_type_ids` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.coupon_money` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.date_info` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.date_type` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.end_clock` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.group_type` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.is_first_limit` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.max_selectable_categories` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.package_id` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.selling_price` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.sort` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.start_clock` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.system_group_type` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.table_area_id` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.table_area_id_list` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.table_area_name` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.tenant_table_area_id` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.tenant_table_area_id_list` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.type` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.usable_count` | 未标注 — 待调研 | ❌ |
|
||||
| `groupPurchasePackage.usable_range` | 未标注 — 待调研 | ❌ |
|
||||
| `couponCode` | 未标注 — 待调研 | ❌ |
|
||||
| `packageCouponAssistants[].couponAssistantId` | 未标注 — 待调研 | ❌ |
|
||||
| `packagePackageService` | 示例为空 — 待调研实际数据 | ❌ |
|
||||
| `packageCouponDetailsList` | 示例为空 — 待调研实际数据 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:待调研项(技术设计阶段完成)
|
||||
|
||||
### 调研 1:ODS 层表方案
|
||||
|
||||
- 选项 A:新建独立表 `ods.group_buy_package_details`
|
||||
- 选项 B:在现有 `ods.group_buy_packages` 中扩展字段
|
||||
- 决策依据:详情数据与列表数据的字段重叠度、数据量差异、写入模式兼容性
|
||||
- 需要调研现有 `ods.group_buy_packages` 的表结构和写入逻辑
|
||||
|
||||
### 调研 2:DWD 层表方案
|
||||
|
||||
- 选项 A:在现有 `dwd.dim_groupbuy_package_ex` 扩展表中新增 JSONB 字段
|
||||
- 选项 B:新建独立的团购详情维度表
|
||||
- 决策依据:现有扩展表的字段密度、SCD2 版本同步复杂度、下游查询模式
|
||||
- 需要调研现有 DWD 团购相关表结构(参考 `docs/reports/dwd-panorama/`)
|
||||
|
||||
### 调研 3:未标注字段用途
|
||||
|
||||
- 获取全部团购的详情数据后,对附录 A 中标记为 ❌ 的字段进行值分布分析
|
||||
- 所有记录值完全相同的字段 → 忽略,不写入 ODS
|
||||
- 值有变化的字段 → 推测用途,决定是否纳入 ODS/DWD 字段映射
|
||||
|
||||
### 调研 4:空数组字段实际数据
|
||||
|
||||
- `packagePackageService`:获取全部团购后确认是否有非空数据
|
||||
- `packageCouponDetailsList`:同上
|
||||
- 如有数据,需补充 ODS 字段定义和 DWD 映射
|
||||
@@ -1,110 +0,0 @@
|
||||
# 实施计划:团购详情接口整合 ETL 数据流
|
||||
|
||||
## 概述
|
||||
|
||||
将飞球团购详情接口(`QueryPackageCouponInfo`)整合到现有 ETL 的 API → ODS → DWD 三层数据流。利用 `BaseOdsTask` 已有的 `detail_endpoint` 二级详情拉取模式(通过 `UnifiedPipeline` + `RateLimiter` + `CancellationToken`),在 `ODS_GROUP_PACKAGE` 任务的 `OdsTaskSpec` 中配置详情拉取参数,将详情数据写入新建的 `ods.group_buy_package_details` 表,并向下传导至 `dwd.dim_groupbuy_package_ex`。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 新建 ODS 详情表 DDL
|
||||
- [x] 1.1 创建 `db/etl_feiqiu/ods/group_buy_package_details.sql`
|
||||
- 按设计文档 §4.1 定义表结构:`coupon_id` BIGINT PK、结构化字段、JSONB 数组字段、ETL 元数据字段
|
||||
- 添加表和列的 COMMENT
|
||||
- _需求覆盖:需求 3 验收标准 1-4_
|
||||
- [x] 1.2 在测试库 `test_etl_feiqiu` 执行 DDL 验证表创建成功
|
||||
- _需求覆盖:需求 3 验收标准 1_
|
||||
|
||||
- [x] 2. 扩展 ODS_GROUP_PACKAGE 任务 — 配置详情拉取
|
||||
- [x] 2.1 在 `tasks/ods/ods_tasks.py` 的 `ODS_GROUP_PACKAGE` OdsTaskSpec 中添加 detail_endpoint 配置
|
||||
- 设置 `detail_endpoint="/PackageCoupon/QueryPackageCouponInfo"`
|
||||
- 设置 `detail_param_builder=lambda rec: {"couponId": rec["id"]}`(将列表表的 `id` 映射为详情接口的 `couponId` 参数)
|
||||
- 设置 `detail_target_table="ods.group_buy_package_details"`
|
||||
- 设置 `detail_data_path=("data",)`
|
||||
- 设置 `detail_id_column="id"`(从 `ods.group_buy_packages` 提取 couponId 列表)
|
||||
- 复用 `BaseOdsTask.execute()` 已有的详情拉取流程(`UnifiedPipeline` + `RateLimiter` + `CancellationToken`)
|
||||
- _需求覆盖:需求 1 验收标准 1-8,需求 2 验收标准 1-6,需求 5 验收标准 1-4_
|
||||
- [x] 2.2 实现详情响应的 `_build_detail_process_fn` 字段提取逻辑
|
||||
- 从 `data.groupPurchasePackage` 提取结构化字段(`package_name`、`duration`、`start_time`、`end_time`、`add_start_clock`、`add_end_clock`、`is_enabled`、`is_delete`、`site_id`、`tenant_id`、`create_time`、`creator_name`)
|
||||
- 从 `data.groupPurchasePackage.tableAreaId` / `tableAreaNameList` 提取台区数组为 JSONB
|
||||
- 从 `data.packageCouponAssistants` 提取助教服务关联数组为 JSONB
|
||||
- 从 `data.grouponSiteInfos` 提取关联门店数组为 JSONB
|
||||
- 从 `data.packagePackageService` 和 `data.packageCouponDetailsList` 提取为 JSONB
|
||||
- 计算 `content_hash`,保留完整原始响应为 `payload`
|
||||
- _需求覆盖:需求 2 验收标准 4,需求 3 验收标准 2-4_
|
||||
- [x] 2.3 实现详情数据的 `_build_detail_write_fn` 写入逻辑
|
||||
- 采用全量快照模式(`SnapshotMode.FULL_TABLE`)写入 `ods.group_buy_package_details`
|
||||
- UPSERT on `coupon_id`,每次运行覆盖全部记录
|
||||
- _需求覆盖:需求 3 验收标准 5_
|
||||
- [x] 2.4 编写 ODS 详情拉取的单元测试
|
||||
- 测试 `detail_param_builder` 参数构造
|
||||
- 测试字段提取逻辑(正常响应、空数组、缺失字段)
|
||||
- 测试 `content_hash` 计算一致性
|
||||
- _需求覆盖:需求 2 验收标准 4-5_
|
||||
|
||||
- [x] 3. 检查点 — ODS 层验证
|
||||
- 确保所有测试通过,ask the user if questions arise。
|
||||
- 用 `--dry-run --tasks ODS_GROUP_PACKAGE` 验证任务注册和配置正确
|
||||
|
||||
- [x] 4. DWD 扩展表 ALTER + 加载逻辑
|
||||
- [x] 4.1 创建迁移脚本 `db/etl_feiqiu/migrations/2026-03-05__add_detail_fields_to_dim_groupbuy_package_ex.sql`
|
||||
- ALTER TABLE 添加 4 个 JSONB 列:`table_area_ids`、`table_area_names`、`assistant_services`、`groupon_site_infos`
|
||||
- 添加列 COMMENT
|
||||
- 在测试库 `test_etl_feiqiu` 执行迁移验证
|
||||
- _需求覆盖:需求 4 验收标准 1_
|
||||
- [x] 4.2 修改 `tasks/dwd/dwd_load_task.py` 的 `TABLE_MAPPING` 和 `COLUMN_OVERRIDES`
|
||||
- 在 `COLUMN_OVERRIDES["dwd.dim_groupbuy_package_ex"]` 中新增 4 个详情字段的映射
|
||||
- _需求覆盖:需求 4 验收标准 2_
|
||||
- [x] 4.3 修改 `_fetch_source_rows` 或 `_merge_dim_scd2` 流程,在加载 `dim_groupbuy_package_ex` 时 LEFT JOIN `ods.group_buy_package_details`
|
||||
- 通过 `ods.group_buy_packages.id = ods.group_buy_package_details.coupon_id` 关联
|
||||
- 将详情表的 `table_area_ids`、`table_area_names`、`assistant_services`、`groupon_site_infos` 合并到 ODS 快照行
|
||||
- 当详情表中 `coupon_id` 在列表表中不存在时,记录警告日志并跳过
|
||||
- 新增字段自动纳入 SCD2 变更检测(现有 `_is_row_changed` 逻辑已支持 JSONB 比较)
|
||||
- _需求覆盖:需求 4 验收标准 2-5_
|
||||
- [x] 4.4 编写 DWD 加载扩展的单元测试
|
||||
- 测试 LEFT JOIN 逻辑:详情存在 / 详情缺失 / 详情多余(应跳过并警告)
|
||||
- 测试 SCD2 变更检测包含新增 JSONB 字段
|
||||
- _需求覆盖:需求 4 验收标准 3-5_
|
||||
|
||||
- [x] 5. 检查点 — DWD 层验证
|
||||
- 确保所有测试通过,ask the user if questions arise。
|
||||
- 用 `--dry-run --tasks DWD_LOAD_FROM_ODS` 验证 DWD 加载配置正确
|
||||
|
||||
- [x] 6. 数据调研 — 获取全部团购详情并分析未标注字段
|
||||
- [x] 6.1 编写一次性调研脚本 `apps/etl/connectors/feiqiu/scripts/research_coupon_details.py`
|
||||
- 使用 `load_dotenv` 加载根 `.env`,通过 `AppConfig.load()` 获取配置
|
||||
- 连接测试库 `test_etl_feiqiu`(`TEST_DB_DSN`)
|
||||
- 从 `ods.group_buy_packages` 读取所有 `coupon_id`
|
||||
- 串行调用详情接口(复用 `RateLimiter(5, 20)`),将原始响应存入 `ods.group_buy_package_details.payload`
|
||||
- _需求覆盖:附录 B 调研 3、4_
|
||||
- [x] 6.2 分析未标注字段的值分布
|
||||
- 对附录 A 中标记为 ❌ 的字段,统计所有记录的值分布
|
||||
- 所有记录值完全相同的字段 → 标记为忽略
|
||||
- 值有变化的字段 → 推测用途,输出分析报告到 `docs/reports/`
|
||||
- 确认 `packagePackageService` 和 `packageCouponDetailsList` 是否有非空数据
|
||||
- 根据分析结果调整 ODS 表字段定义和 DWD 映射(如需要)
|
||||
- _需求覆盖:需求 3 验收标准 6,附录 B 调研 3、4_
|
||||
|
||||
- [x] 7. 文档同步更新
|
||||
- [x] 7.1 更新 ODS 层文档
|
||||
- 更新 ODS DDL 文档(新增 `ods.group_buy_package_details` 表定义)
|
||||
- 更新 ODS 字段映射文档(新增 `QueryPackageCouponInfo` → `ods.group_buy_package_details` 映射)
|
||||
- 更新 BD Manual(`docs/database/BD_Manual_*.md`)新增详情表说明
|
||||
- _需求覆盖:需求 6 验收标准 1_
|
||||
- [x] 7.2 更新 DWD 层文档
|
||||
- 更新 DWD 全景文档(`docs/reports/dwd-panorama/dwd-dimension-panorama.md` 中 `dim_groupbuy_package_ex` 章节)
|
||||
- 更新 DWD 表结构概览文档,反映新增的 4 个 JSONB 字段
|
||||
- _需求覆盖:需求 6 验收标准 2_
|
||||
- [x] 7.3 更新 ETL README 和架构文档
|
||||
- 更新 README 任务清单,反映 `ODS_GROUP_PACKAGE` 任务新增的详情拉取子流程
|
||||
- _需求覆盖:需求 6 验收标准 3_
|
||||
|
||||
- [x] 8. 最终检查点 — 全量验证
|
||||
- 37 个单元测试全部通过(ODS 16 + DWD 12 + column ref 9)
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
|
||||
- `RateLimiter`(`api/rate_limiter.py`)和 `CancellationToken`(`utils/cancellation.py`)已在 etl-unified-pipeline 中实现,本 spec 直接复用,不重复创建
|
||||
- `BaseOdsTask.execute()` 已内置 `detail_endpoint` 二级详情拉取模式(通过 `UnifiedPipeline`),Task 2 利用此现有机制而非新建独立的 `DetailFetcher` 类
|
||||
- 每个任务引用具体需求条款以确保可追溯性
|
||||
- 检查点确保增量验证,避免问题累积
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,498 +0,0 @@
|
||||
# 设计文档:ETL DWS/Flow 重构
|
||||
|
||||
## 概述
|
||||
|
||||
本设计覆盖飞球 ETL 连接器的四阶段重构:
|
||||
|
||||
1. **BaseDwsTask 模板方法重构**:在基类中提供默认 extract()/load(),子类通过声明 `DATE_COL` + 实现 `_do_extract()` 即可完成大部分工作;提取公共辅助方法到 `dws_helpers.py`;财务任务共享提取层;合并 MV 刷新 + 数据清理任务;MemberIndexBaseTask 模板方法。
|
||||
2. **--layers CLI 参数**:新增 `--layers ODS,DWD,DWS,INDEX` 自由组合参数,保留 `--pipeline` 作为快捷别名;去掉硬编码回退,统一走 `TaskRegistry.get_tasks_by_layer()`。
|
||||
3. **任务依赖声明**:在 TaskMeta 中增加 `depends_on` 字段,`_resolve_tasks()` 执行拓扑排序。
|
||||
4. **关键词重命名**:`pipeline → flow`(类名、变量名、CLI 参数、日志);`pipelines → connectors`(目录路径)。
|
||||
|
||||
执行顺序严格按 1→2→3→4→收尾,每阶段完成后运行回归测试。
|
||||
|
||||
## 架构
|
||||
|
||||
### 当前架构
|
||||
|
||||
```
|
||||
BaseTask (tasks/base_task.py)
|
||||
└── BaseDwsTask (tasks/dws/base_dws_task.py)
|
||||
├── 15 个 DWS 子类(各自实现 extract/transform/load)
|
||||
└── BaseIndexTask (tasks/dws/index/base_index_task.py)
|
||||
└── MemberIndexBaseTask (tasks/dws/index/member_index_base.py)
|
||||
├── WinbackIndexTask
|
||||
└── NewconvIndexTask
|
||||
|
||||
PipelineRunner (orchestration/pipeline_runner.py)
|
||||
├── PIPELINE_LAYERS: 7 种固定 Flow 定义
|
||||
└── _resolve_tasks(): 层→任务映射(含硬编码回退)
|
||||
|
||||
TaskRegistry (orchestration/task_registry.py)
|
||||
└── TaskMeta: task_class, requires_db_config, layer, task_type
|
||||
```
|
||||
|
||||
### 目标架构
|
||||
|
||||
```
|
||||
BaseTask (tasks/base_task.py) [不变]
|
||||
└── BaseDwsTask (tasks/dws/base_dws_task.py) [新增默认 extract/load]
|
||||
├── DWS 子类(仅声明 DATE_COL + 实现 _do_extract/transform)
|
||||
├── FinanceBaseTask (tasks/dws/finance_base_task.py) [新增]
|
||||
│ ├── FinanceDailyTask
|
||||
│ ├── FinanceRechargeTask
|
||||
│ ├── FinanceIncomeStructureTask
|
||||
│ └── FinanceDiscountDetailTask
|
||||
├── DwsMaintenanceTask (tasks/dws/maintenance_task.py) [合并]
|
||||
└── BaseIndexTask
|
||||
└── MemberIndexBaseTask [新增模板 execute]
|
||||
├── WinbackIndexTask(仅实现 _calculate_scores/_save_results)
|
||||
└── NewconvIndexTask
|
||||
|
||||
FlowRunner (orchestration/flow_runner.py) [重命名]
|
||||
├── FLOW_LAYERS: 保留快捷别名
|
||||
└── _resolve_tasks(): 拓扑排序 + 纯 Registry 解析
|
||||
|
||||
TaskRegistry (orchestration/task_registry.py)
|
||||
└── TaskMeta: + depends_on: list[str]
|
||||
|
||||
dws_helpers.py (tasks/dws/dws_helpers.py) [新增]
|
||||
└── mask_mobile(), calc_days_since(), parse_id_list() 等
|
||||
|
||||
目录: apps/etl/connectors/feiqiu/ [重命名]
|
||||
```
|
||||
|
||||
### 变更影响范围
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[BaseDwsTask 重构] --> B[15 个 DWS 子类简化]
|
||||
A --> C[dws_helpers.py 提取]
|
||||
A --> D[FinanceBaseTask 提取]
|
||||
A --> E[DwsMaintenanceTask 合并]
|
||||
A --> F[MemberIndexBaseTask 模板]
|
||||
G[--layers 参数] --> H[CLI main.py]
|
||||
G --> I[PipelineRunner._resolve_tasks]
|
||||
G --> J[去掉硬编码回退]
|
||||
K[任务依赖] --> L[TaskMeta.depends_on]
|
||||
K --> M[拓扑排序]
|
||||
N[关键词重命名] --> O[PipelineRunner → FlowRunner]
|
||||
N --> P[pipelines → connectors 路径]
|
||||
N --> Q[所有文档更新]
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 组件 1:BaseDwsTask 默认模板方法
|
||||
|
||||
```python
|
||||
class BaseDwsTask(BaseTask):
|
||||
DATE_COL: str | None = None # 子类声明日期列名
|
||||
|
||||
def _do_extract(self, context: TaskContext) -> list[dict]:
|
||||
"""子类实现:返回从 DWD 提取的原始行列表。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def extract(self, context: TaskContext) -> dict:
|
||||
"""默认实现:调用 _do_extract 并包装为标准字典。
|
||||
子类可覆盖以自定义提取逻辑。
|
||||
"""
|
||||
rows = self._do_extract(context)
|
||||
return {
|
||||
"rows": rows,
|
||||
"start_date": context.window_start.date(),
|
||||
"end_date": context.window_end.date(),
|
||||
"site_id": context.store_id,
|
||||
}
|
||||
|
||||
def load(self, transformed, context: TaskContext) -> dict:
|
||||
"""默认实现:delete-before-insert 幂等写入。
|
||||
子类可覆盖以自定义加载逻辑。
|
||||
"""
|
||||
if not transformed:
|
||||
return {"counts": {"fetched": 0, "inserted": 0, "updated": 0, "skipped": 0, "errors": 0}}
|
||||
date_col = self.DATE_COL or "stat_date"
|
||||
deleted = self.delete_existing_data(context, date_col=date_col)
|
||||
inserted = self.bulk_insert(transformed)
|
||||
return {
|
||||
"counts": {"fetched": len(transformed), "inserted": inserted, "updated": 0, "skipped": 0, "errors": 0},
|
||||
"extra": {"deleted": deleted},
|
||||
}
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- `_do_extract()` 而非直接修改 `extract()` 签名,是为了保持向后兼容——已覆盖 `extract()` 的子类无需改动。
|
||||
- `DATE_COL = None` 作为哨兵值,未声明时 load() 回退到 `"stat_date"` 默认值。
|
||||
- 子类迁移是渐进式的:先在基类添加默认实现,再逐个子类迁移。
|
||||
- **营业日切点**:所有 `stat_date` / `stat_month` 等日期列的值为营业日,以 `BUSINESS_DAY_START_HOUR`(默认 08:00)为分割点。08:00 前的记录归属前一天/前一月。月统计 = 当月1日 08:00 ~ 次月1日 08:00,周统计 = 周一 08:00 ~ 次周一 08:00。
|
||||
|
||||
### 组件 2:dws_helpers.py 公共辅助模块
|
||||
|
||||
```python
|
||||
# tasks/dws/dws_helpers.py
|
||||
|
||||
def mask_mobile(phone: str | None) -> str | None:
|
||||
"""手机号脱敏:138****1234"""
|
||||
|
||||
def calc_days_since(target_date: date | None, base_date: date | None = None) -> int | None:
|
||||
"""计算距今天数"""
|
||||
|
||||
def parse_id_list(value: Any) -> set[int]:
|
||||
"""解析逗号分隔的 ID 列表字符串为 int 集合"""
|
||||
|
||||
def safe_division(numerator, denominator, default=Decimal("0")) -> Decimal:
|
||||
"""安全除法,分母为零时返回默认值"""
|
||||
```
|
||||
|
||||
**设计决策**:使用独立模块而非 Mixin,因为这些是纯函数,不依赖实例状态。
|
||||
|
||||
### 组件 3:FinanceBaseTask 共享提取层
|
||||
|
||||
```python
|
||||
class FinanceBaseTask(BaseDwsTask):
|
||||
"""财务任务共享基类,提供公共数据提取方法。"""
|
||||
|
||||
def _extract_settlement_summary(self, site_id, start_date, end_date) -> list[dict]:
|
||||
"""结算汇总提取(共享 SQL)"""
|
||||
|
||||
def _extract_recharge_summary(self, site_id, start_date, end_date) -> list[dict]:
|
||||
"""充值汇总提取"""
|
||||
|
||||
def _extract_groupbuy_summary(self, site_id, start_date, end_date) -> list[dict]:
|
||||
"""团购汇总提取"""
|
||||
|
||||
def _extract_platform_summary(self, site_id, start_date, end_date) -> list[dict]:
|
||||
"""平台结算提取"""
|
||||
```
|
||||
|
||||
**设计决策**:使用继承(FinanceBaseTask)而非 Mixin,因为财务任务的提取方法需要访问 `self.db` 和 `self.config`,且财务任务形成清晰的子类族。
|
||||
|
||||
### 组件 4:DwsMaintenanceTask 合并任务
|
||||
|
||||
```python
|
||||
class DwsMaintenanceTask(BaseDwsTask):
|
||||
"""合并 MV 刷新 + 数据清理为单一维护任务。"""
|
||||
|
||||
def get_task_code(self) -> str:
|
||||
return "DWS_MAINTENANCE"
|
||||
|
||||
def extract(self, context): ...
|
||||
def transform(self, extracted, context): ...
|
||||
|
||||
def load(self, transformed, context) -> dict:
|
||||
stats = {"refreshed": 0, "cleaned": 0}
|
||||
if self._is_mv_enabled():
|
||||
stats["refreshed"] = self._refresh_all_views()
|
||||
if self._is_retention_enabled():
|
||||
stats["cleaned"] = self._cleanup_all_tables(context)
|
||||
return {"counts": stats}
|
||||
```
|
||||
|
||||
**设计决策**:合并后的任务内部复用原 BaseMvRefreshTask 和 DwsRetentionCleanupTask 的核心逻辑,但作为单一任务注册和调度。
|
||||
|
||||
### 组件 5:MemberIndexBaseTask 模板方法
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### 组件 6:TaskMeta 依赖声明与拓扑排序
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TaskMeta:
|
||||
task_class: type
|
||||
requires_db_config: bool = True
|
||||
layer: str | None = None
|
||||
task_type: str = "etl"
|
||||
depends_on: list[str] = field(default_factory=list) # 新增
|
||||
```
|
||||
|
||||
拓扑排序算法(Kahn's algorithm):
|
||||
|
||||
```python
|
||||
def topological_sort(task_codes: list[str], registry: TaskRegistry) -> list[str]:
|
||||
"""对任务列表执行拓扑排序。
|
||||
|
||||
- 仅对当前执行列表内的任务排序
|
||||
- depends_on 中引用的任务不在列表内时记录警告
|
||||
- 检测循环依赖并抛出 ValueError
|
||||
"""
|
||||
in_degree = {code: 0 for code in task_codes}
|
||||
graph = {code: [] for code in task_codes}
|
||||
task_set = set(task_codes)
|
||||
|
||||
for code in task_codes:
|
||||
meta = registry.get_metadata(code)
|
||||
if meta and meta.depends_on:
|
||||
for dep in meta.depends_on:
|
||||
if dep in task_set:
|
||||
graph[dep].append(code)
|
||||
in_degree[code] += 1
|
||||
else:
|
||||
logger.warning("任务 %s 依赖 %s,但后者不在当前执行列表中", code, dep)
|
||||
|
||||
queue = deque(code for code in task_codes if in_degree[code] == 0)
|
||||
result = []
|
||||
while queue:
|
||||
node = queue.popleft()
|
||||
result.append(node)
|
||||
for neighbor in graph[node]:
|
||||
in_degree[neighbor] -= 1
|
||||
if in_degree[neighbor] == 0:
|
||||
queue.append(neighbor)
|
||||
|
||||
if len(result) != len(task_codes):
|
||||
cycle_tasks = [c for c in task_codes if c not in result]
|
||||
raise ValueError(f"检测到循环依赖: {cycle_tasks}")
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### 组件 7:--layers CLI 参数
|
||||
|
||||
```python
|
||||
# cli/main.py 新增参数
|
||||
parser.add_argument(
|
||||
"--layers",
|
||||
help="ETL 层自由组合,逗号分隔(ODS,DWD,DWS,INDEX)",
|
||||
)
|
||||
|
||||
# 互斥校验
|
||||
if args.layers and args.pipeline:
|
||||
parser.error("--layers 和 --pipeline/--flow 互斥,请只指定其中一个")
|
||||
|
||||
# 层解析
|
||||
VALID_LAYERS = {"ODS", "DWD", "DWS", "INDEX"}
|
||||
def parse_layers(raw: str) -> list[str]:
|
||||
layers = [l.strip().upper() for l in raw.split(",")]
|
||||
invalid = set(layers) - VALID_LAYERS
|
||||
if invalid:
|
||||
raise ValueError(f"无效的层名: {invalid}")
|
||||
return layers
|
||||
```
|
||||
|
||||
### 组件 8:FlowRunner 重命名
|
||||
|
||||
重命名映射:
|
||||
|
||||
| 原名 | 新名 | 文件 |
|
||||
|------|------|------|
|
||||
| `PipelineRunner` | `FlowRunner` | `orchestration/flow_runner.py` |
|
||||
| `PIPELINE_LAYERS` | `FLOW_LAYERS` | 同上 |
|
||||
| `pipeline_runner.py` | `flow_runner.py` | 文件名 |
|
||||
| `--pipeline` | `--flow`(保留 `--pipeline` 弃用别名) | `cli/main.py` |
|
||||
| 日志中 "Pipeline" / "Flow" | 统一为 "Flow" | 全局 |
|
||||
|
||||
### 组件 9:路径重命名 pipelines → connectors
|
||||
|
||||
```
|
||||
apps/etl/pipelines/feiqiu/ → apps/etl/connectors/feiqiu/
|
||||
```
|
||||
|
||||
影响范围:
|
||||
- `pyproject.toml` workspace 成员声明
|
||||
- 所有 `from pipelines.feiqiu...` 导入(ETL 内部使用相对导入,影响较小)
|
||||
- 脚本中的路径引用(`scripts/`、`run_etl.bat`、`run_etl.sh`)
|
||||
- 文档中的路径引用
|
||||
- `.env` 中的路径配置
|
||||
- CI/CD 配置(如有)
|
||||
|
||||
## 数据模型
|
||||
|
||||
### TaskMeta 扩展
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TaskMeta:
|
||||
task_class: type
|
||||
requires_db_config: bool = True
|
||||
layer: str | None = None
|
||||
task_type: str = "etl"
|
||||
depends_on: list[str] = field(default_factory=list) # 新增:依赖的任务代码列表
|
||||
```
|
||||
|
||||
### 已知任务依赖关系
|
||||
|
||||
| 任务 | 依赖 | 说明 |
|
||||
|------|------|------|
|
||||
| `DWS_ASSISTANT_FINANCE` | `DWS_ASSISTANT_SALARY` | 财务分析需要工资计算结果 |
|
||||
| `DWS_ASSISTANT_MONTHLY` | `DWS_ASSISTANT_DAILY` | 月度汇总基于日度明细 |
|
||||
| `DWS_MAINTENANCE` | 所有其他 DWS 任务 | MV 刷新和清理应在数据写入后执行 |
|
||||
| `DWS_WINBACK_INDEX` | `DWS_MEMBER_VISIT`, `DWS_MEMBER_CONSUMPTION` | 指数计算依赖会员行为数据 |
|
||||
| `DWS_NEWCONV_INDEX` | `DWS_MEMBER_VISIT`, `DWS_MEMBER_CONSUMPTION` | 同上 |
|
||||
| `DWS_RELATION_INDEX` | `DWS_ASSISTANT_DAILY` | 关系指数依赖助教服务记录 |
|
||||
|
||||
### DWS 子类 DATE_COL 映射
|
||||
|
||||
| 任务类 | DATE_COL | 目标表 |
|
||||
|--------|----------|--------|
|
||||
| AssistantDailyTask | `stat_date` | `dws_assistant_daily_detail` |
|
||||
| AssistantMonthlyTask | `stat_month` | `dws_assistant_monthly_summary` |
|
||||
| AssistantCustomerTask | `stat_date` | `dws_assistant_customer_stats` |
|
||||
| AssistantSalaryTask | `salary_month` | `dws_assistant_salary_calc` |
|
||||
| AssistantFinanceTask | `stat_date` | `dws_assistant_finance_analysis` |
|
||||
| MemberConsumptionTask | `stat_date` | `dws_member_consumption_summary` |
|
||||
| MemberVisitTask | `visit_date` | `dws_member_visit_detail` |
|
||||
| FinanceDailyTask | `stat_date` | `dws_finance_daily_summary` |
|
||||
| FinanceRechargeTask | `stat_date` | `dws_finance_recharge_summary` |
|
||||
| FinanceIncomeStructureTask | `stat_date` | `dws_finance_income_structure` |
|
||||
| FinanceDiscountDetailTask | `stat_date` | `dws_finance_discount_detail` |
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*正确性属性是系统在所有合法执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1:默认 extract() 返回标准结构
|
||||
|
||||
*对于任意* 声明了 DATE_COL 且未覆盖 extract() 的 BaseDwsTask 子类,以及任意合法的 TaskContext,调用 extract(context) 应返回包含 "rows"、"start_date"、"end_date"、"site_id" 键的字典,且 "rows" 的值等于 _do_extract(context) 的返回值。
|
||||
|
||||
**Validates: Requirements 1.1, 1.4**
|
||||
|
||||
### Property 2:默认 load() 幂等写入与标准统计
|
||||
|
||||
*对于任意* 非空的 transformed 列表和任意合法的 TaskContext,BaseDwsTask 默认 load() 应返回包含 "counts" 键的字典,其中 "counts" 包含 "fetched"、"inserted"、"updated"、"skipped"、"errors" 五个整数键,且 "fetched" 等于 len(transformed)。对于空的 transformed 列表,所有计数应为 0。
|
||||
|
||||
**Validates: Requirements 1.2, 1.5**
|
||||
|
||||
### Property 3:dws_helpers 函数等价性
|
||||
|
||||
*对于任意* 合法输入,dws_helpers 模块中的 mask_mobile()、calc_days_since()、parse_id_list() 函数应产生与原子类内联实现完全相同的输出。具体地:
|
||||
- *对于任意* 11 位数字字符串,mask_mobile() 应返回中间 4 位被 `****` 替换的字符串
|
||||
- *对于任意* 两个 date 对象(target_date, base_date),calc_days_since() 应返回 (base_date - target_date).days
|
||||
- *对于任意* 包含逗号分隔整数的字符串,parse_id_list() 应返回对应的 int 集合
|
||||
|
||||
**Validates: Requirements 2.3**
|
||||
|
||||
### Property 4:DwsMaintenanceTask 配置控制
|
||||
|
||||
*对于任意* mv_enabled 和 retention_enabled 的布尔组合,DwsMaintenanceTask.load() 应:
|
||||
- 当 mv_enabled=True 时执行物化视图刷新,否则跳过
|
||||
- 当 retention_enabled=True 时执行数据清理,否则跳过
|
||||
- 返回的统计字典始终包含 "refreshed" 和 "cleaned" 键
|
||||
|
||||
**Validates: Requirements 4.3, 4.4**
|
||||
|
||||
### Property 5:--layers 解析正确性
|
||||
|
||||
*对于任意* {ODS, DWD, DWS, INDEX} 的非空子集,以逗号分隔拼接为字符串后,parse_layers() 应返回包含且仅包含该子集元素的列表,且元素均为大写。对于包含无效层名的字符串,parse_layers() 应抛出 ValueError。
|
||||
|
||||
**Validates: Requirements 6.1, 6.2**
|
||||
|
||||
### Property 6:配置优先级——配置值优先于 Registry
|
||||
|
||||
*对于任意* 层名和任意非空的配置任务列表,_resolve_tasks() 应返回配置中指定的任务列表,而非 TaskRegistry.get_tasks_by_layer() 的结果。当配置为空时,应返回 Registry 的结果。
|
||||
|
||||
**Validates: Requirements 7.2**
|
||||
|
||||
### Property 7:拓扑排序正确性
|
||||
|
||||
*对于任意* 有向无环图(DAG)表示的任务依赖关系,topological_sort() 返回的列表中,每个任务的所有依赖(在当前列表内的)都应排在该任务之前。
|
||||
|
||||
**Validates: Requirements 8.3**
|
||||
|
||||
### Property 8:循环依赖检测
|
||||
|
||||
*对于任意* 包含至少一个环的有向图表示的任务依赖关系,topological_sort() 应抛出 ValueError,且错误信息中包含环中涉及的任务代码。
|
||||
|
||||
**Validates: Requirements 8.4**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### BaseDwsTask 模板方法
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 子类未实现 _do_extract() 且未覆盖 extract() | 抛出 NotImplementedError |
|
||||
| 子类未声明 DATE_COL | load() 回退到 "stat_date" 默认值 |
|
||||
| _do_extract() 返回 None | extract() 将 rows 设为空列表 |
|
||||
| bulk_insert() 失败 | 异常向上传播,由 BaseTask.execute() 的 try/except 捕获并 rollback |
|
||||
|
||||
### 拓扑排序
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 循环依赖 | 抛出 ValueError,包含循环涉及的任务列表 |
|
||||
| 依赖任务不在执行列表中 | 记录 WARNING 日志,继续执行 |
|
||||
| 空任务列表 | 返回空列表 |
|
||||
|
||||
### CLI 参数
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| --layers 和 --pipeline/--flow 同时指定 | argparse 报错退出 |
|
||||
| --layers 包含无效层名 | 抛出 ValueError,提示有效层名 |
|
||||
| --pipeline 使用已弃用参数 | 输出 DeprecationWarning,正常执行 |
|
||||
|
||||
### 路径重命名
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 导入路径未更新 | ImportError,需在重命名脚本中全量扫描 |
|
||||
| 配置文件中的旧路径 | 启动时检查并输出警告 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试框架
|
||||
|
||||
- 单元测试:`pytest`
|
||||
- 属性测试:`hypothesis`(Python 的属性测试库)
|
||||
- 测试工具:`apps/etl/pipelines/feiqiu/tests/unit/task_test_utils.py` 提供 FakeDB/FakeAPI
|
||||
|
||||
### 属性测试配置
|
||||
|
||||
- 每个属性测试最少运行 100 次迭代
|
||||
- 使用 `@settings(max_examples=100)` 配置
|
||||
- 每个属性测试用注释标注对应的设计属性编号
|
||||
- 标注格式:`# Feature: etl-dws-flow-refactor, Property N: <属性标题>`
|
||||
|
||||
### 双轨测试方法
|
||||
|
||||
**属性测试**(验证普遍性质):
|
||||
- Property 1-2:BaseDwsTask 默认模板方法的返回值结构和行为
|
||||
- Property 3:dws_helpers 函数等价性
|
||||
- Property 4:DwsMaintenanceTask 配置控制
|
||||
- Property 5:--layers 解析正确性
|
||||
- Property 6:配置优先级
|
||||
- Property 7:拓扑排序正确性
|
||||
- Property 8:循环依赖检测
|
||||
|
||||
**单元测试**(验证具体示例和边界条件):
|
||||
- DwsMaintenanceTask 执行顺序(先刷新后清理)
|
||||
- TaskRegistry 注册项替换(旧任务移除、新任务添加)
|
||||
- --pipeline 快捷别名映射
|
||||
- --layers 和 --pipeline 互斥报错
|
||||
- --pipeline 弃用警告
|
||||
- 空 Registry 返回空列表(边界条件)
|
||||
- 依赖任务不在执行列表中的警告(边界条件)
|
||||
- 路径重命名后的导入正确性
|
||||
|
||||
### 测试文件组织
|
||||
|
||||
| 测试文件 | 内容 |
|
||||
|----------|------|
|
||||
| `tests/unit/test_base_dws_template.py` | Property 1-2 + BaseDwsTask 模板方法单元测试 |
|
||||
| `tests/unit/test_dws_helpers.py` | Property 3 + dws_helpers 函数单元测试 |
|
||||
| `tests/unit/test_maintenance_task.py` | Property 4 + DwsMaintenanceTask 单元测试 |
|
||||
| `tests/unit/test_layers_cli.py` | Property 5 + --layers CLI 参数单元测试 |
|
||||
| `tests/unit/test_resolve_tasks.py` | Property 6 + _resolve_tasks 配置优先级单元测试 |
|
||||
| `tests/unit/test_topological_sort.py` | Property 7-8 + 拓扑排序单元测试 |
|
||||
| `tests/unit/test_flow_rename.py` | FlowRunner 重命名相关单元测试 |
|
||||
| `tests/test_etl_refactor_properties.py` | Monorepo 级属性测试(根目录) |
|
||||
@@ -1,167 +0,0 @@
|
||||
# 需求文档:ETL DWS/Flow 重构
|
||||
|
||||
## 简介
|
||||
|
||||
对 NeoZQYY Monorepo 的飞球 ETL 连接器进行大型重构,涵盖四个主要方向:
|
||||
1. BaseDwsTask 模板方法重构——消除 DWS 子类中的样板代码
|
||||
2. `--layers` CLI 参数替代固定 pipeline 名称——提升用户体验
|
||||
3. 任务依赖声明与拓扑排序——消除隐式依赖风险
|
||||
4. 关键词重命名(pipeline → flow、pipelines → connectors)——统一术语与路径
|
||||
|
||||
执行顺序严格按 1→2→3→4→收尾,每一步完成后进行回归测试。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **BaseDwsTask**:DWS 层任务基类,位于 `tasks/dws/base_dws_task.py`,提供 DWD 数据读取、幂等写入、配置缓存等通用能力
|
||||
- **BaseIndexTask**:INDEX 层指数算法基类,继承 BaseDwsTask,位于 `tasks/dws/index/base_index_task.py`
|
||||
- **MemberIndexBaseTask**:会员指数共享基类,继承 BaseIndexTask,位于 `tasks/dws/index/member_index_base.py`
|
||||
- **TaskRegistry**:任务注册表,维护 task_code → TaskMeta 映射,位于 `orchestration/task_registry.py`
|
||||
- **TaskMeta**:任务元数据数据类,包含 task_class、requires_db_config、layer、task_type 字段
|
||||
- **PipelineRunner**:Flow 编排器,根据 Flow 定义执行多层 ETL 任务,位于 `orchestration/pipeline_runner.py`
|
||||
- **TaskExecutor**:单任务执行器,管理游标、运行记录和任务生命周期,位于 `orchestration/task_executor.py`
|
||||
- **Flow**:ETL 编排单元,定义一组按层顺序执行的任务集合(原名 pipeline)
|
||||
- **Layer**:ETL 数据处理层级,包括 ODS、DWD、DWS、INDEX
|
||||
- **Connector**:ETL 连接器,对接特定上游 SaaS 的数据抽取模块(原名 pipeline 目录)
|
||||
- **DATE_COL**:DWS 子类声明的日期列名,用于 extract 和 delete_existing_data 的时间过滤。日期值为营业日(以 `BUSINESS_DAY_START_HOUR`(默认 08:00)为日切点)
|
||||
- **TaskContext**:运行期上下文数据类,包含 store_id、window_start/end、window_minutes、cursor
|
||||
- **拓扑排序**:根据任务间依赖关系确定执行顺序的算法,确保被依赖任务先于依赖方执行
|
||||
- **幂等**:同一操作执行多次与执行一次效果相同,本系统通过 delete-before-insert 实现
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:BaseDwsTask 默认 extract/load 模板方法
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望 BaseDwsTask 提供默认的 extract() 和 load() 实现,以便 DWS 子类只需声明 DATE_COL 并实现 _do_extract() 和 transform(),从而减少每个子类 20-30 行样板代码。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 一个 DWS 子类声明了 DATE_COL 类属性且未覆盖 extract(),THE BaseDwsTask SHALL 使用 DATE_COL 从 DWD 层按时间窗口提取数据并传递给 transform()
|
||||
2. WHEN 一个 DWS 子类声明了 DATE_COL 类属性且未覆盖 load(),THE BaseDwsTask SHALL 执行 delete_existing_data(date_col=DATE_COL) 后调用 bulk_insert(),并返回标准统计字典
|
||||
3. WHEN 一个 DWS 子类覆盖了 extract() 或 load(),THE BaseDwsTask SHALL 使用子类的覆盖实现而非默认实现
|
||||
4. WHEN 默认 extract() 执行时,THE BaseDwsTask SHALL 调用子类实现的 _do_extract(context) 方法获取原始数据
|
||||
5. THE BaseDwsTask 默认 load() SHALL 返回包含 fetched、inserted、updated、skipped、errors 键的统计字典
|
||||
|
||||
### 需求 2:DWS 公共辅助方法提取
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望将散落在多个 DWS 子类中的重复辅助方法提取到公共位置,以便消除代码重复并统一行为。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE dws_helpers 模块 SHALL 提供 _mask_mobile()、_calc_days_since()、_parse_id_list() 等公共辅助函数
|
||||
2. WHEN 多个 DWS 子类使用相同的辅助逻辑时,THE 子类 SHALL 调用 dws_helpers 中的公共实现而非各自维护副本
|
||||
3. WHEN dws_helpers 中的辅助函数被调用时,THE 函数 SHALL 产生与原子类内联实现完全相同的输出结果
|
||||
|
||||
### 需求 3:财务任务共享提取层
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望财务类 DWS 任务(FinanceDailyTask、FinanceRechargeTask、FinanceIncomeStructureTask、FinanceDiscountDetailTask)共享数据提取逻辑,以便减少重复的 SQL 查询和数据获取代码。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE FinanceExtractMixin 或 FinanceBaseTask SHALL 提供财务任务共用的数据提取方法(结算汇总、充值汇总、团购汇总等)
|
||||
2. WHEN 财务类 DWS 子类执行 extract() 时,THE 子类 SHALL 通过共享提取层获取公共数据,仅补充各自特有的提取逻辑
|
||||
3. WHEN 共享提取层返回数据时,THE 数据 SHALL 与原各子类独立提取的结果在数值精度和字段结构上完全一致
|
||||
|
||||
### 需求 4:MV 刷新与数据清理任务合并
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望将 DWS_MV_REFRESH_FINANCE_DAILY、DWS_MV_REFRESH_ASSISTANT_DAILY 和 DWS_RETENTION_CLEANUP 三个任务合并为一个统一的维护任务,以便简化调度配置和减少任务数量。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 合并后的 DWS_MAINTENANCE 任务 SHALL 在单次执行中完成物化视图刷新和历史数据清理
|
||||
2. WHEN DWS_MAINTENANCE 任务执行时,THE 任务 SHALL 先执行物化视图刷新,再执行数据清理
|
||||
3. WHEN 物化视图刷新或数据清理功能被配置为禁用时,THE DWS_MAINTENANCE 任务 SHALL 跳过对应步骤并记录日志
|
||||
4. WHEN DWS_MAINTENANCE 任务完成时,THE 任务 SHALL 返回包含刷新视图数和清理行数的统计信息
|
||||
5. THE TaskRegistry SHALL 移除原 DWS_MV_REFRESH_FINANCE_DAILY、DWS_MV_REFRESH_ASSISTANT_DAILY、DWS_RETENTION_CLEANUP 三个注册项,替换为 DWS_MAINTENANCE
|
||||
|
||||
### 需求 5:MemberIndexBaseTask 模板方法
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望 MemberIndexBaseTask 提供模板方法 execute(),以便子类(WinbackIndexTask、NewconvIndexTask)只需实现 _calculate_scores() 和 _save_results(),减少重复的编排代码。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE MemberIndexBaseTask SHALL 提供 execute() 模板方法,按顺序执行:获取站点信息 → 加载参数 → 构建会员活动数据 → 调用 _calculate_scores() → 归一化 → 调用 _save_results()
|
||||
2. WHEN 子类实现 _calculate_scores(member_activities, params) 时,THE 方法 SHALL 接收会员活动数据和参数字典,返回原始评分字典
|
||||
3. WHEN 子类实现 _save_results(normalized_scores, context) 时,THE 方法 SHALL 接收归一化后的评分和上下文,完成数据持久化
|
||||
4. WHEN MemberIndexBaseTask 的 execute() 执行完成时,THE 方法 SHALL 返回与原子类 execute() 相同结构的结果字典
|
||||
|
||||
### 需求 6:--layers CLI 参数
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望使用 `--layers ODS,DWD,DWS,INDEX` 的自由组合方式替代固定的 pipeline 名称,以便更灵活地控制 ETL 执行范围。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户指定 `--layers ODS,DWD` 时,THE CLI SHALL 解析为 ["ODS", "DWD"] 层列表并按顺序执行对应任务
|
||||
2. WHEN 用户指定 `--layers` 参数时,THE CLI SHALL 接受 ODS、DWD、DWS、INDEX 四个层的任意组合
|
||||
3. THE CLI SHALL 保留 `--pipeline` 参数作为快捷别名(如 `--pipeline api_full` 等价于 `--layers ODS,DWD,DWS,INDEX`)
|
||||
4. WHEN 用户同时指定 `--layers` 和 `--pipeline` 时,THE CLI SHALL 报错并提示两者互斥
|
||||
5. WHEN `--layers` 包含 DWS 或 INDEX 层时,THE PipelineRunner SHALL 跳过完整性校验或仅执行轻量级行数校验
|
||||
|
||||
### 需求 7:统一层→任务解析
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望去掉 _resolve_tasks() 中的硬编码回退列表,统一走 TaskRegistry.get_tasks_by_layer() 获取任务,以便新增任务时只需在 TaskRegistry 注册即可自动纳入 Flow。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE PipelineRunner._resolve_tasks() SHALL 仅通过 TaskRegistry.get_tasks_by_layer() 获取各层任务列表,移除所有硬编码回退列表
|
||||
2. WHEN 配置中指定了 run.ods_tasks / run.dws_tasks / run.index_tasks 时,THE _resolve_tasks() SHALL 优先使用配置值
|
||||
3. WHEN TaskRegistry.get_tasks_by_layer() 返回空列表且无配置覆盖时,THE _resolve_tasks() SHALL 记录警告日志并返回空列表
|
||||
|
||||
### 需求 8:任务依赖声明
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望在 TaskMeta 中声明任务间的依赖关系,以便系统自动进行拓扑排序,消除隐式依赖导致的执行顺序错误。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE TaskMeta 数据类 SHALL 包含 depends_on: list[str] 字段,默认为空列表
|
||||
2. WHEN 注册任务时指定 depends_on 时,THE TaskRegistry SHALL 存储依赖关系
|
||||
3. WHEN _resolve_tasks() 生成任务列表时,THE PipelineRunner SHALL 对任务列表执行拓扑排序,确保被依赖任务排在依赖方之前
|
||||
4. WHEN 任务依赖关系中存在循环依赖时,THE 拓扑排序 SHALL 抛出明确的错误信息,指出循环涉及的任务
|
||||
5. WHEN 任务 A 声明 depends_on 包含任务 B,且任务 B 不在当前执行列表中时,THE 拓扑排序 SHALL 记录警告日志但继续执行
|
||||
|
||||
### 需求 9:关键词重命名 pipeline → flow
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望将代码中所有 "pipeline" 相关术语统一为 "flow",以便术语一致性和代码可读性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE PipelineRunner 类 SHALL 重命名为 FlowRunner
|
||||
2. THE PIPELINE_LAYERS 常量 SHALL 重命名为 FLOW_LAYERS
|
||||
3. THE CLI 参数 `--pipeline` SHALL 重命名为 `--flow`,同时保留 `--pipeline` 作为已弃用别名
|
||||
4. WHEN 用户使用已弃用的 `--pipeline` 参数时,THE CLI SHALL 输出弃用警告并正常执行
|
||||
5. THE 代码中所有 pipeline_runner 模块名 SHALL 重命名为 flow_runner
|
||||
6. THE 所有日志消息、注释和文档中的 "pipeline" 术语 SHALL 替换为 "flow"
|
||||
|
||||
### 需求 10:路径重命名 pipelines → connectors
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望将 `apps/etl/pipelines` 目录重命名为 `apps/etl/connectors`,以便目录名准确反映其"连接器"语义。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 目录 `apps/etl/pipelines/` SHALL 重命名为 `apps/etl/connectors/`
|
||||
2. WHEN 路径重命名完成后,THE 所有 Python 导入路径 SHALL 更新为使用新路径
|
||||
3. WHEN 路径重命名完成后,THE 所有配置文件、脚本和文档中的旧路径引用 SHALL 更新为新路径
|
||||
4. WHEN 路径重命名完成后,THE pyproject.toml 中的 workspace 成员声明 SHALL 更新为新路径
|
||||
5. WHEN 路径重命名完成后,THE 所有测试 SHALL 通过且无导入错误
|
||||
|
||||
### 需求 11:回归测试与数据验证
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望重构后的系统通过完整的回归测试,以便确保数据处理无错误和偏移。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN BaseDwsTask 模板方法重构完成后,THE 所有现有 DWS 单元测试 SHALL 通过且无失败
|
||||
2. WHEN --layers 参数实现完成后,THE CLI 参数解析测试 SHALL 覆盖所有合法和非法的层组合
|
||||
3. WHEN 任务依赖声明实现完成后,THE 拓扑排序测试 SHALL 覆盖正常依赖、循环依赖和缺失依赖场景
|
||||
4. WHEN 关键词和路径重命名完成后,THE 所有现有测试 SHALL 通过且无导入错误
|
||||
5. WHEN 整体重构完成后,THE 系统 SHALL 通过端到端 dry-run 测试,验证 ODS→DWD→DWS→INDEX 全链路无异常
|
||||
|
||||
### 需求 12:文档同步更新
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望所有相关文档在重构后同步更新,以便文档与代码保持一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 重构完成后,THE `docs/etl-feiqiu-architecture.md` SHALL 反映所有类名、方法名和术语变更
|
||||
2. WHEN 重构完成后,THE `apps/etl/pipelines/feiqiu/docs/` 下的所有文档 SHALL 更新路径引用和术语
|
||||
3. WHEN 重构完成后,THE CLI 帮助文本和示例 SHALL 反映新的 `--layers` 和 `--flow` 参数
|
||||
4. WHEN 重构完成后,THE `tasks/README.md` SHALL 更新任务列表和继承关系说明
|
||||
@@ -1,190 +0,0 @@
|
||||
# 实施计划:ETL DWS/Flow 重构
|
||||
|
||||
## 概述
|
||||
|
||||
按 4 个阶段顺序实施:BaseDwsTask 模板方法重构 → --layers CLI 参数 → 任务依赖声明 → 关键词/路径重命名。每个阶段完成后运行回归测试。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. BaseDwsTask 默认模板方法
|
||||
- [x] 1.1 在 BaseDwsTask 中添加 DATE_COL 类属性和默认 extract()/load() 实现
|
||||
- 添加 `DATE_COL: str | None = None` 类属性
|
||||
- 添加 `_do_extract(self, context) -> list[dict]` 抽象方法(raise NotImplementedError)
|
||||
- 实现默认 `extract()`:调用 `_do_extract()` 并包装为标准字典
|
||||
- 实现默认 `load()`:delete_existing_data + bulk_insert,返回标准统计字典
|
||||
- _Requirements: 1.1, 1.2, 1.4, 1.5_
|
||||
|
||||
- [x] 1.2 编写 BaseDwsTask 默认模板方法的属性测试
|
||||
- **Property 1: 默认 extract() 返回标准结构**
|
||||
- **Validates: Requirements 1.1, 1.4**
|
||||
- **Property 2: 默认 load() 幂等写入与标准统计**
|
||||
- **Validates: Requirements 1.2, 1.5**
|
||||
|
||||
- [x] 1.3 迁移 DWS 子类使用默认模板方法
|
||||
- 为每个 DWS 子类声明 DATE_COL
|
||||
- 将各子类的 extract() 逻辑迁移到 _do_extract()
|
||||
- 移除与默认 load() 行为一致的子类 load() 覆盖
|
||||
- 保留有自定义逻辑的子类覆盖(如 AssistantSalaryTask 的月度删除)
|
||||
- 涉及文件:assistant_daily_task.py, assistant_monthly_task.py, assistant_customer_task.py, assistant_finance_task.py, member_consumption_task.py, member_visit_task.py, finance_daily_task.py, finance_recharge_task.py, finance_income_task.py, finance_discount_task.py
|
||||
- _Requirements: 1.1, 1.2, 1.3_
|
||||
|
||||
- [x] 1.4 运行现有 DWS 单元测试确认无回归
|
||||
- `cd apps/etl/pipelines/feiqiu && pytest tests/unit/test_dws_tasks.py -v`
|
||||
- _Requirements: 11.1_
|
||||
|
||||
- [x] 2. 公共辅助方法提取与财务基类
|
||||
- [x] 2.1 创建 dws_helpers.py 公共辅助模块
|
||||
- 创建 `tasks/dws/dws_helpers.py`
|
||||
- 提取 mask_mobile()、calc_days_since()、parse_id_list()、safe_division() 等函数
|
||||
- 更新各 DWS 子类的导入,替换内联实现为 dws_helpers 调用
|
||||
- _Requirements: 2.1, 2.2_
|
||||
|
||||
- [x] 2.2 编写 dws_helpers 函数等价性属性测试
|
||||
- **Property 3: dws_helpers 函数等价性**
|
||||
- **Validates: Requirements 2.3**
|
||||
|
||||
- [x] 2.3 创建 FinanceBaseTask 共享提取层
|
||||
- 创建 `tasks/dws/finance_base_task.py`
|
||||
- 从 FinanceDailyTask 提取共享方法:_extract_settlement_summary, _extract_recharge_summary, _extract_groupbuy_summary, _extract_platform_summary
|
||||
- 迁移 FinanceDailyTask, FinanceRechargeTask, FinanceIncomeStructureTask, FinanceDiscountDetailTask 继承 FinanceBaseTask
|
||||
- _Requirements: 3.1, 3.2, 3.3_
|
||||
|
||||
- [x] 3. MV 刷新与数据清理合并 + MemberIndexBaseTask 模板
|
||||
- [x] 3.1 创建 DwsMaintenanceTask 合并任务
|
||||
- 创建 `tasks/dws/maintenance_task.py`
|
||||
- 合并 BaseMvRefreshTask 和 DwsRetentionCleanupTask 的核心逻辑
|
||||
- 在 TaskRegistry 中注册 DWS_MAINTENANCE,移除原三个任务注册
|
||||
- 更新 `tasks/dws/__init__.py` 导出
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
|
||||
|
||||
- [x] 3.2 编写 DwsMaintenanceTask 属性测试和单元测试
|
||||
- **Property 4: DwsMaintenanceTask 配置控制**
|
||||
- **Validates: Requirements 4.3, 4.4**
|
||||
- 单元测试:执行顺序(先刷新后清理)、注册项替换
|
||||
|
||||
- [x] 3.3 重构 MemberIndexBaseTask 模板方法
|
||||
- 在 MemberIndexBaseTask 中实现 execute() 模板方法
|
||||
- 添加 _calculate_scores() 和 _save_results() 抽象方法
|
||||
- 迁移 WinbackIndexTask 和 NewconvIndexTask 使用新模板
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4_
|
||||
|
||||
- [x] 4. 检查点 - 阶段 1 回归测试
|
||||
- 运行 `cd apps/etl/pipelines/feiqiu && pytest tests/unit -v` 确保所有测试通过
|
||||
- 确保所有测试通过,如有问题请询问用户
|
||||
|
||||
- [x] 5. --layers CLI 参数与统一层解析
|
||||
- [x] 5.1 实现 --layers CLI 参数
|
||||
- 在 cli/main.py 中添加 `--layers` 参数
|
||||
- 实现 parse_layers() 函数:解析逗号分隔的层名,校验合法性
|
||||
- 添加 --layers 和 --pipeline 互斥校验
|
||||
- 更新 main() 函数:当指定 --layers 时,构造层列表传递给 PipelineRunner
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4_
|
||||
|
||||
- [x] 5.2 编写 --layers 解析属性测试
|
||||
- **Property 5: --layers 解析正确性**
|
||||
- **Validates: Requirements 6.1, 6.2**
|
||||
|
||||
- [x] 5.3 统一 _resolve_tasks() 去掉硬编码回退
|
||||
- 移除 _resolve_tasks() 中所有硬编码回退列表
|
||||
- 统一走 TaskRegistry.get_tasks_by_layer() 获取任务
|
||||
- 保留配置优先级:run.ods_tasks / run.dws_tasks / run.index_tasks > Registry
|
||||
- 空 Registry + 无配置时记录警告并返回空列表
|
||||
- _Requirements: 7.1, 7.2, 7.3_
|
||||
|
||||
- [x] 5.4 编写配置优先级属性测试
|
||||
- **Property 6: 配置优先级——配置值优先于 Registry**
|
||||
- **Validates: Requirements 7.2**
|
||||
|
||||
- [x] 5.5 实现 DWS/INDEX 层轻量级校验
|
||||
- 当 --layers 包含 DWS 或 INDEX 时,跳过完整性校验或仅执行行数校验
|
||||
- _Requirements: 6.5_
|
||||
|
||||
- [x] 6. 任务依赖声明与拓扑排序
|
||||
- [x] 6.1 扩展 TaskMeta 添加 depends_on 字段
|
||||
- 在 TaskMeta 数据类中添加 `depends_on: list[str] = field(default_factory=list)`
|
||||
- 更新 TaskRegistry.register() 接受 depends_on 参数
|
||||
- 为已知依赖关系添加声明(DWS_ASSISTANT_FINANCE → DWS_ASSISTANT_SALARY 等)
|
||||
- _Requirements: 8.1, 8.2_
|
||||
|
||||
- [x] 6.2 实现拓扑排序函数
|
||||
- 创建 `orchestration/topological_sort.py`
|
||||
- 实现 Kahn's algorithm 拓扑排序
|
||||
- 处理循环依赖检测(抛出 ValueError)
|
||||
- 处理缺失依赖警告(记录日志继续执行)
|
||||
- 在 PipelineRunner._resolve_tasks() 中集成拓扑排序
|
||||
- _Requirements: 8.3, 8.4, 8.5_
|
||||
|
||||
- [x] 6.3 编写拓扑排序属性测试
|
||||
- **Property 7: 拓扑排序正确性**
|
||||
- **Validates: Requirements 8.3**
|
||||
- **Property 8: 循环依赖检测**
|
||||
- **Validates: Requirements 8.4**
|
||||
|
||||
- [x] 7. 检查点 - 阶段 2+3 回归测试
|
||||
- 运行 `cd apps/etl/pipelines/feiqiu && pytest tests/unit -v` 确保所有测试通过
|
||||
- 运行 `cd C:\NeoZQYY && pytest tests/ -v` 确保 Monorepo 属性测试通过
|
||||
- 确保所有测试通过,如有问题请询问用户
|
||||
|
||||
- [x] 8. 关键词重命名 pipeline → flow
|
||||
- [x] 8.1 重命名 PipelineRunner → FlowRunner
|
||||
- 将 `orchestration/pipeline_runner.py` 重命名为 `orchestration/flow_runner.py`
|
||||
- 类名 PipelineRunner → FlowRunner
|
||||
- 常量 PIPELINE_LAYERS → FLOW_LAYERS
|
||||
- 更新所有导入引用(task_executor.py, cli/main.py, scheduler.py 等)
|
||||
- _Requirements: 9.1, 9.2, 9.5_
|
||||
|
||||
- [x] 8.2 更新 CLI 参数 --pipeline → --flow
|
||||
- 将 `--pipeline` 重命名为 `--flow`
|
||||
- 保留 `--pipeline` 作为已弃用别名(使用 argparse dest 映射)
|
||||
- 使用已弃用参数时输出 DeprecationWarning
|
||||
- 更新 --layers 互斥校验同时检查 --flow 和 --pipeline
|
||||
- _Requirements: 9.3, 9.4_
|
||||
|
||||
- [x] 8.3 更新所有日志消息和注释中的 pipeline 术语
|
||||
- 全局搜索替换日志中的 "Pipeline" / "pipeline" → "Flow" / "flow"
|
||||
- 更新代码注释中的术语
|
||||
- _Requirements: 9.6_
|
||||
|
||||
- [x] 9. 路径重命名 pipelines → connectors
|
||||
- [x] 9.1 重命名目录 apps/etl/pipelines → apps/etl/connectors
|
||||
- 执行目录重命名
|
||||
- 更新 pyproject.toml workspace 成员声明
|
||||
- 更新所有 Python 导入路径
|
||||
- _Requirements: 10.1, 10.2, 10.4_
|
||||
|
||||
- [x] 9.2 更新所有配置文件、脚本和文档中的路径引用
|
||||
- 更新 .env / .env.template 中的路径
|
||||
- 更新 run_etl.bat / run_etl.sh 中的路径
|
||||
- 更新 scripts/ 目录下引用旧路径的脚本
|
||||
- _Requirements: 10.3_
|
||||
|
||||
- [x] 9.3 运行全量测试确认路径重命名无回归
|
||||
- `cd apps/etl/connectors/feiqiu && pytest tests/unit -v`
|
||||
- `cd C:\NeoZQYY && pytest tests/ -v`
|
||||
- _Requirements: 10.5_
|
||||
|
||||
- [x] 10. 文档同步更新
|
||||
- [x] 10.1 更新架构文档和模块文档
|
||||
- 更新 `docs/etl-feiqiu-architecture.md`:类名、方法名、术语、路径
|
||||
- 更新 `apps/etl/connectors/feiqiu/docs/` 下所有文档
|
||||
- 更新 `tasks/README.md`:任务列表和继承关系
|
||||
- _Requirements: 12.1, 12.2, 12.4_
|
||||
|
||||
- [x] 10.2 更新 CLI 帮助文本和示例
|
||||
- 更新 cli/main.py 中的 epilog 示例
|
||||
- 更新 argparse 帮助文本反映 --layers 和 --flow 参数
|
||||
- _Requirements: 12.3_
|
||||
|
||||
- [x] 11. 最终检查点 - 全量回归测试
|
||||
- 运行 `cd apps/etl/connectors/feiqiu && pytest tests/unit -v`
|
||||
- 运行 `cd C:\NeoZQYY && pytest tests/ -v`
|
||||
- 确保所有测试通过,如有问题请询问用户
|
||||
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5_
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选,可跳过以加速 MVP
|
||||
- 每个任务引用具体需求编号以确保可追溯性
|
||||
- 检查点确保增量验证
|
||||
- 属性测试验证普遍正确性属性,单元测试验证具体示例和边界条件
|
||||
- 阶段 4(关键词/路径重命名)风险最高,建议在独立 Git 分支上执行
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,374 +0,0 @@
|
||||
# 设计文档:ETL 管道全流程调试与 Debug
|
||||
|
||||
## 概述
|
||||
|
||||
本设计覆盖 `apps/etl/pipelines/feiqiu/` 下 ETL 管道的全流程调试,采用五阶段策略:
|
||||
|
||||
1. **分层单元调试**:逐层(ODS → DWD → DWS → INDEX)使用 FakeDB/FakeAPI 和真实数据库验证任务逻辑
|
||||
2. **全量数据刷新**:执行 2026-01-01 至 2026-02-16 的 `api_full` Flow,内嵌性能计时,边执行边 Debug,发现问题即修复并重试
|
||||
3. **黑盒数据校验**:从 API 源数据出发,逐层对比各 Schema 各表的记录数、金额汇总、可疑值检测和抽样逐字段比对
|
||||
4. **架构优化分析**:分析 ETL 整体结构,在保证稳定和正确的基础上提出精简架构的建议,生成架构报告
|
||||
5. **报告生成**:基于全量刷新阶段采集的计时数据生成性能分析报告,汇总所有调试结果生成 Debug 报告
|
||||
|
||||
性能计时从全量刷新阶段开始就嵌入采集(每任务/每层/API 调用耗时),报告阶段仅做分析和输出,不再单独重跑流程采集数据。
|
||||
|
||||
调试使用 `.env` 中配置的真实 API 和数据库连接(test_etl_feiqiu),所有发现的问题和修复记录在结构化 Debug 报告中。性能计时在全量刷新阶段实时采集,报告阶段读取中间数据做分析输出。
|
||||
|
||||
## 架构
|
||||
|
||||
### 调试流程架构
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[阶段1: 分层单元调试] --> B[阶段2: 全量数据刷新<br/>内嵌性能计时]
|
||||
B --> C[阶段3: 黑盒数据校验]
|
||||
C --> D[阶段4: 架构优化分析]
|
||||
D --> E[阶段5: 报告生成<br/>含性能分析]
|
||||
|
||||
subgraph 阶段1 [分层单元调试]
|
||||
A1[ODS 层: API抓取 → 表写入] --> A2[DWD 层: ODS → DWD 清洗装载]
|
||||
A2 --> A3[DWS 层: DWD → DWS 汇总]
|
||||
A3 --> A4[INDEX 层: 指数算法计算]
|
||||
A4 --> A5[编排层: PipelineRunner + TaskExecutor]
|
||||
A5 --> A6[CLI 入口: 参数解析 + 模式切换]
|
||||
A6 --> A7[配置体系: AppConfig 加载合并]
|
||||
end
|
||||
|
||||
subgraph 阶段2 [全量数据刷新 - 内嵌计时 + 边执行边Debug]
|
||||
B1[api_full Flow 执行<br/>每任务/每层计时] --> B2{发现Bug?}
|
||||
B2 -->|是| B3[修复Bug]
|
||||
B3 --> B4[重试失败的层/任务]
|
||||
B4 --> B2
|
||||
B2 -->|否| B5[increment_verify 校验]
|
||||
B5 --> B6[自动补齐缺失数据]
|
||||
B6 --> B7[输出计时 JSON 中间文件]
|
||||
end
|
||||
|
||||
subgraph 阶段3 [黑盒数据校验]
|
||||
C1[API → ODS 记录数对比] --> C2[ODS → DWD 记录数+金额对比]
|
||||
C2 --> C3[DWD → DWS 聚合一致性]
|
||||
C3 --> C4[可疑值检测: 边缘值/空值/重复]
|
||||
C4 --> C5[抽样100条逐字段比对]
|
||||
end
|
||||
|
||||
subgraph 阶段4 [架构优化分析]
|
||||
D1[代码结构分析] --> D2[冗余识别]
|
||||
D2 --> D3[精简建议]
|
||||
end
|
||||
|
||||
subgraph 阶段5 [报告生成 - 含性能分析]
|
||||
E1[读取计时 JSON] --> E2[瓶颈识别 + 优化建议]
|
||||
E2 --> E3[汇总 Debug 报告]
|
||||
end
|
||||
```
|
||||
|
||||
### 现有系统架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CLI[cli/main.py] --> PR[PipelineRunner]
|
||||
CLI --> TE[TaskExecutor]
|
||||
PR --> TE
|
||||
TE --> TR[TaskRegistry<br/>52个任务]
|
||||
TE --> CM[CursorManager]
|
||||
TE --> RT[RunTracker]
|
||||
|
||||
TR --> ODS[ODS 任务<br/>BaseOdsTask × 23]
|
||||
TR --> DWD[DWD 任务<br/>DwdLoadTask]
|
||||
TR --> DWS[DWS 任务<br/>BaseDwsTask × 15]
|
||||
TR --> IDX[INDEX 任务 × 4]
|
||||
TR --> UTL[工具类任务 × 7]
|
||||
|
||||
ODS --> API[APIClient<br/>上游 SaaS]
|
||||
ODS --> DB[(PostgreSQL<br/>ods.*)]
|
||||
DWD --> DB
|
||||
DWS --> DB
|
||||
IDX --> DB
|
||||
|
||||
QC[IntegrityChecker] --> API
|
||||
QC --> DB
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 调试脚本组件
|
||||
|
||||
调试通过一组 Python 脚本实现,放置在 `apps/etl/pipelines/feiqiu/scripts/debug/` 目录下:
|
||||
|
||||
| 脚本 | 职责 | 对应需求 |
|
||||
|------|------|----------|
|
||||
| `debug_ods.py` | ODS 层逐任务调试:连接真实 API 和 DB,执行单个 ODS 任务并验证写入结果 | 需求 1, 9 |
|
||||
| `debug_dwd.py` | DWD 层调试:执行 DWD_LOAD_FROM_ODS 并验证 TABLE_MAP 中每对映射的记录数 | 需求 2, 9 |
|
||||
| `debug_dws.py` | DWS 层调试:逐个执行 DWS 任务并验证汇总结果 | 需求 3, 9 |
|
||||
| `debug_index.py` | INDEX 层调试:执行 4 个指数任务并验证计算结果 | 需求 4, 9 |
|
||||
| `debug_orchestration.py` | 编排层调试:验证 PipelineRunner 和 TaskExecutor 的流程控制 | 需求 5 |
|
||||
| `run_full_refresh.py` | 全量刷新:执行 2026-01-01 ~ 2026-02-16 的 api_full,内嵌性能计时,边执行边 Debug,发现问题即修复重试,输出计时 JSON | 需求 10, 13 |
|
||||
| `debug_blackbox.py` | 黑盒校验:从 API 源数据逐层对比各表数据完整性 + 可疑值检测 + 抽样逐字段比对 | 需求 12 |
|
||||
| `analyze_performance.py` | 性能分析:读取全量刷新采集的计时 JSON,统计耗时,识别瓶颈,生成性能优化报告 | 需求 13 |
|
||||
| `analyze_architecture.py` | 架构分析:分析代码结构,识别冗余和可精简点,生成架构优化报告 | 需求 14 |
|
||||
| `generate_report.py` | 报告生成:汇总所有调试结果生成 Markdown 报告 | 需求 11 |
|
||||
|
||||
### 关键接口
|
||||
|
||||
```python
|
||||
# 调试脚本的统一入口接口
|
||||
class DebugResult:
|
||||
"""单个调试步骤的结果"""
|
||||
layer: str # "ODS" / "DWD" / "DWS" / "INDEX" / "ORCHESTRATION"
|
||||
task_code: str # 任务代码
|
||||
status: str # "PASS" / "FAIL" / "WARN" / "ERROR"
|
||||
message: str # 结果描述
|
||||
details: dict # 详细信息(记录数、错误堆栈等)
|
||||
fix_applied: str | None # 已应用的修复措施
|
||||
|
||||
class BlackboxCheckResult:
|
||||
"""黑盒校验单表结果"""
|
||||
layer: str # "API_ODS" / "ODS_DWD" / "DWD_DWS"
|
||||
source_table: str # 源表名
|
||||
target_table: str # 目标表名
|
||||
source_count: int # 源记录数
|
||||
target_count: int # 目标记录数
|
||||
count_diff: int # 差异数
|
||||
amount_diffs: list # 金额差异列表
|
||||
missing_keys: list # 缺失记录主键
|
||||
mismatch_count: int # 内容不一致数
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
### Debug 报告结构
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class DebugReport:
|
||||
"""Debug 报告数据模型"""
|
||||
title: str # 报告标题
|
||||
generated_at: datetime # 生成时间
|
||||
environment: dict # 环境信息(DB DSN, API base, store_id)
|
||||
|
||||
# 分层调试结果
|
||||
ods_results: list[DebugResult] # ODS 层调试结果
|
||||
dwd_results: list[DebugResult] # DWD 层调试结果
|
||||
dws_results: list[DebugResult] # DWS 层调试结果
|
||||
index_results: list[DebugResult] # INDEX 层调试结果
|
||||
orchestration_results: list[DebugResult] # 编排层调试结果
|
||||
|
||||
# 黑盒校验结果
|
||||
blackbox_results: list[BlackboxCheckResult]
|
||||
|
||||
# 全量刷新结果
|
||||
full_refresh: dict # {status, layers, counts, duration}
|
||||
verification: dict # {status, total_tables, consistent, backfilled}
|
||||
|
||||
# 性能分析
|
||||
performance_report: dict # {layer_timings, bottlenecks, recommendations}
|
||||
|
||||
# 架构优化
|
||||
architecture_report: dict # {structure_analysis, redundancies, simplification_suggestions}
|
||||
|
||||
# 汇总
|
||||
total_issues: int # 发现的问题总数
|
||||
fixed_issues: int # 已修复的问题数
|
||||
remaining_issues: int # 遗留问题数
|
||||
```
|
||||
|
||||
### 黑盒校验数据流
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
API[上游 API] -->|iter_paginated| COUNT_API[API 记录数]
|
||||
API -->|抽样100条| SAMPLE[抽样记录集]
|
||||
ODS[(ods.* 表)] -->|SELECT COUNT| COUNT_ODS[ODS 记录数]
|
||||
ODS -->|可疑值检测| SUSPECT[可疑值: 边缘值/空值/重复]
|
||||
DWD[(dwd.* 表)] -->|SELECT COUNT| COUNT_DWD[DWD 记录数]
|
||||
DWS[(dws.* 表)] -->|SELECT SUM| SUM_DWS[DWS 汇总值]
|
||||
|
||||
COUNT_API --> CMP1{API vs ODS<br/>记录数对比}
|
||||
COUNT_ODS --> CMP1
|
||||
COUNT_ODS --> CMP2{ODS vs DWD<br/>记录数+金额对比}
|
||||
COUNT_DWD --> CMP2
|
||||
COUNT_DWD --> CMP3{DWD vs DWS<br/>聚合一致性}
|
||||
SUM_DWS --> CMP3
|
||||
|
||||
SAMPLE --> CMP4{抽样逐字段比对<br/>API vs ODS 100条}
|
||||
ODS --> CMP4
|
||||
|
||||
SUSPECT --> CMP5{可疑值分析<br/>追溯上游原因}
|
||||
|
||||
CMP1 --> RPT[校验报告]
|
||||
CMP2 --> RPT
|
||||
CMP3 --> RPT
|
||||
CMP4 --> RPT
|
||||
CMP5 --> RPT
|
||||
```
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性是在系统所有有效执行中都应成立的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
基于前置分析,以下属性覆盖了可自动化测试的验收标准。本项目的许多需求(特别是需求 8-12 的黑盒校验和全量刷新)依赖真实数据库和 API 连接,属于集成测试范畴,不适合属性测试。属性测试聚焦于可用 FakeDB/FakeAPI 验证的核心逻辑。
|
||||
|
||||
### Property 1: ODS 任务提取记录数一致性
|
||||
|
||||
*对任意* ODS 任务和 FakeAPI 提供的任意非空记录列表,任务执行后 FakeDB 接收到的记录数应等于 API 提供的记录数减去被跳过的记录数(缺失主键 + 重复哈希)。
|
||||
|
||||
**Validates: Requirements 1.1, 1.2**
|
||||
|
||||
### Property 2: ODS 冲突处理策略正确性
|
||||
|
||||
*对任意* `ods_conflict_mode` 配置值(nothing/backfill/update),BaseOdsTask 生成的 SQL 语句应包含对应的冲突处理子句:nothing → `DO NOTHING`,backfill → `COALESCE`,update → `IS DISTINCT FROM`。
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 3: ODS 跳过缺失主键记录
|
||||
|
||||
*对任意*包含缺失主键字段的记录集合,BaseOdsTask 的 skipped 计数应等于缺失主键的记录数,且这些记录不应出现在写入的行中。
|
||||
|
||||
**Validates: Requirements 1.4**
|
||||
|
||||
### Property 4: ODS content_hash 去重
|
||||
|
||||
*对任意*两条内容相同的 ODS 记录(仅 fetched_at 不同),计算出的 content_hash 应相同;当已有记录的 content_hash 与新记录相同时,新记录应被跳过。
|
||||
|
||||
**Validates: Requirements 1.5**
|
||||
|
||||
### Property 5: ODS 快照删除标记
|
||||
|
||||
*对任意*启用 `snapshot_missing_delete` 的 ODS 任务,当 API 返回的记录集是已有记录集的真子集时,差集中的记录应被标记为 `is_delete=1`。
|
||||
|
||||
**Validates: Requirements 1.7**
|
||||
|
||||
### Property 6: DWD FACT_MAPPINGS 列映射完整性
|
||||
|
||||
*对任意* FACT_MAPPINGS 中的映射条目 `(dwd_col, ods_expr, cast_type)`,当 `ods_expr` 是简单列名时,该列应存在于对应的 ODS 源表中。
|
||||
|
||||
**Validates: Requirements 2.4**
|
||||
|
||||
### Property 7: DWD only_tables 过滤
|
||||
|
||||
*对任意*非空的 `dwd.only_tables` 配置列表,DwdLoadTask 处理的表集合应是配置列表与 TABLE_MAP 键集合的交集。
|
||||
|
||||
**Validates: Requirements 2.6**
|
||||
|
||||
### Property 8: DWS 分段累加一致性
|
||||
|
||||
*对任意*时间窗口和分段配置,BaseTask 的 `_accumulate_counts` 方法对各分段计数的累加结果应等于各分段计数的逐键求和。
|
||||
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
### Property 9: PipelineRunner Flow 层解析
|
||||
|
||||
*对任意*有效的 Flow 名称(PIPELINE_LAYERS 的键),PipelineRunner 解析出的层列表应与 PIPELINE_LAYERS 中定义的值完全一致。
|
||||
|
||||
**Validates: Requirements 5.1, 10.2**
|
||||
|
||||
### Property 10: PipelineRunner 无效 Flow 拒绝
|
||||
|
||||
*对任意*不在 PIPELINE_LAYERS 键集合中的字符串,PipelineRunner.run() 应抛出 ValueError。
|
||||
|
||||
**Validates: Requirements 5.2**
|
||||
|
||||
### Property 11: TaskExecutor 工具类任务跳过游标
|
||||
|
||||
*对任意*被 TaskRegistry 标记为 `requires_db_config=False` 的任务代码,TaskExecutor 应通过 `_run_utility_task` 路径执行,不调用 CursorManager 和 RunTracker。
|
||||
|
||||
**Validates: Requirements 5.5**
|
||||
|
||||
### Property 12: CLI data_source 解析
|
||||
|
||||
*对任意* `--data-source` 参数值(online/offline/hybrid)和 `--pipeline-flow` 参数值(FULL/FETCH_ONLY/INGEST_ONLY),`resolve_data_source` 应返回正确的映射值,且 `--data-source` 优先于 `--pipeline-flow`。
|
||||
|
||||
**Validates: Requirements 6.3, 6.4**
|
||||
|
||||
### Property 13: AppConfig 优先级合并
|
||||
|
||||
*对任意*嵌套字典 DEFAULTS 和 CLI 覆盖,`_deep_merge` 后 CLI 中的键值应覆盖 DEFAULTS 中的同名键值,未被覆盖的键应保持原值。
|
||||
|
||||
**Validates: Requirements 7.1**
|
||||
|
||||
### Property 14: AppConfig store_id 验证
|
||||
|
||||
*对任意*非整数字符串作为 `app.store_id`,AppConfig._normalize 应抛出 SystemExit。
|
||||
|
||||
**Validates: Requirements 7.2**
|
||||
|
||||
### Property 15: AppConfig DSN 组装
|
||||
|
||||
*对任意* host、port、name、user、password 组合(db.dsn 为空时),AppConfig._normalize 应组装出格式为 `postgresql://{user}:{password}@{host}:{port}/{name}` 的 DSN 字符串。
|
||||
|
||||
**Validates: Requirements 7.3**
|
||||
|
||||
### Property 16: AppConfig 点号路径 get
|
||||
|
||||
*对任意*嵌套字典和有效的点号路径,`config.get(path)` 应返回路径对应的值;对无效路径应返回 default 参数值。
|
||||
|
||||
**Validates: Requirements 7.4**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 数据库连接错误
|
||||
- 调试脚本在启动时验证数据库连接,连接失败时记录错误详情并在报告中标注
|
||||
- 单表处理失败时回滚该表事务,继续处理后续表(DWD 层已实现此逻辑)
|
||||
- 全量刷新过程中连接断开时,利用 `DatabaseConnection.ensure_open()` 尝试重连
|
||||
|
||||
### API 连接错误
|
||||
- APIClient 内置重试机制(`retry_max=3`,指数退避)
|
||||
- API 返回非 0 code 时抛出 ValueError,由上层捕获并记录
|
||||
- Token 过期时在报告中标注,提示用户更新 `.env` 中的 `API_TOKEN`
|
||||
|
||||
### 任务执行错误
|
||||
- ODS 任务:数据库异常时回滚并递增 errors 计数
|
||||
- DWD 任务:单表失败时回滚该表,继续后续表,最终汇总错误
|
||||
- DWS/INDEX 任务:继承 BaseTask 的异常处理,回滚后重新抛出
|
||||
- 工具类任务:异常直接向上传播,不影响游标和运行记录
|
||||
|
||||
### 数据质量错误
|
||||
- 黑盒校验发现的不一致记录在报告中详细列出主键
|
||||
- 金额差异超过阈值时标记为 WARN 级别
|
||||
- 校验后自动补齐通过 `run_backfill` 执行,补齐失败的记录单独记录
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 双轨测试方法
|
||||
|
||||
本项目采用单元测试 + 属性测试的双轨方法:
|
||||
|
||||
- **属性测试**:使用 `hypothesis` 库验证上述 16 个正确性属性,每个属性至少运行 100 次迭代
|
||||
- **单元测试**:使用 `pytest` 验证具体示例、边界情况和错误条件
|
||||
- **集成测试**:使用真实数据库(`TEST_DB_DSN`)验证端到端数据流
|
||||
|
||||
### 属性测试配置
|
||||
|
||||
- 库:`hypothesis`(已在项目依赖中)
|
||||
- 最小迭代次数:100
|
||||
- 每个属性测试必须引用设计文档中的属性编号
|
||||
- 标签格式:`Feature: etl-pipeline-debug, Property {number}: {property_text}`
|
||||
|
||||
### 测试文件组织
|
||||
|
||||
```
|
||||
apps/etl/pipelines/feiqiu/tests/unit/
|
||||
├── test_debug_ods_properties.py # Property 1-5: ODS 层属性测试
|
||||
├── test_debug_dwd_properties.py # Property 6-8: DWD/DWS 层属性测试
|
||||
├── test_debug_orchestration_properties.py # Property 9-12: 编排层属性测试
|
||||
├── test_debug_config_properties.py # Property 13-16: 配置层属性测试
|
||||
└── (现有测试文件保持不变)
|
||||
```
|
||||
|
||||
### 集成测试
|
||||
|
||||
集成测试通过调试脚本实现,连接真实数据库和 API:
|
||||
- `debug_ods.py`:逐个执行 ODS 任务,验证写入结果
|
||||
- `debug_dwd.py`:执行 DWD 装载,验证映射正确性
|
||||
- `debug_blackbox.py`:黑盒校验,逐层对比数据完整性
|
||||
- `run_full_refresh.py`:全量刷新 + 校验
|
||||
|
||||
### 现有测试基础设施
|
||||
|
||||
项目已有完善的测试工具:
|
||||
- `FakeDBOperations`:拦截并记录 SQL 操作,提供 commit/rollback 计数
|
||||
- `FakeAPIClient`:返回预置内存数据,记录调用参数
|
||||
- `OfflineAPIClient`:从归档 JSON 回放数据
|
||||
- `RealDBOperationsAdapter`:连接真实 PostgreSQL 的适配器
|
||||
- `create_test_config()`:构建测试用 AppConfig
|
||||
@@ -1,188 +0,0 @@
|
||||
# 需求文档:ETL 管道全流程调试与 Debug
|
||||
|
||||
## 简介
|
||||
|
||||
对 `apps/etl/pipelines/feiqiu/` 下的 ETL 管道进行全流程调试,覆盖 ODS → DWD → DWS → INDEX 四层数据处理,识别并修复代码缺陷,生成 Debug 报告。调试范围包括 CLI 入口、编排层、任务执行层、数据库操作层和数据质量校验层。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **ETL_Pipeline**:从上游 SaaS API 抽取数据,经 ODS → DWD → DWS 三层处理的完整数据管道
|
||||
- **ODS_Layer**:原始数据层(Operational Data Store),负责从 API 抓取数据并落地到 `ods.*` 表
|
||||
- **DWD_Layer**:明细数据层(Data Warehouse Detail),负责从 ODS 清洗装载,维度走 SCD2,事实按时间增量
|
||||
- **DWS_Layer**:汇总数据层(Data Warehouse Summary),负责从 DWD 聚合生成业务汇总(助教业绩、财务日报等)
|
||||
- **INDEX_Layer**:指数算法层,负责计算自定义业务指数(WBI/NCI/RS/ML)
|
||||
- **TaskExecutor**:任务执行器,封装单个 ETL 任务的完整执行生命周期
|
||||
- **PipelineRunner**:Flow 编排器,根据 Flow 定义执行多层 ETL 任务
|
||||
- **TaskRegistry**:任务注册表,管理 52 个已注册任务的元数据和工厂方法
|
||||
- **CursorManager**:游标管理器,负责记录和推进每个任务的时间水位
|
||||
- **FakeDB**:测试用伪数据库操作对象,拦截并记录 SQL 操作
|
||||
- **FakeAPI**:测试用伪 API 客户端,返回预置内存数据
|
||||
- **Debug_Report**:调试报告,记录发现的问题、修复措施和验证结果
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:ODS 层任务调试
|
||||
|
||||
**用户故事:** 作为开发者,我希望验证 ODS 层所有任务能正确从 API 抓取数据并写入 ODS 表,以确保原始数据完整落地。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 使用 FakeAPI 提供样例数据执行 ODS 任务, THE TaskExecutor SHALL 正确调用 API 分页接口并提取记录列表
|
||||
2. WHEN ODS 任务接收到 API 返回的记录, THE BaseOdsTask SHALL 按照 DB 表结构动态构建 INSERT 语句并写入 ODS 表
|
||||
3. WHEN ODS 记录的主键已存在于目标表, THE BaseOdsTask SHALL 根据 `ods_conflict_mode` 配置执行对应的冲突处理策略(nothing/backfill/update)
|
||||
4. WHEN ODS 记录缺少必需的主键字段值, THE BaseOdsTask SHALL 跳过该记录并递增 skipped 计数
|
||||
5. WHEN `content_hash` 列存在且新记录的哈希值与已有记录相同, THE BaseOdsTask SHALL 跳过该记录以避免无意义更新
|
||||
6. IF ODS 任务执行过程中发生数据库异常, THEN THE BaseOdsTask SHALL 回滚当前事务并在 counts 中递增 errors 计数
|
||||
7. WHEN `snapshot_missing_delete` 配置启用且表包含 `is_delete` 列, THE BaseOdsTask SHALL 将窗口内未出现在 API 返回中的记录标记为已删除
|
||||
|
||||
### 需求 2:DWD 层装载调试
|
||||
|
||||
**用户故事:** 作为开发者,我希望验证 DWD 装载任务能正确从 ODS 清洗数据并写入 DWD 表,以确保维度和事实数据准确。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN DWD_LOAD_FROM_ODS 任务执行, THE DwdLoadTask SHALL 遍历 TABLE_MAP 中所有 DWD→ODS 映射关系并逐表处理
|
||||
2. WHEN 处理维度表(dim_*), THE DwdLoadTask SHALL 使用 SCD2 合并策略写入,保留历史版本
|
||||
3. WHEN 处理事实表(dwd_*), THE DwdLoadTask SHALL 按时间窗口增量写入,使用 FACT_ORDER_CANDIDATES 中的时间列过滤
|
||||
4. WHEN ODS 源列名与 DWD 目标列名不同, THE DwdLoadTask SHALL 使用 FACT_MAPPINGS 中定义的列映射和类型转换正确转换
|
||||
5. WHEN 单张 DWD 表处理失败, THE DwdLoadTask SHALL 回滚该表事务并继续处理后续表,在结果中汇总错误信息
|
||||
6. WHEN `dwd.only_tables` 配置或 `DWD_ONLY_TABLES` 环境变量指定了表名列表, THE DwdLoadTask SHALL 仅处理指定的表
|
||||
|
||||
### 需求 3:DWS 层汇总调试
|
||||
|
||||
**用户故事:** 作为开发者,我希望验证 DWS 层汇总任务能正确从 DWD 聚合生成业务报表数据,以确保汇总结果准确。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN DWS 汇总任务执行, THE DWS_Task SHALL 从 DWD 层读取明细数据并按业务规则聚合写入 DWS 表
|
||||
2. WHEN TaskRegistry 按 "DWS" 层查询任务列表, THE TaskRegistry SHALL 返回所有 15 个已注册的 DWS 层任务代码
|
||||
3. WHEN DWS 任务的时间窗口跨越多个分段, THE DWS_Task SHALL 按分段逐段处理并累加计数结果
|
||||
|
||||
### 需求 4:INDEX 层指数算法调试
|
||||
|
||||
**用户故事:** 作为开发者,我希望验证 INDEX 层指数算法任务能正确计算业务指数,以确保 WBI/NCI/RS/ML 指数结果准确。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN INDEX 层任务执行, THE INDEX_Task SHALL 从 DWD/DWS 层读取数据并按指数算法公式计算结果
|
||||
2. WHEN TaskRegistry 按 "INDEX" 层查询任务列表, THE TaskRegistry SHALL 返回所有 4 个已注册的 INDEX 层任务代码(DWS_WINBACK_INDEX, DWS_NEWCONV_INDEX, DWS_ML_MANUAL_IMPORT, DWS_RELATION_INDEX)
|
||||
|
||||
### 需求 5:编排层调试
|
||||
|
||||
**用户故事:** 作为开发者,我希望验证 PipelineRunner 和 TaskExecutor 的编排逻辑正确,以确保多层 ETL 任务按正确顺序执行。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN PipelineRunner 接收到有效的 Flow 名称, THE PipelineRunner SHALL 按 PIPELINE_LAYERS 定义的层顺序解析并执行任务
|
||||
2. IF PipelineRunner 接收到无效的 Flow 名称, THEN THE PipelineRunner SHALL 抛出 ValueError 并包含描述性错误信息
|
||||
3. WHEN PipelineRunner 在 `verify_only` 模式下执行, THE PipelineRunner SHALL 跳过增量 ETL 并直接执行校验逻辑
|
||||
4. WHEN PipelineRunner 在 `increment_verify` 模式下执行, THE PipelineRunner SHALL 先执行增量 ETL 再执行校验逻辑
|
||||
5. WHEN TaskExecutor 执行工具类任务, THE TaskExecutor SHALL 跳过游标管理和运行记录,直接执行任务
|
||||
6. WHEN TaskExecutor 执行 ODS 任务且 data_source 包含 fetch 阶段, THE TaskExecutor SHALL 使用 RecordingAPIClient 抓取并落盘后入库
|
||||
7. WHEN 任务执行成功且返回有效的时间窗口, THE TaskExecutor SHALL 通过 CursorManager 推进游标水位
|
||||
|
||||
### 需求 6:CLI 入口调试
|
||||
|
||||
**用户故事:** 作为开发者,我希望验证 CLI 入口能正确解析参数并启动对应的执行模式,以确保命令行操作可靠。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN CLI 接收到 `--pipeline` 参数, THE CLI SHALL 进入 Flow 模式并使用 PipelineRunner 执行
|
||||
2. WHEN CLI 接收到 `--tasks` 参数但无 `--pipeline`, THE CLI SHALL 进入传统模式并使用 TaskExecutor 直接执行任务列表
|
||||
3. WHEN CLI 接收到 `--data-source` 参数, THE CLI SHALL 使用指定的数据源模式(online/offline/hybrid)
|
||||
4. WHEN CLI 接收到已弃用的 `--pipeline-flow` 参数, THE CLI SHALL 映射为对应的 `data_source` 值并发出 DeprecationWarning
|
||||
5. WHEN CLI 未指定时间窗口参数, THE CLI SHALL 使用 `--lookback-hours`(默认 24 小时)计算回溯窗口
|
||||
|
||||
### 需求 7:配置体系调试
|
||||
|
||||
**用户故事:** 作为开发者,我希望验证配置加载和合并逻辑正确,以确保 DEFAULTS < ENV < CLI 的优先级链可靠。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN AppConfig.load 被调用, THE AppConfig SHALL 按 DEFAULTS → ENV → CLI 的优先级深度合并配置
|
||||
2. WHEN 配置中 `app.store_id` 缺失或非整数, THE AppConfig SHALL 抛出 SystemExit 并包含描述性错误信息
|
||||
3. WHEN 配置中 `db.dsn` 为空, THE AppConfig SHALL 从 host/port/name/user/password 组装 DSN 字符串
|
||||
4. WHEN 使用点号路径调用 `config.get()`, THE AppConfig SHALL 正确遍历嵌套字典并返回对应值
|
||||
|
||||
### 需求 8:数据质量校验调试
|
||||
|
||||
**用户故事:** 作为开发者,我希望验证数据完整性校验逻辑能正确检测 ODS→DWD 之间的数据差异,以确保数据一致性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 执行 DWD vs ODS 校验, THE IntegrityChecker SHALL 对 TABLE_MAP 中每对 DWD→ODS 表比较记录数和金额列汇总
|
||||
2. WHEN 校验发现 DWD 与 ODS 记录数不一致, THE IntegrityChecker SHALL 在结果中报告 count diff 值
|
||||
3. WHEN `compare_content` 配置启用, THE IntegrityChecker SHALL 通过哈希比较检测字段级内容差异并报告 mismatch 数量
|
||||
|
||||
### 需求 9:真实数据调试
|
||||
|
||||
**用户故事:** 作为开发者,我希望使用真实的数据库和 API 连接进行调试,以确保发现的问题贴近生产环境。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE ETL_Pipeline SHALL 使用 `apps/etl/pipelines/feiqiu/.env` 中配置的真实 API 地址和数据库 DSN 进行调试
|
||||
2. WHEN 执行调试任务, THE ETL_Pipeline SHALL 连接真实 PostgreSQL 实例(test_etl_feiqiu 数据库)验证数据读写
|
||||
3. WHEN 执行调试任务, THE ETL_Pipeline SHALL 调用真实上游 SaaS API 验证数据抓取和分页逻辑
|
||||
4. IF 真实 API 或数据库连接失败, THEN THE ETL_Pipeline SHALL 记录连接错误详情并在 Debug 报告中标注
|
||||
|
||||
### 需求 10:全量数据更新与校验
|
||||
|
||||
**用户故事:** 作为开发者,我希望在调试完成后执行 2026-01-01 至 2026-02-16 的全量数据更新和校验,以确保历史数据完整一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 调试修复完成后, THE ETL_Pipeline SHALL 使用 `api_full` Flow 执行 2026-01-01 00:00 至 2026-02-16 00:00 的全量数据更新
|
||||
2. WHEN 全量更新执行, THE PipelineRunner SHALL 按 ODS → DWD → DWS → INDEX 顺序依次处理所有层
|
||||
3. WHEN 全量更新完成后, THE ETL_Pipeline SHALL 执行 `increment_verify` 模式对同一时间窗口进行数据一致性校验
|
||||
4. WHEN 校验发现数据不一致, THE IntegrityChecker SHALL 自动执行补齐操作并记录补齐结果
|
||||
5. THE Debug_Report SHALL 包含全量更新的执行统计(各层记录数、耗时)和校验结果摘要
|
||||
|
||||
### 需求 11:Debug 报告生成
|
||||
|
||||
**用户故事:** 作为开发者,我希望获得一份结构化的 Debug 报告,记录所有发现的问题和修复措施,以便追溯和复查。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Debug_Report SHALL 包含以下章节:概述、发现的问题列表、修复措施、验证结果、全量更新统计、遗留问题
|
||||
2. WHEN 发现代码缺陷, THE Debug_Report SHALL 记录缺陷位置(文件路径+行号)、缺陷描述、严重程度和修复方案
|
||||
3. WHEN 修复已验证通过, THE Debug_Report SHALL 记录验证方式(单元测试/属性测试/手动验证)和验证结果
|
||||
4. THE Debug_Report SHALL 输出为 Markdown 文件,存放在 `apps/etl/pipelines/feiqiu/docs/reports/` 目录下
|
||||
|
||||
### 需求 12:黑盒数据完整性校验
|
||||
|
||||
**用户故事:** 作为检查者,我希望以黑盒视角从 API 源数据出发,逐层对比各 Schema 各表的数据是否完整,以独立验证 ETL 管道的正确性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 执行黑盒校验, THE IntegrityChecker SHALL 从上游 API 重新拉取指定时间窗口的源数据作为基准
|
||||
2. WHEN 获取到 API 源数据后, THE IntegrityChecker SHALL 将 API 记录数与 ODS 表记录数逐端点对比,报告缺失和多余记录
|
||||
3. WHEN ODS 层校验完成后, THE IntegrityChecker SHALL 将 ODS 表记录数与 DWD 表记录数逐映射对比,报告数量差异
|
||||
4. WHEN DWD 层校验完成后, THE IntegrityChecker SHALL 验证 DWS 汇总表的聚合结果与 DWD 明细数据的一致性
|
||||
5. WHEN 校验发现金额类字段汇总不一致, THE IntegrityChecker SHALL 报告差异金额和涉及的具体记录主键
|
||||
6. THE IntegrityChecker SHALL 生成逐层校验报告,包含每张表的记录数对比、金额汇总对比和内容哈希差异统计
|
||||
7. WHEN 校验发现缺失记录, THE IntegrityChecker SHALL 输出缺失记录的主键列表,便于定位补齐
|
||||
8. WHEN 执行黑盒校验, THE IntegrityChecker SHALL 检测可疑值(边缘值、空值、重复记录等),分析可能的流程问题并追溯原因
|
||||
9. WHEN 全量更新完成后, THE IntegrityChecker SHALL 从新数据中抽样 100 条记录,逐字段与上游 API 源数据比对,验证字段级一致性
|
||||
|
||||
### 需求 13:性能分析
|
||||
|
||||
**用户故事:** 作为开发者,我希望分析 ETL 整体流程的性能瓶颈,在保证稳定和数据处理结果正确的基础上提高数据处理性能。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Performance_Analyzer SHALL 统计各层(ODS/DWD/DWS/INDEX)和各任务的执行耗时
|
||||
2. THE Performance_Analyzer SHALL 识别耗时最长的前 5 个任务作为性能瓶颈
|
||||
3. THE Performance_Analyzer SHALL 分析数据库查询的执行计划,识别缺失索引和全表扫描
|
||||
4. THE Performance_Analyzer SHALL 分析 API 调用的响应时间和分页效率
|
||||
5. THE Performance_Analyzer SHALL 生成性能优化报告,包含瓶颈分析和具体优化建议,存放在 `apps/etl/pipelines/feiqiu/docs/reports/` 目录下
|
||||
|
||||
### 需求 14:架构优化分析
|
||||
|
||||
**用户故事:** 作为开发者,我希望分析 ETL 整体结构,在保证稳定和数据处理结果正确的基础上精简架构。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Architecture_Analyzer SHALL 分析代码结构,识别重复代码和冗余模块
|
||||
2. THE Architecture_Analyzer SHALL 评估各层之间的耦合度,识别可解耦的组件
|
||||
3. THE Architecture_Analyzer SHALL 分析任务注册表中 52 个任务的分类合理性
|
||||
4. THE Architecture_Analyzer SHALL 生成架构优化报告,包含结构分析、冗余识别和精简建议,存放在 `apps/etl/pipelines/feiqiu/docs/reports/` 目录下
|
||||
@@ -1,168 +0,0 @@
|
||||
# 实现计划:ETL 管道全流程调试与 Debug
|
||||
|
||||
## 概述
|
||||
|
||||
按五阶段策略实现 ETL 管道全流程调试:分层单元调试 → 全量数据刷新(内嵌计时,边执行边 Debug)→ 黑盒数据校验 → 架构优化分析 → Debug 报告生成(含性能分析)。性能计时从全量刷新阶段开始就嵌入采集,报告阶段仅做分析和输出。调试脚本放置在 `apps/etl/pipelines/feiqiu/scripts/debug/` 目录下。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 阶段1:分层单元调试 - ODS 层
|
||||
- [x] 1.1 编写 `scripts/debug/debug_ods.py` 调试脚本
|
||||
- 连接真实 API 和数据库(从 `.env` 加载配置)
|
||||
- 逐个执行 23 个 ODS 任务(小窗口,如最近 2 小时)
|
||||
- 验证每个任务的返回结果:status、counts(fetched/inserted/updated/skipped/errors)
|
||||
- 检查 ODS 表实际写入行数是否与 counts 一致
|
||||
- 记录每个任务的执行结果到 DebugResult 列表
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 9.1, 9.2, 9.3_
|
||||
|
||||
- [x] 1.2 编写 ODS 层属性测试 `tests/unit/test_debug_ods_properties.py`
|
||||
- **Property 1: ODS 任务提取记录数一致性**
|
||||
- **Validates: Requirements 1.1, 1.2**
|
||||
- **Property 2: ODS 冲突处理策略正确性**
|
||||
- **Validates: Requirements 1.3**
|
||||
- **Property 3: ODS 跳过缺失主键记录**
|
||||
- **Validates: Requirements 1.4**
|
||||
- **Property 4: ODS content_hash 去重**
|
||||
- **Validates: Requirements 1.5**
|
||||
- **Property 5: ODS 快照删除标记**
|
||||
- **Validates: Requirements 1.7**
|
||||
|
||||
- [x] 2. 阶段1:分层单元调试 - DWD 层
|
||||
- [x] 2.1 编写 `scripts/debug/debug_dwd.py` 调试脚本
|
||||
- 执行 DWD_LOAD_FROM_ODS 任务
|
||||
- 验证 TABLE_MAP 中每对 DWD→ODS 映射的处理结果
|
||||
- 检查维度表 SCD2 版本链完整性
|
||||
- 检查事实表时间窗口增量写入正确性
|
||||
- 验证 FACT_MAPPINGS 列映射和类型转换
|
||||
- 记录每张表的处理结果(inserted/updated/errors)
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 9.1, 9.2_
|
||||
|
||||
- [x] 2.2 编写 DWD 层属性测试 `tests/unit/test_debug_dwd_properties.py`
|
||||
- **Property 6: DWD FACT_MAPPINGS 列映射完整性**
|
||||
- **Validates: Requirements 2.4**
|
||||
- **Property 7: DWD only_tables 过滤**
|
||||
- **Validates: Requirements 2.6**
|
||||
- **Property 8: DWS 分段累加一致性**
|
||||
- **Validates: Requirements 3.3**
|
||||
|
||||
- [x] 3. 阶段1:分层单元调试 - DWS 和 INDEX 层
|
||||
- [x] 3.1 编写 `scripts/debug/debug_dws.py` 调试脚本
|
||||
- 逐个执行 15 个 DWS 汇总任务
|
||||
- 验证每个任务的返回结果和 DWS 表写入情况
|
||||
- 检查汇总数据与 DWD 明细数据的一致性(抽样验证)
|
||||
- _Requirements: 3.1, 3.2, 3.3, 9.1, 9.2_
|
||||
|
||||
- [x] 3.2 编写 `scripts/debug/debug_index.py` 调试脚本
|
||||
- 执行 4 个 INDEX 层任务(WBI/NCI/RS/ML)
|
||||
- 验证指数计算结果的合理性(非空、范围检查)
|
||||
- _Requirements: 4.1, 4.2, 9.1, 9.2_
|
||||
|
||||
- [x] 4. 阶段1:分层单元调试 - 编排层和配置层
|
||||
- [x] 4.1 编写 `scripts/debug/debug_orchestration.py` 调试脚本
|
||||
- 验证 PipelineRunner 的 Flow 解析逻辑(所有 7 种 Flow)
|
||||
- 验证 TaskExecutor 的任务分发逻辑(ODS/DWD/DWS/工具类)
|
||||
- 验证 CursorManager 的游标推进逻辑
|
||||
- 验证 CLI 参数解析(传统模式 vs Flow 模式)
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 6.1, 6.2, 6.3, 6.4, 6.5_
|
||||
|
||||
- [x] 4.2 编写编排层属性测试 `tests/unit/test_debug_orchestration_properties.py`
|
||||
- **Property 9: PipelineRunner Flow 层解析**
|
||||
- **Validates: Requirements 5.1, 10.2**
|
||||
- **Property 10: PipelineRunner 无效 Flow 拒绝**
|
||||
- **Validates: Requirements 5.2**
|
||||
- **Property 11: TaskExecutor 工具类任务跳过游标**
|
||||
- **Validates: Requirements 5.5**
|
||||
- **Property 12: CLI data_source 解析**
|
||||
- **Validates: Requirements 6.3, 6.4**
|
||||
|
||||
- [x] 4.3 编写配置层属性测试 `tests/unit/test_debug_config_properties.py`
|
||||
- **Property 13: AppConfig 优先级合并**
|
||||
- **Validates: Requirements 7.1**
|
||||
- **Property 14: AppConfig store_id 验证**
|
||||
- **Validates: Requirements 7.2**
|
||||
- **Property 15: AppConfig DSN 组装**
|
||||
- **Validates: Requirements 7.3**
|
||||
- **Property 16: AppConfig 点号路径 get**
|
||||
- **Validates: Requirements 7.4**
|
||||
|
||||
- [x] 5. 检查点 - 阶段1 完成
|
||||
- 确保所有单元调试脚本可运行,所有属性测试通过,询问用户是否有问题。
|
||||
|
||||
- [x] 6. 阶段2:全量数据刷新(内嵌计时,边执行边 Debug)
|
||||
- [x] 6.1 编写 `scripts/debug/run_full_refresh.py` 全量刷新脚本
|
||||
- 使用 `api_full` Flow 执行 2026-01-01 00:00 至 2026-02-16 00:00 的全量更新
|
||||
- 按层逐步执行:先 ODS,再 DWD,再 DWS,最后 INDEX
|
||||
- 内嵌性能计时:记录每个任务的开始/结束时间、每层的总耗时、API 调用响应时间,计时的项目颗粒度尽可能精细。
|
||||
- 每层执行后检查结果,发现错误时记录并尝试修复
|
||||
- 支持从失败的层/任务重试(断点续跑)
|
||||
- 全量更新完成后执行 `increment_verify` 校验
|
||||
- 校验发现不一致时自动补齐
|
||||
- 将计时数据和执行统计(记录数、耗时)写入 JSON 中间文件供后续性能分析使用
|
||||
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 13.1, 13.4, 9.1, 9.2, 9.3_
|
||||
|
||||
- [x] 6.2 执行全量刷新并修复发现的 Bug
|
||||
- 运行 `run_full_refresh.py`
|
||||
- 对执行过程中发现的每个 Bug:定位原因、修复代码、重试验证
|
||||
- 将所有发现的问题和修复记录到 DebugResult 列表
|
||||
- _Requirements: 10.1, 10.2, 10.3, 10.4_
|
||||
|
||||
- [x] 7. 检查点 - 阶段2 完成
|
||||
- 确保全量刷新成功完成,所有测试通过,询问用户是否有问题。
|
||||
|
||||
- [x] 8. 阶段3:黑盒数据校验
|
||||
- [x] 8.1 编写 `scripts/debug/debug_blackbox.py` 黑盒校验脚本
|
||||
- API → ODS:逐端点从 API 拉取数据,与 ODS 表记录数对比
|
||||
- ODS → DWD:按 TABLE_MAP 逐对比较记录数和金额列汇总
|
||||
- DWD → DWS:验证汇总表聚合结果与明细数据一致性
|
||||
- 可疑值检测:扫描各表中的边缘值、空值、重复记录,分析可能的流程问题
|
||||
- 抽样比对:从新数据中随机抽样 100 条记录,逐字段与上游 API 源数据比对
|
||||
- 输出缺失记录主键列表和差异金额详情
|
||||
- 生成逐层校验报告
|
||||
- _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7, 12.8, 12.9_
|
||||
|
||||
- [x] 8.2 执行黑盒校验并记录结果
|
||||
- 运行 `debug_blackbox.py`
|
||||
- 分析校验结果,对发现的不一致追溯原因
|
||||
- 将校验结果记录到 BlackboxCheckResult 列表
|
||||
- _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7, 12.8, 12.9_
|
||||
|
||||
- [x] 9. 检查点 - 阶段3 完成
|
||||
- 确保黑盒校验完成,所有测试通过,询问用户是否有问题。
|
||||
|
||||
- [x] 10. 阶段4:架构优化分析
|
||||
- [x] 10.1 编写 `scripts/debug/analyze_architecture.py` 架构分析脚本
|
||||
- 分析代码结构:模块依赖关系、文件大小、函数复杂度
|
||||
- 识别重复代码和冗余模块
|
||||
- 评估各层之间的耦合度
|
||||
- 分析 52 个任务的分类合理性
|
||||
- 生成架构优化报告(Markdown),存放在 `docs/reports/`
|
||||
- _Requirements: 14.1, 14.2, 14.3, 14.4_
|
||||
|
||||
- [x] 11. 阶段5:Debug 报告生成(含性能分析)
|
||||
- [x] 11.1 编写 `scripts/debug/analyze_performance.py` 性能分析脚本
|
||||
- 读取全量刷新阶段采集的计时 JSON 中间文件
|
||||
- 统计各层(ODS/DWD/DWS/INDEX)和各任务的执行耗时
|
||||
- 识别耗时最长的前 5 个任务作为性能瓶颈
|
||||
- 分析关键 SQL 查询的执行计划(EXPLAIN ANALYZE)
|
||||
- 分析 API 调用的响应时间和分页效率
|
||||
- 生成性能优化报告(Markdown),存放在 `docs/reports/`
|
||||
- _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5_
|
||||
|
||||
- [x] 11.2 编写 `scripts/debug/generate_report.py` 报告生成脚本
|
||||
- 汇总所有阶段的调试结果
|
||||
- 生成结构化 Markdown 报告,包含:概述、发现的问题列表、修复措施、验证结果、全量更新统计、黑盒校验结果、性能分析摘要、架构优化摘要、遗留问题
|
||||
- 输出到 `apps/etl/pipelines/feiqiu/docs/reports/debug_report_YYYYMMDD.md`
|
||||
- _Requirements: 11.1, 11.2, 11.3, 11.4_
|
||||
|
||||
- [x] 12. 最终检查点 - 全部完成
|
||||
- 确保所有测试通过,所有报告已生成,询问用户是否有问题。
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的任务为可选任务,可跳过以加快 MVP 进度
|
||||
- 每个任务引用具体需求以确保可追溯性
|
||||
- 检查点确保增量验证
|
||||
- 属性测试验证通用正确性属性
|
||||
- 单元测试验证具体示例和边界情况
|
||||
- 调试脚本使用真实 API 和数据库连接(`.env` 配置)
|
||||
- 全量刷新采用迭代式:发现 Bug 即修复并重试
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,354 +0,0 @@
|
||||
# 设计文档:ETL 员工维度表(staff_info)
|
||||
|
||||
## 概述
|
||||
|
||||
为飞球 ETL 连接器新增员工维度表,从 `SearchSystemStaffInfo` API 抓取球房全体员工数据(店长、主管、教练、收银员、助教管理员等),经 ODS 落地后清洗装载至 DWD 层。员工表与现有助教表(`assistant_accounts_master`)是完全独立的实体。
|
||||
|
||||
## API 响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"total": 15,
|
||||
"staffProfiles": [
|
||||
{
|
||||
"id": 3020236636900101,
|
||||
"cashierPointId": 2790685415443270,
|
||||
"cashierPointName": "默认",
|
||||
"job_num": "",
|
||||
"staff_name": "葛芃",
|
||||
"mobile": "13811638071",
|
||||
"auth_code": "",
|
||||
"avatar": "",
|
||||
"create_time": "2025-12-24 00:03:37",
|
||||
"entry_time": "2025-12-23 08:00:00",
|
||||
"is_delete": 0,
|
||||
"leave_status": 0,
|
||||
"resign_time": "2225-12-24 00:03:37",
|
||||
"site_id": 2790685415443269,
|
||||
"staff_identity": 2,
|
||||
"status": 1,
|
||||
"system_role_id": 4,
|
||||
"system_user_id": 3020236636293893,
|
||||
"tenant_id": 2790683160709957,
|
||||
"tenant_org_id": 2790685415443269,
|
||||
"job": "店长",
|
||||
"shop_name": "朗朗桌球",
|
||||
"account_status": 1,
|
||||
"is_reserve": 1,
|
||||
"groupName": "",
|
||||
"groupId": 0,
|
||||
"alias_name": "葛芃",
|
||||
"staff_profile_id": 0,
|
||||
"site_label": "",
|
||||
"rank_id": -1,
|
||||
"ding_talk_synced": 1,
|
||||
"new_rank_id": 0,
|
||||
"new_staff_identity": 0,
|
||||
"salary_grant_enabled": 2,
|
||||
"rankName": "无职级",
|
||||
"entry_type": 1,
|
||||
"userRoles": [],
|
||||
"entry_sign_status": 0,
|
||||
"resign_sign_status": 0,
|
||||
"criticism_status": 1,
|
||||
"gender": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"code": 0
|
||||
}
|
||||
```
|
||||
|
||||
## 1. ODS 层设计
|
||||
|
||||
### 1.1 ODS 任务规格
|
||||
|
||||
```python
|
||||
OdsTaskSpec(
|
||||
code="ODS_STAFF_INFO",
|
||||
class_name="OdsStaffInfoTask",
|
||||
table_name="ods.staff_info_master",
|
||||
endpoint="/PersonnelManagement/SearchSystemStaffInfo",
|
||||
data_path=("data",),
|
||||
list_key="staffProfiles",
|
||||
pk_columns=(_int_col("id", "id", required=True),),
|
||||
extra_params={
|
||||
"workStatusEnum": 0,
|
||||
"dingTalkSynced": 0,
|
||||
"staffIdentity": 0,
|
||||
"rankId": 0,
|
||||
"criticismStatus": 0,
|
||||
"signStatus": -1,
|
||||
},
|
||||
include_source_endpoint=False,
|
||||
include_fetched_at=False,
|
||||
include_record_index=True,
|
||||
requires_window=False,
|
||||
time_fields=None,
|
||||
snapshot_mode=SnapshotMode.FULL_TABLE,
|
||||
description="员工档案 ODS:SearchSystemStaffInfo -> staffProfiles 原始 JSON",
|
||||
)
|
||||
```
|
||||
|
||||
### 1.2 ODS 表 DDL:`ods.staff_info_master`
|
||||
|
||||
```sql
|
||||
CREATE TABLE ods.staff_info_master (
|
||||
id BIGINT NOT NULL,
|
||||
tenant_id BIGINT,
|
||||
site_id BIGINT,
|
||||
tenant_org_id BIGINT,
|
||||
system_user_id BIGINT,
|
||||
staff_name TEXT,
|
||||
alias_name TEXT,
|
||||
mobile TEXT,
|
||||
avatar TEXT,
|
||||
gender INTEGER,
|
||||
job TEXT,
|
||||
job_num TEXT,
|
||||
staff_identity INTEGER,
|
||||
status INTEGER,
|
||||
account_status INTEGER,
|
||||
system_role_id INTEGER,
|
||||
rank_id INTEGER,
|
||||
rank_name TEXT,
|
||||
new_rank_id INTEGER,
|
||||
new_staff_identity INTEGER,
|
||||
leave_status INTEGER,
|
||||
entry_time TIMESTAMP WITHOUT TIME ZONE,
|
||||
resign_time TIMESTAMP WITHOUT TIME ZONE,
|
||||
create_time TIMESTAMP WITHOUT TIME ZONE,
|
||||
is_delete INTEGER,
|
||||
is_reserve INTEGER,
|
||||
shop_name TEXT,
|
||||
site_label TEXT,
|
||||
cashier_point_id BIGINT,
|
||||
cashier_point_name TEXT,
|
||||
group_id BIGINT,
|
||||
group_name TEXT,
|
||||
staff_profile_id BIGINT,
|
||||
auth_code TEXT,
|
||||
auth_code_create TIMESTAMP WITHOUT TIME ZONE,
|
||||
ding_talk_synced INTEGER,
|
||||
salary_grant_enabled INTEGER,
|
||||
entry_type INTEGER,
|
||||
entry_sign_status INTEGER,
|
||||
resign_sign_status INTEGER,
|
||||
criticism_status INTEGER,
|
||||
user_roles JSONB,
|
||||
-- ETL 元数据
|
||||
content_hash TEXT NOT NULL,
|
||||
source_file TEXT,
|
||||
fetched_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
payload JSONB NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ods.staff_info_master IS '员工档案主数据(来源:SearchSystemStaffInfo API)';
|
||||
```
|
||||
|
||||
### 1.3 ODS 列名映射说明
|
||||
|
||||
API 返回的驼峰字段在 ODS 层统一转为蛇形命名(由 BaseOdsTask 自动处理):
|
||||
- `cashierPointId` → `cashier_point_id`
|
||||
- `cashierPointName` → `cashier_point_name`
|
||||
- `staffName` / `staff_name` → `staff_name`(API 已是蛇形)
|
||||
- `systemUserId` / `system_user_id` → `system_user_id`
|
||||
- `tenantOrgId` / `tenant_org_id` → `tenant_org_id`
|
||||
- `groupName` → `group_name`(注意:API 返回驼峰 `groupName`)
|
||||
- `groupId` → `group_id`(API 返回驼峰 `groupId`)
|
||||
- `rankName` → `rank_name`(API 返回驼峰 `rankName`)
|
||||
- `userRoles` → `user_roles`(数组,存为 JSONB)
|
||||
- `authCodeCreate` / `auth_code_create` → `auth_code_create`
|
||||
|
||||
## 2. DWD 层设计
|
||||
|
||||
### 2.1 主表 DDL:`dwd.dim_staff`
|
||||
|
||||
核心业务字段,高频查询使用。
|
||||
|
||||
```sql
|
||||
CREATE TABLE dwd.dim_staff (
|
||||
staff_id BIGINT NOT NULL,
|
||||
staff_name TEXT,
|
||||
alias_name TEXT,
|
||||
mobile TEXT,
|
||||
gender INTEGER,
|
||||
job TEXT,
|
||||
tenant_id BIGINT,
|
||||
site_id BIGINT,
|
||||
system_role_id INTEGER,
|
||||
staff_identity INTEGER,
|
||||
status INTEGER,
|
||||
leave_status INTEGER,
|
||||
entry_time TIMESTAMP WITH TIME ZONE,
|
||||
resign_time TIMESTAMP WITH TIME ZONE,
|
||||
is_delete INTEGER,
|
||||
-- SCD2
|
||||
scd2_start_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
scd2_end_time TIMESTAMP WITH TIME ZONE,
|
||||
scd2_is_current INTEGER,
|
||||
scd2_version INTEGER,
|
||||
PRIMARY KEY (staff_id, scd2_start_time)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE dwd.dim_staff IS '员工档案维度主表(SCD2)';
|
||||
```
|
||||
|
||||
### 2.2 扩展表 DDL:`dwd.dim_staff_ex`
|
||||
|
||||
次要/低频变更字段。
|
||||
|
||||
```sql
|
||||
CREATE TABLE dwd.dim_staff_ex (
|
||||
staff_id BIGINT NOT NULL,
|
||||
avatar TEXT,
|
||||
job_num TEXT,
|
||||
account_status INTEGER,
|
||||
rank_id INTEGER,
|
||||
rank_name TEXT,
|
||||
new_rank_id INTEGER,
|
||||
new_staff_identity INTEGER,
|
||||
is_reserve INTEGER,
|
||||
shop_name TEXT,
|
||||
site_label TEXT,
|
||||
tenant_org_id BIGINT,
|
||||
system_user_id BIGINT,
|
||||
cashier_point_id BIGINT,
|
||||
cashier_point_name TEXT,
|
||||
group_id BIGINT,
|
||||
group_name TEXT,
|
||||
staff_profile_id BIGINT,
|
||||
auth_code TEXT,
|
||||
auth_code_create TIMESTAMP WITH TIME ZONE,
|
||||
ding_talk_synced INTEGER,
|
||||
salary_grant_enabled INTEGER,
|
||||
entry_type INTEGER,
|
||||
entry_sign_status INTEGER,
|
||||
resign_sign_status INTEGER,
|
||||
criticism_status INTEGER,
|
||||
create_time TIMESTAMP WITH TIME ZONE,
|
||||
user_roles JSONB,
|
||||
-- SCD2
|
||||
scd2_start_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
scd2_end_time TIMESTAMP WITH TIME ZONE,
|
||||
scd2_is_current INTEGER,
|
||||
scd2_version INTEGER,
|
||||
PRIMARY KEY (staff_id, scd2_start_time)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE dwd.dim_staff_ex IS '员工档案维度扩展表(SCD2)';
|
||||
```
|
||||
|
||||
### 2.3 TABLE_MAP 映射
|
||||
|
||||
```python
|
||||
# 在 DwdLoadTask.TABLE_MAP 中新增:
|
||||
"dwd.dim_staff": "ods.staff_info_master",
|
||||
"dwd.dim_staff_ex": "ods.staff_info_master",
|
||||
```
|
||||
|
||||
### 2.4 FACT_MAPPINGS 字段映射
|
||||
|
||||
```python
|
||||
# dim_staff 主表映射
|
||||
"dwd.dim_staff": [
|
||||
("staff_id", "id", None),
|
||||
("entry_time", "entry_time", "timestamptz"),
|
||||
("resign_time", "resign_time", "timestamptz"),
|
||||
],
|
||||
# dim_staff_ex 扩展表映射
|
||||
"dwd.dim_staff_ex": [
|
||||
("staff_id", "id", None),
|
||||
("rank_name", "rankname", None),
|
||||
("cashier_point_id", "cashierpointid", "bigint"),
|
||||
("cashier_point_name", "cashierpointname", None),
|
||||
("group_id", "groupid", "bigint"),
|
||||
("group_name", "groupname", None),
|
||||
("system_user_id", "systemuserid", "bigint"),
|
||||
("tenant_org_id", "tenantorgid", "bigint"),
|
||||
("auth_code_create", "auth_code_create", "timestamptz"),
|
||||
("create_time", "create_time", "timestamptz"),
|
||||
("user_roles", "userroles", "jsonb"),
|
||||
],
|
||||
```
|
||||
|
||||
说明:
|
||||
- ODS 层的列名由 BaseOdsTask 自动从 API 驼峰转为蛇形(如 `cashierPointId` → `cashierpointid`,注意 PG 列名全小写无下划线)
|
||||
- DWD 主表中 `staff_name`、`alias_name`、`mobile` 等与 ODS 同名列自动映射,无需显式配置
|
||||
- `staff_id` 映射自 ODS 的 `id` 列
|
||||
|
||||
## 3. 数据流概览
|
||||
|
||||
```
|
||||
API: SearchSystemStaffInfo
|
||||
↓ (POST, 分页, extra_params 筛选)
|
||||
ODS: ods.staff_info_master
|
||||
↓ (SCD2 合并, FULL_TABLE 快照)
|
||||
DWD: dwd.dim_staff + dwd.dim_staff_ex
|
||||
```
|
||||
|
||||
## 4. 测试框架
|
||||
|
||||
- 测试框架:`pytest` + `hypothesis`
|
||||
- 单元测试使用 `FakeDB` / `FakeAPI`(`tests/unit/task_test_utils.py`)
|
||||
|
||||
## 5. 正确性属性
|
||||
|
||||
### P1:ODS 任务规格完整性
|
||||
对于 `ODS_STAFF_INFO` 任务规格,以下属性必须成立:
|
||||
- `code == "ODS_STAFF_INFO"`
|
||||
- `table_name == "ods.staff_info_master"`
|
||||
- `endpoint == "/PersonnelManagement/SearchSystemStaffInfo"`
|
||||
- `list_key == "staffProfiles"`
|
||||
- `snapshot_mode == SnapshotMode.FULL_TABLE`
|
||||
- `requires_window == False`
|
||||
- `time_fields is None`
|
||||
- `"staffProfiles"` 存在于 `DEFAULT_LIST_KEYS` 中
|
||||
- `"ODS_STAFF_INFO"` 存在于 `ENABLED_ODS_CODES` 中
|
||||
|
||||
验证方式:单元测试直接断言
|
||||
|
||||
### P2:DWD 映射完整性
|
||||
对于 DWD 装载配置,以下属性必须成立:
|
||||
- `TABLE_MAP["dwd.dim_staff"] == "ods.staff_info_master"`
|
||||
- `TABLE_MAP["dwd.dim_staff_ex"] == "ods.staff_info_master"`
|
||||
- `FACT_MAPPINGS["dwd.dim_staff"]` 包含 `staff_id` → `id` 的映射
|
||||
- `FACT_MAPPINGS["dwd.dim_staff_ex"]` 包含 `staff_id` → `id` 的映射
|
||||
|
||||
验证方式:单元测试直接断言
|
||||
|
||||
### P3:ODS 列名提取一致性(属性测试)
|
||||
对于任意 API 返回的员工记录(含驼峰和蛇形混合字段名),经 BaseOdsTask 处理后:
|
||||
- 所有字段名转为小写蛇形
|
||||
- `id` 字段不为空且为正整数
|
||||
- `payload` 字段包含完整原始 JSON
|
||||
|
||||
验证方式:hypothesis 属性测试,生成随机员工记录验证转换一致性
|
||||
|
||||
## 6. 文件变更清单
|
||||
|
||||
### 代码变更
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `apps/etl/connectors/feiqiu/api/client.py` | 修改 | `DEFAULT_LIST_KEYS` 添加 `"staffProfiles"` |
|
||||
| `apps/etl/connectors/feiqiu/tasks/ods/ods_tasks.py` | 修改 | 新增 `ODS_STAFF_INFO` 任务规格 + 注册到 `ENABLED_ODS_CODES` |
|
||||
| `apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py` | 修改 | `TABLE_MAP` 和 `FACT_MAPPINGS` 新增 dim_staff/dim_staff_ex 映射 |
|
||||
|
||||
### DDL / 迁移
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `db/etl_feiqiu/migrations/2026-02-22__add_staff_info_tables.sql` | 新增 | ODS + DWD 建表迁移脚本 |
|
||||
| `docs/database/ddl/etl_feiqiu__ods.sql` | 修改 | 追加 `ods.staff_info_master` DDL |
|
||||
| `docs/database/ddl/etl_feiqiu__dwd.sql` | 修改 | 追加 `dwd.dim_staff` + `dwd.dim_staff_ex` DDL |
|
||||
|
||||
### 文档
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `apps/etl/connectors/feiqiu/docs/database/ODS/mappings/mapping_SearchSystemStaffInfo_staff_info_master.md` | 新增 | API→ODS 字段映射文档 |
|
||||
| `apps/etl/connectors/feiqiu/docs/database/ODS/main/BD_manual_staff_info_master.md` | 新增 | ODS 表 BD 手册 |
|
||||
| `apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_dim_staff.md` | 新增 | DWD 主表 BD 手册 |
|
||||
| `apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_dim_staff_ex.md` | 新增 | DWD 扩展表 BD 手册 |
|
||||
| `apps/etl/connectors/feiqiu/docs/database/README.md` | 修改 | 增加员工表条目 |
|
||||
| `apps/etl/connectors/feiqiu/docs/etl_tasks/ods_tasks.md` | 修改 | 增加 ODS_STAFF_INFO 任务说明 |
|
||||
| `docs/database/README.md` | 修改 | 增加员工相关表条目 |
|
||||
@@ -1,82 +0,0 @@
|
||||
# 需求文档:ETL 员工维度表(staff_info)
|
||||
|
||||
## 概述
|
||||
|
||||
为飞球 ETL 连接器新增"员工"维度表,从上游 `SearchSystemStaffInfo` API 抓取球房员工数据,经 ODS 落地后清洗装载至 DWD 层,走 SCD2 缓慢变化维度。
|
||||
|
||||
## 用户故事
|
||||
|
||||
### US-1:API 对接与 ODS 落地
|
||||
作为 ETL 开发者,我需要将 `SearchSystemStaffInfo` API 融入现有 API 请求框架,并将原始数据落地到 ODS 层,以便后续清洗使用。
|
||||
|
||||
验收标准:
|
||||
- 1.1 在 `ODS_TASK_SPECS` 中新增 `ODS_STAFF_INFO` 任务规格,endpoint 为 `/PersonnelManagement/SearchSystemStaffInfo`
|
||||
- 1.2 API 请求体包含必要的筛选参数(`workStatusEnum`、`staffIdentity` 等),使用现有 `APIClient.iter_paginated` 分页机制
|
||||
- 1.3 ODS 表 `ods.staff_info_master` 包含 API 返回的所有业务字段 + ETL 元数据列(`content_hash`、`source_file`、`fetched_at`、`payload`)
|
||||
- 1.4 任务配置为 `snapshot_mode=FULL_TABLE`(全量快照,无时间窗口),`requires_window=False`
|
||||
- 1.5 在 `DEFAULT_LIST_KEYS` 中添加 `staffProfiles`(API 响应的列表键名)
|
||||
- 1.6 在 `ENABLED_ODS_CODES` 中注册 `ODS_STAFF_INFO`
|
||||
|
||||
### US-2:DWD 维度表设计与 SCD2 装载
|
||||
作为数据分析师,我需要一张清洗后的员工维度表,以便在 DWS 汇总层关联员工信息。
|
||||
|
||||
验收标准:
|
||||
- 2.1 创建 `dwd.dim_staff` 主表,包含核心业务字段(员工 ID、姓名、手机号、角色、门店、在职状态等)+ SCD2 列
|
||||
- 2.2 创建 `dwd.dim_staff_ex` 扩展表,包含次要/低频变更字段 + SCD2 列
|
||||
- 2.3 在 `DwdLoadTask.TABLE_MAP` 中注册 `dwd.dim_staff` → `ods.staff_info_master` 和 `dwd.dim_staff_ex` → `ods.staff_info_master` 的映射
|
||||
- 2.4 在 `DwdLoadTask.FACT_MAPPINGS` 中配置字段映射(ODS 列名 → DWD 列名,含必要的类型转换)
|
||||
- 2.5 DWD 装载走 SCD2 合并路径,变更检测正常工作
|
||||
|
||||
### US-3:DDL 创建与归档
|
||||
作为 DBA,我需要 ODS 和 DWD 层的 DDL 被正确创建并归档到项目文档中。
|
||||
|
||||
验收标准:
|
||||
- 3.1 编写 ODS 层 DDL(`ods.staff_info_master`),在测试库执行
|
||||
- 3.2 编写 DWD 层 DDL(`dwd.dim_staff`、`dwd.dim_staff_ex`),在测试库执行
|
||||
- 3.3 DDL 归档至 `docs/database/ddl/etl_feiqiu__ods.sql` 和 `docs/database/ddl/etl_feiqiu__dwd.sql`
|
||||
- 3.4 编写迁移脚本至 `db/etl_feiqiu/migrations/`(日期前缀)
|
||||
|
||||
### US-4:文档增补
|
||||
作为团队成员,我需要所有相关文档同步更新,以便理解新增的数据流。
|
||||
|
||||
验收标准:
|
||||
- 4.1 新增 ODS mapping 文档:`apps/etl/connectors/feiqiu/docs/database/ODS/mappings/mapping_SearchSystemStaffInfo_staff_info_master.md`
|
||||
- 4.2 新增 ODS BD_manual 文档:`apps/etl/connectors/feiqiu/docs/database/ODS/main/BD_manual_staff_info_master.md`
|
||||
- 4.3 新增 DWD BD_manual 文档:`apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_dim_staff.md`
|
||||
- 4.4 更新 `apps/etl/connectors/feiqiu/docs/database/README.md`,增加员工表条目
|
||||
- 4.5 更新 `apps/etl/connectors/feiqiu/docs/etl_tasks/ods_tasks.md`,增加 ODS_STAFF_INFO 任务说明
|
||||
- 4.6 更新 `docs/database/README.md`,增加员工相关表的条目
|
||||
- 4.7 新增 DWD BD_manual 扩展表文档:`apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_dim_staff_ex.md`(如有扩展表)
|
||||
|
||||
## API 信息
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| URL | `https://pc.ficoo.vip/apiprod/admin/v1/PersonnelManagement/SearchSystemStaffInfo` |
|
||||
| 方法 | POST |
|
||||
| 端点路径 | `/PersonnelManagement/SearchSystemStaffInfo` |
|
||||
| 认证 | Bearer Token(标准飞球 API 认证) |
|
||||
| 分页 | `page` + `limit`(与现有接口一致) |
|
||||
|
||||
### 请求体参数
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| workStatusEnum | int | 0 | 在职状态筛选(0=全部) |
|
||||
| dingTalkSynced | int | 0 | 钉钉同步状态(0=全部) |
|
||||
| staffIdentity | int | 0 | 员工身份筛选(0=全部) |
|
||||
| rankId | int | 0 | 职级筛选(0=全部) |
|
||||
| criticismStatus | int | 0 | 批评状态(0=全部) |
|
||||
| signStatus | int | -1 | 签约状态(-1=全部) |
|
||||
| page | int | 1 | 页码 |
|
||||
| limit | int | 50 | 每页条数 |
|
||||
|
||||
## 技术约束
|
||||
|
||||
- 员工表为维度表,DWD 层走 SCD2
|
||||
- ODS 使用 `SnapshotMode.FULL_TABLE`(全量快照软删除)
|
||||
- 不需要时间窗口(`requires_window=False`,`time_fields=None`)
|
||||
- 主键为 `id`(员工 ID)
|
||||
- API 响应结构已确认:`data.staffProfiles` 为列表键,`data.total` 为总数
|
||||
- 员工表与助教表(`assistant_accounts_master`)是完全独立的实体
|
||||
- DWD 层拆分为主表(`dim_staff`)+ 扩展表(`dim_staff_ex`)
|
||||
@@ -1,33 +0,0 @@
|
||||
# 任务列表:ETL 员工维度表(staff_info)
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. DDL 创建与数据库执行
|
||||
- [x] 1.1 编写迁移脚本 `db/etl_feiqiu/migrations/2026-02-22__add_staff_info_tables.sql`,包含 ODS + DWD 三张表的 CREATE TABLE 语句
|
||||
- [x] 1.2 在测试库(test_etl_feiqiu)执行迁移脚本,创建 `ods.staff_info_master`、`dwd.dim_staff`、`dwd.dim_staff_ex`
|
||||
- [x] 1.3 将 DDL 归档追加至 `docs/database/ddl/etl_feiqiu__ods.sql` 和 `docs/database/ddl/etl_feiqiu__dwd.sql`
|
||||
|
||||
- [x] 2. ODS 任务注册
|
||||
- [x] 2.1 在 `apps/etl/connectors/feiqiu/api/client.py` 的 `DEFAULT_LIST_KEYS` 中添加 `"staffProfiles"`
|
||||
- [x] 2.2 在 `apps/etl/connectors/feiqiu/tasks/ods/ods_tasks.py` 的 `ODS_TASK_SPECS` 中新增 `ODS_STAFF_INFO` 任务规格
|
||||
- [x] 2.3 在 `ENABLED_ODS_CODES` 集合中注册 `"ODS_STAFF_INFO"`
|
||||
|
||||
- [x] 3. DWD 映射注册
|
||||
- [x] 3.1 在 `apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py` 的 `TABLE_MAP` 中新增 `dwd.dim_staff` 和 `dwd.dim_staff_ex` 的映射
|
||||
- [x] 3.2 在 `FACT_MAPPINGS` 中新增 `dwd.dim_staff` 和 `dwd.dim_staff_ex` 的字段映射配置
|
||||
|
||||
- [x] 4. 单元测试
|
||||
- [x] 4.1 编写 ODS 任务规格完整性测试(验证 P1 属性)
|
||||
- [x] 4.2 编写 DWD 映射完整性测试(验证 P2 属性)
|
||||
|
||||
- [x] 5. 属性测试
|
||||
- [x] 5.1 [PBT] 编写 ODS 列名提取一致性属性测试(验证 P3 属性):对于任意员工记录,字段名转换和 payload 保留正确
|
||||
|
||||
- [x] 6. 文档增补
|
||||
- [x] 6.1 新增 ODS mapping 文档:`apps/etl/connectors/feiqiu/docs/database/ODS/mappings/mapping_SearchSystemStaffInfo_staff_info_master.md`
|
||||
- [x] 6.2 新增 ODS BD_manual 文档:`apps/etl/connectors/feiqiu/docs/database/ODS/main/BD_manual_staff_info_master.md`
|
||||
- [x] 6.3 新增 DWD BD_manual 主表文档:`apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_dim_staff.md`
|
||||
- [x] 6.4 新增 DWD BD_manual 扩展表文档:`apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_dim_staff_ex.md`
|
||||
- [x] 6.5 更新 `apps/etl/connectors/feiqiu/docs/database/README.md`,增加员工表条目
|
||||
- [x] 6.6 更新 `apps/etl/connectors/feiqiu/docs/etl_tasks/ods_tasks.md`,增加 ODS_STAFF_INFO 任务说明
|
||||
- [x] 6.7 更新 `docs/database/README.md`,增加员工相关表条目
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "a277a91a-b35c-4d48-b4a2-09df0e47b71b", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -1,834 +0,0 @@
|
||||
# 技术设计:ETL 统一请求编排与线程模型改造
|
||||
|
||||
> 对应需求文档:[requirements.md](./requirements.md)
|
||||
|
||||
## 1. 架构概览
|
||||
|
||||
本次改造将现有 21 个 ODS 任务从"同步串行执行"迁移到统一的"串行请求 + 异步处理 + 单线程写库"管道架构。核心设计原则:
|
||||
|
||||
- **请求串行化**:所有 API 请求通过全局 `RequestScheduler` 排队,严格一个接一个发送,遵循 `RateLimiter` 限流
|
||||
- **处理并行化**:API 响应提交到 `ProcessingPool` 多线程处理(字段提取、hash 计算等),不阻塞请求线程
|
||||
- **写入串行化**:所有数据库写入由单个 `WriteWorker` 线程执行,避免并发写入冲突
|
||||
- **配置灵活化**:通过 `PipelineConfig` 支持全局默认 + 任务级覆盖
|
||||
- **可取消**:通过 `CancellationToken` 支持外部取消信号,优雅中断
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FlowRunner │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Unified_Pipeline │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ │
|
||||
│ │ │ Request │───▶│ Processing │───▶│ Write │ │ │
|
||||
│ │ │ Scheduler │ │ Pool │ │ Worker │ │ │
|
||||
│ │ │ (串行请求) │ │ (N 工作线程) │ │ (单线程) │ │ │
|
||||
│ │ └──────┬───────┘ └──────────────┘ └─────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌──────▼───────┐ ┌──────────────┐ │ │
|
||||
│ │ │ Rate │ │ Cancellation │ │ │
|
||||
│ │ │ Limiter │ │ Token │ │ │
|
||||
│ │ │ (5-20s) │ │ (外部取消) │ │ │
|
||||
│ │ └──────────────┘ └──────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ DWD_Loader (多线程 SCD2 调度) │ │
|
||||
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
|
||||
│ │ │ Table1 │ │ Table2 │ │ Table3 │ │ Table4 │ ... │ │
|
||||
│ │ │ SCD2 │ │ SCD2 │ │ SCD2 │ │ SCD2 │ │ │
|
||||
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.1 设计决策与理由
|
||||
|
||||
| 决策 | 选项 | 理由 |
|
||||
|------|------|------|
|
||||
| 请求串行 vs 并行 | 串行 | 上游飞球 API 无并发友好设计,并行请求易触发风控;串行 + 限流是最安全的策略 |
|
||||
| 处理线程数 | 默认 2 | ODS 数据处理是轻量 CPU 操作(JSON 解析、hash 计算),2 线程足够消化请求间隔产生的积压 |
|
||||
| 写入单线程 | 单线程 | PostgreSQL 单连接写入避免锁竞争和事务冲突,简化错误处理和回滚逻辑 |
|
||||
| Pipeline 嵌入 vs 独立 | 嵌入 BaseOdsTask | Pipeline 作为 BaseOdsTask 内部执行引擎,对外接口(TaskExecutor、FlowRunner)完全不变 |
|
||||
| DWD 多线程 | 调度层并行 | 仅在调度层并行调用 `_merge_dim_scd2()`,方法本身不改,每张表独立事务 |
|
||||
|
||||
## 2. 架构
|
||||
|
||||
### 2.1 整体数据流
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[FlowRunner.run] --> B[TaskExecutor.run_tasks]
|
||||
B --> C{ODS 任务}
|
||||
C --> D[BaseOdsTask.execute]
|
||||
D --> E[UnifiedPipeline.run]
|
||||
E --> F[RequestScheduler<br/>串行请求 + RateLimiter]
|
||||
F --> G[ProcessingPool<br/>多线程处理]
|
||||
G --> H[WriteWorker<br/>单线程写库]
|
||||
H --> I[ODS 表写入完成]
|
||||
|
||||
I --> J{有 Detail_Mode?}
|
||||
J -->|是| K[DetailFetcher<br/>二级详情拉取]
|
||||
K --> F
|
||||
J -->|否| L[返回结果]
|
||||
K --> L
|
||||
|
||||
L --> M[DWD_Loader]
|
||||
M --> N[多线程 SCD2 调度]
|
||||
N --> O[DWD 表写入完成]
|
||||
```
|
||||
|
||||
### 2.2 线程模型详细设计
|
||||
|
||||
```
|
||||
主线程(RequestScheduler)
|
||||
│
|
||||
│ for request in request_queue:
|
||||
│ if cancel_token.is_cancelled: break
|
||||
│ resp = api_client.post(endpoint, params)
|
||||
│ processing_queue.put((request_id, resp))
|
||||
│ rate_limiter.wait(cancel_token.event)
|
||||
│
|
||||
│ processing_queue.put(SENTINEL × worker_count)
|
||||
│ 等待所有 worker 完成
|
||||
│ write_queue.put(SENTINEL)
|
||||
│ 等待 writer 完成
|
||||
│
|
||||
├──▶ Worker Thread 1 ──┐
|
||||
├──▶ Worker Thread 2 ──┤
|
||||
│ │
|
||||
│ processing_queue │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ (id, resp) │───▶ 字段提取 / content_hash 计算
|
||||
│ │ (id, resp) │ write_queue.put(processed_rows)
|
||||
│ │ SENTINEL │
|
||||
│ └─────────────┘ │
|
||||
│ │
|
||||
│ ▼
|
||||
│ Write Thread (单线程)
|
||||
│ ┌─────────────┐
|
||||
│ │ write_queue │
|
||||
│ │ batch=100 │──▶ UPSERT / INSERT
|
||||
│ │ timeout=5s │
|
||||
│ │ SENTINEL │
|
||||
│ └─────────────┘
|
||||
│
|
||||
▼
|
||||
PipelineResult(统计信息)
|
||||
```
|
||||
|
||||
关键设计点:
|
||||
- `processing_queue`:`queue.Queue(maxsize=queue_size)`,默认 100,满时 `RequestScheduler` 阻塞(背压机制)
|
||||
- `write_queue`:`queue.Queue(maxsize=queue_size * 2)`,默认 200
|
||||
- SENTINEL:`None` 对象,通知线程退出
|
||||
- 取消信号:主线程检查 `cancel_token`,worker/writer 通过 SENTINEL 正常退出
|
||||
- 批量写入:累积到 `batch_size`(默认 100)或等待 `batch_timeout`(默认 5 秒)后执行一次
|
||||
|
||||
### 2.3 取消信号传递链
|
||||
|
||||
```
|
||||
外部触发(Admin-web / CLI / 超时)
|
||||
│ cancel_token.cancel()
|
||||
▼
|
||||
RequestScheduler
|
||||
│ rate_limiter.wait() 提前返回 False
|
||||
│ 主循环 break,不再发新请求
|
||||
▼
|
||||
ProcessingPool
|
||||
│ 通过 SENTINEL 正常退出
|
||||
│ 已入队数据全部处理完成
|
||||
▼
|
||||
WriteWorker
|
||||
│ 通过 SENTINEL 正常退出
|
||||
│ 已处理数据全部写入 DB
|
||||
▼
|
||||
返回 PipelineResult(cancelled=True, ...)
|
||||
```
|
||||
|
||||
## 3. 组件与接口
|
||||
|
||||
### 3.1 PipelineConfig(配置数据类)
|
||||
|
||||
文件:`apps/etl/connectors/feiqiu/config/pipeline_config.py`
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class PipelineConfig:
|
||||
"""统一管道配置,支持全局默认 + 任务级覆盖。"""
|
||||
workers: int = 2 # ProcessingPool 工作线程数
|
||||
queue_size: int = 100 # 处理队列容量
|
||||
batch_size: int = 100 # WriteWorker 批量写入阈值
|
||||
batch_timeout: float = 5.0 # WriteWorker 等待超时(秒)
|
||||
rate_min: float = 5.0 # RateLimiter 最小间隔(秒)
|
||||
rate_max: float = 20.0 # RateLimiter 最大间隔(秒)
|
||||
max_consecutive_failures: int = 10 # 连续失败中断阈值
|
||||
|
||||
def __post_init__(self):
|
||||
if self.workers < 1:
|
||||
raise ValueError(f"workers 必须 >= 1,当前值: {self.workers}")
|
||||
if self.queue_size < 1:
|
||||
raise ValueError(f"queue_size 必须 >= 1,当前值: {self.queue_size}")
|
||||
if self.batch_size < 1:
|
||||
raise ValueError(f"batch_size 必须 >= 1,当前值: {self.batch_size}")
|
||||
if self.rate_min > self.rate_max:
|
||||
raise ValueError(
|
||||
f"rate_min({self.rate_min}) 不能大于 rate_max({self.rate_max})"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_app_config(cls, config: AppConfig, task_code: str | None = None) -> "PipelineConfig":
|
||||
"""从 AppConfig 加载,支持 pipeline.<task_code>.* 任务级覆盖。"""
|
||||
def _get(key: str, default):
|
||||
# 优先任务级 → 全局级 → 默认值
|
||||
if task_code:
|
||||
val = config.get(f"pipeline.{task_code.lower()}.{key}")
|
||||
if val is not None:
|
||||
return type(default)(val)
|
||||
val = config.get(f"pipeline.{key}")
|
||||
if val is not None:
|
||||
return type(default)(val)
|
||||
return default
|
||||
|
||||
return cls(
|
||||
workers=_get("workers", 2),
|
||||
queue_size=_get("queue_size", 100),
|
||||
batch_size=_get("batch_size", 100),
|
||||
batch_timeout=_get("batch_timeout", 5.0),
|
||||
rate_min=_get("rate_min", 5.0),
|
||||
rate_max=_get("rate_max", 20.0),
|
||||
max_consecutive_failures=_get("max_consecutive_failures", 10),
|
||||
)
|
||||
```
|
||||
|
||||
### 3.2 CancellationToken(取消令牌)
|
||||
|
||||
文件:`apps/etl/connectors/feiqiu/utils/cancellation.py`
|
||||
|
||||
```python
|
||||
class CancellationToken:
|
||||
"""线程安全的取消令牌,封装 threading.Event。"""
|
||||
def __init__(self, timeout: float | None = None):
|
||||
self._event = threading.Event()
|
||||
self._timer: threading.Timer | None = None
|
||||
if timeout is not None and timeout > 0:
|
||||
self._timer = threading.Timer(timeout, self.cancel)
|
||||
self._timer.daemon = True
|
||||
self._timer.start()
|
||||
|
||||
def cancel(self):
|
||||
"""发出取消信号。"""
|
||||
self._event.set()
|
||||
|
||||
@property
|
||||
def is_cancelled(self) -> bool:
|
||||
return self._event.is_set()
|
||||
|
||||
@property
|
||||
def event(self) -> threading.Event:
|
||||
return self._event
|
||||
|
||||
def dispose(self):
|
||||
"""清理超时定时器。"""
|
||||
if self._timer is not None:
|
||||
self._timer.cancel()
|
||||
self._timer = None
|
||||
```
|
||||
|
||||
### 3.3 RateLimiter(限流器)
|
||||
|
||||
文件:`apps/etl/connectors/feiqiu/api/rate_limiter.py`
|
||||
|
||||
```python
|
||||
class RateLimiter:
|
||||
"""请求间隔控制器,支持取消信号中断等待。"""
|
||||
def __init__(self, min_interval: float = 5.0, max_interval: float = 20.0):
|
||||
if min_interval > max_interval:
|
||||
raise ValueError(
|
||||
f"min_interval({min_interval}) 不能大于 max_interval({max_interval})"
|
||||
)
|
||||
self._min = min_interval
|
||||
self._max = max_interval
|
||||
self._last_interval: float = 0.0
|
||||
|
||||
def wait(self, cancel_event: threading.Event | None = None) -> bool:
|
||||
"""等待随机间隔。返回 False 表示被取消信号中断。
|
||||
将等待时间拆分为 0.5s 小段,每段检查 cancel_event。"""
|
||||
interval = random.uniform(self._min, self._max)
|
||||
self._last_interval = interval
|
||||
remaining = interval
|
||||
while remaining > 0:
|
||||
if cancel_event and cancel_event.is_set():
|
||||
return False
|
||||
sleep_time = min(0.5, remaining)
|
||||
time.sleep(sleep_time)
|
||||
remaining -= sleep_time
|
||||
return True
|
||||
|
||||
@property
|
||||
def last_interval(self) -> float:
|
||||
return self._last_interval
|
||||
```
|
||||
|
||||
### 3.4 UnifiedPipeline(统一管道引擎)
|
||||
|
||||
文件:`apps/etl/connectors/feiqiu/pipeline/unified_pipeline.py`
|
||||
|
||||
这是核心组件,封装"串行请求 + 异步处理 + 单线程写库"的完整执行引擎。
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PipelineResult:
|
||||
"""管道执行结果统计。"""
|
||||
status: str = "SUCCESS" # SUCCESS / PARTIAL / CANCELLED / FAILED
|
||||
total_requests: int = 0
|
||||
completed_requests: int = 0
|
||||
total_fetched: int = 0
|
||||
total_inserted: int = 0
|
||||
total_updated: int = 0
|
||||
total_skipped: int = 0
|
||||
request_failures: int = 0
|
||||
processing_failures: int = 0
|
||||
write_failures: int = 0
|
||||
cancelled: bool = False
|
||||
errors: list[dict] = field(default_factory=list)
|
||||
timing: dict[str, float] = field(default_factory=dict) # 各阶段耗时
|
||||
|
||||
class UnifiedPipeline:
|
||||
"""统一管道引擎:串行请求 + 异步处理 + 单线程写库。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_client: APIClient,
|
||||
db_connection,
|
||||
logger: logging.Logger,
|
||||
config: PipelineConfig,
|
||||
cancel_token: CancellationToken | None = None,
|
||||
):
|
||||
self.api = api_client
|
||||
self.db = db_connection
|
||||
self.logger = logger
|
||||
self.config = config
|
||||
self.cancel_token = cancel_token or CancellationToken()
|
||||
self._rate_limiter = RateLimiter(config.rate_min, config.rate_max)
|
||||
|
||||
def run(
|
||||
self,
|
||||
requests: Iterable[PipelineRequest],
|
||||
process_fn: Callable[[Any], list[dict]],
|
||||
write_fn: Callable[[list[dict]], WriteResult],
|
||||
) -> PipelineResult:
|
||||
"""执行管道。
|
||||
|
||||
Args:
|
||||
requests: 请求迭代器(由 BaseOdsTask 生成,包含 endpoint、params 等)
|
||||
process_fn: 处理函数,将 API 响应转换为待写入记录列表
|
||||
write_fn: 写入函数,将记录批量写入数据库
|
||||
"""
|
||||
if self.cancel_token.is_cancelled:
|
||||
return PipelineResult(status="CANCELLED", cancelled=True)
|
||||
|
||||
processing_queue = queue.Queue(maxsize=self.config.queue_size)
|
||||
write_queue = queue.Queue(maxsize=self.config.queue_size * 2)
|
||||
result = PipelineResult()
|
||||
|
||||
# 启动处理线程池
|
||||
workers = []
|
||||
for i in range(self.config.workers):
|
||||
t = threading.Thread(
|
||||
target=self._process_worker,
|
||||
args=(processing_queue, write_queue, process_fn, result),
|
||||
name=f"pipeline-worker-{i}",
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
workers.append(t)
|
||||
|
||||
# 启动写入线程
|
||||
writer = threading.Thread(
|
||||
target=self._write_worker,
|
||||
args=(write_queue, write_fn, result),
|
||||
name="pipeline-writer",
|
||||
daemon=True,
|
||||
)
|
||||
writer.start()
|
||||
|
||||
# 主线程:串行请求
|
||||
self._request_loop(requests, processing_queue, result)
|
||||
|
||||
# 发送 SENTINEL 到处理队列
|
||||
for _ in workers:
|
||||
processing_queue.put(None)
|
||||
for w in workers:
|
||||
w.join()
|
||||
|
||||
# 发送 SENTINEL 到写入队列
|
||||
write_queue.put(None)
|
||||
writer.join()
|
||||
|
||||
# 确定最终状态
|
||||
if result.cancelled:
|
||||
result.status = "CANCELLED"
|
||||
elif result.request_failures + result.processing_failures + result.write_failures > 0:
|
||||
result.status = "PARTIAL"
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### 3.5 BaseOdsTask 改造
|
||||
|
||||
文件:`apps/etl/connectors/feiqiu/tasks/ods/ods_tasks.py`(修改现有文件)
|
||||
|
||||
改造策略:在 `BaseOdsTask.execute()` 内部用 `UnifiedPipeline` 替代现有的同步循环,但保留所有现有功能(时间窗口解析、分页拉取、结构感知写入、快照软删除、content_hash 去重)。
|
||||
|
||||
```python
|
||||
class BaseOdsTask(BaseTask):
|
||||
"""改造后的 ODS 任务基类。"""
|
||||
|
||||
def execute(self, cursor_data: dict | None = None) -> dict:
|
||||
spec = self.SPEC
|
||||
# ... 现有的窗口解析、分段逻辑保持不变 ...
|
||||
|
||||
# 构建 PipelineConfig(支持任务级覆盖)
|
||||
pipeline_config = PipelineConfig.from_app_config(self.config, spec.code)
|
||||
cancel_token = getattr(self, '_cancel_token', None)
|
||||
|
||||
pipeline = UnifiedPipeline(
|
||||
api_client=self.api,
|
||||
db_connection=self.db,
|
||||
logger=self.logger,
|
||||
config=pipeline_config,
|
||||
cancel_token=cancel_token,
|
||||
)
|
||||
|
||||
# 将现有的分页请求逻辑封装为 PipelineRequest 迭代器
|
||||
# 将现有的 _insert_records_schema_aware 封装为 write_fn
|
||||
# 将现有的字段提取/hash 计算封装为 process_fn
|
||||
result = pipeline.run(
|
||||
requests=self._build_requests(spec, segments, store_id, page_size),
|
||||
process_fn=self._build_process_fn(spec),
|
||||
write_fn=self._build_write_fn(spec, source_file),
|
||||
)
|
||||
|
||||
# ... 快照软删除逻辑保持不变 ...
|
||||
# ... 结果构建逻辑保持不变 ...
|
||||
```
|
||||
|
||||
关键约束:
|
||||
- `OdsTaskSpec` 数据类的所有现有字段保持不变
|
||||
- `_insert_records_schema_aware()`、`_mark_missing_as_deleted()` 等方法保持不变
|
||||
- `TaskExecutor` 调用 `task.execute(cursor_data)` 的接口保持不变
|
||||
- `TaskRegistry` 中的注册代码保持不变
|
||||
|
||||
### 3.6 OdsTaskSpec 扩展(Detail_Mode 支持)
|
||||
|
||||
在现有 `OdsTaskSpec` 数据类中新增可选字段:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class OdsTaskSpec:
|
||||
# ... 所有现有字段保持不变 ...
|
||||
|
||||
# Detail_Mode 可选配置(新增)
|
||||
detail_endpoint: str | None = None # 详情接口 endpoint
|
||||
detail_param_builder: Callable[[dict], dict] | None = None # 详情请求参数构造
|
||||
detail_target_table: str | None = None # 详情数据目标表名
|
||||
detail_data_path: tuple[str, ...] | None = None # 详情数据的 data_path
|
||||
detail_list_key: str | None = None # 详情数据的 list_key
|
||||
detail_id_column: str | None = None # 从列表数据中提取 ID 的列名
|
||||
```
|
||||
|
||||
当 `detail_endpoint` 为 `None` 时,Pipeline 跳过详情拉取阶段,行为与纯列表模式完全一致。
|
||||
|
||||
### 3.7 DWD 多线程调度器
|
||||
|
||||
文件:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`(修改现有文件)
|
||||
|
||||
改造 `DwdLoadTask.load()` 方法,将现有的串行 `for dwd_table, ods_table in TABLE_MAP` 循环改为 `concurrent.futures.ThreadPoolExecutor` 并行调度:
|
||||
|
||||
```python
|
||||
def load(self, extracted: dict[str, Any], context: TaskContext) -> dict[str, Any]:
|
||||
now = extracted["now"]
|
||||
parallel_workers = int(self.config.get("dwd.parallel_workers", 4))
|
||||
|
||||
# 将表分为维度表和事实表两组
|
||||
dim_tables = [(d, o) for d, o in self.TABLE_MAP.items()
|
||||
if self._table_base(d).startswith("dim_")]
|
||||
fact_tables = [(d, o) for d, o in self.TABLE_MAP.items()
|
||||
if not self._table_base(d).startswith("dim_")]
|
||||
|
||||
summary = []
|
||||
errors = []
|
||||
|
||||
# 维度表并行 SCD2 合并(每张表独立事务、独立数据库连接)
|
||||
with ThreadPoolExecutor(max_workers=parallel_workers) as executor:
|
||||
futures = {}
|
||||
for dwd_table, ods_table in dim_tables:
|
||||
if only_tables and ...: # 过滤逻辑保持不变
|
||||
continue
|
||||
future = executor.submit(
|
||||
self._process_single_table, dwd_table, ods_table, now, context
|
||||
)
|
||||
futures[future] = dwd_table
|
||||
|
||||
for future in as_completed(futures):
|
||||
dwd_table = futures[future]
|
||||
try:
|
||||
table_result = future.result()
|
||||
summary.append(table_result)
|
||||
except Exception as exc:
|
||||
errors.append({"table": dwd_table, "error": str(exc)})
|
||||
|
||||
# 事实表同样并行处理
|
||||
# ... 类似逻辑 ...
|
||||
|
||||
return {"tables": summary, "errors": len(errors), "error_details": errors}
|
||||
```
|
||||
|
||||
关键约束:
|
||||
- `_merge_dim_scd2()` 方法本身不改
|
||||
- 每张表使用独立的数据库连接和事务
|
||||
- 单张表失败不影响其他表
|
||||
|
||||
### 3.8 任务日志管理器
|
||||
|
||||
文件:`apps/etl/connectors/feiqiu/utils/task_log_buffer.py`(新建)
|
||||
|
||||
```python
|
||||
class TaskLogBuffer:
|
||||
"""任务级日志缓冲区,收集单个任务的所有日志,任务完成后一次性输出。"""
|
||||
|
||||
def __init__(self, task_code: str, parent_logger: logging.Logger):
|
||||
self.task_code = task_code
|
||||
self._buffer: list[LogEntry] = []
|
||||
self._lock = threading.Lock()
|
||||
self._parent = parent_logger
|
||||
|
||||
def log(self, level: int, message: str, *args, **kwargs):
|
||||
"""线程安全地缓冲一条日志。"""
|
||||
with self._lock:
|
||||
self._buffer.append(LogEntry(
|
||||
timestamp=datetime.now(),
|
||||
level=level,
|
||||
task_code=self.task_code,
|
||||
message=message % args if args else message,
|
||||
))
|
||||
|
||||
def flush(self) -> list[LogEntry]:
|
||||
"""将缓冲区内容按时间顺序一次性输出到父 logger,并返回日志列表。"""
|
||||
with self._lock:
|
||||
entries = sorted(self._buffer, key=lambda e: e.timestamp)
|
||||
for entry in entries:
|
||||
self._parent.log(
|
||||
entry.level,
|
||||
"[%s] %s",
|
||||
entry.task_code,
|
||||
entry.message,
|
||||
)
|
||||
self._buffer.clear()
|
||||
return entries
|
||||
```
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 PipelineConfig 配置命名空间
|
||||
|
||||
在 `AppConfig` 中新增 `pipeline.*` 命名空间:
|
||||
|
||||
| 配置键 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `pipeline.workers` | int | 2 | ProcessingPool 工作线程数 |
|
||||
| `pipeline.queue_size` | int | 100 | 处理队列容量 |
|
||||
| `pipeline.batch_size` | int | 100 | WriteWorker 批量写入阈值 |
|
||||
| `pipeline.batch_timeout` | float | 5.0 | WriteWorker 等待超时(秒) |
|
||||
| `pipeline.rate_min` | float | 5.0 | RateLimiter 最小间隔(秒) |
|
||||
| `pipeline.rate_max` | float | 20.0 | RateLimiter 最大间隔(秒) |
|
||||
| `pipeline.max_consecutive_failures` | int | 10 | 连续失败中断阈值 |
|
||||
| `pipeline.<TASK_CODE>.workers` | int | - | 任务级覆盖:工作线程数 |
|
||||
| `pipeline.<TASK_CODE>.rate_min` | float | - | 任务级覆盖:最小间隔 |
|
||||
| `pipeline.<TASK_CODE>.rate_max` | float | - | 任务级覆盖:最大间隔 |
|
||||
| `dwd.parallel_workers` | int | 4 | DWD 层并行线程数 |
|
||||
|
||||
任务级覆盖示例(`.env`):
|
||||
```
|
||||
PIPELINE_WORKERS=2
|
||||
PIPELINE_RATE_MIN=5.0
|
||||
PIPELINE_ODS_GROUP_PACKAGE.RATE_MIN=8.0
|
||||
PIPELINE_ODS_GROUP_PACKAGE.RATE_MAX=25.0
|
||||
```
|
||||
|
||||
CLI 参数覆盖:
|
||||
```
|
||||
--pipeline-workers 4
|
||||
--pipeline-batch-size 200
|
||||
--pipeline-rate-min 3.0
|
||||
--pipeline-rate-max 15.0
|
||||
```
|
||||
|
||||
### 4.2 PipelineRequest 数据类
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PipelineRequest:
|
||||
"""管道请求描述。"""
|
||||
endpoint: str
|
||||
params: dict
|
||||
page_size: int | None = 200
|
||||
data_path: tuple[str, ...] = ("data",)
|
||||
list_key: str | None = None
|
||||
segment_index: int = 0 # 所属窗口分段索引
|
||||
is_detail: bool = False # 是否为详情请求
|
||||
detail_id: Any = None # 详情请求的 ID
|
||||
```
|
||||
|
||||
### 4.3 PipelineResult 数据类
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PipelineResult:
|
||||
"""管道执行结果。"""
|
||||
status: str = "SUCCESS"
|
||||
total_requests: int = 0
|
||||
completed_requests: int = 0
|
||||
total_fetched: int = 0
|
||||
total_inserted: int = 0
|
||||
total_updated: int = 0
|
||||
total_skipped: int = 0
|
||||
total_deleted: int = 0
|
||||
request_failures: int = 0
|
||||
processing_failures: int = 0
|
||||
write_failures: int = 0
|
||||
cancelled: bool = False
|
||||
errors: list[dict] = field(default_factory=list)
|
||||
timing: dict[str, float] = field(default_factory=dict)
|
||||
# Detail_Mode 统计(仅在启用时填充)
|
||||
detail_success: int = 0
|
||||
detail_failure: int = 0
|
||||
detail_skipped: int = 0
|
||||
```
|
||||
|
||||
### 4.4 WriteResult 数据类
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class WriteResult:
|
||||
"""单次批量写入结果。"""
|
||||
inserted: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
errors: int = 0
|
||||
```
|
||||
|
||||
### 4.5 LogEntry 数据类
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class LogEntry:
|
||||
"""日志条目。"""
|
||||
timestamp: datetime
|
||||
level: int
|
||||
task_code: str
|
||||
message: str
|
||||
```
|
||||
|
||||
### 4.6 现有数据模型不变
|
||||
|
||||
以下现有数据模型保持不变:
|
||||
- `OdsTaskSpec`:仅新增 Detail_Mode 可选字段,所有现有字段不变
|
||||
- `TaskContext`:不变
|
||||
- `TaskMeta`:不变
|
||||
- `SnapshotMode`:不变
|
||||
- `ColumnSpec`:不变
|
||||
|
||||
|
||||
## 5. 正确性属性
|
||||
|
||||
*属性(Property)是系统在所有有效执行中都应保持为真的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
以下属性基于需求文档中的验收标准推导,经过冗余消除和合并后得到 16 个独立属性。
|
||||
|
||||
### Property 1: 请求严格串行
|
||||
|
||||
*对于任意*一组提交到 RequestScheduler 的 API 请求,每个请求的发送时间戳必须严格晚于上一个请求的响应完成时间戳,无论请求来自同一个 ODS 任务还是不同的 ODS 任务。
|
||||
|
||||
**Validates: Requirements 1.2, 1.6**
|
||||
|
||||
### Property 2: RateLimiter 间隔范围
|
||||
|
||||
*对于任意*有效的 min_interval 和 max_interval 配置(min_interval <= max_interval),RateLimiter.wait() 的实际等待时间始终在 [min_interval, max_interval] 范围内(允许 ±0.5s 的系统调度误差)。
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 3: PipelineConfig 构造与验证
|
||||
|
||||
*对于任意*一组配置参数,当 workers >= 1、queue_size >= 1、batch_size >= 1 且 rate_min <= rate_max 时,PipelineConfig 应成功构造并正确存储所有参数值;当任一条件不满足时,应抛出 ValueError。
|
||||
|
||||
**Validates: Requirements 1.5, 4.1, 4.4, 4.5**
|
||||
|
||||
### Property 4: 配置分层与任务级覆盖
|
||||
|
||||
*对于任意*任务代码和配置值组合,PipelineConfig.from_app_config() 应遵循优先级:任务级配置(`pipeline.<task_code>.*`)> 全局配置(`pipeline.*`)> 默认值。当任务级配置存在时,应覆盖全局配置;当任务级配置不存在时,应回退到全局配置。
|
||||
|
||||
**Validates: Requirements 1.4, 2.3, 2.6, 4.2, 4.3, 4.6, 7.2**
|
||||
|
||||
### Property 5: 管道完成语义
|
||||
|
||||
*对于任意*一组成功的 API 请求(无失败、无取消),UnifiedPipeline.run() 返回时,PipelineResult 中的 total_fetched 应等于所有请求返回的记录总数,且 total_inserted + total_updated + total_skipped 应等于 total_fetched。
|
||||
|
||||
**Validates: Requirements 2.7**
|
||||
|
||||
### Property 6: WriteWorker 批量大小约束
|
||||
|
||||
*对于任意*配置的 batch_size,WriteWorker 每次调用 write_fn 时传入的记录数不超过 batch_size。
|
||||
|
||||
**Validates: Requirements 2.5**
|
||||
|
||||
### Property 7: CancellationToken 状态转换
|
||||
|
||||
*对于任意* CancellationToken 实例,初始状态 is_cancelled 为 False;调用 cancel() 后 is_cancelled 变为 True 且不可逆转。对于配置了超时的 CancellationToken,在超时时间到达后 is_cancelled 自动变为 True。
|
||||
|
||||
**Validates: Requirements 3.1, 3.6**
|
||||
|
||||
### Property 8: 取消后已入队数据不丢失
|
||||
|
||||
*对于任意*管道执行过程中触发取消信号的时刻,管道返回时:(a) 不再发送新的 API 请求,(b) 所有已提交到 processing_queue 的数据全部被处理完成,(c) 所有已处理完成的数据全部被写入数据库,(d) 返回结果的 status 为 CANCELLED 且 cancelled 为 True。
|
||||
|
||||
**Validates: Requirements 3.2, 3.3, 3.4, 3.5**
|
||||
|
||||
### Property 9: 迁移前后输出等价
|
||||
|
||||
*对于任意* ODS 任务和相同的输入数据(API 响应序列),通过 UnifiedPipeline 执行后产生的数据库写入结果(inserted/updated/skipped 计数和记录内容)应与迁移前的同步串行执行完全一致。
|
||||
|
||||
**Validates: Requirements 5.1, 5.3, 5.4, 5.5**
|
||||
|
||||
### Property 10: Detail_Mode 可选性
|
||||
|
||||
*对于任意* OdsTaskSpec,当 detail_endpoint 为 None 时,管道执行应跳过详情拉取阶段,结果中 detail_success/detail_failure/detail_skipped 均为 0;当 detail_endpoint 已配置时,管道应在列表拉取完成后执行详情拉取,且详情请求遵循与列表请求相同的限流规则。
|
||||
|
||||
**Validates: Requirements 6.1, 6.3, 6.4**
|
||||
|
||||
### Property 11: 单项失败不中断整体
|
||||
|
||||
*对于任意*管道执行中的单个失败项(API 请求失败、处理异常、详情接口错误、DWD 单表合并失败),管道应继续处理后续项目,不中断整体流程,且失败项被正确记录在结果的 errors 列表中。
|
||||
|
||||
**Validates: Requirements 6.5, 9.1, 9.2, 7.3, 7.4**
|
||||
|
||||
### Property 12: 连续失败触发中断
|
||||
|
||||
*对于任意*管道执行,当连续失败次数超过 max_consecutive_failures 配置值时,管道应主动中断执行,返回结果的 status 为 FAILED。当连续失败次数未超过阈值时,管道应继续执行。
|
||||
|
||||
**Validates: Requirements 9.5**
|
||||
|
||||
### Property 13: 写入失败回滚当前批次
|
||||
|
||||
*对于任意*批量写入操作,当 write_fn 抛出数据库异常时,当前批次的事务应被回滚(不产生部分写入),该批次的记录被标记为写入失败,后续批次不受影响。
|
||||
|
||||
**Validates: Requirements 9.3**
|
||||
|
||||
### Property 14: 结果统计完整性
|
||||
|
||||
*对于任意*管道执行(包括 Detail_Mode 和 DWD 多线程),返回结果中的统计信息应完整且一致:request_failures + processing_failures + write_failures 应等于 errors 列表的长度;detail_success + detail_failure + detail_skipped 应等于详情请求总数;DWD 汇总中成功表数 + 失败表数应等于总表数。
|
||||
|
||||
**Validates: Requirements 6.6, 8.2, 9.4, 7.5**
|
||||
|
||||
### Property 15: 日志缓冲区按任务隔离
|
||||
|
||||
*对于任意*多个并发任务的日志流,每个 TaskLogBuffer 的 flush() 输出应仅包含该任务的日志条目,且按时间戳升序排列,不包含其他任务的日志。
|
||||
|
||||
**Validates: Requirements 10.1, 10.4**
|
||||
|
||||
### Property 16: DWD 并行与串行结果一致
|
||||
|
||||
*对于任意*一组 DWD 表的 SCD2 合并操作,多线程并行执行的最终结果(每张表的 inserted/updated 计数)应与串行逐表执行的结果完全一致。
|
||||
|
||||
**Validates: Requirements 7.1**
|
||||
|
||||
## 6. 错误处理
|
||||
|
||||
### 6.1 错误分类与处理策略
|
||||
|
||||
| 错误类型 | 触发条件 | 处理方式 | 影响范围 |
|
||||
|----------|----------|----------|----------|
|
||||
| API 请求失败 | HTTP 错误、超时、API 返回错误码 | 由 `APIClient` 内置重试(3 次指数退避);耗尽后记录错误,继续下一个请求 | 单个请求 |
|
||||
| 处理异常 | 字段提取、hash 计算等抛出异常 | 捕获异常,记录错误日志(含记录标识),标记为处理失败,继续处理队列 | 单条记录 |
|
||||
| 写入失败 | 数据库错误(约束冲突、连接断开等) | 回滚当前批次事务,记录错误日志(含批次大小),标记为写入失败 | 单个批次 |
|
||||
| 连续失败 | 连续 N 次请求/处理/写入失败 | 主动中断管道,status=FAILED | 整个任务 |
|
||||
| 取消信号 | 外部触发 cancel_token.cancel() | 停止新请求,等待已入队数据处理完成后退出 | 整个任务 |
|
||||
| 配置错误 | workers<1, rate_min>rate_max 等 | 构造时抛出 ValueError,任务不启动 | 整个任务 |
|
||||
| DWD 单表失败 | SCD2 合并过程中异常 | 回滚该表事务,记录错误,继续处理其他表 | 单张表 |
|
||||
|
||||
### 6.2 连续失败计数逻辑
|
||||
|
||||
```python
|
||||
consecutive_failures = 0
|
||||
|
||||
for request in requests:
|
||||
try:
|
||||
response = api_client.post(...)
|
||||
consecutive_failures = 0 # 成功则重置
|
||||
except Exception:
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= config.max_consecutive_failures:
|
||||
result.status = "FAILED"
|
||||
break
|
||||
```
|
||||
|
||||
### 6.3 事务管理
|
||||
|
||||
- ODS 层:每个窗口分段(segment)的数据在该分段全部处理完成后统一 commit,分段失败时 rollback 该分段(保留现有语义)
|
||||
- DWD 层:每张表独立事务,单表失败 rollback 不影响其他表
|
||||
- WriteWorker:每个批次独立事务,批次失败 rollback 不影响后续批次
|
||||
|
||||
## 7. 测试策略
|
||||
|
||||
### 7.1 测试框架
|
||||
|
||||
- 单元测试:`pytest`(ETL 模块内 `tests/unit/`)
|
||||
- 属性测试:`hypothesis`(Monorepo 级 `tests/`)
|
||||
- 每个属性测试最少运行 100 次迭代
|
||||
|
||||
### 7.2 属性测试计划
|
||||
|
||||
每个正确性属性对应一个属性测试,使用 `hypothesis` 库实现:
|
||||
|
||||
| 属性 | 测试文件 | 生成器策略 |
|
||||
|------|----------|-----------|
|
||||
| P1: 请求严格串行 | `tests/test_pipeline_properties.py` | 生成随机请求序列,用 FakeAPI 记录时间戳 |
|
||||
| P2: RateLimiter 间隔范围 | `tests/test_rate_limiter_properties.py` | 生成随机 (min, max) 对,验证 wait() 时间 |
|
||||
| P3: PipelineConfig 构造 | `tests/test_pipeline_config_properties.py` | 生成随机配置参数组合(含无效值) |
|
||||
| P4: 配置分层覆盖 | `tests/test_pipeline_config_properties.py` | 生成随机的多层配置字典 |
|
||||
| P5: 管道完成语义 | `tests/test_pipeline_properties.py` | 生成随机记录集,验证计数一致 |
|
||||
| P6: WriteWorker 批量约束 | `tests/test_pipeline_properties.py` | 生成随机 batch_size 和记录流 |
|
||||
| P7: CancellationToken 状态 | `tests/test_cancellation_properties.py` | 生成随机超时值 |
|
||||
| P8: 取消后数据不丢失 | `tests/test_pipeline_properties.py` | 生成随机请求序列 + 随机取消时刻 |
|
||||
| P9: 迁移等价 | `tests/test_migration_properties.py` | 生成随机 API 响应,对比新旧实现 |
|
||||
| P10: Detail_Mode 可选性 | `tests/test_detail_mode_properties.py` | 生成有/无 detail_endpoint 的 OdsTaskSpec |
|
||||
| P11: 单项失败不中断 | `tests/test_pipeline_properties.py` | 生成含随机失败的请求序列 |
|
||||
| P12: 连续失败中断 | `tests/test_pipeline_properties.py` | 生成连续失败序列 + 随机阈值 |
|
||||
| P13: 写入失败回滚 | `tests/test_pipeline_properties.py` | 生成含随机写入失败的批次 |
|
||||
| P14: 结果统计完整性 | `tests/test_pipeline_properties.py` | 生成随机执行结果,验证计数一致性 |
|
||||
| P15: 日志缓冲区隔离 | `tests/test_log_buffer_properties.py` | 生成多任务随机日志流 |
|
||||
| P16: DWD 并行串行一致 | `tests/test_dwd_parallel_properties.py` | 生成随机表集合 + mock SCD2 |
|
||||
|
||||
每个测试必须包含注释标签:
|
||||
```python
|
||||
# Feature: etl-unified-pipeline, Property 1: 请求严格串行
|
||||
```
|
||||
|
||||
### 7.3 单元测试计划
|
||||
|
||||
单元测试聚焦于具体示例、边界条件和集成点:
|
||||
|
||||
| 测试目标 | 测试文件 | 覆盖内容 |
|
||||
|----------|----------|----------|
|
||||
| RateLimiter | `tests/unit/test_rate_limiter.py` | 边界:min=max、取消中断、min>max 抛错 |
|
||||
| CancellationToken | `tests/unit/test_cancellation.py` | 边界:预取消、超时=0、dispose |
|
||||
| PipelineConfig | `tests/unit/test_pipeline_config.py` | 边界:无效参数、CLI 覆盖 |
|
||||
| UnifiedPipeline | `tests/unit/test_unified_pipeline.py` | 集成:FakeAPI + FakeDB 端到端 |
|
||||
| TaskLogBuffer | `tests/unit/test_task_log_buffer.py` | 边界:空缓冲区、并发写入 |
|
||||
| DWD 多线程调度 | `tests/unit/test_dwd_parallel.py` | 集成:mock SCD2 + 单表失败 |
|
||||
| Detail_Mode | `tests/unit/test_detail_mode.py` | 集成:列表→详情完整流程 |
|
||||
|
||||
### 7.4 测试环境
|
||||
|
||||
- 单元测试使用 FakeDB/FakeAPI,不涉及真实数据库连接
|
||||
- 属性测试使用 `hypothesis` 库,最少 100 次迭代
|
||||
- 集成测试(如需)使用 `test_etl_feiqiu` 测试库,通过 `TEST_DB_DSN` 连接
|
||||
@@ -1,159 +0,0 @@
|
||||
# 需求文档:ETL 统一请求编排与线程模型改造
|
||||
|
||||
## 简介
|
||||
|
||||
对飞球 Connector ETL 系统(`apps/etl/connectors/feiqiu/`)的请求编排和线程模型进行全局统一化改造。当前系统所有 ODS 任务在 `BaseOdsTask.execute()` 中同步串行执行 API 请求、数据处理和数据库写入,无限流机制、无取消信号、无并行处理能力。本次改造建立统一的请求编排框架,所有 21 个 ODS 任务迁移到"串行请求 + 异步处理 + 单线程写库"架构,支持全局限流(5-20 秒随机间隔)、外部取消信号、可选的"列表→详情"二级拉取模式,并对 DWD 层加载进行多线程优化。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **ETL_System**:飞球 Connector ETL 系统(`apps/etl/connectors/feiqiu/`),负责从飞球 SaaS API 拉取数据并加载到 PostgreSQL 的 ODS → DWD → DWS 各层
|
||||
- **Unified_Pipeline**:统一请求编排框架,所有 ODS 任务共用的"串行请求 + 异步处理 + 单线程写库"执行引擎
|
||||
- **Request_Scheduler**:全局请求调度器,负责将所有 API 请求统一排队、串行发送、遵循限流规则
|
||||
- **Rate_Limiter**:请求间隔控制器,控制相邻两次 API 请求之间的随机等待时间(默认 5-20 秒均匀分布),防止触发上游风控
|
||||
- **Processing_Pool**:异步处理线程池,多个工作线程并行消费 API 响应数据,执行字段提取、数据清洗、content_hash 计算等 CPU 密集操作
|
||||
- **Write_Worker**:单线程写入工作器,汇总所有处理完成的结果,统一执行数据库写入操作,保证写入串行化
|
||||
- **CancellationToken**:取消令牌,外部组件(如 Admin-web、CLI)通过设置该令牌通知正在执行的任务中断
|
||||
- **ODS_Task**:ODS 层数据拉取任务的统称,当前共 21 个,通过 `OdsTaskSpec` 数据类定义、`_build_task_class()` 动态生成任务类
|
||||
- **Detail_Mode**:二级详情拉取模式,在列表接口拉取完成后逐条调用详情接口获取更丰富的数据,属于可选能力
|
||||
- **Pipeline_Config**:管道配置,包含 worker 数、队列大小、批量写入阈值、限流间隔等参数,不同任务可独立配置
|
||||
- **BaseOdsTask**:当前 ODS 任务基类(`tasks/ods/ods_tasks.py`),封装时间窗口解析、API 分页拉取、结构感知写入、快照软删除等核心逻辑
|
||||
- **TaskExecutor**:任务执行器(`orchestration/task_executor.py`),封装单个任务的执行生命周期(游标管理、运行记录、数据源路由)
|
||||
- **FlowRunner**:流程编排器(`orchestration/flow_runner.py`),编排多层任务(ODS → DWD → DWS → INDEX)的执行顺序
|
||||
- **DWD_Loader**:DWD 层加载任务(`DwdLoadTask`),通过 `_merge_dim_scd2()` 执行 SCD2 合并,将 ODS 原始数据转换为维度/事实表
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:统一请求调度器
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望所有 API 请求通过统一的调度器串行发送并遵循限流规则,避免触发上游风控导致 IP 封禁或数据拉取失败。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Request_Scheduler SHALL 维护一个全局请求队列,所有 ODS 任务的 API 请求统一进入该队列排队等待发送
|
||||
2. THE Request_Scheduler SHALL 严格串行发送请求:等待上一个请求的 HTTP 响应完整返回后,再等待限流间隔,然后发送队列中的下一个请求
|
||||
3. THE Rate_Limiter SHALL 在每两个相邻请求之间插入 5 至 20 秒之间的随机等待时间(均匀分布)
|
||||
4. THE Rate_Limiter SHALL 支持通过 Pipeline_Config 调整最小间隔(默认 5 秒)和最大间隔(默认 20 秒),不同任务可配置不同的间隔范围
|
||||
5. IF Rate_Limiter 初始化时最小间隔大于最大间隔,THEN THE Rate_Limiter SHALL 抛出 `ValueError` 并包含描述性错误信息
|
||||
6. WHEN 同一次 FlowRunner 执行中包含多个 ODS 任务时,THE Request_Scheduler SHALL 按任务注册顺序依次处理每个任务的请求,同一时刻仅有一个 HTTP 请求在途
|
||||
7. THE Request_Scheduler SHALL 在每个请求完成后记录请求耗时、响应状态码和目标 endpoint 到日志
|
||||
|
||||
### 需求 2:异步处理与单线程写库架构
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望 API 响应数据的处理(字段提取、清洗、hash 计算)能与请求发送并行执行,同时保证数据库写入的串行化,在不增加 API 压力的前提下提升整体吞吐。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Unified_Pipeline SHALL 在每个 API 请求的响应返回后,立即将响应数据提交到 Processing_Pool 的任务队列,不阻塞 Request_Scheduler 的限流等待计时
|
||||
2. THE Processing_Pool SHALL 支持多个工作线程并行消费处理队列中的响应数据,执行字段提取、数据清洗、content_hash 计算、record 层合并等操作
|
||||
3. THE Processing_Pool 的工作线程数量 SHALL 通过 Pipeline_Config 配置(默认值 2),不同任务可独立配置
|
||||
4. THE Write_Worker SHALL 作为单独的线程运行,从处理完成队列中消费数据,统一执行数据库 INSERT/UPSERT 操作
|
||||
5. THE Write_Worker SHALL 支持批量写入:累积到配置的阈值(默认 100 条记录)或等待超时(默认 5 秒)后执行一次批量写入
|
||||
6. THE Write_Worker 的批量写入阈值和等待超时 SHALL 通过 Pipeline_Config 配置,不同任务可独立配置
|
||||
7. WHEN 所有请求发送完毕后,THE Unified_Pipeline SHALL 等待 Processing_Pool 和 Write_Worker 全部完成后再返回最终结果
|
||||
8. THE Unified_Pipeline SHALL 保证多线程读库操作的安全性:Processing_Pool 中的工作线程可并行读取数据库(如查询最新 content_hash),使用独立的只读数据库连接
|
||||
|
||||
### 需求 3:外部取消信号支持
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望能通过 Admin-web 或 CLI 发送取消信号中断正在执行的 ODS 任务,避免长时间运行的任务无法停止。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CancellationToken SHALL 提供线程安全的 `cancel()` 方法和 `is_cancelled` 属性,供外部组件触发取消
|
||||
2. WHEN CancellationToken 被触发时,THE Request_Scheduler SHALL 在当前请求的限流等待期或响应等待期中断,不再发起后续请求
|
||||
3. WHEN CancellationToken 被触发时,THE Processing_Pool SHALL 完成当前已提交到队列中的所有数据处理任务,不丢弃已入队的数据
|
||||
4. WHEN CancellationToken 被触发时,THE Write_Worker SHALL 将所有已处理完成的数据写入数据库后再退出,保证已处理数据的持久化
|
||||
5. WHEN 任务因取消信号中断时,THE Unified_Pipeline SHALL 返回部分完成的统计结果(已完成的请求数、已处理的记录数、已写入的记录数),任务状态标记为 `CANCELLED`
|
||||
6. THE CancellationToken SHALL 支持超时自动取消:可在创建时指定最大执行时间(秒),超时后自动触发取消信号
|
||||
7. IF CancellationToken 在任务启动前已处于取消状态,THEN THE Unified_Pipeline SHALL 立即返回空结果,不发送任何请求
|
||||
|
||||
### 需求 4:Pipeline 配置体系
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望线程模型的各项参数(worker 数、队列大小、批量写入阈值、限流间隔)足够灵活,不同接口可以有不同的配置,以适应不同 API 的特性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Pipeline_Config SHALL 支持以下可配置参数:Processing_Pool 工作线程数(`workers`,默认 2)、处理队列容量(`queue_size`,默认 100)、Write_Worker 批量写入阈值(`batch_size`,默认 100)、Write_Worker 等待超时秒数(`batch_timeout`,默认 5.0)、Rate_Limiter 最小间隔秒数(`rate_min`,默认 5.0)、Rate_Limiter 最大间隔秒数(`rate_max`,默认 20.0)
|
||||
2. THE Pipeline_Config SHALL 遵循现有配置分层体系(根 `.env` < `.env.local` < 环境变量 < CLI 参数),通过 `AppConfig` 的 `pipeline.*` 命名空间读取
|
||||
3. THE Pipeline_Config SHALL 支持任务级覆盖:通过 `pipeline.<task_code>.*` 命名空间为特定任务指定独立配置,未指定时回退到 `pipeline.*` 全局默认值
|
||||
4. IF Pipeline_Config 中 `workers` 小于 1 或 `queue_size` 小于 1,THEN THE Unified_Pipeline SHALL 抛出 `ValueError` 并包含描述性错误信息
|
||||
5. IF Pipeline_Config 中 `batch_size` 小于 1,THEN THE Unified_Pipeline SHALL 抛出 `ValueError` 并包含描述性错误信息
|
||||
6. THE Pipeline_Config SHALL 支持运行时通过 CLI 参数 `--pipeline-workers`、`--pipeline-batch-size`、`--pipeline-rate-min`、`--pipeline-rate-max` 覆盖全局默认值
|
||||
|
||||
### 需求 5:现有 ODS 任务迁移
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望现有 21 个 ODS 任务全部迁移到统一管道框架上,保持功能完全等价,不丢失任何现有能力。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Unified_Pipeline SHALL 完整保留 BaseOdsTask 的所有现有功能:时间窗口解析(`_resolve_window`)、窗口分段(`build_window_segments`)、API 分页拉取(`iter_paginated`)、结构感知写入(`_insert_records_schema_aware`)、快照软删除(`_mark_missing_as_deleted`)、content_hash 去重(`skip_unchanged`)
|
||||
2. THE Unified_Pipeline SHALL 保留 OdsTaskSpec 数据类的所有现有字段定义,迁移后的任务通过相同的 `OdsTaskSpec` 实例配置
|
||||
3. WHEN 迁移完成后,THE ETL_System 对每个 ODS 任务执行相同输入数据时 SHALL 产生与迁移前完全相同的数据库写入结果(相同的 inserted/updated/skipped 计数和相同的记录内容)
|
||||
4. THE Unified_Pipeline SHALL 保留现有的 `endpoint_routing` 逻辑(recent/former 路由拆分),迁移后的请求路由行为与现有系统一致
|
||||
5. THE Unified_Pipeline SHALL 保留现有的 `source_file`、`source_endpoint`、`fetched_at` 等元数据写入逻辑
|
||||
6. THE Unified_Pipeline SHALL 兼容现有的 `TaskExecutor` 执行生命周期(游标管理、运行记录、数据源路由),迁移后 TaskExecutor 无需修改调用方式
|
||||
7. WHEN 迁移完成后,THE TaskRegistry 中所有 21 个 ODS 任务的注册代码和元数据 SHALL 保持不变
|
||||
|
||||
### 需求 6:二级详情拉取模式
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望统一管道框架支持"列表接口拉完后逐条调详情"的二级拉取模式,以便团购详情等需要二次请求的业务能在框架内实现。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Unified_Pipeline SHALL 支持可选的 Detail_Mode:在列表接口的所有分页数据拉取并写入 ODS 完成后,从已写入的记录中提取 ID 列表,逐条调用详情接口
|
||||
2. THE OdsTaskSpec SHALL 新增可选字段支持 Detail_Mode 配置:详情接口 endpoint、详情请求参数构造函数、详情数据目标表名、详情数据的 data_path 和 list_key
|
||||
3. WHEN OdsTaskSpec 未配置 Detail_Mode 相关字段时,THE Unified_Pipeline SHALL 跳过详情拉取阶段,行为与纯列表拉取模式完全一致
|
||||
4. THE Detail_Mode 的详情请求 SHALL 通过 Request_Scheduler 统一排队,遵循与列表请求相同的限流规则
|
||||
5. IF 详情接口对某个 ID 返回错误或超时,THEN THE Unified_Pipeline SHALL 记录错误日志(含 ID 和错误信息)并继续处理下一个 ID,不中断整体流程
|
||||
6. WHEN 详情拉取完成后,THE Unified_Pipeline SHALL 在任务执行结果中包含详情拉取的统计信息(详情成功数、详情失败数、详情跳过数),与列表拉取统计分开记录
|
||||
|
||||
### 需求 7:DWD 层多线程优化
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望 DWD 层加载任务能利用多线程并行处理多张表的 SCD2 合并,缩短 DWD 层的整体执行时间。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE DWD_Loader SHALL 支持多线程并行执行多张 DWD 表的 SCD2 合并操作,每张表的合并在独立线程中运行
|
||||
2. THE DWD_Loader 的并行线程数 SHALL 通过配置参数控制(默认值 4),通过 `AppConfig` 的 `dwd.parallel_workers` 读取
|
||||
3. THE DWD_Loader SHALL 保证每张表的 SCD2 合并操作在独立的数据库事务中执行,单张表失败不影响其他表的处理
|
||||
4. WHEN 某张 DWD 表的 SCD2 合并失败时,THE DWD_Loader SHALL 记录错误日志(含表名和错误信息),将该表标记为失败,继续处理其他表
|
||||
5. THE DWD_Loader SHALL 在所有表处理完成后返回汇总结果:成功表数、失败表数、每张表的 inserted/updated 计数
|
||||
6. THE DWD_Loader 的现有 `_merge_dim_scd2()` 方法 SHALL 保持不变,多线程优化仅在调度层面并行调用该方法
|
||||
|
||||
### 需求 8:可观测性与监控
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望统一管道框架提供充分的运行时可观测性,便于监控执行状态和排查问题。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Unified_Pipeline SHALL 在任务执行过程中记录以下关键指标到日志:当前请求队列深度、Processing_Pool 活跃线程数、Write_Worker 待写入队列深度、已完成请求数/总请求数
|
||||
2. THE Unified_Pipeline SHALL 在任务完成后输出执行摘要:总耗时、请求阶段耗时、处理阶段耗时、写入阶段耗时、各阶段的记录数统计
|
||||
3. WHEN Processing_Pool 的任务队列达到容量上限时,THE Unified_Pipeline SHALL 记录警告日志,Request_Scheduler 暂停发送新请求直到队列有空位(背压机制)
|
||||
4. WHEN Write_Worker 的待写入队列积压超过 `queue_size * 2` 时,THE Unified_Pipeline SHALL 记录警告日志
|
||||
5. THE Unified_Pipeline SHALL 与现有的 `EtlTimer` 集成,在 FlowRunner 的计时报告中体现各 ODS 任务的请求/处理/写入阶段耗时
|
||||
|
||||
### 需求 9:错误处理与容错
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望统一管道框架具备完善的错误处理机制,单个请求或记录的失败不影响整体任务的执行。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. IF 单个 API 请求失败(HTTP 错误、超时、API 返回错误码),THEN THE Request_Scheduler SHALL 按现有 `APIClient` 的重试策略(最多 3 次,指数退避)重试,重试耗尽后记录错误并继续处理下一个请求
|
||||
2. IF Processing_Pool 中某条记录的处理抛出异常,THEN THE Processing_Pool SHALL 记录错误日志(含记录标识和异常信息),将该记录标记为处理失败,继续处理队列中的其他记录
|
||||
3. IF Write_Worker 执行批量写入时发生数据库错误,THEN THE Write_Worker SHALL 回滚当前批次的事务,记录错误日志(含批次大小和错误信息),将该批次的记录标记为写入失败
|
||||
4. WHEN 任务执行完成后,THE Unified_Pipeline SHALL 在执行结果中汇总所有错误:请求失败数、处理失败数、写入失败数,以及每个失败项的错误摘要
|
||||
5. IF 任务执行过程中连续失败次数超过配置阈值(默认 10 次),THEN THE Unified_Pipeline SHALL 主动中断任务执行,将任务状态标记为 `FAILED`,避免无效重试浪费时间
|
||||
6. THE Unified_Pipeline SHALL 保留现有 BaseOdsTask 的事务管理语义:每个窗口分段(segment)的数据在该分段全部处理完成后统一 commit,分段失败时 rollback 该分段
|
||||
|
||||
### 需求 10:Admin-web 日志输出优化
|
||||
|
||||
**用户故事:** 作为 ETL 运维人员,我希望在 Admin-web 管理后台查看 ETL 执行日志时,各个任务的日志按任务分组、有序展示,避免多任务并行执行时日志行交叉混乱导致难以阅读和排查问题。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE ETL_System SHALL 为每个 ODS 任务的执行日志添加任务标识前缀(任务代码),使日志行可按任务归属区分
|
||||
2. THE Admin-web SHALL 支持按任务代码过滤和分组展示 ETL 执行日志,用户可选择查看单个任务的日志或全部日志
|
||||
3. THE Unified_Pipeline SHALL 在多线程环境下保证日志写入的原子性:每条日志消息作为完整的一行输出,不会被其他线程的日志截断或插入
|
||||
4. THE ETL_System SHALL 为每个任务维护独立的日志缓冲区,任务完成后将该任务的完整日志按时间顺序一次性输出到 Admin-web,避免执行过程中不同任务的日志行交叉
|
||||
5. THE Admin-web SHALL 在 ETL 执行结果页面中按任务分段展示日志:每个任务的日志折叠为独立区块,展开后显示该任务的完整执行日志(含时间戳、日志级别、消息内容)
|
||||
6. WHEN 多个 ODS 任务在同一次 FlowRunner 执行中运行时,THE Admin-web SHALL 在顶部展示任务执行时间线概览(每个任务的开始时间、结束时间、状态),用户可点击跳转到对应任务的日志区块
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
# 实施计划:ETL 统一请求编排与线程模型改造
|
||||
|
||||
## 概述
|
||||
|
||||
将飞球 Connector ETL 系统的 ODS 任务从同步串行执行迁移到"串行请求 + 异步处理 + 单线程写库"统一管道架构。按组件依赖顺序逐步实现:基础组件 → 核心引擎 → 任务迁移 → DWD 优化 → 日志优化。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 实现基础组件(PipelineConfig、CancellationToken、RateLimiter)
|
||||
- [x] 1.1 创建 `apps/etl/connectors/feiqiu/config/pipeline_config.py`,实现 `PipelineConfig` 数据类
|
||||
- 定义 `workers`、`queue_size`、`batch_size`、`batch_timeout`、`rate_min`、`rate_max`、`max_consecutive_failures` 字段及默认值
|
||||
- 实现 `__post_init__` 参数校验(workers>=1、queue_size>=1、batch_size>=1、rate_min<=rate_max)
|
||||
- 实现 `from_app_config(config, task_code)` 类方法,支持 `pipeline.<task_code>.*` 任务级覆盖 → 全局 `pipeline.*` → 默认值的三级回退
|
||||
- _需求: 1.3, 1.4, 1.5, 2.3, 2.5, 2.6, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
|
||||
|
||||
- [x] 1.2 编写 PipelineConfig 属性测试
|
||||
- **Property 3: PipelineConfig 构造与验证** — 生成随机配置参数组合(含无效值),验证合法参数成功构造、非法参数抛出 ValueError
|
||||
- **Property 4: 配置分层与任务级覆盖** — 生成随机多层配置字典,验证任务级 > 全局级 > 默认值的优先级
|
||||
- 测试文件:`tests/test_pipeline_config_properties.py`
|
||||
- **验证: 需求 1.4, 1.5, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6**
|
||||
|
||||
- [x] 1.3 创建 `apps/etl/connectors/feiqiu/utils/cancellation.py`,实现 `CancellationToken` 类
|
||||
- 基于 `threading.Event` 实现线程安全的 `cancel()` 方法和 `is_cancelled` 属性
|
||||
- 实现超时自动取消(构造时传入 `timeout` 秒数,通过 `threading.Timer` 触发)
|
||||
- 实现 `dispose()` 清理定时器
|
||||
- _需求: 3.1, 3.6_
|
||||
|
||||
- [x] 1.4 编写 CancellationToken 属性测试
|
||||
- **Property 7: CancellationToken 状态转换** — 生成随机超时值,验证初始 False、cancel() 后 True 且不可逆、超时自动触发
|
||||
- 测试文件:`tests/test_cancellation_properties.py`
|
||||
- **验证: 需求 3.1, 3.6**
|
||||
|
||||
- [x] 1.5 创建 `apps/etl/connectors/feiqiu/api/rate_limiter.py`,实现 `RateLimiter` 类
|
||||
- 构造时校验 `min_interval <= max_interval`,否则抛出 `ValueError`
|
||||
- 实现 `wait(cancel_event)` 方法:生成 `[min, max]` 均匀分布随机间隔,拆分为 0.5s 小段轮询 cancel_event
|
||||
- 暴露 `last_interval` 属性
|
||||
- _需求: 1.3, 1.5_
|
||||
|
||||
- [x] 1.6 编写 RateLimiter 属性测试
|
||||
- **Property 2: RateLimiter 间隔范围** — 生成随机 (min, max) 对,验证 wait() 实际等待时间在 [min, max] ± 0.5s 范围内
|
||||
- 测试文件:`tests/test_rate_limiter_properties.py`
|
||||
- **验证: 需求 1.3**
|
||||
|
||||
- [x] 1.7 编写基础组件单元测试
|
||||
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_pipeline_config.py`、`tests/unit/test_cancellation.py`、`tests/unit/test_rate_limiter.py`
|
||||
- 覆盖边界条件:RateLimiter min=max、CancellationToken 预取消/timeout=0/dispose、PipelineConfig 无效参数/CLI 覆盖
|
||||
- _需求: 1.3, 1.5, 3.1, 3.6, 4.1, 4.4, 4.5_
|
||||
|
||||
- [x] 2. 检查点 — 基础组件验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 3. 实现核心管道引擎(UnifiedPipeline)
|
||||
- [x] 3.1 创建数据类 `PipelineRequest`、`PipelineResult`、`WriteResult`
|
||||
- 文件:`apps/etl/connectors/feiqiu/pipeline/models.py`
|
||||
- `PipelineRequest`:endpoint、params、page_size、data_path、list_key、segment_index、is_detail、detail_id
|
||||
- `PipelineResult`:status、各阶段计数、errors 列表、timing 字典、Detail_Mode 统计
|
||||
- `WriteResult`:inserted、updated、skipped、errors
|
||||
- _需求: 2.7, 6.6, 8.2, 9.4_
|
||||
|
||||
- [x] 3.2 创建 `apps/etl/connectors/feiqiu/pipeline/unified_pipeline.py`,实现 `UnifiedPipeline` 核心引擎
|
||||
- 实现 `__init__`:接收 api_client、db_connection、logger、PipelineConfig、CancellationToken,初始化 RateLimiter
|
||||
- 实现 `run(requests, process_fn, write_fn) -> PipelineResult` 主方法:
|
||||
- 预取消检查(cancel_token 已取消则立即返回空结果)
|
||||
- 创建 processing_queue(maxsize=queue_size)和 write_queue(maxsize=queue_size*2)
|
||||
- 启动 N 个 worker 线程(`_process_worker`)和 1 个 writer 线程(`_write_worker`)
|
||||
- 主线程执行 `_request_loop`:串行发送请求、限流等待、取消检查、背压阻塞
|
||||
- 发送 SENTINEL 通知线程退出,join 等待完成
|
||||
- 计算最终 status(SUCCESS/PARTIAL/CANCELLED/FAILED)
|
||||
- _需求: 1.1, 1.2, 1.6, 2.1, 2.2, 2.4, 2.7, 2.8, 3.2, 3.7, 8.3_
|
||||
|
||||
- [x] 3.3 实现 `_request_loop` 请求调度逻辑
|
||||
- 遍历 requests 迭代器,逐个发送 API 请求
|
||||
- 每个请求完成后记录耗时、状态码、endpoint 到日志
|
||||
- 将响应数据 put 到 processing_queue(满时阻塞 = 背压)
|
||||
- 请求间调用 rate_limiter.wait(cancel_event),被取消则 break
|
||||
- 实现连续失败计数:成功重置为 0,失败 +1,超过 max_consecutive_failures 则中断
|
||||
- _需求: 1.2, 1.7, 3.2, 8.1, 8.3, 9.1, 9.5_
|
||||
|
||||
- [x] 3.4 实现 `_process_worker` 处理线程逻辑
|
||||
- 从 processing_queue 消费数据,调用 process_fn 处理
|
||||
- 处理结果 put 到 write_queue
|
||||
- 单条记录处理异常时捕获、记录错误、标记失败、继续处理
|
||||
- 收到 SENTINEL 时退出
|
||||
- _需求: 2.1, 2.2, 9.2_
|
||||
|
||||
- [x] 3.5 实现 `_write_worker` 写入线程逻辑
|
||||
- 从 write_queue 消费数据,累积到 batch_size 或等待 batch_timeout 后调用 write_fn 批量写入
|
||||
- 写入失败时回滚当前批次事务、记录错误、标记失败、继续处理后续批次
|
||||
- 队列积压超过 queue_size*2 时记录警告日志
|
||||
- 收到 SENTINEL 时将剩余数据 flush 写入后退出
|
||||
- _需求: 2.4, 2.5, 2.6, 8.4, 9.3, 9.6_
|
||||
|
||||
- [x] 3.6 编写 UnifiedPipeline 属性测试
|
||||
- **Property 1: 请求严格串行** — 用 FakeAPI 记录时间戳,验证每个请求发送时间 > 上一个响应完成时间
|
||||
- **Property 5: 管道完成语义** — 生成随机记录集,验证 total_fetched == total_inserted + total_updated + total_skipped
|
||||
- **Property 6: WriteWorker 批量大小约束** — 生成随机 batch_size 和记录流,验证每次 write_fn 调用的记录数 <= batch_size
|
||||
- **Property 8: 取消后已入队数据不丢失** — 生成随机请求序列 + 随机取消时刻,验证已入队数据全部处理和写入
|
||||
- **Property 11: 单项失败不中断整体** — 生成含随机失败的请求序列,验证后续项目继续处理
|
||||
- **Property 12: 连续失败触发中断** — 生成连续失败序列 + 随机阈值,验证超过阈值时中断
|
||||
- **Property 13: 写入失败回滚当前批次** — 生成含随机写入失败的批次,验证回滚且后续批次不受影响
|
||||
- **Property 14: 结果统计完整性** — 验证各计数字段的一致性关系
|
||||
- 测试文件:`tests/test_pipeline_properties.py`
|
||||
- **验证: 需求 1.2, 1.6, 2.5, 2.7, 3.2, 3.3, 3.4, 3.5, 6.6, 8.2, 9.1, 9.2, 9.3, 9.4, 9.5**
|
||||
|
||||
- [x] 3.7 编写 UnifiedPipeline 单元测试
|
||||
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_unified_pipeline.py`
|
||||
- 使用 FakeAPI + FakeDB 端到端测试:正常流程、空请求、预取消、背压触发
|
||||
- _需求: 2.7, 3.7, 8.1, 8.3_
|
||||
|
||||
- [x] 4. 检查点 — 核心引擎验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 5. BaseOdsTask 改造与 ODS 任务迁移
|
||||
- [x] 5.1 扩展 `OdsTaskSpec` 数据类,新增 Detail_Mode 可选字段
|
||||
- 在 `apps/etl/connectors/feiqiu/tasks/ods/ods_tasks.py` 中为 `OdsTaskSpec` 新增:`detail_endpoint`、`detail_param_builder`、`detail_target_table`、`detail_data_path`、`detail_list_key`、`detail_id_column`
|
||||
- 所有新增字段默认值为 `None`,不影响现有 21 个任务的 OdsTaskSpec 实例
|
||||
- _需求: 6.2, 6.3_
|
||||
|
||||
- [x] 5.2 改造 `BaseOdsTask.execute()` 方法,嵌入 UnifiedPipeline
|
||||
- 在 `execute()` 内部构建 `PipelineConfig.from_app_config(self.config, spec.code)`
|
||||
- 将现有分页请求逻辑封装为 `_build_requests()` → `Iterable[PipelineRequest]`
|
||||
- 将现有字段提取/hash 计算封装为 `_build_process_fn()` → `Callable`
|
||||
- 将现有 `_insert_records_schema_aware` 封装为 `_build_write_fn()` → `Callable`
|
||||
- 调用 `pipeline.run(requests, process_fn, write_fn)` 替代现有同步循环
|
||||
- 保留快照软删除(`_mark_missing_as_deleted`)、endpoint_routing、元数据写入(source_file、source_endpoint、fetched_at)
|
||||
- 保留 TaskExecutor 调用接口不变(`task.execute(cursor_data)` 签名不变)
|
||||
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_
|
||||
|
||||
- [x] 5.3 实现 Detail_Mode 详情拉取逻辑
|
||||
- 在 `BaseOdsTask` 中实现 `_build_detail_requests()` 方法:从已写入 ODS 的记录中提取 ID 列表,生成 `PipelineRequest(is_detail=True)` 序列
|
||||
- 详情请求通过同一个 UnifiedPipeline 的 RequestScheduler 排队,遵循相同限流规则
|
||||
- 单个详情请求失败时记录错误日志(含 ID 和错误信息),继续处理下一个
|
||||
- 在 PipelineResult 中填充 detail_success/detail_failure/detail_skipped 统计
|
||||
- _需求: 6.1, 6.4, 6.5, 6.6_
|
||||
|
||||
- [x] 5.4 编写 Detail_Mode 属性测试
|
||||
- **Property 10: Detail_Mode 可选性** — 生成有/无 detail_endpoint 的 OdsTaskSpec,验证无配置时跳过详情阶段、有配置时执行详情拉取且遵循限流
|
||||
- 测试文件:`tests/test_detail_mode_properties.py`
|
||||
- **验证: 需求 6.1, 6.3, 6.4**
|
||||
|
||||
- [x] 5.5 编写迁移等价属性测试
|
||||
- **Property 9: 迁移前后输出等价** — 生成随机 API 响应序列,对比 UnifiedPipeline 与原同步串行实现的数据库写入结果(inserted/updated/skipped 计数和记录内容)
|
||||
- 测试文件:`tests/test_migration_properties.py`
|
||||
- **验证: 需求 5.1, 5.3, 5.4, 5.5**
|
||||
|
||||
- [x] 5.6 编写 Detail_Mode 和迁移单元测试
|
||||
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_detail_mode.py`
|
||||
- 覆盖:列表→详情完整流程、无 detail_endpoint 跳过、详情单条失败不中断
|
||||
- _需求: 6.1, 6.3, 6.5_
|
||||
|
||||
- [x] 6. 检查点 — ODS 迁移验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 7. DWD 层多线程优化
|
||||
- [x] 7.1 改造 `apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py` 中的 `DwdLoadTask.load()` 方法
|
||||
- 从 `AppConfig` 读取 `dwd.parallel_workers`(默认 4)
|
||||
- 将现有串行 `for dwd_table, ods_table in TABLE_MAP` 循环改为 `concurrent.futures.ThreadPoolExecutor` 并行调度
|
||||
- 每张表调用 `_process_single_table()` 在独立线程中执行,使用独立数据库连接和事务
|
||||
- `_merge_dim_scd2()` 方法本身不改
|
||||
- 单张表失败时捕获异常、记录错误日志(含表名和错误信息)、标记失败、继续处理其他表
|
||||
- 所有表处理完成后返回汇总结果:成功表数、失败表数、每张表的 inserted/updated 计数
|
||||
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
|
||||
|
||||
- [x] 7.2 编写 DWD 并行属性测试
|
||||
- **Property 16: DWD 并行与串行结果一致** — 生成随机表集合 + mock SCD2,验证多线程并行执行的结果与串行逐表执行完全一致
|
||||
- 测试文件:`tests/test_dwd_parallel_properties.py`
|
||||
- **验证: 需求 7.1**
|
||||
|
||||
- [x] 7.3 编写 DWD 多线程单元测试
|
||||
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_dwd_parallel.py`
|
||||
- 覆盖:mock SCD2 正常并行、单表失败不影响其他表、汇总结果正确
|
||||
- _需求: 7.3, 7.4, 7.5_
|
||||
|
||||
- [x] 8. 可观测性与日志优化
|
||||
- [x] 8.1 在 UnifiedPipeline 中集成运行时指标日志
|
||||
- 在 `_request_loop` 中定期记录:当前请求队列深度、ProcessingPool 活跃线程数、WriteWorker 待写入队列深度、已完成请求数/总请求数
|
||||
- 在 `run()` 返回前计算并记录执行摘要:总耗时、请求/处理/写入各阶段耗时、各阶段记录数统计
|
||||
- 与现有 `EtlTimer` 集成,在 FlowRunner 计时报告中体现各 ODS 任务的阶段耗时
|
||||
- _需求: 8.1, 8.2, 8.5_
|
||||
|
||||
- [x] 8.2 创建 `apps/etl/connectors/feiqiu/utils/task_log_buffer.py`,实现 `TaskLogBuffer` 类
|
||||
- 实现线程安全的 `log(level, message)` 方法,将日志条目缓冲到内存列表
|
||||
- 实现 `flush()` 方法:按时间戳升序排列,一次性输出到父 logger,添加 `[task_code]` 前缀
|
||||
- 定义 `LogEntry` 数据类(timestamp、level、task_code、message)
|
||||
- _需求: 10.1, 10.3, 10.4_
|
||||
|
||||
- [x] 8.3 编写日志缓冲区属性测试
|
||||
- **Property 15: 日志缓冲区按任务隔离** — 生成多任务随机日志流,验证每个 TaskLogBuffer 的 flush() 仅包含该任务日志且按时间戳升序
|
||||
- 测试文件:`tests/test_log_buffer_properties.py`
|
||||
- **验证: 需求 10.1, 10.4**
|
||||
|
||||
- [x] 8.4 编写 TaskLogBuffer 单元测试
|
||||
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_task_log_buffer.py`
|
||||
- 覆盖:空缓冲区 flush、并发多线程写入、日志前缀格式
|
||||
- _需求: 10.1, 10.3, 10.4_
|
||||
|
||||
- [x] 9. 检查点 — DWD 优化与日志验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 10. Admin-web 日志展示优化
|
||||
- [x] 10.1 在 `apps/etl/connectors/feiqiu/` 中集成 TaskLogBuffer 到 BaseOdsTask 和 FlowRunner
|
||||
- 在 BaseOdsTask.execute() 中创建 TaskLogBuffer 实例,替代直接 logger 调用
|
||||
- 在 FlowRunner 中为每个任务分配独立的 TaskLogBuffer,任务完成后调用 flush()
|
||||
- 保证多线程环境下日志写入原子性(每条日志完整一行)
|
||||
- _需求: 10.1, 10.3, 10.4_
|
||||
|
||||
- [x] 10.2 在 `apps/admin-web/` 中实现按任务分组的日志展示
|
||||
- 在 ETL 执行结果页面中按任务分段展示日志:每个任务折叠为独立区块
|
||||
- 展开后显示该任务的完整执行日志(时间戳、日志级别、消息内容)
|
||||
- 支持按任务代码过滤和分组展示
|
||||
- 顶部展示任务执行时间线概览(每个任务的开始/结束时间、状态),可点击跳转
|
||||
- _需求: 10.2, 10.5, 10.6_
|
||||
|
||||
- [x] 11. CLI 参数扩展
|
||||
- [x] 11.1 在 `apps/etl/connectors/feiqiu/cli/` 中添加 Pipeline 相关 CLI 参数
|
||||
- 新增 `--pipeline-workers`、`--pipeline-batch-size`、`--pipeline-rate-min`、`--pipeline-rate-max` 参数
|
||||
- 将 CLI 参数值注入到 AppConfig,使其在 PipelineConfig.from_app_config() 中生效
|
||||
- _需求: 4.6_
|
||||
|
||||
- [x] 12. 最终检查点 — 全量验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
|
||||
- 每个任务引用了具体的需求编号,确保可追溯
|
||||
- 检查点任务用于增量验证,确保每个阶段的正确性
|
||||
- 属性测试验证通用正确性属性,单元测试验证具体示例和边界条件
|
||||
- 属性测试位于 Monorepo 级 `tests/` 目录,单元测试位于 ETL 模块内 `tests/unit/`
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "4b6736e7-40fc-40a9-82f7-809f80253fe2", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -1,481 +0,0 @@
|
||||
# Design Document: 赠送卡矩阵细分数据 (gift-card-breakdown)
|
||||
|
||||
## Overview
|
||||
|
||||
本设计解决 BOARD-3 财务看板赠送卡 3×4 矩阵中细分单元格全部返回 0 的问题。根因是 DWS 层 `dws_finance_recharge_summary` 仅存储赠送卡总额,未按卡类型(酒水卡/台费卡/抵用券)拆分。
|
||||
|
||||
改动贯穿全栈数据链路:DDL 新增 6 个字段 → ETL 拆分填充 → RLS 视图 + FDW 同步 → 后端 SQL 查询 + 接口返回 → 小程序替换 mock 数据。
|
||||
|
||||
关键约束:消费行无法按卡类型拆分(上游飞球 API 的 `dwd_settlement_head.gift_card_amount` 仅提供总额),消费行细分列保持 0。
|
||||
|
||||
## Architecture
|
||||
|
||||
### 数据流向
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph DWD["DWD 层(数据源)"]
|
||||
DCA["dim_member_card_account<br/>card_type_id + balance"]
|
||||
DRO["dwd_recharge_order<br/>point_amount"]
|
||||
DSH["dwd_settlement_head<br/>gift_card_amount(仅总额)"]
|
||||
end
|
||||
|
||||
subgraph ETL["ETL(FinanceRechargeTask)"]
|
||||
ECB["_extract_card_balances()<br/>按 card_type_id 分组余额"]
|
||||
EGR["_extract_gift_recharge_breakdown()<br/>JOIN 按 card_type_id 分组新增"]
|
||||
TF["transform()<br/>合并 6 个新字段到 record"]
|
||||
end
|
||||
|
||||
subgraph DWS["DWS 层"]
|
||||
DFS["dws_finance_recharge_summary<br/>+6 新字段"]
|
||||
end
|
||||
|
||||
subgraph VIEW["视图 + FDW"]
|
||||
RLS["app.v_dws_finance_recharge_summary<br/>RLS 行级安全"]
|
||||
FDW["FDW 外部表<br/>业务库映射"]
|
||||
end
|
||||
|
||||
subgraph BACKEND["后端"]
|
||||
FQ["fdw_queries.get_finance_recharge()<br/>SQL SUM 6 字段"]
|
||||
BS["board_service._build_recharge()<br/>环比计算(自动适配)"]
|
||||
end
|
||||
|
||||
subgraph MP["小程序"]
|
||||
BF["board-finance.ts<br/>替换 mock → API 调用"]
|
||||
end
|
||||
|
||||
DCA --> ECB
|
||||
DRO -->|JOIN| EGR
|
||||
DCA -->|card_type_id| EGR
|
||||
DSH -.->|总额,无法拆分| TF
|
||||
ECB --> TF
|
||||
EGR --> TF
|
||||
TF --> DFS
|
||||
DFS --> RLS
|
||||
RLS --> FDW
|
||||
FDW --> FQ
|
||||
FQ --> BS
|
||||
BS --> BF
|
||||
```
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 层级 | 文件 | 改动类型 |
|
||||
|------|------|----------|
|
||||
| DDL | `db/etl_feiqiu/migrations/2026-xx-xx_add_gift_breakdown_fields.sql` | 新增 |
|
||||
| DDL 基线 | `docs/database/ddl/etl_feiqiu__dws.sql` | 修改 |
|
||||
| ETL | `apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py` | 修改 |
|
||||
| RLS 视图 | `db/zqyy_app/migrations/` | 新增 |
|
||||
| FDW | `db/fdw/` | 新增/修改 |
|
||||
| 后端 | `apps/backend/app/services/fdw_queries.py` | 修改 |
|
||||
| 小程序 | `apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts` | 修改 |
|
||||
| BD 手册 | `apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_finance_recharge_summary.md` | 修改 |
|
||||
|
||||
不需要修改的文件:
|
||||
- `apps/backend/app/schemas/xcx_board.py` — `GiftCell`/`GiftRow`/`RechargePanel` 已预留 `liquor`/`table_fee`/`voucher` 字段
|
||||
- `apps/backend/app/services/board_service.py` — `_build_recharge()` 环比逻辑已遍历 `gift_rows` 所有 cell,自动适配
|
||||
- `apps/backend/app/routers/xcx_board.py` — 路由无需变更
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### 1. DDL 迁移脚本
|
||||
|
||||
新增 6 个 `NUMERIC(14,2)` 字段到 `dws.dws_finance_recharge_summary`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE dws.dws_finance_recharge_summary
|
||||
ADD COLUMN IF NOT EXISTS gift_liquor_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS gift_table_fee_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS gift_voucher_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS gift_liquor_recharge NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS gift_table_fee_recharge NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS gift_voucher_recharge NUMERIC(14,2) NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
### 2. ETL — `FinanceRechargeTask` 修改
|
||||
|
||||
#### 2a. `_extract_card_balances()` 拆分
|
||||
|
||||
当前实现将三种赠送卡余额合并为 `gift_balance`。修改后按 `card_type_id` 分别返回:
|
||||
|
||||
```python
|
||||
# 现有常量(沿用)
|
||||
CASH_CARD_TYPE_ID = 2793249295533893
|
||||
GIFT_CARD_TYPE_IDS = [2791990152417157, 2793266846533445, 2794699703437125]
|
||||
|
||||
# card_type_id → 字段名映射
|
||||
GIFT_TYPE_FIELD_MAP = {
|
||||
2794699703437125: 'gift_liquor_balance', # 酒水卡
|
||||
2791990152417157: 'gift_table_fee_balance', # 台费卡
|
||||
2793266846533445: 'gift_voucher_balance', # 抵用券
|
||||
}
|
||||
```
|
||||
|
||||
返回值新增 3 个 key:`gift_liquor_balance`、`gift_table_fee_balance`、`gift_voucher_balance`。原有 `gift_balance` 保持不变(向后兼容)。
|
||||
|
||||
#### 2b. 新增 `_extract_gift_recharge_breakdown()`
|
||||
|
||||
通过 `dwd_recharge_order JOIN dim_member_card_account`(关联 `tenant_member_card_id` → `tenant_member_id`)按 `card_type_id` 分组查询赠送金额(`point_amount`):
|
||||
|
||||
```python
|
||||
GIFT_RECHARGE_FIELD_MAP = {
|
||||
2794699703437125: 'gift_liquor_recharge', # 酒水卡
|
||||
2791990152417157: 'gift_table_fee_recharge', # 台费卡
|
||||
2793266846533445: 'gift_voucher_recharge', # 抵用券
|
||||
}
|
||||
```
|
||||
|
||||
SQL 逻辑:
|
||||
```sql
|
||||
SELECT dca.card_type_id, SUM(ro.point_amount) AS gift_recharge
|
||||
FROM dwd.dwd_recharge_order ro
|
||||
JOIN dwd.dim_member_card_account dca
|
||||
ON ro.tenant_member_card_id = dca.tenant_member_id
|
||||
WHERE ro.site_id = %s
|
||||
AND {biz_date_expr} >= %s AND {biz_date_expr} <= %s
|
||||
AND dca.card_type_id IN (2794699703437125, 2791990152417157, 2793266846533445)
|
||||
AND dca.scd2_is_current = 1
|
||||
AND COALESCE(dca.is_delete, 0) = 0
|
||||
GROUP BY dca.card_type_id
|
||||
```
|
||||
|
||||
返回 `dict`:`{gift_liquor_recharge: Decimal, gift_table_fee_recharge: Decimal, gift_voucher_recharge: Decimal}`,缺失的卡类型默认 0。
|
||||
|
||||
#### 2c. `extract()` 调用新方法
|
||||
|
||||
```python
|
||||
def extract(self, context):
|
||||
# ... 现有逻辑 ...
|
||||
recharge_summary = self._extract_recharge_summary(site_id, start_date, end_date)
|
||||
card_balances = self._extract_card_balances(site_id, end_date)
|
||||
gift_recharge_breakdown = self._extract_gift_recharge_breakdown(site_id, start_date, end_date)
|
||||
return {
|
||||
'recharge_summary': recharge_summary,
|
||||
'card_balances': card_balances,
|
||||
'gift_recharge_breakdown': gift_recharge_breakdown,
|
||||
'start_date': start_date, 'end_date': end_date, 'site_id': site_id,
|
||||
}
|
||||
```
|
||||
|
||||
#### 2d. `transform()` 合并新字段
|
||||
|
||||
在 `record` dict 构建时追加 6 个字段:
|
||||
|
||||
```python
|
||||
record = {
|
||||
# ... 现有字段 ...
|
||||
'gift_liquor_balance': self.safe_decimal(balance.get('gift_liquor_balance', 0)),
|
||||
'gift_table_fee_balance': self.safe_decimal(balance.get('gift_table_fee_balance', 0)),
|
||||
'gift_voucher_balance': self.safe_decimal(balance.get('gift_voucher_balance', 0)),
|
||||
'gift_liquor_recharge': self.safe_decimal(gift_recharge.get('gift_liquor_recharge', 0)),
|
||||
'gift_table_fee_recharge': self.safe_decimal(gift_recharge.get('gift_table_fee_recharge', 0)),
|
||||
'gift_voucher_recharge': self.safe_decimal(gift_recharge.get('gift_voucher_recharge', 0)),
|
||||
}
|
||||
```
|
||||
|
||||
幂等策略:沿用现有 delete-before-insert(按 `site_id` + `stat_date`),新字段随整行写入。
|
||||
|
||||
### 3. RLS 视图重建
|
||||
|
||||
`app.v_dws_finance_recharge_summary` 需要 `CREATE OR REPLACE VIEW` 更新列列表,包含 6 个新字段。RLS 策略基于 `site_id`,新字段自动继承。
|
||||
|
||||
### 4. FDW 外部表同步
|
||||
|
||||
通过 `IMPORT FOREIGN SCHEMA` 重新导入,幂等脚本已支持(先 DROP 再 IMPORT)。
|
||||
|
||||
### 5. 后端 — `fdw_queries.get_finance_recharge()`
|
||||
|
||||
SQL 新增 6 个字段的 SUM 聚合:
|
||||
|
||||
```sql
|
||||
SELECT SUM(recharge_cash) AS actual_income,
|
||||
SUM(first_recharge_cash) AS first_charge,
|
||||
SUM(renewal_cash) AS renew_charge,
|
||||
SUM(cash_card_balance) AS card_balance,
|
||||
SUM(total_card_balance) AS all_card_balance,
|
||||
SUM(gift_card_balance) AS gift_balance_total,
|
||||
-- 新增 6 字段
|
||||
SUM(gift_liquor_balance) AS gift_liquor_balance,
|
||||
SUM(gift_table_fee_balance) AS gift_table_fee_balance,
|
||||
SUM(gift_voucher_balance) AS gift_voucher_balance,
|
||||
SUM(gift_liquor_recharge) AS gift_liquor_recharge,
|
||||
SUM(gift_table_fee_recharge) AS gift_table_fee_recharge,
|
||||
SUM(gift_voucher_recharge) AS gift_voucher_recharge
|
||||
FROM app.v_dws_finance_recharge_summary
|
||||
WHERE stat_date >= %s::date AND stat_date <= %s::date
|
||||
```
|
||||
|
||||
`gift_rows` 构建逻辑更新:
|
||||
|
||||
```python
|
||||
gift_rows = [
|
||||
{"label": "新增", "total": _gc(gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge),
|
||||
"liquor": _gc(gift_liquor_recharge), "table_fee": _gc(gift_table_fee_recharge), "voucher": _gc(gift_voucher_recharge)},
|
||||
{"label": "消费", "total": _gc(0.0), # 消费总额从现有字段获取
|
||||
"liquor": _gc(0.0), "table_fee": _gc(0.0), "voucher": _gc(0.0)},
|
||||
{"label": "余额", "total": _gc(gift_balance),
|
||||
"liquor": _gc(gift_liquor_balance), "table_fee": _gc(gift_table_fee_balance), "voucher": _gc(gift_voucher_balance)},
|
||||
]
|
||||
```
|
||||
|
||||
注意:新增行的 `total` 使用三个细分字段之和(而非 `recharge_gift`),保证 `total = liquor + table_fee + voucher` 恒等。
|
||||
|
||||
### 6. 后端 — `_empty_recharge_data()`
|
||||
|
||||
空默认值结构不变(已经是全 0),无需修改。
|
||||
|
||||
### 7. 小程序 — `board-finance.ts`
|
||||
|
||||
替换 mock 数据为真实 API 调用。字段映射:
|
||||
|
||||
| 后端字段 | 小程序字段 |
|
||||
|----------|-----------|
|
||||
| `GiftRow.liquor.value` | `item.wine` |
|
||||
| `GiftRow.table_fee.value` | `item.table` |
|
||||
| `GiftRow.voucher.value` | `item.coupon` |
|
||||
| `GiftCell.compare` | `item.*Compare` |
|
||||
|
||||
数据转换在 API 响应处理层完成(后端返回 camelCase,小程序按现有字段名映射)。
|
||||
|
||||
|
||||
## Data Models
|
||||
|
||||
### DWS 表结构变更
|
||||
|
||||
`dws.dws_finance_recharge_summary` 新增字段:
|
||||
|
||||
| 字段名 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `gift_liquor_balance` | NUMERIC(14,2) | 0 | 酒水卡余额(当日末快照) |
|
||||
| `gift_table_fee_balance` | NUMERIC(14,2) | 0 | 台费卡余额(当日末快照) |
|
||||
| `gift_voucher_balance` | NUMERIC(14,2) | 0 | 抵用券余额(当日末快照) |
|
||||
| `gift_liquor_recharge` | NUMERIC(14,2) | 0 | 酒水卡新增充值(赠送部分) |
|
||||
| `gift_table_fee_recharge` | NUMERIC(14,2) | 0 | 台费卡新增充值(赠送部分) |
|
||||
| `gift_voucher_recharge` | NUMERIC(14,2) | 0 | 抵用券新增充值(赠送部分) |
|
||||
|
||||
### 恒等式约束
|
||||
|
||||
1. 余额恒等:`gift_card_balance = gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`
|
||||
2. 新增恒等:`recharge_gift = gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge`
|
||||
|
||||
### 卡类型 ID 映射(硬编码常量)
|
||||
|
||||
| card_type_id | 类型 | 余额字段 | 新增字段 |
|
||||
|---|---|---|---|
|
||||
| 2794699703437125 | 酒水卡 | `gift_liquor_balance` | `gift_liquor_recharge` |
|
||||
| 2791990152417157 | 台费卡 | `gift_table_fee_balance` | `gift_table_fee_recharge` |
|
||||
| 2793266846533445 | 抵用券 | `gift_voucher_balance` | `gift_voucher_recharge` |
|
||||
|
||||
### 现有 Schema(无需修改)
|
||||
|
||||
`GiftCell`(Pydantic):
|
||||
```python
|
||||
class GiftCell(CamelModel):
|
||||
value: float
|
||||
compare: str | None = None
|
||||
down: bool | None = None
|
||||
flat: bool | None = None
|
||||
```
|
||||
|
||||
`GiftRow`(Pydantic):
|
||||
```python
|
||||
class GiftRow(CamelModel):
|
||||
label: str # "新增" / "消费" / "余额"
|
||||
total: GiftCell
|
||||
liquor: GiftCell
|
||||
table_fee: GiftCell
|
||||
voucher: GiftCell
|
||||
```
|
||||
|
||||
### ETL 内部数据结构
|
||||
|
||||
`_extract_card_balances()` 返回值扩展:
|
||||
```python
|
||||
{
|
||||
'cash_balance': Decimal,
|
||||
'gift_balance': Decimal, # 保留(向后兼容)
|
||||
'total_balance': Decimal,
|
||||
'gift_liquor_balance': Decimal, # 新增
|
||||
'gift_table_fee_balance': Decimal, # 新增
|
||||
'gift_voucher_balance': Decimal, # 新增
|
||||
}
|
||||
```
|
||||
|
||||
`_extract_gift_recharge_breakdown()` 返回值:
|
||||
```python
|
||||
{
|
||||
'gift_liquor_recharge': Decimal,
|
||||
'gift_table_fee_recharge': Decimal,
|
||||
'gift_voucher_recharge': Decimal,
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||
|
||||
### Property 1: 余额恒等式
|
||||
|
||||
*For any* DWS 记录,`gift_card_balance` 的值必须等于 `gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`。即对于任意随机生成的三种赠送卡余额,写入 DWS 后总额字段与细分字段之和恒等。
|
||||
|
||||
**Validates: Requirements 1.3, 10.3**
|
||||
|
||||
### Property 2: 新增恒等式
|
||||
|
||||
*For any* DWS 记录,`recharge_gift` 的值必须等于 `gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge`。即对于任意随机生成的三种赠送卡新增金额,写入 DWS 后总额字段与细分字段之和恒等。
|
||||
|
||||
**Validates: Requirements 1.4**
|
||||
|
||||
### Property 3: ETL 余额提取 round-trip
|
||||
|
||||
*For any* 一组 `dim_member_card_account` 记录(包含随机的 `card_type_id` 和 `balance`),`_extract_card_balances()` 返回的各类型余额必须等于对应 `card_type_id` 的 `balance` 之和。当某种卡类型无记录时,对应余额为 0。
|
||||
|
||||
**Validates: Requirements 2.1, 2.2, 2.3, 10.1**
|
||||
|
||||
### Property 4: ETL 新增提取 round-trip
|
||||
|
||||
*For any* 一组 `dwd_recharge_order` + `dim_member_card_account` 记录(包含随机的 `card_type_id` 和 `point_amount`),`_extract_gift_recharge_breakdown()` 返回的各类型新增金额必须等于对应 `card_type_id` 的 `point_amount` 之和。当某种卡类型无充值记录时,对应新增为 0。
|
||||
|
||||
**Validates: Requirements 3.1, 3.2, 3.3, 10.2**
|
||||
|
||||
### Property 5: transform 正确合并细分字段
|
||||
|
||||
*For any* 随机生成的 `card_balances` dict(含 3 个余额字段)和 `gift_recharge_breakdown` dict(含 3 个新增字段),`transform()` 输出的 record 必须包含这 6 个字段且值与输入一致。当输入 dict 缺少某个 key 时,对应字段为 0。
|
||||
|
||||
**Validates: Requirements 4.1, 4.2**
|
||||
|
||||
### Property 6: 后端接口返回正确的细分数据
|
||||
|
||||
*For any* DWS 表中的一组记录(包含随机的 6 个细分字段值),`get_finance_recharge()` 返回的 `gift_rows` 中余额行的 `liquor`/`table_fee`/`voucher` 值必须等于对应字段的 SUM,新增行同理。
|
||||
|
||||
**Validates: Requirements 7.2, 7.3**
|
||||
|
||||
### Property 7: 消费行细分列始终为 0
|
||||
|
||||
*For any* 调用 `get_finance_recharge()` 的返回结果,`gift_rows` 中消费行(label="消费")的 `liquor.value`、`table_fee.value`、`voucher.value` 必须为 0。`total.value` 可以为任意非负值。
|
||||
|
||||
**Validates: Requirements 7.4, 8.1, 8.2**
|
||||
|
||||
### Property 8: 环比计算对新字段正确适配
|
||||
|
||||
*For any* 两组随机生成的当期和上期 DWS 数据,`_build_recharge()` 在 `compare=1` 时,`gift_rows` 中每个 cell 的 `compare` 字段必须等于 `calc_compare(当期值, 上期值)` 的结果。
|
||||
|
||||
**Validates: Requirements 7.6**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### ETL 层
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|----------|
|
||||
| DWD 层某种卡类型无记录 | 对应字段写入 0(`dict.get()` 默认值) |
|
||||
| `_extract_gift_recharge_breakdown()` SQL 查询失败 | 由 ETL 框架统一捕获异常,任务标记失败,不写入脏数据 |
|
||||
| `dim_member_card_account` 中出现未知 `card_type_id` | 忽略(仅处理 `GIFT_CARD_TYPE_IDS` 中的三种) |
|
||||
| 余额恒等式不满足 | ETL 不做运行时校验(由数据质量检查任务事后验证) |
|
||||
|
||||
### 后端层
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|----------|
|
||||
| FDW 查询失败 | `_build_recharge()` 捕获异常,降级返回 `None`(现有逻辑) |
|
||||
| 查询结果为空 | 返回 `_empty_recharge_data()`(全 0 结构) |
|
||||
| 新字段在视图中不存在(迁移未执行) | SQL 报错,降级为 `None` |
|
||||
|
||||
### 小程序层
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|----------|
|
||||
| API 返回错误或超时 | 展示加载失败提示,不显示 mock 数据 |
|
||||
| API 返回的 `gift_rows` 结构不完整 | 使用默认值 0 填充缺失字段 |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
使用 `hypothesis` 库(项目已有依赖),测试文件放置在 `tests/` 目录(Monorepo 级属性测试)。
|
||||
|
||||
每个属性测试最少 100 次迭代,使用 `@settings(max_examples=100)` 配置。
|
||||
|
||||
#### 测试文件
|
||||
|
||||
`tests/test_gift_card_breakdown_properties.py`
|
||||
|
||||
#### 属性测试清单
|
||||
|
||||
| 属性 | 测试方法 | 生成器 |
|
||||
|------|----------|--------|
|
||||
| P1: 余额恒等式 | 生成随机三种余额,验证总额 = 三者之和 | `st.decimals(min_value=0, max_value=999999, places=2)` |
|
||||
| P2: 新增恒等式 | 生成随机三种新增,验证总额 = 三者之和 | 同上 |
|
||||
| P3: ETL 余额提取 | 生成随机 card_account 行,mock DB,验证提取结果 | `st.lists(st.fixed_dictionaries({...}))` |
|
||||
| P4: ETL 新增提取 | 生成随机 recharge_order + card_account,mock DB,验证提取结果 | 同上 |
|
||||
| P5: transform 合并 | 生成随机 extracted dict,验证 record 包含正确字段 | `st.fixed_dictionaries({...})` |
|
||||
| P6: 后端接口细分数据 | 生成随机 DWS 行,mock FDW 查询,验证 gift_rows | `st.fixed_dictionaries({...})` |
|
||||
| P7: 消费行始终为 0 | 生成随机数据,验证消费行细分列 = 0 | 任意输入 |
|
||||
| P8: 环比适配 | 生成随机当期/上期数据,验证 compare 计算 | `st.tuples(amount_strategy, amount_strategy)` |
|
||||
|
||||
标签格式:`# Feature: gift-card-breakdown, Property {N}: {title}`
|
||||
|
||||
### 单元测试
|
||||
|
||||
使用 `pytest`,测试文件放置在各模块的 `tests/` 目录。
|
||||
|
||||
#### ETL 单元测试
|
||||
|
||||
`apps/etl/connectors/feiqiu/tests/unit/test_finance_recharge_task.py`
|
||||
|
||||
| 测试用例 | 说明 |
|
||||
|----------|------|
|
||||
| `test_extract_card_balances_with_all_types` | 三种卡类型都有记录时返回正确余额 |
|
||||
| `test_extract_card_balances_missing_type` | 某种卡类型无记录时返回 0 |
|
||||
| `test_extract_gift_recharge_breakdown_basic` | 基本新增拆分正确 |
|
||||
| `test_extract_gift_recharge_breakdown_empty` | 无充值记录时全部返回 0 |
|
||||
| `test_transform_includes_new_fields` | transform 输出包含 6 个新字段 |
|
||||
|
||||
#### 后端单元测试
|
||||
|
||||
`apps/backend/tests/test_fdw_queries_gift.py`
|
||||
|
||||
| 测试用例 | 说明 |
|
||||
|----------|------|
|
||||
| `test_get_finance_recharge_gift_breakdown` | 返回正确的细分数据 |
|
||||
| `test_get_finance_recharge_empty` | 无数据时返回全 0 |
|
||||
| `test_gift_rows_consumption_always_zero` | 消费行细分列始终为 0 |
|
||||
|
||||
### 集成验证(手动)
|
||||
|
||||
ETL 跑数后执行验证 SQL:
|
||||
|
||||
```sql
|
||||
-- 验证余额恒等式
|
||||
SELECT site_id, stat_date,
|
||||
gift_card_balance,
|
||||
gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance AS sum_breakdown,
|
||||
gift_card_balance - (gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance) AS diff
|
||||
FROM dws.dws_finance_recharge_summary
|
||||
WHERE gift_card_balance != gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance;
|
||||
|
||||
-- 验证新增恒等式
|
||||
SELECT site_id, stat_date,
|
||||
recharge_gift,
|
||||
gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge AS sum_breakdown,
|
||||
recharge_gift - (gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge) AS diff
|
||||
FROM dws.dws_finance_recharge_summary
|
||||
WHERE recharge_gift != gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge;
|
||||
|
||||
-- 验证与 DWD 源数据一致
|
||||
SELECT 'DWS' AS source,
|
||||
SUM(gift_liquor_balance) AS liquor,
|
||||
SUM(gift_table_fee_balance) AS table_fee,
|
||||
SUM(gift_voucher_balance) AS voucher
|
||||
FROM dws.dws_finance_recharge_summary
|
||||
WHERE stat_date = CURRENT_DATE
|
||||
UNION ALL
|
||||
SELECT 'DWD',
|
||||
SUM(CASE WHEN card_type_id = 2794699703437125 THEN balance ELSE 0 END),
|
||||
SUM(CASE WHEN card_type_id = 2791990152417157 THEN balance ELSE 0 END),
|
||||
SUM(CASE WHEN card_type_id = 2793266846533445 THEN balance ELSE 0 END)
|
||||
FROM dwd.dim_member_card_account
|
||||
WHERE scd2_is_current = 1 AND COALESCE(is_delete, 0) = 0
|
||||
AND card_type_id IN (2794699703437125, 2791990152417157, 2793266846533445);
|
||||
```
|
||||
@@ -1,131 +0,0 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
BOARD-3 财务看板的「预收资产」板块包含赠送卡 3×4 矩阵(3 行:新增/消费/余额;4 列:合计/酒水卡/台费卡/抵用券)。当前矩阵除余额行的 total 列外,所有细分单元格均返回 0。
|
||||
|
||||
根因:DWS 层 `dws_finance_recharge_summary` 只存储赠送卡总额(`recharge_gift`、`gift_card_balance`),未按卡类型拆分。DWD 层 `dim_member_card_account` 已通过 `card_type_id` 区分三种赠送卡类型(酒水卡、台费卡、抵用券),数据源完备。
|
||||
|
||||
本需求在 DWS 层新增 6 个字段(3 种卡类型 × 余额+新增),修改 ETL 拆分填充逻辑,更新数据库视图层,修改后端接口返回细分数据,并完成小程序联调,使赠送卡矩阵正确展示按卡类型拆分的数据。
|
||||
|
||||
关键约束:消费行无法按卡类型拆分(上游飞球 API 的结算单 `dwd_settlement_head.gift_card_amount` 仅提供赠送卡消费总额,不提供按卡类型的消费明细)。
|
||||
|
||||
## Glossary
|
||||
|
||||
- **DWS_Finance_Recharge_Summary**: DWS 层充值汇总表(`dws.dws_finance_recharge_summary`),按 `site_id` + `summary_date` 存储每日充值/余额汇总数据
|
||||
- **ETL_Finance_Recharge_Task**: ETL 任务类 `FinanceRechargeTask`,负责从 DWD 层提取数据并写入 DWS 层充值汇总表
|
||||
- **Gift_Card_Matrix**: 赠送卡矩阵,BOARD-3 财务看板中的 3×4 数据表格(行:新增/消费/余额;列:合计/酒水卡/台费卡/抵用券)
|
||||
- **DWD_Dim_Member_Card_Account**: DWD 层会员卡账户维度表(`dwd.dim_member_card_account`),通过 `card_type_id` 区分卡类型
|
||||
- **DWD_Recharge_Order**: DWD 层充值订单表(`dwd.dwd_recharge_order`),包含充值金额 `point_amount`(赠送部分)
|
||||
- **RLS_View**: 行级安全视图 `app.v_dws_finance_recharge_summary`,基于 `site_id` 实现多门店数据隔离
|
||||
- **FDW_Foreign_Table**: 通过 `postgres_fdw` 从 ETL 库映射到业务库的外部表
|
||||
- **Finance_Board_API**: FastAPI 接口 `GET /api/xcx/board/finance`,返回财务看板数据(含 `RechargePanel.gift_rows`)
|
||||
- **Board_Finance_Page**: 微信小程序页面 `board-finance`,渲染财务看板赠送卡矩阵
|
||||
- **FDW_Queries_Service**: 后端服务 `fdw_queries.py`,封装 FDW 外部表的 SQL 查询逻辑
|
||||
- **Card_Type_ID**: 飞球系统中区分卡类型的标识符,三种赠送卡 ID 已在 ETL 中硬编码为 `GIFT_CARD_TYPE_IDS`
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: DWS 层赠送卡细分字段扩展
|
||||
|
||||
**User Story:** 作为数据工程师,我需要在 DWS 充值汇总表中新增赠送卡按卡类型拆分的字段,以便下游查询能获取细分数据。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN DDL 迁移脚本执行完成, THE DWS_Finance_Recharge_Summary SHALL 包含以下 6 个新增字段:`gift_liquor_balance`(NUMERIC(14,2))、`gift_table_fee_balance`(NUMERIC(14,2))、`gift_voucher_balance`(NUMERIC(14,2))、`gift_liquor_recharge`(NUMERIC(14,2))、`gift_table_fee_recharge`(NUMERIC(14,2))、`gift_voucher_recharge`(NUMERIC(14,2))
|
||||
2. THE DWS_Finance_Recharge_Summary SHALL 对所有 6 个新增字段设置默认值为 0
|
||||
3. WHEN 新增字段写入完成, THE DWS_Finance_Recharge_Summary SHALL 满足余额恒等式:`gift_card_balance = gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`
|
||||
4. WHEN 新增字段写入完成, THE DWS_Finance_Recharge_Summary SHALL 满足新增恒等式:`recharge_gift = gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge`
|
||||
|
||||
### Requirement 2: ETL 赠送卡余额按卡类型拆分
|
||||
|
||||
**User Story:** 作为数据工程师,我需要 ETL 任务按卡类型拆分赠送卡余额,以便 DWS 层存储各类型赠送卡的当日末余额。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN ETL_Finance_Recharge_Task 执行余额提取, THE ETL_Finance_Recharge_Task SHALL 从 DWD_Dim_Member_Card_Account 按 `card_type_id` 分组查询三种赠送卡(酒水卡 2794699703437125、台费卡 2791990152417157、抵用券 2793266846533445)的余额合计
|
||||
2. WHEN ETL_Finance_Recharge_Task 完成余额提取, THE ETL_Finance_Recharge_Task SHALL 将三种赠送卡余额分别写入 `gift_liquor_balance`、`gift_table_fee_balance`、`gift_voucher_balance` 字段
|
||||
3. IF DWD_Dim_Member_Card_Account 中某种赠送卡类型无记录, THEN THE ETL_Finance_Recharge_Task SHALL 将该类型余额字段写入 0
|
||||
|
||||
### Requirement 3: ETL 赠送卡新增充值按卡类型拆分
|
||||
|
||||
**User Story:** 作为数据工程师,我需要 ETL 任务按卡类型拆分赠送卡新增充值金额,以便 DWS 层存储各类型赠送卡的每日新增数据。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN ETL_Finance_Recharge_Task 执行新增充值提取, THE ETL_Finance_Recharge_Task SHALL 通过 DWD_Recharge_Order JOIN DWD_Dim_Member_Card_Account(关联 `tenant_member_card_id` → `tenant_member_id`)按 `card_type_id` 分组查询三种赠送卡的 `point_amount` 合计
|
||||
2. WHEN ETL_Finance_Recharge_Task 完成新增充值提取, THE ETL_Finance_Recharge_Task SHALL 将三种赠送卡新增金额分别写入 `gift_liquor_recharge`、`gift_table_fee_recharge`、`gift_voucher_recharge` 字段
|
||||
3. IF DWD_Recharge_Order 中某种赠送卡类型在指定日期无充值记录, THEN THE ETL_Finance_Recharge_Task SHALL 将该类型新增字段写入 0
|
||||
|
||||
### Requirement 4: ETL Transform 阶段写入细分字段
|
||||
|
||||
**User Story:** 作为数据工程师,我需要 ETL 的 transform 阶段将提取到的细分数据正确写入 record dict,以便 load 阶段持久化到 DWS 表。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN ETL_Finance_Recharge_Task 执行 transform, THE ETL_Finance_Recharge_Task SHALL 将余额提取结果(3 个字段)和新增充值提取结果(3 个字段)合并写入当日 record dict
|
||||
2. WHEN ETL_Finance_Recharge_Task 执行 transform 且提取方法返回空结果, THE ETL_Finance_Recharge_Task SHALL 将对应字段填充为 0
|
||||
3. THE ETL_Finance_Recharge_Task SHALL 使用与现有字段一致的 delete-before-insert 幂等策略写入 6 个新增字段
|
||||
|
||||
### Requirement 5: RLS 视图同步新增字段
|
||||
|
||||
**User Story:** 作为数据工程师,我需要 RLS 视图包含新增的 6 个字段,以便业务库通过视图访问细分数据并保持行级安全隔离。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN RLS_View 重建完成, THE RLS_View SHALL 包含 DWS_Finance_Recharge_Summary 的全部 6 个新增字段
|
||||
2. THE RLS_View SHALL 对新增字段保持与现有字段一致的 `site_id` 行级安全过滤策略
|
||||
3. WHEN 非授权门店用户查询 RLS_View, THE RLS_View SHALL 不返回其他门店的赠送卡细分数据
|
||||
|
||||
### Requirement 6: FDW 外部表同步
|
||||
|
||||
**User Story:** 作为数据工程师,我需要 FDW 外部表同步新增字段,以便后端应用通过业务库访问 DWS 层的赠送卡细分数据。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN `IMPORT FOREIGN SCHEMA` 重新执行, THE FDW_Foreign_Table SHALL 包含 DWS_Finance_Recharge_Summary 的全部 6 个新增字段
|
||||
2. THE FDW_Foreign_Table SHALL 通过幂等脚本完成同步,支持重复执行不报错
|
||||
|
||||
### Requirement 7: 后端接口返回赠送卡细分数据
|
||||
|
||||
**User Story:** 作为门店管理者,我需要财务看板接口返回赠送卡按卡类型拆分的数据,以便小程序展示完整的赠送卡矩阵。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN Finance_Board_API 被调用, THE FDW_Queries_Service SHALL 在 SQL 查询中包含 6 个新增字段的 SUM 聚合
|
||||
2. WHEN Finance_Board_API 返回 `gift_rows`, THE Finance_Board_API SHALL 在余额行的 `liquor`、`table_fee`、`voucher` 列返回对应的细分余额数值(非 0)
|
||||
3. WHEN Finance_Board_API 返回 `gift_rows`, THE Finance_Board_API SHALL 在新增行的 `liquor`、`table_fee`、`voucher` 列返回对应的细分新增数值(非 0,当日有充值时)
|
||||
4. WHEN Finance_Board_API 返回 `gift_rows` 且消费行无法按卡类型拆分, THE Finance_Board_API SHALL 在消费行的 `liquor`、`table_fee`、`voucher` 列返回 0,仅 `total` 列返回消费总额
|
||||
5. WHEN Finance_Board_API 被调用且无数据, THE FDW_Queries_Service SHALL 返回所有新增字段默认值为 0 的空数据结构
|
||||
6. WHEN Finance_Board_API 被调用且 compare 参数启用, THE Finance_Board_API SHALL 对赠送卡细分字段正确计算环比数据
|
||||
|
||||
### Requirement 8: 消费行数据处理策略
|
||||
|
||||
**User Story:** 作为门店管理者,我需要了解赠送卡消费行的数据展示策略,以便正确理解矩阵中消费数据的含义。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Finance_Board_API SHALL 在赠送卡矩阵消费行的 `total` 列返回赠送卡消费总额(来源 `dwd_settlement_head.gift_card_amount` 汇总)
|
||||
2. WHILE 上游飞球 API 不提供按卡类型拆分的消费明细, THE Finance_Board_API SHALL 在消费行的 `liquor`、`table_fee`、`voucher` 列返回 0
|
||||
3. IF 未来上游 API 提供按卡类型拆分的消费明细, THEN THE ETL_Finance_Recharge_Task SHALL 扩展消费拆分字段(不在本次需求范围内)
|
||||
|
||||
### Requirement 9: 小程序赠送卡矩阵渲染
|
||||
|
||||
**User Story:** 作为门店管理者,我需要在小程序财务看板中看到赠送卡矩阵正确展示后端返回的细分数据,以便了解各类赠送卡的使用情况。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN Board_Finance_Page 加载完成, THE Board_Finance_Page SHALL 从 Finance_Board_API 获取真实数据替换当前 mock 数据
|
||||
2. WHEN Board_Finance_Page 渲染赠送卡矩阵, THE Board_Finance_Page SHALL 按字段映射关系正确展示数据:`liquor` → 酒水卡列、`table_fee` → 台费卡列、`voucher` → 抵用券列
|
||||
3. WHEN Finance_Board_API 返回环比数据, THE Board_Finance_Page SHALL 在对应单元格展示环比变化标识
|
||||
4. IF Finance_Board_API 返回错误或超时, THEN THE Board_Finance_Page SHALL 展示加载失败提示,不显示 mock 数据
|
||||
|
||||
### Requirement 10: 数据一致性验证
|
||||
|
||||
**User Story:** 作为数据工程师,我需要验证 ETL 写入的细分数据与 DWD 层源数据一致,以便确保数据准确性。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN ETL 跑数完成, THE DWS_Finance_Recharge_Summary SHALL 满足:三种赠送卡余额之和(`gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`)等于 DWD_Dim_Member_Card_Account 中对应三种 `card_type_id` 的 `balance` 之和
|
||||
2. WHEN ETL 跑数完成, THE DWS_Finance_Recharge_Summary SHALL 满足:三种赠送卡新增之和(`gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge`)等于 DWD_Recharge_Order 中对应三种卡类型当日 `point_amount` 之和
|
||||
3. THE DWS_Finance_Recharge_Summary SHALL 满足:`gift_card_balance` 字段值等于 `gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`(总额与细分之和恒等)
|
||||
@@ -1,147 +0,0 @@
|
||||
# Implementation Plan: 赠送卡矩阵细分数据 (gift-card-breakdown)
|
||||
|
||||
## Overview
|
||||
|
||||
贯穿全栈数据链路的改动:DDL 新增 6 字段 → ETL 拆分填充 → RLS 视图 + FDW 同步 → 后端 SQL + 接口返回 → 小程序替换 mock。消费行因上游 API 限制,细分列保持 0。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. DDL 迁移与基线同步
|
||||
- [x] 1.1 创建 DDL 迁移脚本 `db/etl_feiqiu/migrations/2026-xx-xx_add_gift_breakdown_fields.sql`
|
||||
- ALTER TABLE 新增 6 个 NUMERIC(14,2) 字段,NOT NULL DEFAULT 0
|
||||
- 使用 `ADD COLUMN IF NOT EXISTS` 保证幂等
|
||||
- _Requirements: 1.1, 1.2_
|
||||
- [x] 1.2 同步 DDL 基线文件 `docs/database/ddl/etl_feiqiu__dws.sql`
|
||||
- 在 `dws_finance_recharge_summary` 表定义中追加 6 个新字段
|
||||
- _Requirements: 1.1_
|
||||
|
||||
- [x] 2. ETL 赠送卡余额拆分
|
||||
- [x] 2.1 修改 `_extract_card_balances()` 按 card_type_id 分组返回细分余额
|
||||
- 文件:`apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py`
|
||||
- 新增 `GIFT_TYPE_FIELD_MAP` 常量映射 card_type_id → 字段名
|
||||
- 返回值新增 `gift_liquor_balance`、`gift_table_fee_balance`、`gift_voucher_balance`
|
||||
- 保留原有 `gift_balance` 字段(向后兼容)
|
||||
- 某种卡类型无记录时对应字段返回 0
|
||||
- _Requirements: 2.1, 2.2, 2.3_
|
||||
- [x] 2.2 编写属性测试:ETL 余额提取 round-trip
|
||||
- **Property 3: ETL 余额提取 round-trip**
|
||||
- 生成随机 `dim_member_card_account` 记录,mock DB,验证各类型余额等于对应 card_type_id 的 balance 之和
|
||||
- 当某种卡类型无记录时,对应余额为 0
|
||||
- **Validates: Requirements 2.1, 2.2, 2.3, 10.1**
|
||||
|
||||
- [x] 3. ETL 赠送卡新增充值拆分
|
||||
- [x] 3.1 新增 `_extract_gift_recharge_breakdown()` 方法
|
||||
- 文件:`apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py`
|
||||
- SQL:`dwd_recharge_order JOIN dim_member_card_account`(`tenant_member_card_id` → `tenant_member_id`)按 card_type_id 分组
|
||||
- 新增 `GIFT_RECHARGE_FIELD_MAP` 常量映射
|
||||
- 返回 `{gift_liquor_recharge, gift_table_fee_recharge, gift_voucher_recharge}`,缺失卡类型默认 0
|
||||
- _Requirements: 3.1, 3.2, 3.3_
|
||||
- [x] 3.2 编写属性测试:ETL 新增提取 round-trip
|
||||
- **Property 4: ETL 新增提取 round-trip**
|
||||
- 生成随机 `dwd_recharge_order` + `dim_member_card_account` 记录,mock DB,验证各类型新增等于对应 card_type_id 的 point_amount 之和
|
||||
- 当某种卡类型无充值记录时,对应新增为 0
|
||||
- **Validates: Requirements 3.1, 3.2, 3.3, 10.2**
|
||||
|
||||
- [x] 4. ETL transform 合并细分字段
|
||||
- [x] 4.1 修改 `extract()` 调用新方法并传递结果
|
||||
- 文件:`apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py`
|
||||
- 在 extract 返回值中新增 `gift_recharge_breakdown` key
|
||||
- _Requirements: 4.1_
|
||||
- [x] 4.2 修改 `transform()` 将 6 个新字段写入 record dict
|
||||
- 使用 `self.safe_decimal()` 处理值,缺失 key 时填充 0
|
||||
- 沿用现有 delete-before-insert 幂等策略
|
||||
- _Requirements: 4.1, 4.2, 4.3_
|
||||
- [x] 4.3 编写属性测试:transform 正确合并细分字段
|
||||
- **Property 5: transform 正确合并细分字段**
|
||||
- 生成随机 `card_balances` dict 和 `gift_recharge_breakdown` dict,验证 record 包含 6 个字段且值正确
|
||||
- 输入 dict 缺少某个 key 时,对应字段为 0
|
||||
- **Validates: Requirements 4.1, 4.2**
|
||||
|
||||
- [x] 5. Checkpoint — ETL 层验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 6. 数据库视图层同步
|
||||
- [x] 6.1 RLS 视图重建 `app.v_dws_finance_recharge_summary`
|
||||
- 创建迁移脚本 `db/zqyy_app/migrations/` 下
|
||||
- `CREATE OR REPLACE VIEW` 包含全部 6 个新字段
|
||||
- 保持 `site_id` 行级安全过滤策略不变
|
||||
- _Requirements: 5.1, 5.2, 5.3_
|
||||
- [x] 6.2 FDW 外部表同步
|
||||
- 创建/更新幂等脚本(先 DROP 再 `IMPORT FOREIGN SCHEMA`)
|
||||
- 支持重复执行不报错
|
||||
- _Requirements: 6.1, 6.2_
|
||||
|
||||
- [ ] 7. 后端接口修改
|
||||
- [x] 7.1 修改 `fdw_queries.get_finance_recharge()` SQL 查询
|
||||
- 文件:`apps/backend/app/services/fdw_queries.py`
|
||||
- SQL 新增 6 个字段的 SUM 聚合
|
||||
- _Requirements: 7.1_
|
||||
- [x] 7.2 修改 `gift_rows` 构建逻辑
|
||||
- 余额行:`liquor`/`table_fee`/`voucher` 填充对应细分余额
|
||||
- 新增行:`liquor`/`table_fee`/`voucher` 填充对应细分新增,`total` 使用三个细分之和
|
||||
- 消费行:`liquor`/`table_fee`/`voucher` 保持 0,`total` 返回消费总额
|
||||
- _Requirements: 7.2, 7.3, 7.4, 8.1, 8.2_
|
||||
- [x] 7.3 修改 `_empty_recharge_data()` 空默认值同步
|
||||
- 确保新增 6 个字段在空数据结构中默认为 0
|
||||
- _Requirements: 7.5_
|
||||
- [x] 7.4 编写属性测试:余额恒等式
|
||||
- **Property 1: 余额恒等式**
|
||||
- 生成随机三种余额,验证 `gift_card_balance = gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`
|
||||
- **Validates: Requirements 1.3, 10.3**
|
||||
- [x] 7.5 编写属性测试:新增恒等式
|
||||
- **Property 2: 新增恒等式**
|
||||
- 生成随机三种新增,验证 `recharge_gift = gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge`
|
||||
- **Validates: Requirements 1.4**
|
||||
- [x] 7.6 编写属性测试:后端接口返回正确细分数据
|
||||
- **Property 6: 后端接口返回正确的细分数据**
|
||||
- 生成随机 DWS 行,mock FDW 查询,验证 gift_rows 余额行和新增行的细分值等于对应字段 SUM
|
||||
- **Validates: Requirements 7.2, 7.3**
|
||||
- [x] 7.7 编写属性测试:消费行细分列始终为 0
|
||||
- **Property 7: 消费行细分列始终为 0**
|
||||
- 验证 gift_rows 消费行的 `liquor.value`、`table_fee.value`、`voucher.value` 始终为 0
|
||||
- **Validates: Requirements 7.4, 8.1, 8.2**
|
||||
- [x] 7.8 编写属性测试:环比计算对新字段正确适配
|
||||
- **Property 8: 环比计算对新字段正确适配**
|
||||
- 生成随机当期/上期数据,验证 `compare=1` 时 gift_rows 每个 cell 的 compare 等于 `calc_compare(当期值, 上期值)`
|
||||
- **Validates: Requirements 7.6**
|
||||
|
||||
- [x] 8. Checkpoint — 后端层验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 9. 小程序联调
|
||||
- [x] 9.1 替换 `board-finance.ts` 中 mock 数据为真实 API 调用
|
||||
- 文件:`apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts`
|
||||
- 移除 `giftRows` 硬编码 mock 数据
|
||||
- 使用 Finance_Board_API 返回的真实数据
|
||||
- 字段映射:`liquor→wine`、`table_fee→table`、`voucher→coupon`,在数据转换层处理
|
||||
- API 返回错误或超时时展示加载失败提示,不显示 mock 数据
|
||||
- _Requirements: 9.1, 9.2, 9.3, 9.4_
|
||||
|
||||
- [ ] 10. 数据一致性验证
|
||||
- [x] 10.1 创建验证 SQL 脚本
|
||||
- 验证余额恒等式:`gift_card_balance = 三种余额之和`
|
||||
- 验证新增恒等式:`recharge_gift = 三种新增之和`
|
||||
- 验证 DWS 与 DWD 源数据一致:DWS 各类型余额 = DWD `dim_member_card_account` 对应 card_type_id 的 balance 之和
|
||||
- 脚本放置在 `scripts/ops/` 或迁移目录
|
||||
- _Requirements: 10.1, 10.2, 10.3_
|
||||
|
||||
- [ ] 11. BD 手册更新
|
||||
- [x] 11.1 更新 `BD_manual_dws_finance_recharge_summary.md`
|
||||
- 文件:`apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_finance_recharge_summary.md`
|
||||
- 新增 6 个字段的说明(字段名、类型、含义、数据来源)
|
||||
- 更新恒等式约束说明
|
||||
- 更新数据流向描述
|
||||
- _Requirements: 1.1, 1.3, 1.4_
|
||||
|
||||
- [x] 12. Final checkpoint — 全链路验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- 设计文档使用 Python(ETL)、SQL(DDL/查询)、TypeScript(小程序),任务中代码示例沿用对应语言
|
||||
- 消费行因上游飞球 API 限制(`dwd_settlement_head.gift_card_amount` 仅提供总额),细分列保持 0
|
||||
- 属性测试使用 `hypothesis` 库,测试文件统一放置在 `tests/test_gift_card_breakdown_properties.py`
|
||||
- 单元测试放置在各模块的 `tests/` 目录下
|
||||
- 所有 DDL 迁移脚本使用 `IF NOT EXISTS` 保证幂等
|
||||
- card_type_id 硬编码沿用现有 `GIFT_CARD_TYPE_IDS` 常量
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "cd30e87b-ce7a-4ff5-8587-f5ae75013e58", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -1,498 +0,0 @@
|
||||
# 技术设计文档:H5 → 微信小程序像素精调
|
||||
|
||||
## 1. 概述
|
||||
|
||||
本设计文档为 17 个页面(共 79 个对照处理单元)的 Step 6-7 像素精调提供技术方案。所有页面已完成 Step 0-5 结构迁移(TS 零诊断、路由注册、四态处理)。
|
||||
|
||||
核心设计思路:以「对照处理单元」为最小粒度,通过两阶段收敛流程(结构级修正 → 像素级精调)系统性消除视觉差异。
|
||||
|
||||
参考文档(不重复,仅索引):
|
||||
- 迁移规则与转换公式:`docs/prd/MIGRATION-PLAYBOOK.md`
|
||||
- 样式标准值:`docs/h5_ui/design-tokens.json`(颜色灰阶、字号、圆角、阴影的标准 rpx 值)
|
||||
- 工具链源码:`scripts/ops/anchor_compare.py`
|
||||
|
||||
## 2. 截图策略
|
||||
|
||||
### 2.1 双端参数对齐
|
||||
|
||||
| 参数 | H5 | MP | 对比基准 |
|
||||
|------|-----|-----|---------|
|
||||
| viewport | 430×752 | 430×752(windowHeight) | 统一 |
|
||||
| DPR | 1.5 | 1.5(MCP 有效 DPR) | 统一 |
|
||||
| 输出尺寸 | 645×1128 | 645×1128 | 统一,无需缩放 |
|
||||
|
||||
> 验证数据(2026-03-10 board-finance 实测):H5 Playwright DPR=1.5 输出 645×1128;MP MCP screenshot 输出 645×1128。两端尺寸完全一致,无需任何缩放处理。
|
||||
|
||||
### 2.2 固定步长滚动方案
|
||||
|
||||
采用固定 600px 步长滚动截图,替代锚点方案。两端使用完全相同的 scrollTop 序列(0, 600, 1200, ...),不依赖锚点位置,消除 H5/MP 元素位置差异导致的对齐问题。
|
||||
|
||||
核心参数:
|
||||
- 步长:600px(逻辑像素)
|
||||
- scrollTop 序列:从 0 开始,每次 +600,最后一屏 clamp 到 `maxScroll = scrollHeight - viewportHeight`
|
||||
- 段数计算:`N = floor(maxScroll / 600) + 1`(首屏 step-0 + 每 600px 一步 + 最后一步 clamp 到 maxScroll);`maxScroll ≤ 10` 的页面视为单屏(N=1)
|
||||
|
||||
实测精度(board-finance,2026-03-10):
|
||||
- H5 端:Playwright `window.scrollTo` 精确命中目标值(偏差 0px)
|
||||
- MP 端:`wx.pageScrollTo` 精确命中目标值
|
||||
|
||||
### 2.3 底部浮动元素处理
|
||||
|
||||
H5 端存在 `#bottomNav`(64px fixed 底部导航)和 `.ai-float-btn-container`(56px 浮动按钮),MP 端使用原生 tabBar(不在 webview 截图中)。
|
||||
|
||||
处理方式:H5 截图前用 JS 隐藏所有底部浮动元素:
|
||||
```javascript
|
||||
document.getElementById('bottomNav').style.display = 'none';
|
||||
document.querySelectorAll('.ai-float-btn-container').forEach(el => el.style.display = 'none');
|
||||
```
|
||||
MP 端原生 tabBar 本来不在截图中,无需处理。MP 端如果存在 AI 浮动按钮,因 H5 端已隐藏,该区域的差异在对比时可忽略,不计入差异率。
|
||||
|
||||
### 2.4 浮动元素专项检测(结构校验阶段)
|
||||
|
||||
长页面滚动截图中,sticky/fixed 元素(如 sticky 导航栏、筛选栏)在每一屏的相同位置出现。通过滚屏上下页对比,可以精准识别这些浮动元素的差异并专项修复。
|
||||
|
||||
检测方法:
|
||||
1. 对同一页面的相邻屏截图进行对比,在不同屏中持续出现在相同位置的差异区域即为 sticky/fixed 元素
|
||||
2. 对 H5 vs MP 的 sticky 区域做像素对比,差异即为浮动元素的样式偏差
|
||||
3. sticky 元素差异在每一屏都会重复出现,修复一次即可消除所有屏的该区域差异
|
||||
|
||||
适用场景:
|
||||
- board-finance:`.safe-area-top`(45px) + `#filterBar`(71px) = 116px sticky
|
||||
- board-coach/board-customer:`.board-tabs`(42px) + `.filter-bar`(68px) = 110px sticky
|
||||
- 所有带 sticky 头部的长页面
|
||||
|
||||
优化效果:修复 sticky 区域差异后,所有屏的差异率会同步下降(因为每屏都包含相同的 sticky 区域)。
|
||||
|
||||
### 2.5 截图操作指南
|
||||
|
||||
#### 2.5.1 H5 截图
|
||||
|
||||
底层技术:Playwright + Chromium,viewport 430×752,DPR=1.5,headless=True。
|
||||
|
||||
流程:
|
||||
1. 打开 Live Server 提供的 H5 页面(`http://localhost:5500/docs/h5_ui/<page>.html`)
|
||||
2. 等待 Tailwind CDN JIT 渲染(1500ms)
|
||||
3. 隐藏滚动条 + 底部浮动元素(`#bottomNav` + `.ai-float-btn-container`)
|
||||
4. 按 scrollTop 序列逐屏截图:先 `scrollTo(0,0)` 再 `scrollTo(0, target)`
|
||||
|
||||
截图脚本:`scripts/ops/anchor_compare.py extract-h5 <page>`(固定 600px 步长模式)
|
||||
|
||||
输出:`docs/h5_ui/compare/<page>/h5--step-<scrollTop>.png`(645×1128)
|
||||
|
||||
#### 2.5.2 MP 截图
|
||||
|
||||
前置条件:微信开发者工具已打开,MCP 已连接。
|
||||
|
||||
流程:
|
||||
1. 导航到目标页面(tabBar 页面用 `switch_tab`,其他用 `navigate_to`)
|
||||
2. 等待页面加载(2000ms)
|
||||
3. 按相同 scrollTop 序列逐屏截图:
|
||||
```javascript
|
||||
// 每屏:先回顶再滚到目标,确保精确
|
||||
wx.pageScrollTo({ scrollTop: 0, duration: 0 })
|
||||
// 等待 200ms
|
||||
wx.pageScrollTo({ scrollTop: target, duration: 0 })
|
||||
// 等待 500ms,读取实际 scrollTop,截图
|
||||
```
|
||||
|
||||
输出:`docs/h5_ui/compare/<page>/mp--step-<scrollTop>.png`(645×1128)
|
||||
|
||||
#### 2.5.3 多维度页面 MP 截图
|
||||
|
||||
board-coach 和 board-customer 为多维度页面,每个维度的卡片模板不同,需逐维度截图。
|
||||
|
||||
board-coach(4 种排序维度):
|
||||
- 维度通过筛选栏下拉切换(`onSortChange`),对应 4 种卡片模板:`perf`(定档业绩)、`salary`(工资)、`sv`(客源储值)、`task`(任务完成)
|
||||
- MP 操作:获取页面快照 → 点击排序筛选下拉 → 选择目标维度 → 等待列表刷新 → 截图
|
||||
- H5 操作:通过 JS 调用 `selectType('perf_desc')` 等切换 dim-container 显隐 → 截图
|
||||
- 子目录:`board-coach/perf/`、`board-coach/salary/`、`board-coach/sv/`、`board-coach/task/`
|
||||
|
||||
board-customer(8 种客户维度):
|
||||
- 维度通过筛选栏下拉切换(`onDimensionChange`),对应 8 种卡片模板:`recall`(最应召回)、`potential`(消费潜力)、`balance`(最高余额)、`recharge`(最近充值)、`recent`(最近到店)、`spend60`(最高消费)、`freq60`(最频繁)、`loyal`(最专一)
|
||||
- MP 操作:获取页面快照 → 点击维度筛选下拉 → 选择目标维度 → 等待列表刷新 → 截图
|
||||
- H5 操作:通过 JS 调用 `selectType('recall')` 等切换 dim-container 显隐 → 截图
|
||||
- 子目录:`board-customer/recall/`、`board-customer/potential/` ... `board-customer/loyal/`
|
||||
|
||||
> 交互文档参考:`docs/h5_ui/interactions/board-coach.md`、`docs/h5_ui/interactions/board-customer.md`
|
||||
|
||||
#### 2.5.4 逐屏对比
|
||||
|
||||
对同一 scrollTop 的 H5/MP 截图做像素对比:
|
||||
```
|
||||
mcp_image_compare_compare_images
|
||||
image1_path: "docs/h5_ui/compare/<page>/h5--step-<N>.png"
|
||||
image2_path: "docs/h5_ui/compare/<page>/mp--step-<N>.png"
|
||||
diff_output_path: "docs/h5_ui/compare/<page>/diff--step-<N>.png"
|
||||
threshold: 0.1
|
||||
```
|
||||
|
||||
### 2.5.5 双端高度不一致处理
|
||||
|
||||
MP 端页面高度可能与 H5 不一致(样式差异导致内容更短或更长)。如果直接按 H5 的 scrollTop 序列去滚 MP,可能滚过头(scrollTo 被 clamp)或漏截(页面更长)。
|
||||
|
||||
处理流程:
|
||||
1. 先截双端 step-0(首屏),对比确认基线
|
||||
2. 再截 step-600(第二屏),读取 MP 端实际 scrollTop:
|
||||
- 如果 MP 实际 scrollTop 远小于 600(被 clamp),说明 MP 页面比 H5 短,后续步骤需按 MP 实际 maxScroll 调整序列
|
||||
- 如果 MP 实际 scrollTop ≈ 600,说明双端高度接近,继续按 H5 序列推进
|
||||
3. 之后逐屏推进,每屏截图前先读取 MP 端实际 scrollTop,确认到达预期位置
|
||||
4. 如果 MP 页面比 H5 长(MP 还能继续滚但 H5 序列已结束),追加额外步骤直到 MP 也到达 maxScroll
|
||||
|
||||
> 此规则防止因双端高度差异导致后续屏截图错位或遗漏。
|
||||
|
||||
### 2.6 长页面级联影响与修正规则
|
||||
|
||||
修正某一屏的 WXSS 时,可能影响后续屏的布局。因此:
|
||||
|
||||
1. 修正后必须重新截取所有屏的截图(因为固定步长方案下,布局变化会影响每屏内容)
|
||||
2. 优先修复 sticky 区域差异(一次修复,所有屏受益)
|
||||
3. 从 step-0 开始顺序审查差异,确保前序屏达标后再处理后续屏
|
||||
4. 如果修正引入回归,回退到受影响的最早屏重新验证
|
||||
|
||||
### 2.7 结构级视觉元素识别清单(阶段一修正范围)
|
||||
|
||||
以下元素类型在双端截图中容易产生大面积差异(>10%),属于阶段一优先识别和修正的对象。严重缺失时可能导致 >15% 触发重写。
|
||||
|
||||
#### 2.7.1 大面积背景
|
||||
|
||||
| 背景类型 | MP 支持 | 处理方式 |
|
||||
|----------|---------|---------|
|
||||
| 纯色 / `linear-gradient` / `radial-gradient` / `repeating-linear-gradient` | ✅ | 直接迁移,查 `docs/h5_ui/design-tokens.json` 取精确色值和方向 |
|
||||
| `backdrop-filter: blur()` | ❌ | 改用 `background: rgba(255,255,255,0.95)` 半透明纯色 |
|
||||
| `url("data:image/svg+xml,...")` | ❌ | 用 CSS 渐变模拟,或导出为 PNG/base64 引用 |
|
||||
|
||||
精调要点:
|
||||
- 渐变方向和色值必须与 H5 完全一致,大面积色差肉眼极其明显
|
||||
- 半透明背景透明度差 0.1 在大面积上可见
|
||||
|
||||
#### 2.7.2 复杂图标与 SVG
|
||||
|
||||
Step 0-5 已完成所有 SVG 迁移决策(详见 `MIGRATION-PLAYBOOK.md` 3.5 节)。精调阶段关注:
|
||||
|
||||
| 图标类型 | 精调关注点 | 已审计案例 |
|
||||
|----------|-----------|-----------|
|
||||
| TDesign `<t-icon>` | `size`(rpx)和 `color` 与 H5 原始 SVG 一致 | 目录按钮 `view-list`、筛选箭头 `caret-down-small` |
|
||||
| 导出 SVG + `<image>` | `width`/`height`(rpx)与 H5 显示尺寸一致 | AI 悬浮按钮、底部导航栏图标 |
|
||||
| 文字/Emoji 替代 | 可接受差异,不计入差异率 | 环比箭头 ↑↓、AI 机器人 🤖 |
|
||||
| CSS 渐变 + 文字组合(如头像) | 渐变方向、色值、圆角、文字大小一致 | 客户头像(8 种渐变色 135deg) |
|
||||
| 帮助"?"图标(文字+圆形背景) | 圆形背景 size/color、文字 font-size 一致 | board-finance 指标标签旁 |
|
||||
|
||||
图标缺失或尺寸严重偏差属于阶段一结构级问题;尺寸/颜色微调属于阶段二。
|
||||
|
||||
#### 2.7.3 带文字的标签(Badge / Tag)
|
||||
|
||||
标签是高频偏差元素,涉及背景色、圆角、字号、内边距的组合,任一偏差都会在截图中明显可见:
|
||||
|
||||
| 标签类型 | 精调维度 | 已审计偏差案例 |
|
||||
|----------|---------|---------------|
|
||||
| 状态 Badge(跟/弃) | 背景渐变、font-size、min-width、height、border-radius | 弃 badge radius 10rpx→应为 12rpx |
|
||||
| 超期标签(超期>7天/≤7天) | 背景透明度、padding、border-radius、文字色 | radius 6rpx→应为 8rpx |
|
||||
| 潜力标签(高频/高客单/高余额) | 背景色、文字色、font-weight | 已正确迁移 |
|
||||
| 高消费标签 | `bg-warning/10` + `text-warning` + `font-bold` | 已正确迁移 |
|
||||
| 板块标题标签(Emoji + 文字) | Emoji 渲染、font-size、间距 | 板块 Emoji 📈💳💰🧾📤🎱 已正确 |
|
||||
|
||||
标签精调规则:
|
||||
- 背景色优先查 `docs/h5_ui/design-tokens.json`,禁止使用非标准色值
|
||||
- `border-radius` 是标签最常见偏差(±2rpx),必须逐个核对
|
||||
- `padding` 注意 H5 Tailwind 的 `px-1.5 py-0.5` 换算(×2×0.875)
|
||||
- 渐变背景标签(如跟/弃 badge)的渐变方向和双色值必须精确
|
||||
|
||||
#### 2.7.4 组合元素(图标+文字+背景)
|
||||
|
||||
多个基础元素组合成的复合 UI 单元,整体偏差会被放大:
|
||||
|
||||
| 组合元素 | 构成 | 精调关注点 |
|
||||
|----------|------|-----------|
|
||||
| 筛选按钮 | 文字 + 下拉箭头 + 背景 + 圆角 | 整体高度、内边距、箭头与文字间距 |
|
||||
| 环比指标 | 数值 + 箭头(↑↓) + 百分比 + 颜色 | 上升色 #e34d59 / 下降色 #00a870、font-size |
|
||||
| 确认收入横条 | 标签 + 金额 + 半透明背景 | `bg-white/10` 透明度、padding、border-radius |
|
||||
| 迷你柱状图 | 柱条 + 数字 + 标签 | 柱条高度/gap/圆角/透明度渐变、数字 font-size |
|
||||
| 助教服务行 | 头像 + 姓名 + 分隔符 + 跟/弃 badge | 分隔符颜色/间距、badge 与文字对齐 |
|
||||
| AI 悬浮按钮 | 图标 + 渐变背景 + 圆角 + 阴影 | 整体 size、border-radius、渐变色 |
|
||||
|
||||
组合元素精调策略:先确认整体布局(flex 方向、对齐方式)正确,再逐个子元素微调。
|
||||
|
||||
## 3. 两阶段收敛流程
|
||||
|
||||
### 3.1 流程图
|
||||
|
||||
```
|
||||
获取双端截图
|
||||
↓
|
||||
image_compare 对比 → 初始差异率
|
||||
↓
|
||||
首轮审计报告(截图 + H5 源码 + MP 源码 三方对照)
|
||||
↓
|
||||
┌─ 差异率 > 10% ──→ 阶段一:结构级修正(每轮 2-5 处)
|
||||
│ ↓ 循环直到 ≤ 10%
|
||||
├─ 差异率 5%~10% ─→ 阶段二:像素级精调(每轮 1-3 处)
|
||||
│ ↓ 循环直到 < 5%
|
||||
├─ 差异率 < 5% ───→ ✅ 通过
|
||||
└─ > 15% 且无法收敛 → 🔄 触发重写
|
||||
```
|
||||
|
||||
### 3.2 首轮审计报告(强制)
|
||||
|
||||
第一轮对比完成后,必须同时读取 H5 源码(HTML + 内联 CSS 的 Tailwind 类名和 `<style>` 块)和 MP 源码(WXML + WXSS),结合 diff 截图三方对照,产出一份完整的审计报告。此报告是后续所有修改的指导依据。
|
||||
|
||||
审计报告格式包含:
|
||||
|
||||
| 章节 | 内容 | 数据来源 |
|
||||
|------|------|---------|
|
||||
| A. 结构对照 | 页面区域结构是否完整、顺序是否一致 | diff 截图 + H5 HTML 结构 |
|
||||
| B. CSS 风险点 | 不支持的 CSS 特性(`::after` 复杂场景、`clip-path`、`backdrop-filter` 等) | H5 `<style>` 块 |
|
||||
| C. 关键样式映射 | 逐元素核对:Tailwind 类名 → computed 值 → WXSS 现值 → 是否一致 | H5 源码 + MP WXSS |
|
||||
| D. 图标处理 | 每个 SVG/图标的迁移决策和当前状态 | H5 源码 + MP WXML |
|
||||
| E. 偏差清单 | 所有 ⚠️ 偏差项汇总,按优先级排序 | C/D 节中标记的偏差 |
|
||||
|
||||
审计报告输出到 `docs/h5_ui/compare/<page>/audit.md`。
|
||||
|
||||
关键原则:
|
||||
- 不能只看 diff 图猜测问题,必须回溯到 H5 源码确认正确值
|
||||
- Tailwind 类名是唯一可信的样式来源(不是肉眼估算)
|
||||
- 每个偏差项必须记录:H5 值(含 Tailwind 类名)→ 换算后的 rpx 值 → MP 现值 → 差异
|
||||
- 审计报告完成后,阶段一/二的修正严格按报告中的偏差清单执行
|
||||
|
||||
### 3.3 阶段一:结构级修正
|
||||
|
||||
目标:消除明显的视觉差异,将差异率从 >10% 降到 ≤10%。
|
||||
|
||||
修正重点(按优先级):
|
||||
1. 区域缺失或顺序错误
|
||||
2. 整块背景色/渐变色不匹配
|
||||
3. 字号明显偏差(≥4rpx)
|
||||
4. 间距明显偏差(≥4rpx)
|
||||
|
||||
操作模式:
|
||||
- 按审计报告偏差清单逐项修正,不靠肉眼猜测
|
||||
- 对照 H5 源码 Tailwind 类名 + `docs/h5_ui/design-tokens.json` 确认正确值
|
||||
- 修改 WXSS → 重新截图 → 重新对比
|
||||
|
||||
### 3.4 阶段二:像素级精调
|
||||
|
||||
目标:消除细微差异,将差异率从 5%~10% 降到 <5%。
|
||||
|
||||
修正重点(按优先级):
|
||||
1. 小边距偏差(padding/margin/gap ±2rpx,如卡片 30→28rpx、列表容器 24→28rpx、分隔符 margin 6→10rpx)
|
||||
2. 圆角(border-radius)偏差(±2rpx,如标签 6→8rpx、卡片 28→32rpx)
|
||||
3. 颜色色值微调(灰阶偏差如 #c5c5c5→#a6a6a6、透明度差 0.1)
|
||||
4. 行高(line-height)缺失或偏差、字重(font-weight)微调(如 600→500)
|
||||
5. 阴影(box-shadow)参数微调
|
||||
|
||||
操作模式:
|
||||
- 使用 image_compare 精确定位差异像素区域
|
||||
- 参考 computed-styles.json(如有)或 H5 源码精确值
|
||||
- 每次只改 1-3 处,验证不引入新差异
|
||||
|
||||
### 3.5 收敛停滞处理
|
||||
|
||||
当差异率连续 3 轮未下降超过 1% 时:
|
||||
1. 分析剩余差异是否为不可消除的结构性差异(字体渲染、抗锯齿、头部区域)
|
||||
2. 若是 → 标注为可接受差异,记录到 report.md,停止该区域精调
|
||||
3. 若否 → 尝试不同修正策略(如换用 flex 布局替代绝对定位)
|
||||
|
||||
## 4. 修改优先级规则(经验提炼)
|
||||
|
||||
基于 board-finance(17 项偏差)、board-coach(17 项偏差)、board-customer(42 项偏差)的实际审计经验,提炼出以下规则:
|
||||
|
||||
### 4.1 高频偏差类型
|
||||
|
||||
| 偏差类型 | 出现频率 | 典型案例 | 修正策略 |
|
||||
|----------|---------|---------|---------|
|
||||
| font-size 偏差 | 极高 | Tab 26rpx→24rpx, 金额 28rpx→32rpx | 查 Tailwind 类名换算 |
|
||||
| border-radius 偏差 | 高 | 标签 6rpx→8rpx, 卡片 28rpx→32rpx, 头像 14rpx→24rpx | 查 `design-tokens.json` |
|
||||
| padding/margin 偏差 | 高 | 卡片 30rpx→28rpx, 列表容器 24rpx→28rpx | 查 Tailwind 类名换算 |
|
||||
| line-height 缺失 | 中 | Tab 文字未写 line-height | 补 `line-height: 36rpx` |
|
||||
| 颜色偏差 | 中 | 灰色 #c5c5c5→#a6a6a6, 边框 #eeeeee→#e7e7e7 | 查 `design-tokens.json` 灰阶 |
|
||||
| font-weight 偏差 | 低 | 数据行 600→500 | 查 Tailwind 类名 |
|
||||
| flex 比例偏差 | 低 | 筛选按钮 1.8→2 | 查 H5 源码 |
|
||||
|
||||
### 4.2 跨页面共性偏差
|
||||
|
||||
以下偏差在三个看板页面中重复出现,修正一个页面后应同步检查其他页面:
|
||||
|
||||
- Tab 导航:font-size 26→24rpx, padding 24→22rpx, line-height 缺失→36rpx
|
||||
- 筛选栏内层:border-radius 14→16rpx(或 32rpx,取决于页面)
|
||||
- 卡片:padding-top/bottom 30→28rpx, margin-bottom 20→22rpx
|
||||
- 标签:border-radius 6→8rpx
|
||||
|
||||
### 4.3 不修复的差异
|
||||
|
||||
以下差异属于结构性差异或设计决策差异,不计入差异率:
|
||||
- 头部区域(H5 safe-area-top vs MP 状态栏+胶囊)
|
||||
- 字体渲染差异(H5 Noto Sans SC vs MP 系统字体)
|
||||
- 环比箭头(H5 SVG vs MP 文字 ↑↓,已确认可接受)
|
||||
- AI 浮动按钮区域(H5 已隐藏,MP 端如存在则忽略该区域差异)
|
||||
|
||||
## 5. 页面清单与截图参数
|
||||
|
||||
所有页面统一使用固定 600px 步长滚动截图,不依赖锚点。步数基于 Playwright 实测的 `maxScroll = scrollHeight - viewportHeight`,公式:`N = floor(maxScroll / 600) + 1`;`maxScroll ≤ 10` 视为单屏(N=1)。
|
||||
|
||||
> 以下数据基于 2026-03-10 Playwright 实测(430×752 视口,DPR=1.5,展开所有折叠区域),验证脚本 `scripts/ops/_verify_step_counts.py`,原始数据 `export/SYSTEM/REPORTS/h5_page_heights/step_counts_verified.json`。
|
||||
|
||||
| # | 页面 | scrollHeight | maxScroll | 实测步数 | scrollTop 序列 | 主要内容区域 |
|
||||
|---|------|-------------|-----------|---------|---------------|-------------|
|
||||
| 1 | board-finance | 5600 | 4848 | 10 | 0,600,...,4800,4848 | 经营一览 / 预收资产 / 应计收入确认 / 现金流入 / 现金流出 / 助教分析 |
|
||||
| 2 | board-coach | 754 | 2 | 1★ | 0 | 助教卡片列表(×4 排序维度:perf/salary/sv/task) |
|
||||
| 3 | board-customer | 752 | 0 | 1 | 0 | 客户卡片列表(×8 客户维度:recall/potential/balance/recharge/recent/spend60/freq60/loyal) |
|
||||
| 4 | task-detail | 2995 | 2243 | 5 | 0,600,1200,1800,2243 | Banner / 关系 / 建议 / 线索 / 备注 |
|
||||
| 5 | task-detail-callback | 2397 | 1645 | 4 | 0,600,1200,1645 | 同上(teal 主题) |
|
||||
| 6 | task-detail-priority | 2389 | 1637 | 4 | 0,600,1200,1637 | 同上(orange 主题) |
|
||||
| 7 | task-detail-relationship | 2275 | 1523 | 4 | 0,600,1200,1523 | 同上(pink 主题) |
|
||||
| 8 | coach-detail | 2918 | 2166 | 5 | 0,600,1200,1800,2166 | Banner / 绩效概览 / 收入明细 / 前10客户 |
|
||||
| 9 | customer-detail | 3070 | 2318 | 5 | 0,600,1200,1800,2318 | Banner / AI洞察 / 线索 / 任务 / 最爱助教 / 消费记录 |
|
||||
| 10 | performance | 7705 | 6953 | 13 | 0,600,...,6600,6953 | Banner / 收入 / 本月业绩 / 上月收入 / 新客 / 常客(最长页面) |
|
||||
| 11 | task-list | 1428 | 676 | 3 | 0,600,676 | Banner业绩卡 / 任务列表 |
|
||||
| 12 | my-profile | 752 | 0 | 1 | 0 | 整页(单屏) |
|
||||
| 13 | customer-service-records | 961 | 209 | 2 | 0,209 | Banner统计 / 记录列表 |
|
||||
| 14 | performance-records | 2677 | 1925 | 5 | 0,600,1200,1800,1925 | Banner / 统计概览 / 记录列表 |
|
||||
| 15 | chat | 1061 | 309 | 2 | 0,309 | 对话区 / 输入区 |
|
||||
| 16 | chat-history | 752 | 0 | 1 | 0 | 整页(单屏) |
|
||||
| 17 | notes | 1709 | 957 | 3 | 0,600,957 | 导航 / 备注列表 |
|
||||
|
||||
> ★ board-coach maxScroll=2px,按实用主义规则(≤10px)视为单屏。
|
||||
> 单屏页面(board-coach、board-customer、my-profile、chat-history)只需 step-0 一张截图。多维度页面需切换维度后分别截图:board-coach ×4 排序维度(通过排序筛选下拉切换)、board-customer ×8 客户维度(通过维度筛选下拉切换)。
|
||||
|
||||
### 5.1 对照处理单元计算
|
||||
|
||||
对照处理单元 = 每页实测步数 × 维度数(无维度的页面维度数=1)。数据来源:2026-03-10 Playwright 实测(`scripts/ops/_verify_step_counts.py`)。
|
||||
|
||||
| 页面 | 步数 | 维度数 | 单元数 |
|
||||
|------|------|--------|--------|
|
||||
| board-finance | 10 | 1 | 10 |
|
||||
| board-coach | 1★ | 4 | 4 |
|
||||
| board-customer | 1 | 8 | 8 |
|
||||
| task-detail | 5 | 1 | 5 |
|
||||
| task-detail-callback | 4 | 1 | 4 |
|
||||
| task-detail-priority | 4 | 1 | 4 |
|
||||
| task-detail-relationship | 4 | 1 | 4 |
|
||||
| coach-detail | 5 | 1 | 5 |
|
||||
| customer-detail | 5 | 1 | 5 |
|
||||
| performance | 13 | 1 | 13 |
|
||||
| task-list | 3 | 1 | 3 |
|
||||
| my-profile | 1 | 1 | 1 |
|
||||
| customer-service-records | 2 | 1 | 2 |
|
||||
| performance-records | 5 | 1 | 5 |
|
||||
| chat | 2 | 1 | 2 |
|
||||
| chat-history | 1 | 1 | 1 |
|
||||
| notes | 3 | 1 | 3 |
|
||||
| **合计** | | | **79** |
|
||||
|
||||
> ★ board-coach maxScroll=2px,按实用主义规则(≤10px)视为单屏。
|
||||
|
||||
## 6. 产出物归档结构
|
||||
|
||||
所有截图、diff、审计报告统一归入 `docs/h5_ui/compare/`,按页面分子目录。
|
||||
|
||||
```
|
||||
docs/h5_ui/compare/
|
||||
├── board-finance/
|
||||
│ ├── h5--step-0.png # H5 截图(645×1128)
|
||||
│ ├── h5--step-600.png
|
||||
│ ├── mp--step-0.png # MP 截图(645×1128)
|
||||
│ ├── mp--step-600.png
|
||||
│ ├── diff--step-0.png # 像素对比 diff 图
|
||||
│ ├── diff--step-600.png
|
||||
│ ├── report.md # 差异率汇总 + 对比分析
|
||||
│ └── audit.md # 三方对照审计报告
|
||||
├── board-coach/
|
||||
│ ├── perf/ # 多维度页面按排序维度分子目录
|
||||
│ │ ├── h5--step-0.png
|
||||
│ │ ├── mp--step-0.png
|
||||
│ │ └── diff--step-0.png
|
||||
│ ├── salary/
|
||||
│ ├── sv/
|
||||
│ ├── task/
|
||||
│ ├── report.md
|
||||
│ └── audit.md
|
||||
├── board-customer/
|
||||
│ ├── recall/ # 8 种客户维度各一个子目录
|
||||
│ ├── potential/
|
||||
│ ├── balance/
|
||||
│ ├── recharge/
|
||||
│ ├── recent/
|
||||
│ ├── spend60/
|
||||
│ ├── freq60/
|
||||
│ ├── loyal/
|
||||
│ ├── report.md
|
||||
│ └── audit.md
|
||||
├── my-profile/ # 单屏页面
|
||||
│ ├── h5--step-0.png
|
||||
│ ├── mp--step-0.png
|
||||
│ ├── diff--step-0.png
|
||||
│ ├── report.md
|
||||
│ └── audit.md
|
||||
...(17 个页面各一个子目录)
|
||||
```
|
||||
|
||||
文件命名规则:
|
||||
- `h5--step-<scrollTop>.png` / `mp--step-<scrollTop>.png` / `diff--step-<scrollTop>.png`
|
||||
- scrollTop 值:0, 600, 1200, 1800 ...
|
||||
- 文件名不含页面名(已在子目录中)
|
||||
- 单屏页面只有 `--step-0` 一组
|
||||
- 多维度页面(board-coach ×4 排序维度、board-customer ×8 客户维度)按维度分子目录
|
||||
|
||||
废弃目录(不再使用):
|
||||
- `docs/h5_ui/screenshots/` → 已清理
|
||||
- `docs/h5_ui/mp-screenshots/` → 已清理
|
||||
- `docs/h5_ui/h5-segments/` → 已清理
|
||||
- `docs/h5_ui/diffs/` → 已清理
|
||||
- `docs/h5_ui/anchors/` → 已清理
|
||||
- `docs/h5_ui/analysis/` → 审计报告迁入各页面子目录
|
||||
|
||||
## 7. 风险与依赖
|
||||
|
||||
### 7.1 外部依赖
|
||||
|
||||
| 依赖项 | 风险等级 | 说明 | Fallback |
|
||||
|--------|---------|------|----------|
|
||||
| 微信开发者工具 MCP 连接 | 中 | MCP 连接偶尔断开(超时/端口占用),需重连 | `reconnect_devtools` 重连;若反复失败,手动截图后放入 `compare/<page>/` |
|
||||
| Playwright 浏览器 | 低 | 首次运行需 `playwright install chromium` | `uv run playwright install chromium` |
|
||||
| image_compare MCP | 低 | MCP server 未启动时 compare_images 不可用 | 使用 `anchor_compare.py compare` 命令行替代 |
|
||||
|
||||
### 7.2 已知限制
|
||||
|
||||
- 字体渲染引擎差异(Chromium vs 微信 webview)可能引入约 1-2% 的固有差异率
|
||||
- 字体渲染差异(H5 Noto Sans SC vs MP 系统字体)不可消除,属于可接受差异
|
||||
- 微信开发者工具截图不支持指定 DPR,实际 DPR 取决于模拟器设置(当前 iPhone 15 Pro Max 有效 DPR=1.5)
|
||||
- board-coach/board-customer 的多维度截图验证(4/8 种维度)需要 Mock 数据支持不同维度的展示
|
||||
|
||||
## 8. 页面执行顺序
|
||||
|
||||
### 8.0 前置任务:TS 零诊断基线检查与共性偏差批量修复
|
||||
|
||||
在进入 A 批次之前,需完成两项前置工作:
|
||||
|
||||
#### 8.0.1 TS 零诊断基线检查
|
||||
|
||||
对 17 个页面的 `.ts` 文件运行 `getDiagnostics`,确认全部为零诊断。这是 Step 0-5 结构迁移的交付基线,如果此时已有 TS 错误,必须先修复再进入像素精调,否则精调过程中引入的新错误会与历史错误混淆。
|
||||
|
||||
#### 8.0.2 跨页面共性偏差批量修复
|
||||
|
||||
基于 4.2 节已识别的跨页面共性偏差,在 `app.wxss` 或共享组件样式中统一修正:
|
||||
|
||||
| 共性偏差 | 涉及页面 | 修正方案 |
|
||||
|----------|---------|---------|
|
||||
| Tab 导航 font-size 26→24rpx | board-finance/coach/customer | 统一修正各页面 Tab 样式 |
|
||||
| Tab 导航 padding 24→22rpx | board-finance/coach/customer | 同上 |
|
||||
| Tab 导航 line-height 缺失→36rpx | board-finance/coach/customer | 同上 |
|
||||
| 筛选栏内层 border-radius 14→16rpx | board-finance/coach/customer | 统一修正筛选栏样式 |
|
||||
| 卡片 padding-top/bottom 30→28rpx | board-finance/coach/customer | 统一修正卡片容器样式 |
|
||||
| 标签 border-radius 6→8rpx | board-finance/coach/customer | 统一修正标签样式 |
|
||||
|
||||
先批量修复共性偏差,再进入各页面的个性化精调,可显著减少重复工作量。修复后需对三个看板页面快速截图验证,确认共性修正未引入新问题。
|
||||
|
||||
### 8.1 批次执行顺序
|
||||
|
||||
按原批次顺序,简单页面优先积累经验:
|
||||
|
||||
| 批次 | 页面 | 预估复杂度 | 对照单元总数 |
|
||||
|------|------|-----------|------------|
|
||||
| A | board-finance, board-coach, board-customer | 高 | 22 |
|
||||
| B | task-list, my-profile | 低-中 | 4 |
|
||||
| C | task-detail(主页面 5 单元)+ 3 变体(各 4 单元,仅验证主题色) | 中 | 17 |
|
||||
| D | coach-detail, customer-detail, customer-service-records | 中 | 12 |
|
||||
| E | performance, performance-records | 高 | 18 |
|
||||
| F | chat, chat-history | 低 | 3 |
|
||||
| G | notes | 低 | 3 |
|
||||
| | **合计** | | **79** |
|
||||
@@ -1,147 +0,0 @@
|
||||
# 需求文档:H5 → 微信小程序像素精调
|
||||
|
||||
## 简介
|
||||
|
||||
本 spec 承接 `h5-miniprogram-migration` 的 Step 0-5 结构迁移成果。17 个页面已全部完成结构迁移、编译验证和结构还原验证。本 spec 专注于 Step 6-7:像素级视觉还原与验收签收。
|
||||
|
||||
核心单位是「对照处理单元」——每个页面被分解为多个可独立截图、独立对比、独立修正的最小区域段。17 个页面共分解为 79 个对照处理单元(详见 design.md §5.1,基于 2026-03-10 Playwright 实测校准)。
|
||||
|
||||
权威参考:
|
||||
- 迁移规则:`docs/prd/MIGRATION-PLAYBOOK.md`
|
||||
- 样式标准:`docs/h5_ui/design-tokens.json`(颜色、字号、圆角、阴影的标准值)
|
||||
- 工具链:`scripts/ops/anchor_compare.py`(固定步长截图 + 逐屏对比)
|
||||
|
||||
## 术语表
|
||||
|
||||
| 术语 | 定义 |
|
||||
|------|------|
|
||||
| 对照处理单元 | 一个页面内可独立截图、独立对比、独立修正的最小区域段(如"经营一览"板块、"助教卡片列表"区域) |
|
||||
| 差异率 | pixelmatch / image_compare 输出的像素差异百分比,仅针对内容区域计算(头部导航栏/状态栏不计入) |
|
||||
| 结构级修正 | 差异率 > 10% 时的修正阶段,修复明显的布局/颜色/缺失问题 |
|
||||
| 像素级精调 | 差异率 5%~10% 时的精调阶段,微调 padding/font-size/color 等 |
|
||||
| 固定步长 | 600px 逻辑像素为一步,两端使用完全相同的 scrollTop 序列逐屏截图 |
|
||||
| 双端截图 | 同一页面的 H5 截图(DPR=1.5, 645×1128)和 MP 截图(DPR=1.5, 645×1128),两端参数完全一致,无需缩放 |
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:页面分解与对照处理单元建立
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望每个页面被分解为可独立处理的最小对照单元,以便逐屏精确对比和修正。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 分解流程 SHALL 对每个页面执行以下步骤:分析 H5 原型结构 → 识别语义区域 → 按固定 600px 步长计算截图屏数 → 建立对照处理单元清单
|
||||
2. THE 对照处理单元 SHALL 满足以下条件:每屏为一个对照单元(645×1128 像素),语义完整性由审计报告人工确认
|
||||
3. THE 步数计算 SHALL 基于 Playwright 实测的 `maxScroll = scrollHeight - viewportHeight`:`N = floor(maxScroll / 600) + 1`;`maxScroll ≤ 10` 的页面视为单屏(N=1)
|
||||
4. WHEN 页面为多维度页面(如 board-coach ×4 排序维度、board-customer ×8 客户维度)时,THE 分解流程 SHALL 按维度分子目录,每个维度独立截图和对比。维度切换通过页面筛选栏下拉操作完成(board-coach 通过排序筛选切换 perf/salary/sv/task 四种卡片模板;board-customer 通过维度筛选切换 recall/potential/balance/recharge/recent/spend60/freq60/loyal 八种卡片模板)
|
||||
5. THE 分解结果 SHALL 输出为每页一份的对照单元清单,包含:单元编号(step-N)、scrollTop 值、预估复杂度
|
||||
|
||||
### 需求 2:双端截图标准
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望 H5 和 MP 的截图在尺寸、视口、DPR 上严格统一,以确保像素对比结果有意义。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE H5 截图 SHALL 使用以下参数:viewport 430×752, DPR=1.5, 输出 645×1128;通过 Playwright 截取(Live Server `http://localhost:5500`),等待 Tailwind CDN JIT 渲染 1500ms
|
||||
2. THE MP 截图 SHALL 使用以下参数:viewport 430×752, DPR=1.5(MCP 有效 DPR), 输出 645×1128;通过微信开发者工具 MCP 截取。两端输出尺寸完全一致,无需任何缩放
|
||||
3. WHEN 对比页面时,THE 截图流程 SHALL 使用固定 600px 步长滚动截图,两端使用完全相同的 scrollTop 序列(0, 600, 1200, ...),不依赖锚点
|
||||
4. THE H5 截图前 SHALL 隐藏底部浮动元素:`#bottomNav`(64px fixed 底部导航)和 `.ai-float-btn-container`(56px 浮动按钮),通过 JS 设置 `display: none`。MP 端原生 tabBar 不在截图中,无需处理
|
||||
5. THE 头部区域(H5 safe-area-top ~46px / MP 状态栏+胶囊 ~88px)SHALL 不计入像素差异率,属结构性差异
|
||||
6. WHEN 截图流程中发现 H5 页面或 MP 页面无法正常渲染时,THE 流程 SHALL 暂停并报告问题,禁止使用异常截图进行对比
|
||||
7. WHEN MP 端页面高度与 H5 不一致时,THE 截图流程 SHALL 先截双端 step-0(首屏)确认基线,再截 step-600(第二屏)校准双端高度差异,之后逐屏推进。每屏截图前读取 MP 端实际 scrollTop,确认到达预期位置
|
||||
|
||||
### 需求 3:两阶段收敛流程
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望像素精调按差异率分阶段处理——先修大问题再调细节——以高效收敛到达标标准。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 收敛流程 SHALL 分为两个阶段:
|
||||
- 阶段一(结构级修正):差异率 > 10%,每轮修复 2-5 处明显差异(布局/颜色/缺失/字号)
|
||||
- 阶段二(像素级精调):差异率 5%~10%,每轮修复 1-3 处精细差异(padding ±2rpx / line-height / border-radius / 色值)
|
||||
2. THE 达标标准 SHALL 为:差异率 < 5% 标记通过;差异率 > 15% 且多轮无法收敛则触发重写
|
||||
3. WHEN 进入每轮修正时,THE 流程 SHALL 先截图对比 → 肉眼审查 diff 图 → 定位差异区域 → 修改 WXSS/WXML → 重新截图验证
|
||||
4. THE 每轮修正 SHALL 控制修改范围(阶段一 2-5 处、阶段二 1-3 处),避免一次改太多难以定位效果
|
||||
5. WHEN 差异率在连续 3 轮内未下降超过 1% 时,THE 流程 SHALL 评估是否为结构性差异(如头部区域、字体渲染差异),若是则标注为可接受差异并停止该区域的精调
|
||||
|
||||
### 需求 4:分析报告持久化
|
||||
|
||||
**用户故事:** 作为项目管理者,我希望每个页面的对比分析结果持久化为 MD 文件,以便追溯和复查。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 每个页面 SHALL 在 `docs/h5_ui/compare/<page>/report.md` 输出对比分析报告
|
||||
2. THE 报告 SHALL 包含:初始差异率、每轮修正记录(轮次/修改内容/修正后差异率)、最终差异率、遗留的可接受差异说明
|
||||
3. THE 报告 SHALL 在每轮修正后更新,记录收敛过程
|
||||
4. THE 截图产出物 SHALL 统一归入 `docs/h5_ui/compare/<page>/`,按页面分子目录:
|
||||
- H5 截图:`h5--step-<scrollTop>.png`
|
||||
- MP 截图:`mp--step-<scrollTop>.png`
|
||||
- Diff 图:`diff--step-<scrollTop>.png`
|
||||
- 审计报告:`audit.md`
|
||||
- 对比报告:`report.md`
|
||||
- 多维度页面按维度再分子目录(如 `board-coach/perf/`)
|
||||
5. THE 每轮新截图 SHALL 覆盖上一轮同名文件,不保留历史版本(依赖 git 追溯)
|
||||
|
||||
### 需求 5:修改优先级规则
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望有明确的修改优先级指导,以便在每轮修正中优先处理影响最大的差异。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 修改优先级 SHALL 按以下顺序排列(从高到低):
|
||||
- P0:区域缺失或顺序错误(结构性问题)
|
||||
- P1:整块背景色/渐变色不匹配
|
||||
- P2:字号(font-size)明显偏差(≥4rpx)
|
||||
- P3:间距(padding/margin/gap)明显偏差(≥4rpx)
|
||||
- P4:圆角(border-radius)偏差
|
||||
- P5:颜色色值微调(灰阶偏差、透明度)
|
||||
- P6:行高(line-height)/ 字重(font-weight)微调
|
||||
- P7:阴影(box-shadow)参数微调
|
||||
2. WHEN 阶段一修正时,THE 流程 SHALL 优先处理 P0-P3 级别的差异
|
||||
3. WHEN 阶段二精调时,THE 流程 SHALL 处理 P4-P7 级别的差异
|
||||
4. THE 修改 SHALL 优先使用 `docs/h5_ui/design-tokens.json` 中定义的标准值,禁止使用非标准灰色(#333/#666/#999 等)
|
||||
5. THE 修改 SHALL 优先使用 flex/盒模型的确定性方案,禁止使用"碰运气"的魔法数
|
||||
|
||||
### 需求 6:验收签收标准
|
||||
|
||||
**用户故事:** 作为项目管理者,我希望每个页面有明确的验收标准,以确保交付质量一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 单页验收 SHALL 满足以下条件:
|
||||
- 内容区差异率 < 5%
|
||||
- TS 编译零诊断
|
||||
- 所有对照处理单元均已逐屏对比
|
||||
- report.md 已归档且记录完整
|
||||
2. THE 批次验收 SHALL 满足以下条件:
|
||||
- 批次内所有页面单页验收通过
|
||||
- 共享组件在批次内所有页面中表现一致
|
||||
- 所有截图和 diff 图已按目录归档
|
||||
3. THE 全局验收 SHALL 满足以下条件:
|
||||
- 17 个页面(79 个对照处理单元)全部单页验收通过
|
||||
- 差异率汇总表已输出(页面名 / 初始差异率 / 最终差异率 / 修正轮数)
|
||||
- TS 编译零诊断复查通过
|
||||
|
||||
### 需求 7:工具链使用规范
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望工具链的使用有明确规范,以确保截图和对比结果的一致性和可复现性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE H5 截图 SHALL 统一通过 `scripts/ops/anchor_compare.py extract-h5 <page>` 获取(固定 600px 步长滚动截图,viewport 430×752, DPR=1.5;通过 Live Server `http://localhost:5500` 访问页面)
|
||||
2. THE MP 截图 SHALL 通过微信开发者工具 MCP 截取,按相同的 scrollTop 序列(0, 600, 1200, ...)逐屏截图
|
||||
3. THE 像素对比 SHALL 使用 `image_compare` power 的 `compare_images` 工具,或 `anchor_compare.py compare <page>` 命令
|
||||
4. WHEN 页面需要逐屏对比时,THE 流程 SHALL 使用 anchor_compare.py 的两步流程:`extract-h5` → `compare`(MP 截图通过 MCP 工具独立获取)
|
||||
5. THE 工具链输出 SHALL 统一存放到 `docs/h5_ui/compare/<page>/`(见需求 4),禁止散放在项目根目录或临时位置
|
||||
|
||||
### 需求 8:风险与异常处理
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望在工具链故障或环境异常时有明确的 Fallback 方案,以避免流程阻塞。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 微信开发者工具 MCP 连接断开时,THE 流程 SHALL 先尝试 `reconnect_devtools` 重连,若连续 3 次失败则暂停该页面任务并报告问题
|
||||
2. WHEN Live Server 端口 5500 被占用时,THE 流程 SHALL 检查端口占用并提示用户释放端口,不得使用其他端口截图(避免 URL 不一致)
|
||||
3. WHEN image_compare MCP 不可用时,THE 流程 SHALL 使用 `anchor_compare.py compare` 命令行作为替代
|
||||
4. WHEN 某页面差异率 > 15% 且连续 5 轮无法收敛时,THE 流程 SHALL 暂停并输出问题分析报告,由用户决定是否触发重写
|
||||
5. THE 每个批次任务 SHALL 在开始前验证页面 TS 零诊断基线,如有编译错误则先修复再进入精调
|
||||
@@ -1,135 +0,0 @@
|
||||
# 实现计划:H5 → 微信小程序视觉还原
|
||||
|
||||
## 概述
|
||||
|
||||
17 个页面(共 79 个对照处理单元)已完成 Step 0-5 结构迁移。本计划覆盖 Step 6-7:视觉还原与验收。
|
||||
|
||||
采用固定 600px 步长滚动截图方案:两端使用完全相同的 scrollTop 序列(0, 600, 1200, ...),不依赖锚点,消除 H5/MP 元素位置差异导致的对齐问题。
|
||||
|
||||
采用混合组织方式:前置阶段按流程批量操作(截图→对比→共性修复),修正阶段按页面逐个完成(审计→修正→验收)。
|
||||
|
||||
## 截图方案要点
|
||||
|
||||
- 双端参数:viewport 430×752, DPR=1.5, 输出 645×1128, 无需缩放
|
||||
- 步长:600px(逻辑像素)
|
||||
- H5 截图:`scripts/ops/anchor_compare.py extract-h5 <page>`(Playwright + Live Server localhost:5500)
|
||||
- MP 截图:MCP 工具(`wx.pageScrollTo` + `screenshot`)
|
||||
- 产出物根目录:`docs/h5_ui/compare/<page>/`
|
||||
- 文件命名:`h5--step-<scrollTop>.png` / `mp--step-<scrollTop>.png` / `diff--step-<scrollTop>.png`
|
||||
- 审计报告:`docs/h5_ui/compare/<page>/audit.md`
|
||||
- 对比报告:`docs/h5_ui/compare/<page>/report.md`
|
||||
- 多维度页面:`docs/h5_ui/compare/<page>/<dimension>/`(如 board-coach/perf/、board-customer/recall/)
|
||||
- 维度切换:board-coach 通过排序筛选下拉切换 perf/salary/sv/task;board-customer 通过维度筛选下拉切换 recall/potential/balance/recharge/recent/spend60/freq60/loyal
|
||||
|
||||
## 逐页视觉还原流程(阶段 3 每页统一流程)
|
||||
|
||||
每个页面子任务按以下步骤执行:
|
||||
|
||||
1. **截图**:先截双端 step-0(首屏)确认基线,再截 step-600(第二屏)校准双端高度差异,之后按固定步长序列逐屏推进。每屏读取 MP 端实际 scrollTop 确认到达预期位置
|
||||
2. **审计报告**:同时读取 H5 源码(HTML + Tailwind 类名)、MP 源码(WXML + WXSS)、diff 截图,三方对照产出 `docs/h5_ui/compare/<page>/audit.md`
|
||||
3. **浮动元素检测**(长页面):对比多屏截图中持续相同位置的差异,识别 sticky/fixed 元素,优先修复(一次修复所有屏受益)
|
||||
4. **阶段一修正循环**(差异率 > 10%):按审计报告偏差清单修正 → 每轮修 2-5 处 → 重新截图对比 → 循环至 ≤ 10%
|
||||
5. **阶段二精调循环**(差异率 5%~10%):精确定位差异区域 → 每轮修 1-3 处 → 重新截图对比 → 循环至 < 5%
|
||||
6. **验收**:差异率 < 5% → 更新 report.md → 标记通过
|
||||
|
||||
### 长页面级联规则
|
||||
|
||||
修正某一屏的 WXSS 后,所有屏必须重新截图对比(布局变化会影响每屏内容)。优先修复 sticky 区域差异,从 step-0 开始顺序审查。
|
||||
|
||||
---
|
||||
|
||||
## 任务
|
||||
|
||||
### 阶段 0 — 前置准备
|
||||
|
||||
- [x] 0. 工具链验证与基线检查
|
||||
- [x] 0.1 验证截图工具链:确认 Playwright 可通过 Live Server (localhost:5500) 截图 H5 页面、微信开发者工具 MCP 可连接截图、image_compare 可对比
|
||||
- [x] 0.2 TS 零诊断基线检查:对 17 个页面的 .ts 文件运行 getDiagnostics,确认全部为零诊断
|
||||
- [x] 0.3 固定步长方案验证:board-finance 实测 10 屏(step 0-4848,scrollHeight=5600, maxScroll=4848),两端精确命中目标 scrollTop,截图 645×1128 完全一致
|
||||
|
||||
---
|
||||
|
||||
### 阶段 1 — 批量截图与初始对比
|
||||
|
||||
- [ ] 1. 批量获取 H5 截图(board-finance 已完成 10 屏验证)
|
||||
- [ ] 1.1 board-finance H5 固定步长截图(10 屏,step 0-4848)— 已通过 test_fixed_step.py 完成
|
||||
- [x] 1.2 重构 anchor_compare.py 为固定步长模式,支持 `extract-h5 <page>` 自动计算页面高度和步数
|
||||
- [ ] 1.3 对剩余 16 个页面运行 H5 固定步长截图
|
||||
|
||||
- [ ] 2. 批量获取 MP 截图(board-finance 已完成 10 屏验证)
|
||||
- [x] 2.1 board-finance MP 固定步长截图(10 屏,step 0-4848)— 已通过 MCP 工具完成
|
||||
- [ ] 2.2 对剩余 16 个页面通过 MCP 工具截取 MP 固定步长截图
|
||||
|
||||
- [ ] 3. 批量初始对比与差异率采集
|
||||
- [x] 3.1 board-finance 逐屏对比完成(差异率 5.01%~17.71%,差异来自组件未精校)
|
||||
- [ ] 3.2 对剩余 16 个页面运行逐屏对比,输出 diff 图到 `docs/h5_ui/compare/<page>/`
|
||||
- [ ] 3.3 汇总差异率表(页面名 / 屏数 / 各屏差异率),按差异率降序排列
|
||||
|
||||
---
|
||||
|
||||
### 阶段 2 — 共性偏差批量修复
|
||||
|
||||
- [ ] 4. 跨页面共性偏差修复
|
||||
- [ ] 4.1 根据阶段 1 差异率和 design.md §4.2 已识别的共性偏差(Tab 导航 font-size/padding/line-height、筛选栏 border-radius、卡片 padding、标签 border-radius),在各页面 WXSS 或共享组件中统一修正
|
||||
- [ ] 4.2 共性修复后重新截图验证:对涉及修改的页面重新截图 + 对比,确认共性修正未引入新问题
|
||||
|
||||
---
|
||||
|
||||
### 阶段 3 — 逐页视觉还原
|
||||
|
||||
按批次组织,每页按「逐页视觉还原流程」执行(截图→审计→浮动元素检测→修正循环→验收)。
|
||||
|
||||
#### A 批次 — 看板页面
|
||||
|
||||
- [ ] 5. board-finance 视觉还原(长页面,10 屏 step 0-4848)。含 sticky 区域检测(safe-area-top 45px + filterBar 71px = 116px)
|
||||
- [ ] 6. board-coach 视觉还原(短页面,4 种排序维度 perf/salary/sv/task 需逐维度截图验证,通过排序筛选下拉切换)
|
||||
- [ ] 7. board-customer 视觉还原(短页面,8 种客户维度 recall/potential/balance/recharge/recent/spend60/freq60/loyal 需逐维度截图验证,通过维度筛选下拉切换)
|
||||
- [ ] 8. A 批次检查点:3 页全部差异率 < 5%,共享组件跨页表现一致
|
||||
|
||||
#### B 批次 — 核心页面
|
||||
|
||||
- [ ] 9. task-list 视觉还原(约 3 屏)
|
||||
- [ ] 10. my-profile 视觉还原(单屏页面)
|
||||
- [ ] 11. B 批次检查点:2 页全部差异率 < 5%
|
||||
|
||||
#### C 批次 — 任务详情页面
|
||||
|
||||
> task-detail 系列 4 个页面共享相同布局,仅 Banner 主题色不同。主页面做完整还原,3 个变体只验证主题色差异。
|
||||
|
||||
- [ ] 12. task-detail 视觉还原(主页面,约 5 屏,完整审计→修正→验收)
|
||||
- [ ] 13. task-detail 变体主题色验证(callback=teal / priority=orange / relationship=pink):逐个截图 → 仅对比含主题色的屏 → 确认主题色正确应用
|
||||
- [ ] 14. C 批次检查点:4 页全部差异率 < 5%
|
||||
|
||||
#### D 批次 — 详情页面
|
||||
|
||||
- [ ] 15. coach-detail 视觉还原(约 5 屏)
|
||||
- [ ] 16. customer-detail 视觉还原(约 5 屏)
|
||||
- [ ] 17. customer-service-records 视觉还原(约 2 屏)
|
||||
- [ ] 18. D 批次检查点:3 页全部差异率 < 5%
|
||||
|
||||
#### E 批次 — 绩效页面
|
||||
|
||||
- [ ] 19. performance 视觉还原(约 13 屏,本 spec 最长页面)
|
||||
- [ ] 20. performance-records 视觉还原(约 5 屏)
|
||||
- [ ] 21. E 批次检查点:2 页全部差异率 < 5%
|
||||
|
||||
#### F 批次 — 对话页面
|
||||
|
||||
- [ ] 22. chat 视觉还原(约 2 屏)
|
||||
- [ ] 23. chat-history 视觉还原(单屏页面)
|
||||
- [ ] 24. F 批次检查点:2 页全部差异率 < 5%
|
||||
|
||||
#### G 批次 — 其他页面
|
||||
|
||||
- [ ] 25. notes 视觉还原(约 3 屏)
|
||||
- [ ] 26. G 批次检查点:差异率 < 5%
|
||||
|
||||
---
|
||||
|
||||
### 阶段 4 — 全局验收
|
||||
|
||||
- [ ] 27. 全局验收
|
||||
- [ ] 27.1 汇总 17 页面差异率表(页面名 / 屏数 / 初始差异率 / 共性修复后差异率 / 最终差异率 / 修正轮数),共 79 个对照处理单元
|
||||
- [ ] 27.2 确认所有 `compare/<page>/report.md` 已归档
|
||||
- [ ] 27.3 TS 编译零诊断复查(17 个页面)
|
||||
- [ ] 27.4 标记 spec 完成
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "cd30e87b-ce7a-4ff5-8587-f5ae75013e58", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -1,552 +0,0 @@
|
||||
# 技术设计文档:H5 → 微信小程序批量迁移
|
||||
|
||||
## 1. 概述
|
||||
|
||||
本设计文档基于 33 条需求(`requirements.md`),为 17 个 H5 原型页面迁移到微信小程序提供技术实现方案。权威参考:`docs/prd/MIGRATION-PLAYBOOK.md`。
|
||||
|
||||
核心约束:
|
||||
- 纯前端迁移,不涉及后端 API 或数据库变更
|
||||
- 输入物分两批:第一批(结构迁移 Step 1-5)、第二批(像素精调 Step 6-7)
|
||||
- 迁移粒度:以"屏/交互态"为最小单位,非整页
|
||||
|
||||
## 2. 架构总览
|
||||
|
||||
### 2.1 目录结构(现有 + 新增)
|
||||
|
||||
```
|
||||
apps/miniprogram/miniprogram/
|
||||
├── app.json / app.ts / app.wxss # 全局配置与样式
|
||||
├── assets/icons/ # SVG 图标(已有 + 新导出)
|
||||
├── components/ # 共享组件
|
||||
│ ├── ai-float-button/ # ✅ 已有
|
||||
│ ├── board-tab-bar/ # ✅ 已有
|
||||
│ ├── filter-dropdown/ # ✅ 已有
|
||||
│ ├── heart-icon/ # ✅ 已有
|
||||
│ ├── star-rating/ # ✅ 已有
|
||||
│ ├── note-modal/ # ✅ 已有
|
||||
│ ├── metric-card/ # ✅ 已有
|
||||
│ ├── hobby-tag/ # ✅ 已有
|
||||
│ ├── banner/ # ✅ 已有
|
||||
│ └── dev-fab/ # ✅ 已有
|
||||
├── pages/ # 页面目录(17 个迁移目标)
|
||||
│ ├── board-finance/ # A 批次 - 看板
|
||||
│ ├── board-coach/
|
||||
│ ├── board-customer/
|
||||
│ ├── task-list/ # B 批次 - 核心
|
||||
│ ├── my-profile/
|
||||
│ ├── task-detail/ # C 批次 - 任务详情
|
||||
│ ├── task-detail-callback/
|
||||
│ ├── task-detail-priority/
|
||||
│ ├── task-detail-relationship/
|
||||
│ ├── coach-detail/ # D 批次 - 详情
|
||||
│ ├── customer-detail/
|
||||
│ ├── customer-service-records/
|
||||
│ ├── performance/ # E 批次 - 绩效
|
||||
│ ├── performance-records/
|
||||
│ ├── chat/ # F 批次 - 对话
|
||||
│ ├── chat-history/
|
||||
│ └── notes/ # G 批次 - 其他
|
||||
└── utils/ # 工具模块
|
||||
├── ai-color.ts # 🆕 AI 图标随机配色
|
||||
├── format.wxs # ✅ 已有 WXS 格式化
|
||||
├── request.ts # ✅ 已有 网络请求
|
||||
├── router.ts # ✅ 已有 路由工具
|
||||
└── ... # 其他已有工具
|
||||
```
|
||||
|
||||
### 2.2 页面四文件结构
|
||||
|
||||
每个页面输出标准四文件:
|
||||
|
||||
```
|
||||
pages/<page>/
|
||||
├── <page>.wxml # 视图模板
|
||||
├── <page>.wxss # 样式
|
||||
├── <page>.ts # 逻辑(TypeScript)
|
||||
└── <page>.json # 页面配置(usingComponents)
|
||||
```
|
||||
|
||||
## 3. 单页迁移工作流设计
|
||||
|
||||
### 3.1 工作流总览(7 步 + 屏级粒度)
|
||||
|
||||
迁移以"屏/交互态"为最小工作单位,而非整页。每个页面的迁移流程:
|
||||
|
||||
```
|
||||
Step 0: 页面分析(确认屏数、子页面、变种、工作量)
|
||||
↓
|
||||
Step 1: 输入物冻结(第一批:结构材料)
|
||||
↓
|
||||
Step 2: 迁移审计报告(7 项审计,不写代码)
|
||||
↓
|
||||
Step 3: 规则化转换(按屏逐个开发)
|
||||
↓
|
||||
Step 4: 编译验证(7 项检查)
|
||||
↓
|
||||
Step 5: 结构还原验证(9 项核对,按屏逐个验证)
|
||||
↓
|
||||
── 第二批输入物补充(截图 + computed-styles)──
|
||||
↓
|
||||
Step 6: 像素级对比(按屏逐段对比 + 微调循环)
|
||||
↓
|
||||
Step 7: 验收签收(12 项清单)
|
||||
```
|
||||
|
||||
### 3.2 Step 0:页面分析(新增步骤)
|
||||
|
||||
在正式迁移前,先对目标页面做结构分析:
|
||||
|
||||
1. 打开 H5 原型截图 + 交互说明文档
|
||||
2. 确认页面总长度(几个屏?)
|
||||
3. 识别子页面/变种(如 task-detail 的 3 个主题色变体)
|
||||
4. 列出所有交互态(弹窗、筛选展开、空状态等)
|
||||
5. 输出工作量估算表:
|
||||
|
||||
```
|
||||
| 单位 | 类型 | 描述 | 预估复杂度 |
|
||||
|------|------|------|-----------|
|
||||
| 屏-1 | 默认态首屏 | Banner + 筛选栏 + 第一板块 | 中 |
|
||||
| 屏-2 | 默认态第二屏 | 第二~三板块 | 中 |
|
||||
| 交互-1 | 筛选下拉 | 时间筛选 + 区域筛选 | 低 |
|
||||
| 交互-2 | 指标弹窗 | 长按指标卡片 | 低 |
|
||||
| ... | ... | ... | ... |
|
||||
```
|
||||
|
||||
### 3.3 Step 3:按屏逐个开发
|
||||
|
||||
规则化转换不是一次性写完整页,而是按屏/交互态逐个推进:
|
||||
|
||||
1. 先完成首屏结构 → 编译通过 → 截图粗看
|
||||
2. 再完成第二屏 → 编译通过 → 截图粗看
|
||||
3. 所有屏完成后 → 处理交互态(弹窗、筛选等)
|
||||
4. 最后处理三态(loading/empty/error)
|
||||
|
||||
### 3.4 Step 5:按屏逐个验证
|
||||
|
||||
结构还原验证同样按屏进行:
|
||||
|
||||
1. 截取小程序当前屏 → 与 H5 原型截图粗略比对
|
||||
2. 9 项核对清单逐项确认
|
||||
3. 通过 → 下一屏;未通过 → 修复后重新验证
|
||||
4. 所有屏 + 所有交互态全部通过 → 进入 Step 6
|
||||
|
||||
### 3.5 差异率过大的处理策略
|
||||
|
||||
当 Step 6 像素对比差异率 > 15% 且多轮微调无法收敛时:
|
||||
- 放弃修补,从零重写该页面(需求 2 第 5 条)
|
||||
- 复杂 Banner 背景 → 导出为 SVG,`<image>` 引用(需求 2 第 6 条)
|
||||
- 复杂 Icon → 导出为 SVG,`<image>` 引用(需求 2 第 7 条)
|
||||
|
||||
## 4. 样式转换系统设计
|
||||
|
||||
### 4.1 缩放公式
|
||||
|
||||
```
|
||||
rpx = H5 px × 2 × 0.875
|
||||
结果取偶数(向最近偶数取整)
|
||||
```
|
||||
|
||||
特例:`borderRadius` 使用简单 ×2(A/B 对比验证差异 < 0.02%)。
|
||||
|
||||
### 4.2 design-tokens 映射
|
||||
|
||||
全局设计令牌来自 `docs/h5_ui/design-tokens.json`,直接映射到 WXSS 变量:
|
||||
|
||||
| Token | 值 | 用途 |
|
||||
|-------|-----|------|
|
||||
| fontSize.xs | 22rpx | 辅助文字 |
|
||||
| fontSize.sm | 24rpx | 次要文字 |
|
||||
| fontSize.base | 28rpx | 正文 |
|
||||
| fontSize.lg | 32rpx | 小标题 |
|
||||
| fontSize.xl | 36rpx | 标题 |
|
||||
| fontSize.2xl | 42rpx | 大标题 |
|
||||
| borderRadius.sm | 8rpx | 小圆角 |
|
||||
| borderRadius.md | 16rpx | 中圆角 |
|
||||
| borderRadius.lg | 24rpx | 大圆角 |
|
||||
| borderRadius.xl | 32rpx | 特大圆角 |
|
||||
| borderRadius.3xl | 48rpx | 全圆角 |
|
||||
|
||||
颜色使用 `colors` 中定义的灰阶(gray-1 ~ gray-13),禁止使用 `#333`、`#666`、`#999` 等非标准灰色。
|
||||
|
||||
### 4.3 两阶段样式数据源
|
||||
|
||||
**结构迁移阶段(Step 3)**:
|
||||
1. H5 源码 Tailwind 类名 → 查速查表换算
|
||||
2. design-tokens.json Token 值
|
||||
3. 目测估算(必须标注 `/* 目测值,待校准 */`)
|
||||
|
||||
**像素精调阶段(Step 6)**:
|
||||
1. computed-styles.json 精确 px 值(最高优先级)
|
||||
2. H5 源码 Tailwind 类名
|
||||
3. design-tokens.json
|
||||
4. H5 截图目测(最低)
|
||||
|
||||
### 4.4 七维度核对
|
||||
|
||||
每个可见元素写 WXSS 时逐项确认:
|
||||
1. font-size
|
||||
2. font-weight
|
||||
3. color
|
||||
4. line-height(必须显式写出)
|
||||
5. padding
|
||||
6. margin / gap
|
||||
7. border / border-radius
|
||||
|
||||
## 5. AI 图标配色系统设计
|
||||
|
||||
### 5.1 配色方案定义
|
||||
|
||||
6 种配色,每种 4 个 CSS 变量:
|
||||
|
||||
```typescript
|
||||
// utils/ai-color.ts
|
||||
const AI_COLOR_SCHEMES = {
|
||||
red: { from: '#e74c3c', to: '#f39c9c', fromDeep: '#c0392b', toDeep: '#e74c3c' },
|
||||
orange: { from: '#e67e22', to: '#f5c77e', fromDeep: '#ca6c17', toDeep: '#e67e22' },
|
||||
yellow: { from: '#d4a017', to: '#f7dc6f', fromDeep: '#b8860b', toDeep: '#d4a017' },
|
||||
blue: { from: '#2980b9', to: '#7ec8e3', fromDeep: '#1a5276', toDeep: '#2980b9' },
|
||||
indigo: { from: '#667eea', to: '#a78bfa', fromDeep: '#4a5fc7', toDeep: '#667eea' },
|
||||
purple: { from: '#764ba2', to: '#c084fc', fromDeep: '#5b3080', toDeep: '#764ba2' },
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 小程序实现方案
|
||||
|
||||
H5 通过 DOM `querySelectorAll` + `classList.add` 实现随机配色。小程序无 DOM API,改用 `setData` + 条件样式:
|
||||
|
||||
```typescript
|
||||
// 页面 onLoad 中调用
|
||||
import { getRandomAiColor } from '../../utils/ai-color';
|
||||
|
||||
Page({
|
||||
data: {
|
||||
aiColorClass: '', // 'ai-color-red' | 'ai-color-orange' | ...
|
||||
aiColorVars: {}, // CSS 变量值对象
|
||||
},
|
||||
onLoad() {
|
||||
const color = getRandomAiColor();
|
||||
this.setData({
|
||||
aiColorClass: color.className,
|
||||
aiColorVars: color.vars,
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- WXML 中使用 -->
|
||||
<view class="ai-inline-icon {{aiColorClass}}">
|
||||
<image src="/assets/icons/ai-robot-sm.svg" mode="aspectFit" />
|
||||
</view>
|
||||
|
||||
<view class="ai-title-badge {{aiColorClass}}">
|
||||
<view class="ai-title-badge-icon">
|
||||
<image src="/assets/icons/ai-robot.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text>AI 推荐</text>
|
||||
</view>
|
||||
```
|
||||
|
||||
### 5.3 两个系列的 WXSS 实现
|
||||
|
||||
**ai-inline-icon**(行首小图标,28rpx):
|
||||
- 渐变背景 + 白色机器人 SVG
|
||||
- 微光扫过动画(12s 周期 `ai-shimmer`)
|
||||
- 尺寸:28rpx × 28rpx(H5 16px × 2 × 0.875 ≈ 28)
|
||||
|
||||
**ai-title-badge**(标题行右侧标识):
|
||||
- 浅色背景 + 主题色文字 + 主题色边框
|
||||
- 呼吸脉冲动画(3s 周期 `ai-pulse`)
|
||||
- 高光扫过动画(14s 周期 `ai-shimmer`)
|
||||
|
||||
### 5.4 ai-float-button 排除
|
||||
|
||||
`ai-float-button` 组件已有固定渐变动画(`#667eea → #764ba2 → #f093fb → #f5576c`),不参与页面级随机配色。无需修改。
|
||||
|
||||
### 5.5 机器人 SVG 复用
|
||||
|
||||
- 大系列(ai-title-badge):复用已有 `assets/icons/ai-robot.svg`
|
||||
- 小系列(ai-inline-icon):从 H5 源码导出白色填充版本,保存为 `assets/icons/ai-robot-sm.svg`
|
||||
|
||||
## 6. 共享组件设计
|
||||
|
||||
### 6.1 已有组件(直接复用)
|
||||
|
||||
| 组件 | 路径 | 用途 | 使用页面 |
|
||||
|------|------|------|---------|
|
||||
| ai-float-button | components/ai-float-button/ | AI 悬浮按钮 | 所有业务页面 |
|
||||
| board-tab-bar | components/board-tab-bar/ | 自定义底部导航 | board-coach, board-customer |
|
||||
| filter-dropdown | components/filter-dropdown/ | 筛选下拉面板 | board-finance/coach/customer |
|
||||
| heart-icon | components/heart-icon/ | 心形评分 | board-customer |
|
||||
| star-rating | components/star-rating/ | 星级评价 | notes |
|
||||
| note-modal | components/note-modal/ | 备注弹窗 | task-list/detail, coach-detail |
|
||||
| metric-card | components/metric-card/ | 指标卡片 | board-finance, performance |
|
||||
| hobby-tag | components/hobby-tag/ | 爱好标签 | board-customer, customer-detail |
|
||||
| banner | components/banner/ | 顶部 Banner | task-list, performance |
|
||||
| dev-fab | components/dev-fab/ | 开发调试按钮 | 所有页面(开发环境) |
|
||||
|
||||
### 6.2 组件注册规范
|
||||
|
||||
每个页面的 `.json` 文件中注册所需组件:
|
||||
|
||||
```json
|
||||
{
|
||||
"usingComponents": {
|
||||
"t-button": "tdesign-miniprogram/button/button",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"filter-dropdown": "/components/filter-dropdown/filter-dropdown"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 事件与路由转换设计
|
||||
|
||||
### 7.1 事件映射表
|
||||
|
||||
| H5 | 小程序 | 说明 |
|
||||
|----|--------|------|
|
||||
| `onclick="fn()"` | `bindtap="fn"` | 基础点击 |
|
||||
| `onclick="fn(id)"` | `data-id="{{id}}" bindtap="fn"` | dataset 传参 |
|
||||
| `event.target.value` | `e.detail.value` | 表单取值 |
|
||||
| `event.target.dataset` | `e.currentTarget.dataset` | dataset 取值 |
|
||||
| `event.preventDefault()` | `catchtap` | 阻止冒泡 |
|
||||
| `classList.toggle` | `setData` + 条件 class | 样式切换 |
|
||||
| `innerHTML` | `setData` + WXML 绑定 | 视图更新 |
|
||||
| `history.back()` | `wx.navigateBack()` | 返回 |
|
||||
| `localStorage` | `wx.setStorageSync` | 本地存储 |
|
||||
| `alert()/confirm()` | `wx.showToast()/wx.showModal()` | 弹窗 |
|
||||
| `longpress` | `bindlongpress` | 长按 |
|
||||
|
||||
### 7.2 路由规则
|
||||
|
||||
| 目标页面类型 | API | 示例 |
|
||||
|-------------|-----|------|
|
||||
| TabBar 页面 | `wx.switchTab` | task-list, board-finance, my-profile |
|
||||
| 普通页面 | `wx.navigateTo` | task-detail, coach-detail, chat |
|
||||
| 重定向 | `wx.redirectTo` | 登录后跳转 |
|
||||
| 返回 | `wx.navigateBack` | 详情页返回 |
|
||||
| 重启 | `wx.reLaunch` | 切换身份 |
|
||||
|
||||
路径规则:以 `/` 开头,不带 `.wxml` 后缀。
|
||||
|
||||
## 8. 弹窗与 z-index 分层设计
|
||||
|
||||
### 8.1 全局 z-index 分层
|
||||
|
||||
```
|
||||
10-29 sticky 元素(Tab 栏 20, 筛选栏 15)
|
||||
30 AI 悬浮按钮
|
||||
50 底部固定操作栏
|
||||
100 自定义底部导航栏(board-tab-bar)
|
||||
999 遮罩层
|
||||
1000 弹窗内容
|
||||
9999 Toast / Loading
|
||||
```
|
||||
|
||||
### 8.2 弹窗实现模式
|
||||
|
||||
所有弹窗遵循统一模式:
|
||||
- 同一时刻只允许一个弹窗打开(互斥)
|
||||
- 遮罩 `bindtap` 关闭,内容区 `catchtap` 防穿透
|
||||
- 背景滚动锁定:`catchtouchmove` 在遮罩层
|
||||
- 底部弹出类添加 `padding-bottom: env(safe-area-inset-bottom)`
|
||||
- 动画统一 200-220ms + `ease`
|
||||
|
||||
## 9. 三态处理设计
|
||||
|
||||
每个页面统一处理 4 种状态:
|
||||
|
||||
```xml
|
||||
<!-- 通用三态模板 -->
|
||||
<view wx:if="{{pageState === 'loading'}}">
|
||||
<t-loading text="加载中..." />
|
||||
</view>
|
||||
<view wx:elif="{{pageState === 'error'}}">
|
||||
<view class="error-state">
|
||||
<text>加载失败,请点击重试</text>
|
||||
<t-button bindtap="onRetry">重试</t-button>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{pageState === 'empty'}}">
|
||||
<text class="empty-text">{{emptyText}}</text>
|
||||
</view>
|
||||
<view wx:else>
|
||||
<!-- 正常内容 -->
|
||||
</view>
|
||||
```
|
||||
|
||||
各页面空状态文案见需求 14。
|
||||
|
||||
## 10. 像素对比工具链设计
|
||||
|
||||
### 10.1 工具链流程
|
||||
|
||||
```
|
||||
H5 截图(DPR=3, 1290px 宽)
|
||||
↓
|
||||
MP 截图(DPR=1.5, 645px)→ ×2 缩放 → 1290px
|
||||
↓
|
||||
pixelmatch 逐像素对比
|
||||
↓
|
||||
按 150px 条带分析差异密度
|
||||
↓
|
||||
定位差异区域 → WXSS 微调 → 循环
|
||||
```
|
||||
|
||||
### 10.2 逐段对比(v2 方案)
|
||||
|
||||
长页面使用 `scripts/ops/anchor_compare.py`:
|
||||
|
||||
```bash
|
||||
# 提取 H5 锚点 + 截图
|
||||
python scripts/ops/anchor_compare.py extract-h5 <page>
|
||||
|
||||
# 生成 MP 截图指令
|
||||
python scripts/ops/anchor_compare.py mp-inst <page>
|
||||
|
||||
# 执行 MP 截图(通过微信开发者工具 MCP)
|
||||
|
||||
# 逐段配对 + 对比
|
||||
python scripts/ops/anchor_compare.py compare <page>
|
||||
```
|
||||
|
||||
### 10.3 scroll-view 页面截图
|
||||
|
||||
使用 `scroll-into-view` 模式:
|
||||
1. `page.setData({ scrollIntoView: '' })` — 清空
|
||||
2. `page.setData({ scrollIntoView: '<section-id>' })` — 设目标
|
||||
3. 等待 1000ms → 截图
|
||||
|
||||
### 10.4 达标标准
|
||||
|
||||
- 前半屏差异率 < 5%:优秀
|
||||
- 前半屏差异率 ≤ 10%:达标
|
||||
- 前半屏差异率 > 15% 且无法收敛:触发重写
|
||||
|
||||
## 11. task-detail 变体策略
|
||||
|
||||
### 11.1 实现方式
|
||||
|
||||
1. 先完成 task-detail 主页面的完整迁移和验收
|
||||
2. 复制 task-detail 四文件到变体目录
|
||||
3. 替换主题色变量(banner 背景色、按钮配色)
|
||||
4. 保持数据结构和布局完全一致
|
||||
|
||||
### 11.2 变体清单
|
||||
|
||||
| 变体 | 差异点 |
|
||||
|------|--------|
|
||||
| task-detail-callback | banner 背景色 + 按钮配色(对照 H5 原型校准) |
|
||||
| task-detail-priority | banner 背景色 + 按钮配色(对照 H5 原型校准) |
|
||||
| task-detail-relationship | banner 背景色 + 按钮配色(对照 H5 原型校准) |
|
||||
|
||||
## 12. 认证与联调设计
|
||||
|
||||
### 12.1 认证守卫
|
||||
|
||||
每个业务页面 `onLoad` 检查登录态:
|
||||
|
||||
```typescript
|
||||
onLoad() {
|
||||
const token = wx.getStorageSync('token');
|
||||
if (!token) {
|
||||
wx.redirectTo({ url: '/pages/login/login' });
|
||||
return;
|
||||
}
|
||||
// 正常加载逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 12.2 开发联调
|
||||
|
||||
- `utils/request.ts` 中 `BASE_URL` 指向 `http://localhost:8000`
|
||||
- 后端 `WX_DEV_MODE=true` 支持 `/api/xcx/dev-login` Mock 登录
|
||||
- Storage + header token 维持登录态
|
||||
|
||||
## 13. 不支持的 CSS 特性替代方案
|
||||
|
||||
| H5 特性 | 小程序替代 |
|
||||
|---------|-----------|
|
||||
| `backdrop-filter: blur()` | `background: rgba(255,255,255,0.95)` |
|
||||
| `*` 通配符选择器 | 逐个元素设置 |
|
||||
| `filter: blur()` | `radial-gradient` 模拟 |
|
||||
| `url("data:image/svg+xml,...")` | CSS 渐变模拟或导出 PNG/base64 |
|
||||
| `::before/::after`(复杂场景) | 额外 `<view>` 模拟 |
|
||||
|
||||
直接支持的特性(无需替代):CSS 变量 `var()`、`linear-gradient`、`animation`/`@keyframes`、`transition`。
|
||||
|
||||
## 14. 批次执行顺序与依赖
|
||||
|
||||
```
|
||||
A-看板(board-finance → board-coach → board-customer)
|
||||
↓ 共享组件验证完毕
|
||||
B-核心(task-list → my-profile)
|
||||
↓
|
||||
C-任务(task-detail → 3 个变体)
|
||||
↓
|
||||
D-详情(coach-detail → customer-detail → customer-service-records)
|
||||
↓
|
||||
E-绩效(performance → performance-records)
|
||||
↓
|
||||
F-对话(chat → chat-history)
|
||||
↓
|
||||
G-其他(notes)
|
||||
```
|
||||
|
||||
A 批次优先:验证共享组件(filter-dropdown、board-tab-bar、metric-card)在实际页面中的表现,为后续批次建立基线。
|
||||
|
||||
## 15. 产出物与中间生成物归档
|
||||
|
||||
迁移过程中会产生大量截图、diff 图、逐段对比图等中间文件。所有生成物必须按类型分目录存放,禁止散放在项目根目录或临时位置。
|
||||
|
||||
### 15.1 目录结构
|
||||
|
||||
```
|
||||
docs/h5_ui/
|
||||
├── screenshots/ # H5 原型截图(输入物,已有)
|
||||
│ ├── <page>.png # 默认态截图
|
||||
│ └── <page>--<state>.png # 交互态截图
|
||||
├── mp-screenshots/ # 🆕 小程序截图(迁移过程生成)
|
||||
│ ├── <page>/ # 按页面分子目录
|
||||
│ │ ├── <page>.png # 默认态全屏截图
|
||||
│ │ ├── <page>--<state>.png # 交互态截图
|
||||
│ │ └── seg-<N>-<section>.png # 逐段截图(anchor_compare 生成)
|
||||
│ └── ...
|
||||
├── diffs/ # 🆕 像素对比结果(迁移过程生成)
|
||||
│ ├── <page>/ # 按页面分子目录
|
||||
│ │ ├── diff-<page>.png # 全屏 diff 图
|
||||
│ │ ├── diff-seg-<N>-<section>.png # 逐段 diff 图
|
||||
│ │ └── report.md # 该页面的对比结果摘要(差异率、问题区域)
|
||||
│ └── ...
|
||||
├── h5-segments/ # 🆕 H5 逐段截图(anchor_compare 生成)
|
||||
│ ├── <page>/
|
||||
│ │ └── seg-<N>-<section>.png
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 15.2 归档规则
|
||||
|
||||
| 生成物类型 | 目标目录 | 命名规则 | 说明 |
|
||||
|-----------|---------|---------|------|
|
||||
| H5 原型截图 | `docs/h5_ui/screenshots/` | `<page>.png` / `<page>--<state>.png` | 输入物,已有,不动 |
|
||||
| MP 全屏截图 | `docs/h5_ui/mp-screenshots/<page>/` | `<page>.png` / `<page>--<state>.png` | 每轮对比更新覆盖 |
|
||||
| MP 逐段截图 | `docs/h5_ui/mp-screenshots/<page>/` | `seg-<N>-<section>.png` | anchor_compare 生成 |
|
||||
| H5 逐段截图 | `docs/h5_ui/h5-segments/<page>/` | `seg-<N>-<section>.png` | anchor_compare 生成 |
|
||||
| 全屏 diff 图 | `docs/h5_ui/diffs/<page>/` | `diff-<page>.png` | pixelmatch 输出 |
|
||||
| 逐段 diff 图 | `docs/h5_ui/diffs/<page>/` | `diff-seg-<N>-<section>.png` | pixelmatch 输出 |
|
||||
| 对比报告 | `docs/h5_ui/diffs/<page>/` | `report.md` | 差异率 + 问题区域摘要 |
|
||||
| 新导出 SVG | `assets/icons/` | `icon-<用途>.svg` / `logo-<名称>.svg` | 小程序工程内 |
|
||||
| 图标映射更新 | `docs/h5_ui/icon-mapping.md` | — | 追加新条目 |
|
||||
| 小程序页面代码 | `apps/miniprogram/miniprogram/pages/<page>/` | 四文件组合 | 最终交付物 |
|
||||
|
||||
### 15.3 管理规则
|
||||
|
||||
1. 按页面分子目录:MP 截图、H5 逐段截图、diff 图均按 `<page>/` 分目录,避免数百张图片平铺
|
||||
2. 每轮覆盖更新:像素精调循环中,每轮新截图覆盖上一轮同名文件,不保留历史版本(git 有历史)
|
||||
3. 逐段截图编号连续:`seg-0`、`seg-1`、`seg-2`...,与 anchor_compare.py 输出一致
|
||||
4. report.md 格式统一:每个页面的 `diffs/<page>/report.md` 记录最终差异率和遗留问题,作为验收依据
|
||||
5. .gitignore 不排除:这些中间文件需要入库,便于团队复查和回溯
|
||||
6. H5 原型截图目录只读:`docs/h5_ui/screenshots/` 是输入物,迁移过程中不往里写 MP 截图或 diff 图
|
||||
@@ -1,424 +0,0 @@
|
||||
# 需求文档:H5 → 微信小程序批量迁移
|
||||
|
||||
## 简介
|
||||
|
||||
将 `docs/h5_ui/pages/` 下 17 个 HTML 原型页面迁移为原生微信小程序页面(WXML/WXSS/TS/JSON)。迁移范围覆盖 7 个批次(A-看板、B-核心、C-任务、D-详情、E-绩效、F-对话、G-其他),每页需走完 7 步标准流程(输入物冻结 → 迁移审计 → 规则化转换 → 编译验证 → 结构还原验证 → 像素级对比 → 验收签收)。本工程为纯前端迁移,不涉及后端 API 开发或数据库变更。
|
||||
|
||||
权威参考文档:`docs/prd/MIGRATION-PLAYBOOK.md`(唯一迁移执行手册)。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Playbook**:`docs/prd/MIGRATION-PLAYBOOK.md`,H5→小程序迁移的唯一权威执行手册
|
||||
- **H5_原型**:`docs/h5_ui/pages/<page>.html`,基于 Tailwind CDN + 内联 SVG + 原生 JS 的单文件 HTML 原型
|
||||
- **小程序页面**:`apps/miniprogram/miniprogram/pages/<page>/` 下的 `.wxml`/`.wxss`/`.ts`/`.json` 四文件组合
|
||||
- **WXML**:微信小程序标记语言,替代 HTML
|
||||
- **WXSS**:微信小程序样式表,替代 CSS,支持 rpx 单位
|
||||
- **rpx**:微信小程序响应式像素单位,750rpx = 屏幕宽度
|
||||
- **缩放公式**:`rpx = H5 px × 2 × 0.875`,结果取偶数(向最近偶数取整)
|
||||
- **design-tokens**:`docs/h5_ui/design-tokens.json`,全局颜色/间距/字号/圆角/阴影定义
|
||||
- **computed-styles**:`docs/h5_ui/computed-styles.json`,H5 元素的浏览器计算样式精确 px 值
|
||||
- **交互说明**:`docs/h5_ui/interactions/<page>.md`,每页的状态变量 + 操作响应 + 状态枚举
|
||||
- **icon-mapping**:`docs/h5_ui/icon-mapping.md`,H5 图标到小程序实现的映射表
|
||||
- **TDesign**:腾讯开源的微信小程序 UI 组件库
|
||||
- **结构还原验证**:对照 H5 原型截图和交互说明,逐项确认区域划分、元素层级、文本内容、图标、导航、弹窗、滚动、三态占位等 9 项结构要素
|
||||
- **像素级对比**:使用工具链(截图 → 尺寸统一 → pixelmatch → 差异分析)量化小程序与 H5 原型的视觉差异百分比
|
||||
- **七维度核对**:每个可见元素写 WXSS 时必须逐一确认的 7 个属性维度(字号、字重、文字颜色、行高、内间距、外间距、边框与圆角)
|
||||
- **三态处理**:每个页面必须处理的 loading / empty / error / normal 四种状态
|
||||
- **TabBar_页面**:task-list、board-finance、my-profile,使用 `wx.switchTab` 跳转
|
||||
- **迁移审计报告**:Step 2 输出的 7 项审计内容(页面结构、CSS 风险点、关键样式映射、图标处理、交互映射、外部依赖、缺失信息)
|
||||
- **差异率**:pixelmatch 对比输出的像素差异百分比,前半屏 < 5% 为优秀,≤ 10% 为达标
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:迁移范围与批次管理
|
||||
|
||||
**用户故事:** 作为项目管理者,我希望 17 个页面按批次有序迁移,以便控制进度和质量。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 迁移工程 SHALL 覆盖以下 17 个页面,按 7 个批次组织:A-看板(board-finance、board-coach、board-customer)、B-核心(task-list、my-profile)、C-任务(task-detail、task-detail-callback、task-detail-priority、task-detail-relationship)、D-详情(coach-detail、customer-detail、customer-service-records)、E-绩效(performance、performance-records)、F-对话(chat、chat-history)、G-其他(notes)
|
||||
2. THE 迁移工程 SHALL 排除以下页面:login、no-permission、reviewing、apply(用户指定不迁移)、home-settings(无交互说明)、ai-icon-demo(无截图和交互说明)
|
||||
3. WHEN 迁移 board-coach、board-customer、board-finance、notes 页面时,THE 迁移流程 SHALL 对已有历史实现按标准流程重新审计、对比 H5 原型、差异修复、像素级验收
|
||||
4. WHEN 迁移其余 13 个页面时,THE 迁移流程 SHALL 从零开始执行全流程迁移
|
||||
|
||||
### 需求 2:7 步标准迁移流程
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望每个页面都走完标准化的 7 步流程,以确保迁移质量一致且可追溯。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 迁移流程 SHALL 对每个页面依次执行以下 7 步:Step 1 输入物冻结、Step 2 迁移审计、Step 3 规则化转换、Step 4 编译验证、Step 5 结构还原验证、Step 6 像素级视觉对比、Step 7 验收签收
|
||||
2. THE 迁移流程 SHALL 禁止跳过任何步骤,后续步骤的执行以前置步骤通过为前提
|
||||
3. WHEN Step 5 结构还原验证未全部通过时,THE 迁移流程 SHALL 禁止进入 Step 6 像素级对比
|
||||
4. WHEN 验收未通过时,THE 迁移流程 SHALL 按问题类型回退到对应步骤(结构错误→Step 5、样式偏差→Step 6、交互缺陷→Step 3、编译报错→Step 4、真机差异→Step 6),并从回退步骤开始重新往下走完所有后续步骤
|
||||
5. WHEN Step 6 像素级对比的差异率过大(前半屏 > 15%)且多轮微调无法收敛时,THE 迁移流程 SHALL 放弃修补,直接按后续规则(需求 5-9、需求 21)从零重写该小程序页面
|
||||
6. WHEN H5 原型页面包含渐变复杂、条纹复杂的 Banner 背景时,THE 迁移流程 SHALL 将该背景生成为 SVG 文件,存放到 `assets/icons/` 目录,在 WXML 中通过 `<image src="/assets/icons/bg-<name>.svg">` 引用
|
||||
7. WHEN H5 原型页面包含精美复杂的 Icon 或装饰性按钮(非标准 TDesign 图标可覆盖)时,THE 迁移流程 SHALL 将其生成为 SVG 文件导出,通过 `<image>` 引用,而非尝试用 WXSS 手动还原
|
||||
|
||||
|
||||
### 需求 3:输入物分批提供
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望输入物按阶段分批提供——结构迁移阶段只需结构和行为材料,像素精调阶段再补充视觉校验材料——以避免因截图等材料未就绪而阻塞结构迁移工作。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 输入物 SHALL 分为两批提供:
|
||||
- **第一批(结构迁移,Step 1-5)**:规则层(Playbook)→ 全局资源层(design-tokens.json、icon-mapping.md)→ 页面源码层(H5 HTML `docs/h5_ui/pages/<page>.html`、自定义 CSS `docs/h5_ui/css/<page>.css`(如有))→ 行为层(`docs/h5_ui/interactions/<page>.md`)
|
||||
- **第二批(像素精调,Step 6-7)**:computed-styles.json 中该页面的精确 px 值、H5 默认态截图 `docs/h5_ui/screenshots/<page>.png`(DPR=3, 1290px 宽)、交互态截图 `docs/h5_ui/screenshots/<page>--<state>.png`
|
||||
2. WHEN 开始结构迁移(Step 1-5)时,THE 迁移流程 SHALL 仅要求第一批输入物齐全;第二批输入物缺失不阻塞结构迁移
|
||||
3. WHEN 进入像素精调(Step 6)时,THE 迁移流程 SHALL 要求第二批输入物齐全;IF 第二批输入物缺失,THEN 标记缺失项并暂停像素精调,不猜测
|
||||
4. IF 第一批中任何必需输入物缺失,THEN THE 迁移流程 SHALL 标记缺失项并暂停该页面迁移,禁止猜测缺失内容
|
||||
5. THE 结构迁移阶段的样式转换 SHALL 基于 H5 源码中的 Tailwind 类名和 design-tokens.json 进行换算,不依赖 computed-styles.json;computed-styles.json 仅在像素精调阶段用于精确校准
|
||||
|
||||
### 需求 4:迁移审计报告(Step 2)
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望在写代码前先输出审计报告,以识别风险点和制定转换策略。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 输入物冻结完成后,THE 迁移流程 SHALL 输出《迁移审计报告》,包含 7 项:A.页面结构(区域划分+组件化边界)、B.CSS 风险点(不支持特性+替代方案)、C.关键样式映射(Tailwind→computed→WXSS 七维度核对)、D.图标处理(每个 SVG 的处理决策)、E.交互映射(H5 DOM→setData+事件绑定)、F.外部依赖(CDN 本地化方案)、G.缺失信息(需补充材料清单)
|
||||
2. THE 迁移审计报告 SHALL 在写任何转换代码之前完成输出
|
||||
3. THE 迁移审计报告中每个 CSS 风险点 SHALL 包含原因、影响、处理方案、验收方式四项说明
|
||||
|
||||
### 需求 5:标签与结构转换规则
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望有明确的 HTML→WXML 标签映射规则,以确保转换的一致性和正确性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 转换引擎 SHALL 按以下映射执行标签转换:`<div>`→`<view>`、`<span>/<p>`→`<text>`、`<img>`→`<image mode="">`(必须指定 mode 和宽高)、`<svg>`→`<image src="xx.svg">` 或 `<t-icon>`、`<button>`→`<t-button>`、`<input>`→`<t-input>`、`<textarea>`→`<t-textarea>`、`<select>`→`<t-picker>`、`<a>`→`bindtap`+`wx.navigateTo`、`<ul>/<li>`→`<view wx:for>`(必须加 `wx:key`)、`<table>`→`<view>` 手动布局、scroll 容器→`<scroll-view>`(必须设固定高度)
|
||||
2. THE 转换引擎 SHALL 禁止在 WXML 中使用任何 HTML 标签
|
||||
3. THE 转换引擎 SHALL 确保 `<text>` 内只嵌套 `<text>`,需要块级布局时外层使用 `<view>`
|
||||
4. WHEN 列表渲染时,THE 转换引擎 SHALL 为每个 `wx:for` 提供 `wx:key` 属性
|
||||
|
||||
### 需求 6:样式转换与缩放规则
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望所有样式值都按统一公式换算,以确保像素级视觉还原。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 样式转换 SHALL 使用核心缩放公式 `rpx = H5 px × 2 × 0.875`,结果取偶数(向最近偶数取整)
|
||||
2. THE 样式转换 SHALL 按以下数据源优先级取值,且区分两个阶段:
|
||||
- **结构迁移阶段(Step 3)**:H5 源码 Tailwind 类名(查速查表换算)→ design-tokens.json Token 值(颜色/圆角/阴影)→ 目测估算(必须标注 `/* 目测值,待校准 */`)
|
||||
- **像素精调阶段(Step 6)**:computed-styles.json 精确 px 值(最高优先级,覆盖结构阶段的换算值)→ H5 源码 Tailwind 类名 → design-tokens.json → H5 截图目测估算(最低)
|
||||
3. THE 样式转换 SHALL 对每个可见元素按七维度逐项核对:字号(font-size)、字重(font-weight)、文字颜色(color)、行高(line-height)、内间距(padding)、外间距(margin/gap)、边框与圆角(border/border-radius)
|
||||
4. THE 样式转换 SHALL 禁止使用不在 design-tokens.json 色阶表中的灰色值(禁止 `#333`、`#666`、`#999` 等非标准灰色)
|
||||
5. THE 样式转换 SHALL 显式写出 `line-height` 值,禁止依赖小程序默认行高
|
||||
6. THE 样式转换 SHALL 使用最小 2rpx 的边框宽度(1rpx 在部分设备不显示)
|
||||
7. THE 样式转换 SHALL 禁止不查任何数据源直接写"看起来差不多"的值
|
||||
|
||||
|
||||
### 需求 7:事件与路由转换规则
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望 H5 事件和路由逻辑有明确的小程序对应方案,以确保交互行为正确迁移。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 转换引擎 SHALL 按以下映射执行事件转换:`onclick="fn()"`→`bindtap="fn"`、`onclick="fn(id)"`→`data-id="{{id}}" bindtap="fn"`(dataset 传参)、`event.target.value`→`e.detail.value`、`event.target.dataset`→`e.currentTarget.dataset`、`event.preventDefault()`→`catchtap`、`classList.toggle`→`setData`+条件 class 绑定、`innerHTML`→`setData`+WXML 数据绑定、`history.back()`→`wx.navigateBack()`、`localStorage`→`wx.setStorageSync`、`alert()/confirm()`→`wx.showToast()/wx.showModal()`
|
||||
2. WHEN 跳转到 TabBar 页面(task-list、board-finance、my-profile)时,THE 路由逻辑 SHALL 使用 `wx.switchTab`,禁止使用 `wx.navigateTo`
|
||||
3. THE 路由逻辑 SHALL 确保所有路径以 `/` 开头且不带 `.wxml` 后缀
|
||||
4. THE 转换引擎 SHALL 禁止使用 `addEventListener`、`document.getElementById` 等 DOM API,所有视图更新通过 `this.setData()` 驱动
|
||||
|
||||
### 需求 8:SVG 图标处理
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望每个 H5 页面中的 SVG 图标都有明确的处理决策,以避免图标缺失或显示异常。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN H5 页面包含内联 SVG 时,THE 图标处理流程 SHALL 按以下优先级决策:TDesign 有语义等价图标→使用 `<t-icon>`、品牌/自定义图标→导出为 `apps/miniprogram/miniprogram/assets/icons/<name>.svg`
|
||||
2. THE 图标处理流程 SHALL 按命名规则导出 SVG:功能图标用 `icon-<用途>.svg`、Logo 类用 `logo-<名称>.svg`
|
||||
3. THE 图标处理流程 SHALL 在导出 SVG 时保留原始 `viewBox`、`fill`、`path`
|
||||
4. WHEN 在小程序中引用 SVG 时,THE 图标引用 SHALL 使用 `<image src="/assets/icons/<name>.svg" mode="aspectFit" />`,并指定宽高
|
||||
5. WHEN 迁移新页面导出新 SVG 时,THE 图标处理流程 SHALL 将新导出的 SVG 追加到 icon-mapping.md 清单
|
||||
|
||||
### 需求 9:不支持的 CSS 特性替代方案
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望所有小程序不支持的 CSS 特性都有明确的替代方案,以避免样式失效。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN H5 使用 `backdrop-filter: blur()` 时,THE 样式转换 SHALL 替换为 `background: rgba(255,255,255,0.95)` 半透明背景
|
||||
2. WHEN H5 使用 `*` 通配符选择器时,THE 样式转换 SHALL 逐个元素设置对应属性
|
||||
3. WHEN H5 使用 `filter: blur()` 时,THE 样式转换 SHALL 使用 `radial-gradient` 模拟扩散效果
|
||||
4. WHEN H5 使用 `url("data:image/svg+xml,...")` 时,THE 样式转换 SHALL 使用 CSS 渐变模拟或导出为 PNG/base64
|
||||
5. WHEN H5 使用 `::before`/`::after` 伪元素且场景复杂时,THE 样式转换 SHALL 使用额外 `<view>` 元素模拟
|
||||
6. THE 样式转换 SHALL 确保 CSS 变量 `var()`、`linear-gradient`、`animation`/`@keyframes`、`transition` 直接迁移(小程序支持)
|
||||
|
||||
### 需求 10:状态栏与安全区适配
|
||||
|
||||
**用户故事:** 作为用户,我希望小程序页面在各种机型(含刘海屏)上正确显示,内容不被遮挡。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 页面使用自定义导航栏(`navigationStyle: 'custom'`)时,THE 页面 SHALL 通过 JS `wx.getSystemInfoSync().statusBarHeight` 获取状态栏高度,并设置 `padding-top`
|
||||
2. THE 页面 SHALL 禁止使用 `env(safe-area-inset-top)` 获取顶部安全区(部分机型不生效),改用 JS 方案
|
||||
3. WHEN 页面包含底部固定栏或底部弹窗时,THE 页面 SHALL 添加 `padding-bottom: env(safe-area-inset-bottom)` 适配刘海屏底部
|
||||
4. WHEN 页面为一屏布局(不滚动)时,THE 页面 SHALL 使用 `height: 100vh` + `box-sizing: border-box`,确保 padding-top 从 100vh 中扣除
|
||||
|
||||
### 需求 11:长页面滚动与吸顶处理
|
||||
|
||||
**用户故事:** 作为用户,我希望长页面(超过一屏)能正常滚动,吸顶元素在滚动时保持固定位置。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 长页面 SHALL 优先使用页面自然滚动(`min-height: 100vh`),禁止用 `scroll-view` 包裹整个页面
|
||||
2. WHEN 页面中某个区域需要独立滚动时(如 chat 页消息区),THE 页面 SHALL 使用 `<scroll-view scroll-y>` 并设固定高度
|
||||
3. THE 吸顶元素 SHALL 使用 `position: sticky`,且父容器不设 `overflow: hidden`
|
||||
4. WHEN 多个 sticky 元素叠加时,THE 页面 SHALL 确保 `z-index` 从上到下递减(20→15→10)
|
||||
5. THE sticky 元素 SHALL 设置 `background-color`,防止内容穿透
|
||||
6. WHEN 页面包含底部固定操作栏时,THE 页面 SHALL 使用 `position: fixed` + `z-index: 50`,并在内容区末尾添加占位 `<view>` 防止内容被遮挡
|
||||
7. WHERE 页面适用下拉刷新(task-list、board-finance、board-coach、board-customer、notes、chat-history),THE 页面 SHALL 启用下拉刷新功能
|
||||
8. WHEN 页面包含 filter-bar(筛选栏)时,THE filter-bar SHALL 统一高度为 70 逻辑像素,所有看板页面(board-finance、board-coach、board-customer)保持一致。此约束确保两端 sticky 区域高度一致(~116px),v2 逐段截图自然对齐。编辑小程序前端页面时须 check 并修正不符合此高度的 filter-bar。
|
||||
|
||||
|
||||
### 需求 12:交互弹窗与状态管理
|
||||
|
||||
**用户故事:** 作为用户,我希望所有弹窗、浮层、下拉面板的交互行为与 H5 原型一致,且不出现穿透、滚动异常等问题。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 弹窗系统 SHALL 遵循统一的 z-index 分层策略:sticky 元素 10-29、AI 悬浮按钮 30、底部固定栏 50、自定义底部导航栏 100、遮罩 999、弹窗内容 1000、Toast/Loading 9999
|
||||
2. THE 弹窗系统 SHALL 确保同一时刻只允许一个弹窗打开(互斥)
|
||||
3. WHEN 弹窗打开时,THE 弹窗 SHALL 使用 `catchtouchmove` 阻止背景页面滚动
|
||||
4. THE 遮罩层 SHALL 使用 `bindtap` 关闭弹窗,弹窗内容区 SHALL 使用 `catchtap` 防止点击穿透
|
||||
5. WHEN 弹窗为底部弹出类型时,THE 弹窗 SHALL 添加 `padding-bottom: env(safe-area-inset-bottom)`
|
||||
6. THE 弹窗动画 SHALL 统一使用 200-220ms 时长和 `ease` 缓动函数
|
||||
7. WHEN 使用筛选下拉面板时,THE 页面 SHALL 复用共享组件 `filter-dropdown`(规范见 Playbook 第八章 8.3)
|
||||
|
||||
### 需求 13:长按菜单交互
|
||||
|
||||
**用户故事:** 作为用户,我希望在 task-list 页面长按任务卡片时弹出操作菜单,且长按和点击手势互不干扰。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户长按任务卡片时,THE task-list 页面 SHALL 使用 `bindlongpress` 触发长按菜单,菜单定位在触摸点附近
|
||||
2. THE 长按菜单 SHALL 包含以下操作项:任务置底、问问助手、备注
|
||||
3. THE 长按菜单 SHALL 使用黑底圆角样式(`rgba(0,0,0,0.85)` 背景、16rpx 圆角、白色文字)
|
||||
4. WHEN 长按菜单显示时,THE task-list 页面 SHALL 在 `onTaskTap` 中跳过跳转逻辑,防止长按后误触发点击跳转
|
||||
5. WHEN 用户点击遮罩或菜单项时,THE 长按菜单 SHALL 关闭
|
||||
|
||||
### 需求 14:三态处理
|
||||
|
||||
**用户故事:** 作为用户,我希望每个页面在加载中、无数据、出错时都有明确的 UI 反馈,而不是空白页面。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 每个页面 SHALL 处理 loading、empty、error、normal 四种状态
|
||||
2. WHEN 页面处于 loading 状态时,THE 页面 SHALL 显示 `<t-loading>` 组件和"加载中..."文字
|
||||
3. WHEN 页面处于 empty 状态时,THE 页面 SHALL 显示对应的空状态文案(task-list→"暂无任务"、board-coach→"暂无助教数据"、board-customer→"暂无客户数据"、board-finance→"暂无财务数据"、chat-history→"暂无对话记录"、notes→"暂无备注记录"、performance-records→"暂无业绩明细"、customer-service-records→"暂无服务记录"、其他→"暂无数据")
|
||||
4. WHEN 页面处于 error 状态时,THE 页面 SHALL 显示"加载失败,请点击重试"文字和重试按钮
|
||||
5. WHEN 用户点击重试按钮时,THE 页面 SHALL 重新加载数据
|
||||
|
||||
### 需求 15:共享组件复用
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望跨页面复用的 UI 元素抽取为共享组件,以保持一致性并减少重复代码。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 迁移工程 SHALL 复用以下已有共享组件:ai-float-button(AI 悬浮按钮,所有业务页面右下角)、board-tab-bar(自定义底部导航栏,board-coach/board-customer 使用)、filter-dropdown(筛选下拉面板,board-finance/coach/customer 使用)、heart-icon(心形评分图标,board-customer 使用)、dev-fab(开发调试按钮)
|
||||
2. THE ai-float-button 组件 SHALL 在所有业务页面右下角显示,默认 bottom 220rpx,使用固定渐变动画(不参与页面级随机配色,详见需求 32)
|
||||
3. THE board-tab-bar 组件 SHALL 用于非 TabBar 的看板子页面,高度 100rpx,包含 safe-area 底部适配
|
||||
4. THE filter-dropdown 组件 SHALL 实现全屏宽度面板 + 半透明遮罩 + 动态 top 计算
|
||||
5. THE heart-icon 组件 SHALL 使用 TS `observers` 监听 `score` 属性变化计算 emoji(WXS 不支持 emoji surrogate pair)
|
||||
|
||||
### 需求 16:TDesign 组件使用规范
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望 TDesign 组件的使用遵循统一规范,以避免样式冲突和事件绑定错误。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 迁移工程 SHALL 按以下替代关系使用 TDesign 组件:`<button>`→`<t-button>`、`<input>`→`<t-input>`、`<textarea>`→`<t-textarea>`、`<select>`→`<t-picker>`、SVG 图标→`<t-icon>`、加载动画→`<t-loading>`、确认弹窗→`<t-dialog>`、底部弹出→`<t-popup>`
|
||||
2. WHEN TDesign 组件事件绑定时,THE 迁移工程 SHALL 使用 `bind:change` 而非 `bindinput`
|
||||
3. WHEN 需要覆盖 TDesign 组件样式时,THE 迁移工程 SHALL 优先使用 CSS 变量,其次外部样式类,再次利用 `addGlobalClass`,最后使用 style 属性
|
||||
4. WHEN 组件与 H5 原型差异过大时,THE 迁移工程 SHALL 使用原生 view 实现而非强行定制 TDesign 组件
|
||||
5. THE 迁移工程 SHALL 确保 `app.json` 中不包含 `"style": "v2"` 配置(会导致 TDesign 样式错乱)
|
||||
6. WHEN 安装新 npm 包后,THE 迁移工程 SHALL 在微信开发者工具中执行"构建 npm"
|
||||
|
||||
|
||||
### 需求 17:编译验证(Step 4)
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望每个页面转换后通过编译验证,以确保代码无语法错误和运行时问题。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 规则化转换完成后,THE 编译验证 SHALL 检查以下 7 项:WXML 编译无错误、WXSS 编译无警告(检查不支持的选择器)、控制台无 JS 运行时错误、图片加载无 404/500 错误(所有 `/assets/` 引用的文件必须存在)、组件注册无 "component not found" 警告、路由跳转无 "navigateTo:fail" 错误、TS 类型定义完整(`Page<IData>()` 中 data 所有字段有初始值)
|
||||
2. THE 编译验证 SHALL 特别检查 WXML 中不包含 JS 方法调用(如 `.toFixed()`),需要格式化的逻辑使用 WXS 模块 `utils/format.wxs`
|
||||
3. IF 编译验证发现错误,THEN THE 迁移流程 SHALL 修复错误后重新验证,直到全部通过
|
||||
|
||||
### 需求 18:结构还原验证(Step 5)
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望在像素对比前先确认页面结构与 H5 原型一致,以避免在结构错误的基础上做无意义的像素调整。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 编译验证通过后,THE 结构还原验证 SHALL 逐项对照 H5 原型截图和交互说明,确认以下 9 项:区域划分(header/content/footer/tabbar 与 H5 一致)、元素层级(嵌套层级与 H5 DOM 结构对应)、列表/卡片数量(Mock 数据条数与 H5 一致)、文本内容(标题/标签/按钮文案完全一致)、图标完整性(所有图标位置有对应实现)、导航结构(自定义导航栏/返回按钮/TabBar 行为正确)、弹窗/浮层(所有弹窗能正常触发和关闭)、滚动行为(长页面可滚动、吸顶元素正确固定)、三态占位(loading/empty/error 三种状态有对应 UI 结构)
|
||||
2. THE 结构还原验证 SHALL 9 项全部通过后才可进入 Step 6 像素级对比
|
||||
3. IF 结构还原验证未通过,THEN THE 迁移流程 SHALL 记录问题、修复、重新验证,循环直到全部通过
|
||||
|
||||
### 需求 19:像素级视觉对比(Step 6)
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望通过工具链量化小程序与 H5 原型的视觉差异,以系统性地收敛渲染偏差。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 结构还原验证全部通过后,THE 像素级对比 SHALL 按以下流程执行:截图(微信开发者工具)→ 尺寸统一(H5 保持 1290px 宽,MP ×2 缩放到 1290px)→ pixelmatch 对比 → 差异分析(按 150px 条带分析差异密度)→ 定位差异区域 → WXSS 微调 → 循环
|
||||
2. THE 像素级对比 SHALL 以前半屏差异率 < 5% 为优秀、≤ 10% 为达标的标准评估
|
||||
3. THE 像素级对比 SHALL 每轮控制 2-5 处修改,避免一次改太多难以定位效果
|
||||
4. THE 像素级对比 SHALL 优先使用 flex/盒模型的确定性方案,禁止使用"碰运气"的魔法数
|
||||
5. THE 像素级对比 SHALL 确保 rpx 换算统一,禁止同一类间距混用 rpx 和 px
|
||||
6. WHEN 需要对比交互态时,THE 像素级对比 SHALL 对弹窗/筛选/空状态等交互态进行视觉检查(弹窗位置、遮罩透明度),交互态不要求像素精确
|
||||
|
||||
### 需求 20:验收签收(Step 7)
|
||||
|
||||
**用户故事:** 作为项目管理者,我希望每个页面迁移完成后通过 12 项验收清单,以确保交付质量。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 验收签收 SHALL 逐项检查以下 12 项:编译零报错、默认态像素对比差异率 ≤ 10%、关键交互态截图齐全、87.5% 缩放一致(抽查 3 个关键元素)、颜色 Token 一致(使用 design-tokens.json 定义的颜色)、TDesign 组件正确使用、共享组件复用、交互逻辑完整(对照 interactions/*.md)、空/错误/加载态已处理、真机预览(iOS + Android 各一台无异常)、导航正确(返回/跳转/TabBar 行为符合 PRD)、认证守卫(未登录自动跳转登录页)
|
||||
2. WHEN 验收未通过时,THE 验收签收 SHALL 按问题严重度回退到对应步骤修复,修复后从回退步骤重新走完所有后续步骤
|
||||
3. THE 验收签收 SHALL 确保每次返工修复后不引入新的编译错误(修 A 不坏 B)
|
||||
|
||||
### 需求 21:转换执行顺序
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望每个页面的代码转换按固定顺序执行,以确保依赖关系正确。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 规则化转换(Step 3)SHALL 按以下顺序执行:创建 4 文件骨架(.wxml/.wxss/.ts/.json)→ .json 注册 usingComponents(TDesign + 自定义组件)→ 转换 WXML(标签映射,保持层级一致)→ 转换 WXSS(Tailwind→手写 WXSS,87.5% 缩放)→ 转换 TS(DOM→setData,事件→bindtap)→ Mock 数据(贴近真实 API 格式,标记 TODO)→ 三态处理(loading/empty/normal/error)
|
||||
2. THE 每个页面 SHALL 输出到 `apps/miniprogram/miniprogram/pages/<page>/` 目录下
|
||||
3. WHEN 页面需要新的 SVG 图标时,THE 转换流程 SHALL 将 SVG 导出到 `apps/miniprogram/miniprogram/assets/icons/` 目录
|
||||
|
||||
### 需求 22:task-detail 变体页面迁移策略
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望 task-detail 的 3 个主题色变体页面高效迁移,避免重复劳动。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 迁移流程 SHALL 先完成 task-detail 主页面的完整迁移和验收
|
||||
2. WHEN task-detail 验收通过后,THE 迁移流程 SHALL 通过复制 task-detail 并替换主题色变量的方式生成 task-detail-callback、task-detail-priority、task-detail-relationship 三个变体
|
||||
3. THE 变体页面 SHALL 仅在以下方面与 task-detail 不同:banner 背景色、按钮配色、页面主题色(对照各自 H5 原型截图校准色值)
|
||||
4. THE 变体页面 SHALL 保持与 task-detail 完全相同的数据结构和页面布局
|
||||
|
||||
|
||||
### 需求 23:页面级功能需求 — A 批次(看板)
|
||||
|
||||
**用户故事:** 作为门店管理者,我希望在小程序中查看财务、助教、客户三个维度的看板数据,以便掌握门店经营状况。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE board-finance 页面 SHALL 包含:时间月份筛选(9 选项+指定周期,最大 366 天)+ 区域筛选(全部/大厅ABC/麻将房/团建房/具体台桌)、财务汇总行(实际收入/支出/净利润三列)、四个板块(营业数据/收入构成/支出构成/利润构成,每行 3 个指标卡片)、长按指标卡片启动助手对话、指标说明弹窗、目录导航面板
|
||||
2. THE board-coach 页面 SHALL 包含:排序维度筛选(7 选项)+ 擅长项目筛选 + 时间月份筛选、助教卡片列表(姓名+等级+擅长项目 | 关系最好客户前三 | 排序维度数值)、点击卡片跳转助教详情页
|
||||
3. THE board-customer 页面 SHALL 包含:客户类型筛选(8 维度)+ 偏爱项目筛选、客户卡片列表(名称+等级+VIP标识 | 最喜欢助教前三 | 核心指标+最近到店)、heart-icon 组件显示、最专一客户表格、点击卡片跳转客户详情页
|
||||
4. THE board-coach 和 board-customer 页面 SHALL 使用 board-tab-bar 共享组件作为底部导航栏
|
||||
5. THE 三个看板页面 SHALL 使用 filter-dropdown 共享组件实现筛选下拉
|
||||
|
||||
### 需求 24:页面级功能需求 — B 批次(核心)
|
||||
|
||||
**用户故事:** 作为助教用户,我希望在小程序首页查看任务列表和个人信息,以便快速了解待办事项和管理账户。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE task-list 页面 SHALL 包含:顶部自定义导航栏(标题"任务",无返回按钮)、Banner 区(用户名+身份+业绩概览+预计收入,整块可点击跳转业绩详情页)、任务列表(单列,按紧急程度排序:红→橙→粉→蓝,不分组)、单条卡片(类型标签带颜色+客户姓名+右箭头+补充信息行)、长按卡片弹出黑底浮层菜单(任务置底/问问助手/备注)、备注弹窗、空状态"暂无任务"
|
||||
2. THE my-profile 页面 SHALL 包含:顶部用户信息区、列表菜单(备注记录/助手对话记录/首页设置/退出账号)
|
||||
3. THE task-list 和 my-profile 页面 SHALL 作为 TabBar 页面,使用 `wx.switchTab` 跳转
|
||||
|
||||
### 需求 25:页面级功能需求 — C 批次(任务详情)
|
||||
|
||||
**用户故事:** 作为助教用户,我希望查看任务详情(含客户信息、消费习惯、关系等级、任务建议),以便有针对性地服务客户。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE task-detail 页面 SHALL 包含:客户基本信息模块 → 消费习惯模块 → 与我的关系模块(等级+说明)→ 任务建议模块、底部固定栏(问问助手+备注按钮)、放弃任务确认弹窗、备注弹窗
|
||||
2. THE task-detail-callback、task-detail-priority、task-detail-relationship 页面 SHALL 与 task-detail 保持相同的数据结构和布局,仅 banner 背景色和按钮配色不同
|
||||
|
||||
### 需求 26:页面级功能需求 — D 批次(详情页)
|
||||
|
||||
**用户故事:** 作为门店管理者,我希望查看助教和客户的详细信息及服务记录,以便深入了解业务情况。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE coach-detail 页面 SHALL 包含:基本信息模块 → 流水与业绩模块 → 工资与上课时长模块 → 前 10 客户指数列表、底部固定栏(问问助手+备注)、备注弹窗
|
||||
2. THE customer-detail 页面 SHALL 包含:基本信息模块 → 消费习惯模块(标签+文本)→ 与我的关系模块(等级+说明)、底部固定栏(问问助手+备注)
|
||||
3. THE customer-service-records 页面 SHALL 展示客户的服务记录列表
|
||||
|
||||
### 需求 27:页面级功能需求 — E 批次(绩效)
|
||||
|
||||
**用户故事:** 作为助教用户,我希望查看本月业绩总览和明细,以便了解自己的工作成果和收入情况。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE performance 页面 SHALL 包含:顶部 Banner(用户名+身份+本月业绩进度+预计收入)、多组指标两列卡片网格(收入构成/台球助教业绩/充值业绩/酒水业绩)、指标卡片(名称+当前值+目标值+完成度%)、仅展示本月数据(无时间切换)
|
||||
2. THE performance-records 页面 SHALL 展示业绩明细列表
|
||||
|
||||
### 需求 28:页面级功能需求 — F 批次(对话)
|
||||
|
||||
**用户故事:** 作为用户,我希望与 AI 助手对话并查看历史对话记录,以便获取业务建议和回顾历史咨询。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE chat 页面 SHALL 包含:仿微信对话界面(左助手气泡/右用户气泡)、引用内容(灰底小卡片:来源类型+标题+摘要)、输入区(文本框+按住说话语音转文字+发送按钮)、会话管理(超 1 小时提示"新对话主题/继续对话")
|
||||
2. THE chat-history 页面 SHALL 包含:对话列表(对话标题+最近时间+消息条数,按更新时间倒序)、点击打开对应会话并滚动到最后一条
|
||||
|
||||
### 需求 29:页面级功能需求 — G 批次(其他)
|
||||
|
||||
**用户故事:** 作为用户,我希望查看所有备注记录,以便回顾之前对客户和任务的备注。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE notes 页面 SHALL 包含:备注列表按时间倒序平铺、每条备注显示:备注全文+关联对象+创建时间、不支持编辑/删除功能
|
||||
2. THE notes 页面 SHALL 对已有历史实现按标准流程重新审计验收
|
||||
|
||||
### 需求 30:认证守卫与开发联调
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望小程序页面在开发阶段能正常联调后端 API,且未登录时自动跳转登录页。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 每个业务页面 SHALL 在加载时检查登录态,未登录时自动跳转登录页
|
||||
2. WHEN 开发联调时,THE 小程序 SHALL 通过 `utils/request.ts` 中的 `BASE_URL` 指向 `http://localhost:8000`
|
||||
3. WHEN 开发联调时,THE 后端 SHALL 启用 `WX_DEV_MODE=true` 开发模式,支持 `/api/xcx/dev-login` Mock 登录
|
||||
4. THE 小程序 SHALL 使用 Storage + header token 方式维持登录态(小程序无 Cookie)
|
||||
|
||||
### 需求 31:全局设计规范
|
||||
|
||||
**用户故事:** 作为用户,我希望小程序的视觉风格和交互模式在所有页面保持一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 小程序 SHALL 使用简体中文,金额元取整,万元保留两位小数
|
||||
2. THE 小程序 SHALL 使用底部 TabBar 导航:任务(task-list)/ 看板(board-finance)/ 我的(my-profile)
|
||||
3. THE 二级/详情页 SHALL 隐藏原生导航栏,使用自定义头部(左上角返回图标)
|
||||
4. THE 所有业务页面 SHALL 在右下角显示 AI 悬浮助手按钮,点击进入助手对话页
|
||||
5. THE 错误态 SHALL 统一显示"加载失败,请点击重试"+ 重试按钮
|
||||
6. THE 空数据态 SHALL 使用纯文字提示(无插画)
|
||||
7. THE 加载态 SHALL 使用"加载中..."纯文字(无骨架屏)
|
||||
|
||||
### 需求 32:AI 图标配色系统
|
||||
|
||||
**用户故事:** 作为用户,我希望页面中的 AI 标识每次加载时随机呈现不同配色,整页统一,增加视觉新鲜感。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE AI 图标配色系统 SHALL 支持 6 种配色方案(红/橙/黄/蓝/靛/紫),每种配色通过 CSS 变量定义渐变色对(`--ai-from`/`--ai-to`/`--ai-from-deep`/`--ai-to-deep`),色值参照 `docs/h5_ui/css/ai-icons.css` 中的定义
|
||||
2. THE AI 图标配色系统 SHALL 包含两个系列的 AI 标识:
|
||||
- `ai-inline-icon`:行首小图标(H5 16px → 28rpx),渐变背景 + 白色机器人 SVG + 微光扫过动画(12s 周期)
|
||||
- `ai-title-badge`:标题行右侧标识(浅色背景 + 主题色文字 + 主题色边框 + 呼吸脉冲动画 3s 周期 + 高光扫过 14s 周期)
|
||||
3. WHEN 页面加载时,THE 配色系统 SHALL 从 6 种配色中随机选取一种,统一应用到该页面所有 `ai-inline-icon` 和 `ai-title-badge` 元素,确保同一页面内所有 AI 标识使用同一配色
|
||||
4. THE ai-float-button(悬浮按钮)SHALL 不参与随机配色,保持固定渐变动画(`#667eea → #764ba2 → #f093fb → #f5576c`)
|
||||
5. THE 配色系统 SHALL 在小程序中通过页面级 `onLoad` 随机选色并 `setData` 传递 class 名的方式实现(替代 H5 的 DOM `querySelectorAll` + `classList.add`)
|
||||
6. THE 机器人 SVG 图标 SHALL 复用已有的 `assets/icons/ai-robot.svg`(大系列)和导出对应的小系列 SVG,不重新生成
|
||||
|
||||
### 需求 33:迁移产出物管理
|
||||
|
||||
**用户故事:** 作为项目管理者,我希望迁移过程中的所有产出物(审计报告、截图、diff 图)有序归档,以便追溯和复查。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 迁移工程 SHALL 将 H5 原型截图保留在 `docs/h5_ui/screenshots/` 目录(只读输入物),命名规则:默认态 `<page>.png`、交互态 `<page>--<state>.png`;迁移过程中禁止向此目录写入 MP 截图或 diff 图
|
||||
2. THE 迁移工程 SHALL 将小程序截图按页面分子目录存放在 `docs/h5_ui/mp-screenshots/<page>/` 目录,命名规则:默认态 `<page>.png`、交互态 `<page>--<state>.png`、逐段截图 `seg-<N>-<section>.png`
|
||||
3. THE 迁移工程 SHALL 将 H5 逐段截图按页面分子目录存放在 `docs/h5_ui/h5-segments/<page>/` 目录,命名规则:`seg-<N>-<section>.png`
|
||||
4. THE 迁移工程 SHALL 将像素对比结果按页面分子目录存放在 `docs/h5_ui/diffs/<page>/` 目录,包含:全屏 diff 图 `diff-<page>.png`、逐段 diff 图 `diff-seg-<N>-<section>.png`、对比报告 `report.md`(差异率 + 问题区域摘要)
|
||||
5. THE 迁移工程 SHALL 将新导出的 SVG 图标存放在 `apps/miniprogram/miniprogram/assets/icons/` 目录
|
||||
6. WHEN 迁移新页面导出新 SVG 时,THE 迁移工程 SHALL 更新 `docs/h5_ui/icon-mapping.md` 图标映射表
|
||||
7. THE 迁移工程 SHALL 确保小程序代码输出到 `apps/miniprogram/miniprogram/pages/<page>/` 目录结构下
|
||||
8. THE 像素精调循环中,每轮新截图 SHALL 覆盖上一轮同名文件,不保留历史版本(依赖 git 追溯历史)
|
||||
@@ -1,474 +0,0 @@
|
||||
# 实现计划:H5 → 微信小程序批量迁移
|
||||
|
||||
## 概述
|
||||
|
||||
基于 33 条需求和技术设计文档,将 17 个 H5 原型页面迁移为原生微信小程序页面。按 A-G 七个批次执行,每页走完 7 步标准流程(含 Step 0 页面分析)。输入物分两批提供:第一批(结构迁移 Step 0-5)、第二批(像素精调 Step 6-7)。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 全局基础设施搭建
|
||||
- [x] 1.1 创建 AI 图标配色工具模块
|
||||
- 文件:`utils/ai-color.ts`
|
||||
- 实现 `AI_COLOR_SCHEMES` 常量(6 种配色:red/orange/yellow/blue/indigo/purple)
|
||||
- 实现 `getRandomAiColor()` 函数,返回 `{ className, vars }` 对象
|
||||
- _需求: 32.1, 32.3, 32.5_
|
||||
|
||||
- [x] 1.2 创建 AI 图标全局 WXSS 样式
|
||||
- 在 `app.wxss` 中添加 `.ai-inline-icon`、`.ai-title-badge`、`.ai-color-*` 6 个配色类
|
||||
- 实现 `ai-shimmer`(12s)和 `ai-pulse`(3s)两个 `@keyframes` 动画
|
||||
- _需求: 32.1, 32.2_
|
||||
|
||||
- [x] 1.3 导出小系列机器人 SVG
|
||||
- 从 H5 源码提取白色填充版机器人 SVG,保存为 `assets/icons/ai-robot-sm.svg`
|
||||
- 复用已有 `assets/icons/ai-robot.svg`(大系列)
|
||||
- 更新 `docs/h5_ui/icon-mapping.md`
|
||||
- _需求: 32.6, 33.2, 33.3_
|
||||
|
||||
- [x] 1.4 创建中间生成物目录结构
|
||||
- 创建 `docs/h5_ui/mp-screenshots/`(MP 截图,按页面分子目录)
|
||||
- 创建 `docs/h5_ui/diffs/`(像素对比结果,按页面分子目录)
|
||||
- 创建 `docs/h5_ui/h5-segments/`(H5 逐段截图,按页面分子目录)
|
||||
- 确认 `.gitignore` 不排除这些目录
|
||||
- _需求: 33.1_
|
||||
|
||||
- [x] 1.5 验证全局基础设施
|
||||
- 编译验证 `app.wxss` 无警告
|
||||
- 在任意已有页面中测试 AI 配色工具模块可正常导入和调用
|
||||
- _需求: 17.1_
|
||||
|
||||
- [ ] 2. A 批次 — board-finance(看板-财务)
|
||||
- [x] 2.1 Step 0: 页面分析
|
||||
- 打开 H5 原型截图 + `interactions/board-finance.md`
|
||||
- 确认屏数(预计 6 段:经营一览/预收资产/应计收入/现金流入/现金流出/助教分析)
|
||||
- 列出所有交互态(时间筛选/区域筛选/指标弹窗/目录面板/长按菜单)
|
||||
- 输出工作量估算表
|
||||
- _需求: 1.1, 1.3_
|
||||
|
||||
- [x] 2.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- 冻结第一批输入物(Playbook + design-tokens + icon-mapping + HTML + CSS + interactions)
|
||||
- 输出《迁移审计报告》7 项(页面结构/CSS 风险/样式映射/图标处理/交互映射/外部依赖/缺失信息)
|
||||
- _需求: 3.1, 3.2, 4.1, 4.2, 4.3_
|
||||
|
||||
- [x] 2.3 Step 3: 规则化转换(按屏逐个开发)
|
||||
- 创建四文件骨架 → .json 注册组件 → 按屏转换 WXML/WXSS/TS
|
||||
- 包含:filter-dropdown 复用、metric-card 复用、ai-float-button 集成
|
||||
- filter-bar 高度统一 70 逻辑像素
|
||||
- Mock 数据 + 三态处理
|
||||
- _需求: 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 16, 21, 23.1, 32_
|
||||
|
||||
- [x] 2.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- 7 项编译检查(WXML/WXSS/控制台/图片/组件/路由/TS 类型)
|
||||
- 9 项结构核对(按屏逐个验证)
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 2.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 1.1)
|
||||
- _需求: 3.3, 19, 20_
|
||||
|
||||
- [ ] 3. A 批次 — board-coach(看板-助教)
|
||||
- [x] 3.1 Step 0: 页面分析
|
||||
- 确认屏数、交互态(排序筛选/擅长项目筛选/时间筛选)
|
||||
- _需求: 1.1, 1.3_
|
||||
|
||||
- [x] 3.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- _需求: 3.1, 3.2, 4_
|
||||
|
||||
- [x] 3.3 Step 3: 规则化转换
|
||||
- P0: error 状态补充(WXML 分支 + TS onRetry + pageState 类型扩展)
|
||||
- P1: 卡片按压反馈(hover-class="coach-card--hover" 替代 :active)
|
||||
- P2: dev-fab 组件注册(JSON 补注册)
|
||||
- P3: 样式偏差修复 8 项(Tab 24rpx/36rpx/22rpx、salary-amount 32rpx、right-sub #a6a6a6 22rpx、right-text 24rpx、right-highlight 28rpx、filter-item--wide flex:2)
|
||||
- _需求: 5-16, 21, 23.2, 23.4, 23.5, 32_
|
||||
|
||||
- [x] 3.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- 7/7 编译检查通过,9/9 结构核对通过
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 3.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 1.2)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 4. A 批次 — board-customer(看板-客户)
|
||||
- [x] 4.1 Step 0: 页面分析
|
||||
- 确认屏数、交互态(客户类型筛选/偏爱项目筛选)
|
||||
- _需求: 1.1, 1.3_
|
||||
|
||||
- [x] 4.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- _需求: 3.1, 3.2, 4_
|
||||
|
||||
- [x] 4.3 Step 3: 规则化转换
|
||||
- P0: error 状态补充(WXML + TS + WXSS)
|
||||
- P1: 卡片按压反馈(hover-class 替代 :active)
|
||||
- P2: dev-fab 组件注册
|
||||
- P3: 16 项 Step 3 级别样式偏差修复(Tab/筛选栏/卡片圆角/头像/中间行/网格/柱状图/专一表等)
|
||||
- _需求: 5-16, 21, 23.3, 23.4, 23.5, 32_
|
||||
|
||||
- [x] 4.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- 7/7 编译检查通过,11/11 结构核对通过
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 4.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 1.3)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 5. 检查点 A — 看板批次验收
|
||||
- 3 个看板页面全部编译零错误
|
||||
- 共享组件(filter-dropdown、board-tab-bar、ai-float-button、dev-fab、heart-icon)在所有页面中正确注册和引用
|
||||
- 四态处理(loading/empty/error/normal)统一实现
|
||||
- Tab 样式跨页面统一(24rpx/36rpx/22rpx)
|
||||
- Step 6-7 像素精调待第二批输入物
|
||||
|
||||
- [ ] 6. B 批次 — task-list(任务列表)
|
||||
- [x] 6.1 Step 0: 页面分析
|
||||
- 确认屏数、交互态(长按菜单/备注弹窗/空状态)
|
||||
- 历史实现差异极大,接近全量重写(Banner 17%、交互 23%、列表分区 0%)
|
||||
- _需求: 1.1_
|
||||
|
||||
- [x] 6.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- 输出审计报告含 7 节(A-G)+ C 节 13 子节样式映射
|
||||
- 识别 8 个 Step 3 工作项(P0-P7),2 个高复杂度
|
||||
- _需求: 3, 4_
|
||||
|
||||
- [x] 6.3 Step 3: 规则化转换(全量重写 4 文件)
|
||||
- P0: Banner 业绩进度卡片(4 层:跳档提示 + 5 段进度条 + 课时红戳奖金 + 预计收入)
|
||||
- P1: 卡片内容改造(第二行→最近到店+余额、第三行→AI 图标+建议、箭头→t-icon)
|
||||
- P2: 列表三区分组(pinnedTasks/normalTasks/abandonedTasks + 分区标签)
|
||||
- P3: 自定义长按菜单(ctx-overlay + ctx-menu + 4 项 + 坐标定位 + 边界检测 + _longPressed 防冲突)
|
||||
- P4: 放弃弹窗(页面内实现,textarea 必填校验 + 红色按钮)
|
||||
- P5: error 状态 + dev-fab 注册 + hover-class
|
||||
- P6: 盖戳动画(@keyframes stampDown + setTimeout 触发)
|
||||
- P7: 样式偏差修复(C9-1 padding 28rpx、C9-2 shadow-sm、C10-1/C10-2 标签渐变、C11-3 line-height)
|
||||
- 移除 hobby-tag,新增 note-modal/dev-fab/t-icon 组件注册
|
||||
- _需求: 5-16, 21, 24.1, 13, 32_
|
||||
|
||||
- [x] 6.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- 7/7 编译检查通过(TS 零诊断、JSON 合法、WXML 闭合、WXSS 语法、组件/数据/事件一致性)
|
||||
- 13/13 结构核对通过(P0-P7 全部验证 + 放弃卡片灰化 + 置顶 amber 阴影 + 长按冲突处理)
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 6.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 2.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [ ] 7. B 批次 — my-profile(个人中心)
|
||||
- [x] 7.1 Step 0: 页面分析
|
||||
- 1 屏、最简页面、100% 功能覆盖、21 处 WXSS 数值偏差
|
||||
- _需求: 1.1_
|
||||
|
||||
- [x] 7.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- 审计报告 7 节(A-G),21 处偏差全部为数值微调,无结构性问题
|
||||
- _需求: 3, 4_
|
||||
|
||||
- [x] 7.3 Step 3: 规则化转换
|
||||
- P1: WXSS 数值校准 21 处(padding/gap/font-size/width/height/shadow/border)
|
||||
- P2: 补充 line-height: 1.5(name/store-name/menu-text)
|
||||
- P3: 边框宽度 1rpx → 2rpx
|
||||
- P4: 头像阴影参数修正为 design-tokens 标准值
|
||||
- TabBar 页面路由配置已就绪
|
||||
- _需求: 5-16, 21, 24.2, 24.3, 32_
|
||||
|
||||
- [x] 7.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 闭合,WXSS 语法正确
|
||||
- 21/21 偏差全部修正,图标 5/5,交互 6/6,外部依赖 0
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 7.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 2.2)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 8. 检查点 B — 核心批次验收
|
||||
- ✅ task-list: 7/7 编译 + 13/13 结构验证通过
|
||||
- ✅ my-profile: 0 诊断 + 21/21 偏差修正验证通过
|
||||
- ✅ TabBar: 3 页面(task-list/board-finance/my-profile)路由+图标+文字配置正确
|
||||
- ✅ 长按菜单: ctx-overlay + ctx-menu + _longPressed 防冲突机制已实现
|
||||
|
||||
- [ ] 9. C 批次 — task-detail(任务详情主页面)
|
||||
- [x] 9.1 Step 0: 页面分析
|
||||
- 6 个内容区域 + 1 固定栏 + 3 弹窗 = 10 功能单元
|
||||
- 已有实现覆盖率 45%,缺失维客线索、近期服务记录、话术气泡、放弃弹窗等
|
||||
- 输出 `docs/h5_ui/analysis/task-detail-step0.md`
|
||||
- _需求: 1.1_
|
||||
|
||||
- [x] 9.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- 审计含在 Step 0 中,识别 11 个工作项(P0-P10)
|
||||
- _需求: 3, 4_
|
||||
|
||||
- [x] 9.3 Step 3: 规则化转换(11 项增量改造)
|
||||
- P0: 维客线索区(5 卡片,4 色标签,展开/收起描述)
|
||||
- P1: 近期服务记录区(3 格汇总 + 3 条记录 + 查看全部链接)
|
||||
- P2: 话术参考气泡(5 条话术 + AI 图标 + 复制/已复制切换 + 气泡尖角 view 模拟)
|
||||
- P3: 放弃弹窗改为自定义实现(遮罩 + textarea + 必填校验 + 红色确认)
|
||||
- P4: 删除备注(垃圾桶图标 + wx.showModal 确认)
|
||||
- P5: 错误态 + 重试(pageState 扩展 'error')
|
||||
- P6: 查看手机号(phoneVisible 切换)
|
||||
- P7: 储值等级标签(金色渐变)
|
||||
- P8: 样式校准(全部按 design-tokens 校准,2rpx 最小边框)
|
||||
- P9: 备注星级评分(star-rating readonly)
|
||||
- P10: 查看全部服务记录链接
|
||||
- _需求: 5-16, 21, 25.1, 32_
|
||||
|
||||
- [x] 9.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 完整,WXSS 语法正确
|
||||
- P0-P10 全部验证通过
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 9.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 3.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 10. C 批次 — task-detail 三个变体
|
||||
- [x] 10.1 复制 task-detail 生成 task-detail-callback
|
||||
- teal 主题,竖线话术,无放弃按钮,无服务记录区,"📞 常规回访要点"
|
||||
- 区域顺序:维客线索→关系→建议→备注
|
||||
- _需求: 22.2, 22.3, 22.4, 25.2_
|
||||
|
||||
- [x] 10.2 复制 task-detail 生成 task-detail-priority
|
||||
- orange 主题,气泡话术(同主页面),有放弃按钮+服务记录,"💡 建议执行"
|
||||
- 区域顺序:关系→建议→维客线索→备注→服务记录(同 task-detail)
|
||||
- _需求: 22.2, 22.3, 22.4, 25.2_
|
||||
|
||||
- [x] 10.3 复制 task-detail 生成 task-detail-relationship
|
||||
- pink 主题,竖线话术,无放弃按钮,无服务记录区,"💝 关系构建重点"
|
||||
- 区域顺序:维客线索→关系→建议→备注
|
||||
- _需求: 22.2, 22.3, 22.4, 25.2_
|
||||
|
||||
- [x] 10.4 三个变体编译验证
|
||||
- 3 个变体 TS 零诊断,12 文件全部创建
|
||||
- _需求: 17_
|
||||
|
||||
- [x] 11. 检查点 C — 任务批次验收
|
||||
- ✅ 文件完整性:4 页面 × 4 文件 = 16 文件全部存在
|
||||
- ✅ TS 编译:4 个 TS 文件零诊断
|
||||
- ✅ app.json 路由:4 个页面路由已注册
|
||||
- ✅ JSON 配置:4 页面均有 `navigationStyle: "custom"` + 组件注册
|
||||
- ✅ 主题色差异:red/teal/orange/pink 四色正确区分
|
||||
- ✅ 导航跳转:task-list → DETAIL_ROUTE_MAP 按 tasktype 分发到 4 个变体
|
||||
- ⚠️ 修复:task-detail.wxss 内容丢失(0 字节),已恢复完整样式(14,879 字节)
|
||||
|
||||
- [x] 12. D 批次 — coach-detail(助教详情)
|
||||
- [x] 12.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 7 个内容区域 + 1 固定栏 + 2 弹窗 = 10 功能单元
|
||||
- 已有实现覆盖率 45%,识别 P0-P9 共 10 个工作项
|
||||
- 输出 `docs/h5_ui/analysis/coach-detail-step0.md`
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [x] 12.2 Step 3: 规则化转换
|
||||
- P0: 任务执行区(6 可见 + 3 隐藏 + 2 已放弃,展开/收起,备注图标弹窗)
|
||||
- P1: 客户关系 TOP5(5 卡片 + 渐变背景 + emoji + 跳转 customer-detail)
|
||||
- P2: 近期服务明细(4 条记录 + 查看更多)
|
||||
- P3: 更多信息(入职日期 + 5 行历史月份表格)
|
||||
- P4: 绩效档位进度条
|
||||
- P5: 备注列表弹窗(底部弹出 + 动态渲染)
|
||||
- P6: 错误态 + 重试
|
||||
- P7: hover-class 补充
|
||||
- P8: safe-area-top + navigationStyle: custom
|
||||
- P9: 样式校准(design-tokens 对齐)
|
||||
- _需求: 5-16, 21, 26.1, 32_
|
||||
|
||||
- [x] 12.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,WXML class 全覆盖,JSON 组件注册完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 12.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 4.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 13. D 批次 — customer-detail(客户详情)
|
||||
- [x] 13.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 7 个内容区域 + 1 固定栏 = 8 功能单元
|
||||
- 已有实现覆盖率高,消费记录三种样式(台桌/商城/充值)已实现
|
||||
- 输出 `docs/h5_ui/analysis/customer-detail-step0.md`
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [x] 13.2 Step 3: 规则化转换
|
||||
- 补充 error 态 + onRetry
|
||||
- 补充 safe-area-top + hover-class
|
||||
- 消费记录三种样式完整(台桌结账/商城订单/充值)
|
||||
- onReachBottom 懒加载分页
|
||||
- note-modal 备注弹窗集成
|
||||
- _需求: 5-16, 21, 26.2, 32_
|
||||
|
||||
- [x] 13.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,WXML class 全覆盖,JSON 组件注册完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 13.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 4.2)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 14. D 批次 — customer-service-records(客户服务记录)
|
||||
- [x] 14.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 1 屏页面,已有实现覆盖率 ~80%,识别 P0-P5 共 6 个工作项
|
||||
- 输出 `docs/h5_ui/analysis/customer-service-records-step0.md`
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [x] 14.2 Step 3-5: 规则化转换 + 编译验证 + 结构还原验证
|
||||
- P0: error 态 + onRetry(WXML 分支 + TS pageState 扩展 'error')
|
||||
- P1: hover-class 替代 :active(record-card--hover + nav-back--hover + retry-btn--hover)
|
||||
- P2: dev-fab 组件注册(JSON 补注册)
|
||||
- P3: navigationStyle: custom + safe-area-top
|
||||
- P4: WXSS 数值校准 12 处(record-card border-radius/padding、record-date/duration/type/income font-size、summary-label/value、footer-text、month-label、customer-name、phone-text/sub-stat/stat-highlight)
|
||||
- P5: 边框 1rpx → 2rpx(month-switcher、month-summary、summary-divider、record-card)
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 5-18, 21, 26.3, 32_
|
||||
|
||||
- [-] 14.3 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 4.3)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 15. 检查点 D — 详情批次验收
|
||||
- ✅ 文件完整性:3 页面 × 4 文件 = 12 文件全部存在
|
||||
- ✅ TS 编译:3 个 TS 文件零诊断
|
||||
- ✅ app.json 路由:coach-detail、customer-detail、customer-service-records 三个路由已注册
|
||||
- ✅ 导航跳转:board-coach → coach-detail(onCoachTap)、board-customer → customer-detail(onCustomerTap)、customer-detail → customer-service-records(onViewAllRecords)
|
||||
|
||||
- [x] 16. E 批次 — performance(业绩总览)
|
||||
- [x] 16.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 已有实现覆盖率 ~85%,识别 P0-P6 共 7 个工作项
|
||||
- 输出 `docs/h5_ui/analysis/performance-step0.md`
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [x] 16.2 Step 3: 规则化转换
|
||||
- P0: error 状态 + onRetry(WXML 分支 + TS pageState 扩展 'error' + try-catch)
|
||||
- P1: hover-class 按压反馈(customer-item + toggle-btn + view-all + income-card)
|
||||
- P2: dev-fab 组件注册
|
||||
- P3: navigationStyle: custom + safe-area-top + 自定义导航栏
|
||||
- P4: 日期分隔线增强(dd-line + dd-stats 每日汇总)
|
||||
- P5: WXSS 数值已合规,无需修改
|
||||
- P6: 边框已是 2rpx,无需修改
|
||||
- _需求: 5-16, 21, 27.1, 32_
|
||||
|
||||
- [x] 16.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 16.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 5.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 17. E 批次 — performance-records(业绩明细)
|
||||
- [x] 17.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 已有实现覆盖率 ~80%,识别 P0-P3 共 4 个工作项
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [x] 17.2 Step 3-5: 规则化转换 + 编译验证 + 结构还原验证
|
||||
- P0: error 状态 + onRetry(WXML 分支 + TS pageState 扩展 + try-catch)
|
||||
- P1: hover-class 按压反馈(month-btn)
|
||||
- P2: dev-fab 组件注册
|
||||
- P3: navigationStyle: custom + safe-area-top + 自定义导航栏
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 5-21, 27.2, 32_
|
||||
|
||||
- [-] 17.3 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 5.2)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 18. 检查点 E — 绩效批次验收
|
||||
- ✅ 文件完整性:2 页面 × 4 文件 = 8 文件全部存在
|
||||
- ✅ TS 编译:2 个 TS 文件零诊断
|
||||
- ✅ app.json 路由:performance 和 performance-records 两个路由已注册
|
||||
- ✅ 导航跳转:task-list → performance(onPerformanceTap)、performance → performance-records(goToRecords)
|
||||
|
||||
- [x] 19. F 批次 — chat(AI 对话)
|
||||
- [x] 19.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 已有实现覆盖率 ~85%,识别 6 个工作项
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [x] 19.2 Step 3: 规则化转换
|
||||
- P0: error 状态 + onRetry(pageState 扩展 'error'、WXML 错误分支、TS try-catch)
|
||||
- P1: hover-class 按压反馈(send-btn--hover)
|
||||
- P2: dev-fab 组件注册
|
||||
- P3: navigationStyle: custom + safe-area-top + 自定义导航栏
|
||||
- P4: border-top 1rpx → 2rpx(.input-bar)
|
||||
- _需求: 5-16, 21, 28.1, 32_
|
||||
|
||||
- [x] 19.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 19.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 6.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 20. F 批次 — chat-history(对话历史)
|
||||
- [x] 20.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 已有实现覆盖率 ~90%,识别 4 个工作项
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [x] 20.2 Step 3: 规则化转换
|
||||
- P0: error 状态 + onRetry(pageState 扩展 'error'、WXML 错误分支、TS try-catch)
|
||||
- P1: hover-class 按压反馈(chat-item--hover 替代 :active)
|
||||
- P2: dev-fab 组件注册 + navigationStyle: custom
|
||||
- P3: safe-area-top + 自定义导航栏
|
||||
- P4: border-bottom 1rpx → 2rpx(.chat-item)
|
||||
- _需求: 5-16, 21, 28.2, 32_
|
||||
|
||||
- [x] 20.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 20.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 6.2)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 21. 检查点 F — 对话批次验收
|
||||
- ✅ 文件完整性:2 页面 × 4 文件 = 8 文件全部存在且非空
|
||||
- ✅ TS 编译:2 个 TS 文件零诊断
|
||||
- ✅ app.json 路由:chat 和 chat-history 两个路由已注册
|
||||
- ✅ JSON 配置:两页面均有 navigationStyle: custom + dev-fab 注册
|
||||
- ✅ 导航跳转:ai-float-button → chat(携带 customerId)、chat-history → chat(携带 historyId)
|
||||
- ✅ 四态处理:两页面均有 loading/empty/error/normal 四态分支
|
||||
- ⚠️ 备注:chat-history 传递 historyId 参数,chat.ts 当前未消费(Mock 阶段预期行为,TODO 标记)
|
||||
|
||||
- [x] 22. G 批次 — notes(备忘录)
|
||||
- [x] 22.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 已有实现覆盖率 ~80%,识别 P1-1~P1-5 + P2-7~P2-8 共 7 个工作项
|
||||
- Tab 切换:H5 HTML 原型无 Tab 结构(平铺列表),保持当前实现
|
||||
- _需求: 1.1, 1.3, 3, 4_
|
||||
|
||||
- [x] 22.2 Step 3: 规则化转换
|
||||
- P1-1/2: hover-class 按压反馈(nav-back--hover、retry-btn--hover)
|
||||
- P1-3: hover 对应 WXSS 样式
|
||||
- P1-4: 启用下拉刷新(enablePullDownRefresh: true)
|
||||
- P1-5: onPullDownRefresh 方法
|
||||
- P2-7: 统一 pageState 模式(loading/empty/error/normal 替代 loading+error boolean)
|
||||
- P2-8: 缩放值微调(padding 28rpx、margin-top 22rpx)
|
||||
- 补充:error/empty 态导航栏、ai-float-button、t-empty/ai-float-button/dev-fab 组件注册
|
||||
- _需求: 5-16, 21, 29, 32_
|
||||
|
||||
- [x] 22.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [-] 22.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 7.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 23. 检查点 G — 最终验收
|
||||
- ✅ 文件完整性:1 页面 × 4 文件 = 4 文件全部存在且非空
|
||||
- ✅ TS 编译:notes.ts 零诊断
|
||||
- ✅ app.json 路由:pages/notes/notes 已注册
|
||||
- ✅ JSON 配置:navigationStyle: custom + enablePullDownRefresh: true + 5 组件注册
|
||||
- ✅ 导航跳转:my-profile → notes(通过 router.ts 映射)
|
||||
- ✅ 四态处理:pageState 统一模式(loading/empty/error/normal)
|
||||
- ✅ 统一规范:hover-class 4/4、safe-area-top 3 态、无 :active、无非标准灰色
|
||||
|
||||
- [x] 24. 全局收尾
|
||||
- [x] 24.1 全量导航验证
|
||||
- 验证所有页面间的跳转路径正确(TabBar 切换、navigateTo、navigateBack)
|
||||
- 验证认证守卫(未登录自动跳转登录页)
|
||||
- _需求: 7, 20, 30, 31_
|
||||
|
||||
- [x] 24.2 全量 AI 图标配色验证
|
||||
- 抽查 3-5 个页面,确认 AI 图标随机配色正常
|
||||
- 确认 ai-float-button 保持固定渐变(不参与随机)
|
||||
- _需求: 32_
|
||||
|
||||
- [x] 24.3 icon-mapping.md 最终更新
|
||||
- 确认所有新导出的 SVG 已记录在 icon-mapping.md 中
|
||||
- _需求: 33.3_
|
||||
|
||||
- [x] 24.4 中间生成物归档验证
|
||||
- 确认所有 MP 截图按页面分目录存放在 `docs/h5_ui/mp-screenshots/<page>/`
|
||||
- 确认所有 diff 图和 report.md 按页面分目录存放在 `docs/h5_ui/diffs/<page>/`
|
||||
- 确认所有 H5 逐段截图按页面分目录存放在 `docs/h5_ui/h5-segments/<page>/`
|
||||
- 确认 `docs/h5_ui/screenshots/` 目录未被写入 MP 截图或 diff 图(只读输入物)
|
||||
- 清理可能遗留在项目根目录或其他位置的临时截图文件
|
||||
- _需求: 33_
|
||||
|
||||
## 备注
|
||||
|
||||
- 每个页面的 Step 0(页面分析)输出工作量估算表,用户确认后再开始 Step 1
|
||||
- 输入物分两批:第一批(Step 0-5 结构迁移)、第二批(Step 6-7 像素精调)
|
||||
- 检查点任务用于批次间的质量门禁,确认通过后再进入下一批次
|
||||
- 有历史实现的页面(board-coach/customer/finance/notes)走审计→对比→修复→验收流程
|
||||
- task-detail 变体通过复制+替换主题色实现,不重复走完整流程
|
||||
- 所有需求编号引用 `requirements.md` 中的需求序号
|
||||
- 中间生成物(MP 截图/H5 逐段截图/diff 图)按页面分子目录存放,每轮覆盖更新,不保留历史版本
|
||||
- `docs/h5_ui/screenshots/` 为只读输入物目录,禁止写入迁移过程生成的文件
|
||||
@@ -1 +0,0 @@
|
||||
{"generationMode": "requirements-first"}
|
||||
@@ -1,449 +0,0 @@
|
||||
# 设计文档:ODS 去重与软删除机制标准化
|
||||
|
||||
## 概述
|
||||
|
||||
本设计对 ODS 层的 `OdsTaskSpec` 配置、content_hash 去重策略、软删除语义进行标准化改造。核心原则:ODS 是追加写入的版本化存储,每次内容变更(包括删除)都是一个新版本行。
|
||||
|
||||
改造分四个阶段:
|
||||
1. **配置精简**(方案 1):删除无效/冗余字段,引入 SnapshotMode 枚举
|
||||
2. **去重优化**(方案 2):默认开启 skip_unchanged,hash 改用 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. 查询快照范围内数据库中已有的业务 ID(is_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_hash,INSERT
|
||||
# 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 4(content_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
|
||||
```
|
||||
@@ -1,143 +0,0 @@
|
||||
# 需求文档: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` 为 None,THEN THE OdsTaskSpec SHALL 在 `__post_init__` 中抛出 ValueError
|
||||
6. IF `snapshot_mode` 为 FULL_TABLE 且 `snapshot_time_column` 不为 None,THEN 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 追加写入的语义一致性。
|
||||
|
||||
**背景:** 软删除有两个触发路径:
|
||||
- **路径 A(API 返回)**:上游 API 的 JSON 响应中自带 `is_delete`/`isDelete` 等字段,由 `_normalize_is_delete_flag` 标准化后随记录正常写入。此路径在需求 5(is_delete 参与 hash)生效后自动产生新版本行,无需额外改造。
|
||||
- **路径 B(快照空缺)**:通过 `_mark_missing_as_deleted` 在快照范围内(FULL_TABLE 模式对比全表,WINDOW 模式仅对比时间窗口内的记录)对比本次抓取的业务 ID 集合与数据库已有记录,发现缺失的 ID 需标记为删除。仅当任务配置了 `snapshot_mode` 为 FULL_TABLE 或 WINDOW 时才触发。此路径是本需求的改造重点。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN `_mark_missing_as_deleted` 检测到某业务 ID 在上游已不存在时(路径 B),THE 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 时(路径 A),THE 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 逐个文件检查并确保内容与代码实现一致
|
||||
@@ -1,141 +0,0 @@
|
||||
# 实现计划: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_column,FULL_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 4(content_hash 确定性)到新签名
|
||||
|
||||
- [x] 5. 方案 3:DDL 迁移 - 添加"取最新版本"索引
|
||||
- [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_hash,INSERT 新行
|
||||
- _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`
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "a7c3e1f2-9b84-4d6e-b5a1-3f8c2d7e9a04", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -1,359 +0,0 @@
|
||||
# 技术设计文档:P4 前置依赖修复
|
||||
|
||||
## 概述
|
||||
|
||||
本设计覆盖 6 个定点修复,修正 P4 核心业务层与 Spec 的实现偏差,为 P6 前端任务模块扫清障碍。
|
||||
|
||||
修复范围:
|
||||
- T1:任务列表返回已放弃任务(GAP-3)— **已实现,需验证**
|
||||
- T2:召回完成检测器过滤任务类型(GAP-6)— **已实现,需验证**
|
||||
- T3:备注回溯重分类器冲突处理(GAP-7)— 需修改 `note_reclassifier.py`
|
||||
- T4:回访完成条件改为「有备注即完成」— 需修改 `note_service.py` + `note_reclassifier.py`
|
||||
- T5:trigger_scheduler last_run_at 事务安全(GAP-9)— 需修改 `trigger_scheduler.py`
|
||||
- T6:cron 默认值改为 07:00 — 需修改 `trigger_scheduler.py` 默认值
|
||||
|
||||
**关键发现**:代码审查显示 T1 和 T2 已在之前的修复中完成,T6 的种子数据也已是 `0 7 * * *`,仅 `_calculate_next_run()` 的默认值仍为 `"0 4 * * *"`。
|
||||
|
||||
## 架构
|
||||
|
||||
本次修复不引入新组件,仅修改现有服务层内部逻辑。涉及的调用链:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant ETL as ETL Pipeline
|
||||
participant TS as TriggerScheduler
|
||||
participant RD as RecallDetector
|
||||
participant NR as NoteReclassifier
|
||||
participant NS as NoteService
|
||||
participant DB as PostgreSQL (biz schema)
|
||||
|
||||
ETL->>TS: fire_event("etl_data_updated")
|
||||
TS->>RD: run(payload)
|
||||
RD->>DB: 查询 active 召回任务 (T2: 仅 recall 类型)
|
||||
RD->>DB: 标记 completed
|
||||
RD->>TS: fire_event("recall_completed")
|
||||
TS->>NR: run(payload)
|
||||
NR->>DB: 查找 normal 备注 → 重分类为 follow_up
|
||||
NR->>DB: 冲突检查 (T3: 查询已有 follow_up_visit)
|
||||
NR->>DB: 创建/跳过/顶替回访任务 (T4: 有备注→completed)
|
||||
|
||||
Note-->>NS: 助教提交备注
|
||||
NS->>DB: 创建备注
|
||||
NS->>DB: 查询 active follow_up_visit 任务 (T4)
|
||||
NS->>DB: 标记 completed(不依赖 AI 评分)
|
||||
```
|
||||
|
||||
事务边界变更(T5):
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "修复前:两个独立事务"
|
||||
A[handler 事务] --> B[last_run_at 事务]
|
||||
end
|
||||
subgraph "修复后:合并为单一事务"
|
||||
C[handler + last_run_at 同一事务]
|
||||
end
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
本次修复不新增接口,仅修改现有服务层内部方法。以下按修复点列出变更:
|
||||
|
||||
### T1:task_manager.get_task_list()(已实现 ✅)
|
||||
|
||||
代码审查确认 `get_task_list()` 已包含:
|
||||
- `WHERE status IN ('active', 'abandoned')`
|
||||
- `ORDER BY CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END ASC, is_pinned DESC, ...`
|
||||
- SELECT 和返回结构包含 `abandon_reason` 字段
|
||||
|
||||
**本次无需代码变更,仅需验证测试覆盖。**
|
||||
|
||||
### T2:recall_detector._process_service_record()(已实现 ✅)
|
||||
|
||||
代码审查确认 `_process_service_record()` 已包含:
|
||||
- `AND task_type IN ('high_priority_recall', 'priority_recall')`
|
||||
|
||||
**本次无需代码变更,仅需验证测试覆盖。**
|
||||
|
||||
### T3:note_reclassifier.run() — 冲突处理
|
||||
|
||||
**当前问题**:`run()` 在步骤 4/5 直接 INSERT 回访任务,无冲突检查。当 AI 占位返回 None 时跳过任务创建,但修复 T4 后(有备注即完成)将不再依赖 AI 返回值。
|
||||
|
||||
**变更方案**:
|
||||
|
||||
在创建 follow_up_visit 任务前,增加冲突检查逻辑:
|
||||
|
||||
```python
|
||||
# 冲突检查:查询同 (site_id, assistant_id, member_id) 的 follow_up_visit 任务
|
||||
cur.execute("""
|
||||
SELECT id, status
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
|
||||
AND task_type = 'follow_up_visit'
|
||||
AND status IN ('active', 'completed')
|
||||
ORDER BY CASE WHEN status = 'completed' THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
""", (site_id, assistant_id, member_id))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
existing_id, existing_status = existing
|
||||
if existing_status == 'completed':
|
||||
# 已完成 → 跳过创建
|
||||
return
|
||||
elif existing_status == 'active':
|
||||
# 顶替:旧任务 → inactive,创建新任务
|
||||
cur.execute("""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""", (existing_id,))
|
||||
_insert_history(cur, existing_id, action="superseded", ...)
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- 查询 `completed` 和 `active` 两种状态,优先检查 `completed`
|
||||
- `inactive` 和 `abandoned` 状态的旧任务不阻止新建(语义上已失效)
|
||||
- 顶替操作记录 `superseded` 历史,保留审计链
|
||||
|
||||
### T4:回访完成条件 — note_service.create_note() + note_reclassifier.run()
|
||||
|
||||
**当前问题**:
|
||||
- `note_service.create_note()` 依赖 `ai_score >= 6` 判定回访完成,但 `ai_analyze_note()` 返回 None → 永远不触发完成
|
||||
- `note_reclassifier.run()` 同样依赖 AI 返回值决定任务状态
|
||||
|
||||
**变更方案 — note_service.create_note()**:
|
||||
|
||||
```python
|
||||
# 修改后:有备注即完成,不依赖 AI 评分
|
||||
if note_type == "follow_up" and task_id is not None:
|
||||
# 保留 AI 占位调用(P5 接入时调用链不变)
|
||||
ai_score = ai_analyze_note(note["id"])
|
||||
if ai_score is not None:
|
||||
cur.execute("UPDATE biz.notes SET ai_score = %s ...", (ai_score, note["id"]))
|
||||
note["ai_score"] = ai_score
|
||||
|
||||
# 不论 ai_score 如何,有备注即标记回访任务完成
|
||||
if task_info and task_info["status"] == "active":
|
||||
cur.execute("""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'completed', completed_at = NOW(),
|
||||
completed_task_type = task_type, updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""", (task_id,))
|
||||
_record_history(cur, task_id, action="completed_by_note", ...)
|
||||
```
|
||||
|
||||
**变更方案 — note_reclassifier.run()**:
|
||||
|
||||
```python
|
||||
# 修改后:不依赖 AI 返回值
|
||||
# 保留 ai_analyze_note() 占位调用
|
||||
ai_score = ai_analyze_note(note_id)
|
||||
|
||||
# 判定任务状态:有备注 → completed,无备注 → active
|
||||
# 此处 note_id 非 None 意味着已找到备注 → 直接 completed
|
||||
task_status = "completed" # 回溯场景:已有备注 = 已完成
|
||||
|
||||
# 若未找到备注(note_id is None),创建 active 任务
|
||||
# (此分支在上方 note_id is None 时已 return)
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- `ai_analyze_note()` 调用保留,返回值仅用于更新 `ai_score` 字段,不影响完成判定
|
||||
- P5 接入后,AI 评分仍会写入 `notes.ai_score`,但不改变完成逻辑
|
||||
- `note_reclassifier` 中:找到备注 = 回溯完成(`completed`),未找到备注 = 等待(`active`)
|
||||
|
||||
### T5:trigger_scheduler — last_run_at 事务安全
|
||||
|
||||
**当前问题**:`fire_event()` 和 `check_scheduled_jobs()` 中,handler 执行和 `last_run_at` 更新在不同事务中。handler 成功但 `last_run_at` commit 失败时,下次重跑会重复处理。
|
||||
|
||||
**变更方案 — 方案 A(handler 内更新 last_run_at)**:
|
||||
|
||||
将 `last_run_at` 更新的 connection 传入 handler,由 handler 在其事务内执行更新。但这要求修改所有 handler 签名,侵入性大。
|
||||
|
||||
**变更方案 — 方案 B(传入 conn,handler 结束后同事务更新)**:
|
||||
|
||||
修改 `fire_event()` 和 `check_scheduled_jobs()`,将 `last_run_at` 更新纳入 handler 的事务范围:
|
||||
|
||||
```python
|
||||
# fire_event() 修改后
|
||||
def fire_event(event_name: str, payload: dict | None = None) -> int:
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# ... 查询 enabled event jobs ...
|
||||
for job_id, job_type, job_name in rows:
|
||||
handler = _JOB_REGISTRY.get(job_type)
|
||||
if not handler:
|
||||
continue
|
||||
try:
|
||||
handler(payload=payload)
|
||||
# handler 成功后,在同一连接上更新 last_run_at
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE biz.trigger_jobs SET last_run_at = NOW() WHERE id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
conn.commit() # handler 数据变更 + last_run_at 一起提交
|
||||
except Exception:
|
||||
conn.rollback() # 一起回滚
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
**关键约束**:handler(如 `recall_detector.run()`、`note_reclassifier.run()`)各自管理独立连接和事务。`trigger_scheduler` 的 `last_run_at` 更新使用调度器自己的连接。这意味着 handler 事务和 `last_run_at` 事务仍然是独立的。
|
||||
|
||||
**实际可行方案**:由于 handler 使用独立连接,真正的"同一事务"需要 handler 接受外部 conn 参数。考虑到改动范围,采用折中方案:
|
||||
1. handler 执行完毕后立即更新 `last_run_at`(当前已是如此)
|
||||
2. 依赖 handler 的幂等性保证重复执行安全(`recall_detector` 只匹配 `status='active'`,已完成的不会重复处理)
|
||||
3. 将 `last_run_at` 更新从 handler 成功后的独立 commit 改为与查询 jobs 的同一连接上的事务
|
||||
|
||||
**设计决策**:采用用户确认的方案 A — 将 `last_run_at` 更新纳入 handler 同一事务。具体实现:handler 接受可选的 `conn` 参数,在 handler 的最后一个事务中附带更新 `last_run_at`。对于 event 类型触发器(`recall_detector`、`note_reclassifier`),在 handler 的最终 commit 前插入 `last_run_at` 更新。
|
||||
|
||||
### T6:cron 默认值 07:00
|
||||
|
||||
**当前问题**:种子数据已是 `"0 7 * * *"`,但 `_calculate_next_run()` 的 fallback 默认值仍为 `"0 4 * * *"`。
|
||||
|
||||
**变更方案**:
|
||||
1. `_calculate_next_run()` 中 `trigger_config.get("cron_expression", "0 4 * * *")` → `"0 7 * * *"`
|
||||
2. 新建迁移脚本 `db/zqyy_app/migrations/2026-03-15__p52_update_cron_0700.sql` 作为幂等更新(确保生产环境一致)
|
||||
|
||||
## 数据模型
|
||||
|
||||
本次修复不新增表或字段。涉及的现有表:
|
||||
|
||||
| 表 | 修改内容 | 修复点 |
|
||||
|---|---|---|
|
||||
| `biz.coach_tasks` | 查询条件变更(无 DDL 变更) | T1, T2, T3, T4 |
|
||||
| `biz.coach_task_history` | 新增 `superseded` action 类型记录 | T3 |
|
||||
| `biz.trigger_jobs` | `last_run_at` 更新时机变更;cron_expression 幂等更新 | T5, T6 |
|
||||
| `biz.notes` | 查询条件变更(无 DDL 变更) | T4 |
|
||||
|
||||
`coach_tasks.status` 状态转换新增路径:
|
||||
- `active → inactive`(T3 顶替场景,由 `note_reclassifier` 触发)
|
||||
|
||||
此路径在原 Spec 状态机中已定义("新回访顶替旧回访"),本次为首次实现。
|
||||
|
||||
## 正确性属性(Correctness Properties)
|
||||
|
||||
*属性是系统在所有合法执行路径上都应保持为真的特征或行为——本质上是对系统行为的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: 任务列表状态过滤
|
||||
|
||||
*For any* 任务集合(包含 active、abandoned、completed、inactive 四种状态的任务),`get_task_list` 的过滤逻辑应仅返回 status 为 `active` 或 `abandoned` 的任务,不包含 `completed` 或 `inactive` 状态的任务。
|
||||
|
||||
**Validates: Requirements 1.1**
|
||||
|
||||
### Property 2: 任务列表排序正确性
|
||||
|
||||
*For any* 包含 active 和 abandoned 任务的列表,排序后应满足:(1) 所有 abandoned 任务排在所有 active 任务之后;(2) active 任务内部按 `is_pinned DESC, priority_score DESC NULLS LAST, created_at ASC` 排序。
|
||||
|
||||
**Validates: Requirements 1.2**
|
||||
|
||||
### Property 3: abandon_reason 与 status 一致性不变量
|
||||
|
||||
*For any* 返回的任务记录,若 `status = 'active'` 则 `abandon_reason` 为 null,若 `status = 'abandoned'` 则 `abandon_reason` 为非空字符串。
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 4: 召回检测器仅完成 recall 类型任务
|
||||
|
||||
*For any* 服务记录和任意任务集合(包含四种 task_type),`_process_service_record` 仅将 `task_type IN ('high_priority_recall', 'priority_recall')` 的 active 任务标记为 completed,`follow_up_visit` 和 `relationship_building` 类型的任务状态不变。
|
||||
|
||||
**Validates: Requirements 2.1, 2.2, 2.3**
|
||||
|
||||
### Property 5: 冲突处理 — 已完成回访任务阻止新建
|
||||
|
||||
*For any* (site_id, assistant_id, member_id) 组合,若已存在 `status = 'completed'` 的 `follow_up_visit` 任务,则 `note_reclassifier` 不创建新的回访任务,且重复触发相同 payload 不产生唯一约束冲突。
|
||||
|
||||
**Validates: Requirements 3.1, 3.4**
|
||||
|
||||
### Property 6: 冲突处理 — active 回访任务被顶替
|
||||
|
||||
*For any* (site_id, assistant_id, member_id) 组合,若已存在 `status = 'active'` 的 `follow_up_visit` 任务,则 `note_reclassifier` 将旧任务标记为 `inactive` 并创建新的回访任务,旧任务的变更记录包含 `superseded` action。
|
||||
|
||||
**Validates: Requirements 3.2**
|
||||
|
||||
### Property 7: 无冲突时正常创建回访任务
|
||||
|
||||
*For any* (site_id, assistant_id, member_id) 组合,若不存在任何 `follow_up_visit` 任务(或仅存在 `inactive`/`abandoned` 状态),则 `note_reclassifier` 正常创建新的回访任务。
|
||||
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
### Property 8: 有备注即完成回访任务,不依赖 AI 评分
|
||||
|
||||
*For any* `ai_analyze_note()` 的返回值(None、0-5、6-10),当助教为关联 `follow_up_visit` 任务的客户提交备注时,该 active 回访任务都应被标记为 `completed`。AI 评分仅更新 `notes.ai_score` 字段,不影响任务完成判定。
|
||||
|
||||
**Validates: Requirements 4.2, 4.3**
|
||||
|
||||
### Property 9: 回溯有备注时创建 completed 回访任务
|
||||
|
||||
*For any* 召回完成事件,若 `note_reclassifier` 在 service_time 之后找到了 normal 备注,则创建的回访任务 `status = 'completed'`。
|
||||
|
||||
**Validates: Requirements 4.4**
|
||||
|
||||
### Property 10: 回溯无备注时创建 active 回访任务
|
||||
|
||||
*For any* 召回完成事件,若 `note_reclassifier` 在 service_time 之后未找到 normal 备注,则创建的回访任务 `status = 'active'`(等待助教提交备注)。
|
||||
|
||||
**Validates: Requirements 4.5**
|
||||
|
||||
### Property 11: 触发器 last_run_at 事务一致性
|
||||
|
||||
*For any* 触发器执行(event/cron/interval 类型),handler 成功时 `last_run_at` 应被更新,handler 抛出异常时 `last_run_at` 应保持不变(整个事务回滚)。
|
||||
|
||||
**Validates: Requirements 5.1, 5.2**
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 处理方式 | 修复点 |
|
||||
|------|---------|--------|
|
||||
| T3 冲突查询失败 | 捕获异常,rollback,记录日志,返回 `tasks_created: 0` | T3 |
|
||||
| T3 顶替 UPDATE 失败 | 捕获异常,rollback,不创建新任务 | T3 |
|
||||
| T4 备注创建成功但任务完成 UPDATE 失败 | 整个事务 rollback(备注也不创建),返回 500 | T4 |
|
||||
| T4 ai_analyze_note() 抛出异常 | 捕获异常,记录日志,继续执行任务完成逻辑(AI 失败不阻塞业务) | T4 |
|
||||
| T5 handler 成功但 commit 失败 | 整个事务回滚(handler 数据变更 + last_run_at),下次重跑依赖幂等性 | T5 |
|
||||
| T5 handler 抛出异常 | rollback,last_run_at 不更新,下次重跑 | T5 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
使用 **Hypothesis** 库(项目已有依赖),每个属性测试最少 100 次迭代。
|
||||
|
||||
每个测试用 comment 标注对应的设计属性:
|
||||
```python
|
||||
# Feature: p4-prerequisite-fixes, Property 1: 任务列表状态过滤
|
||||
```
|
||||
|
||||
属性测试重点覆盖:
|
||||
- Property 1-3:任务列表过滤、排序、字段不变量(纯函数可提取测试)
|
||||
- Property 4:召回检测器类型过滤(SQL 条件验证)
|
||||
- Property 5-7:冲突处理三分支(状态机测试)
|
||||
- Property 8:AI 评分不影响完成判定(参数化 ai_score 返回值)
|
||||
- Property 9-10:回溯场景任务状态(有/无备注两分支)
|
||||
- Property 11:事务一致性(mock handler 成功/失败)
|
||||
|
||||
### 单元测试
|
||||
|
||||
单元测试聚焦于:
|
||||
- T1 边界:全部 active(无 abandoned)、全部 abandoned(无 active)、混合场景
|
||||
- T2 边界:仅有 follow_up_visit 任务时返回 0 completed
|
||||
- T3 边界:同时存在 completed 和 active 的 follow_up_visit(优先检查 completed → 跳过)
|
||||
- T4 边界:task_id 为 None 时不触发完成逻辑;非 follow_up_visit 任务不触发
|
||||
- T5 边界:handler 抛出特定异常类型时的回滚行为
|
||||
- T6 具体值:`_calculate_next_run("cron", {})` 默认使用 `"0 7 * * *"`;迁移脚本 SQL 正确性
|
||||
|
||||
### 测试配置
|
||||
|
||||
```python
|
||||
from hypothesis import given, settings, strategies as st
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(...)
|
||||
def test_property_name(...):
|
||||
# Feature: p4-prerequisite-fixes, Property N: property_text
|
||||
...
|
||||
```
|
||||
|
||||
测试文件位置:`tests/` 目录(Monorepo 级属性测试,与现有 P4 属性测试同级)。
|
||||
@@ -1,94 +0,0 @@
|
||||
# 需求文档:P4 前置依赖修复
|
||||
|
||||
## 简介
|
||||
|
||||
P4 核心业务层已实现并通过属性测试,但对比 Spec 发现 6 处实现偏差(来源:`docs/reports/P4-spec-vs-implementation-gap-analysis.md`)。这些偏差会影响 P6(前端任务模块)的正常开发,必须前置修复。本需求覆盖 GAP-3、GAP-6、GAP-7、GAP-9 的代码修复,以及两项新增修正(回访完成条件、cron 时间)。
|
||||
|
||||
修复范围:6 个定点修复,无新表、无新接口,仅修改现有服务层逻辑和一条迁移脚本。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Task_Manager**: 任务管理服务(`task_manager.py`),负责任务列表查询和状态操作
|
||||
- **Recall_Detector**: 召回完成检测器(`recall_detector.py`),ETL 数据更新后匹配服务记录完成召回任务
|
||||
- **Note_Reclassifier**: 备注回溯重分类器(`note_reclassifier.py`),召回完成后回溯普通备注为回访备注并创建回访任务
|
||||
- **Note_Service**: 备注服务(`note_service.py`),负责备注 CRUD 和回访任务自动完成
|
||||
- **Trigger_Scheduler**: 触发器调度框架(`trigger_scheduler.py`),统一管理 cron/interval/event 三种触发方式
|
||||
- **coach_tasks**: 助教任务表(`biz.coach_tasks`)
|
||||
- **trigger_jobs**: 触发器配置表(`biz.trigger_jobs`)
|
||||
- **active**: 任务有效状态
|
||||
- **abandoned**: 任务已放弃状态
|
||||
- **inactive**: 任务无效状态(被顶替)
|
||||
- **completed**: 任务已完成状态
|
||||
- **high_priority_recall**: 高优先召回任务类型
|
||||
- **priority_recall**: 优先召回任务类型
|
||||
- **follow_up_visit**: 客户回访任务类型
|
||||
- **relationship_building**: 关系构建任务类型
|
||||
- **abandon_reason**: 放弃原因字段
|
||||
- **last_run_at**: 触发器上次运行时间戳
|
||||
- **ai_analyze_note()**: AI 备注分析占位函数(P5 实现,当前返回 None)
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:任务列表返回已放弃任务
|
||||
|
||||
**用户故事:** 作为助教,我希望在任务列表中看到已放弃的任务及其放弃原因,以便执行「取消放弃」操作恢复任务。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 助教请求任务列表, THE Task_Manager SHALL 返回 active 和 abandoned 两种状态的任务
|
||||
2. THE Task_Manager SHALL 按以下顺序排列任务:abandoned 排在所有 active 任务之后,active 任务内部按 is_pinned DESC、priority_score DESC NULLS LAST、created_at ASC 排序
|
||||
3. THE Task_Manager SHALL 在返回结构中始终包含 abandon_reason 字段,active 状态任务的 abandon_reason 为 null,abandoned 状态任务的 abandon_reason 为非空字符串
|
||||
4. WHEN 不存在 abandoned 任务时, THE Task_Manager SHALL 仅返回 active 任务,返回结构不变(abandon_reason 为 null)
|
||||
|
||||
### 需求 2:召回完成检测器过滤任务类型
|
||||
|
||||
**用户故事:** 作为系统运维人员,我希望召回完成检测器仅完成召回类任务,避免错误地将回访和关系构建任务标记为已完成。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL 检测到新增服务记录, THE Recall_Detector SHALL 仅匹配 task_type 为 high_priority_recall 或 priority_recall 的 active 任务进行完成标记
|
||||
2. WHILE 存在 active 的 follow_up_visit 任务, THE Recall_Detector SHALL 跳过该任务,不执行完成标记
|
||||
3. WHILE 存在 active 的 relationship_building 任务, THE Recall_Detector SHALL 跳过该任务,不执行完成标记
|
||||
|
||||
### 需求 3:备注回溯重分类器冲突处理
|
||||
|
||||
**用户故事:** 作为系统运维人员,我希望备注回溯重分类器在创建回访任务时正确处理冲突,避免唯一约束违反和重复任务。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Note_Reclassifier 准备创建 follow_up_visit 任务且同 (site_id, assistant_id, member_id) 已存在 completed 的 follow_up_visit 任务, THEN THE Note_Reclassifier SHALL 跳过创建(回访完成语义已满足)
|
||||
2. WHEN Note_Reclassifier 准备创建 follow_up_visit 任务且同 (site_id, assistant_id, member_id) 已存在 active 的 follow_up_visit 任务, THEN THE Note_Reclassifier SHALL 将旧任务标记为 inactive 并记录变更历史,然后创建新的 follow_up_visit 任务(顶替方案)
|
||||
3. WHEN Note_Reclassifier 准备创建 follow_up_visit 任务且同 (site_id, assistant_id, member_id) 不存在任何 follow_up_visit 任务, THE Note_Reclassifier SHALL 正常创建新任务
|
||||
4. IF Note_Reclassifier 重复触发相同 payload, THEN THE Note_Reclassifier SHALL 不产生唯一约束冲突错误
|
||||
|
||||
### 需求 4:回访任务完成条件改为「有备注即完成」
|
||||
|
||||
**用户故事:** 作为助教,我希望为客户提交备注后对应的回访任务自动完成,无需等待 AI 评分。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 助教通过 Note_Service 为某客户创建备注, THE Note_Service SHALL 查询该客户是否存在 active 的 follow_up_visit 任务(通过 user_assistant_binding 获取 assistant_id,再匹配 site_id、assistant_id、member_id)
|
||||
2. WHEN 存在 active 的 follow_up_visit 任务且助教提交了备注, THE Note_Service SHALL 将该任务标记为 completed 并记录变更历史,完成判定不依赖 ai_analyze_note() 的返回值
|
||||
3. THE Note_Service SHALL 保留 ai_analyze_note() 占位调用(P5 接入时调用链不变),但 ai_analyze_note() 的返回值不作为回访任务完成的判定条件
|
||||
4. WHEN Note_Reclassifier 回溯发现已有备注, THE Note_Reclassifier SHALL 直接创建 status='completed' 的回访任务(回溯完成),不依赖 AI 评分
|
||||
5. WHEN Note_Reclassifier 回溯未发现备注, THE Note_Reclassifier SHALL 创建 status='active' 的回访任务(等待助教提交备注)
|
||||
|
||||
### 需求 5:trigger_scheduler last_run_at 事务安全
|
||||
|
||||
**用户故事:** 作为系统运维人员,我希望触发器的 last_run_at 更新与 handler 执行在同一事务中,避免 handler 成功但 last_run_at 更新失败导致重复处理。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Trigger_Scheduler 执行 event 类型触发器(fire_event), THE Trigger_Scheduler SHALL 将 last_run_at 更新纳入 handler 执行的同一事务范围,handler 成功与 last_run_at 更新要么一起提交要么一起回滚
|
||||
2. WHEN Trigger_Scheduler 执行 cron/interval 类型触发器(check_scheduled_jobs), THE Trigger_Scheduler SHALL 将 last_run_at 和 next_run_at 更新纳入 handler 执行的同一事务范围
|
||||
3. IF handler 执行成功但事务提交失败, THEN THE Trigger_Scheduler SHALL 回滚整个事务(包括 handler 的数据变更和 last_run_at 更新),下次重跑时 handler 的幂等性保证不会产生副作用
|
||||
|
||||
### 需求 6:任务生成器 cron 时间改为 07:00
|
||||
|
||||
**用户故事:** 作为运营人员,我希望任务生成器在每天早上 7 点运行,与门店营业节奏匹配。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 迁移脚本 SHALL 将 trigger_jobs 表中 task_generator 的 cron_expression 从 `0 4 * * *` 更新为 `0 7 * * *`
|
||||
2. THE Trigger_Scheduler SHALL 在 _calculate_next_run() 中使用 `0 7 * * *` 作为 cron 默认值
|
||||
3. WHEN task_generator 触发器下次运行时间被计算, THE Trigger_Scheduler SHALL 基于 `0 7 * * *` 计算正确的 next_run_at
|
||||
@@ -1,104 +0,0 @@
|
||||
# Implementation Plan: P4 前置依赖修复
|
||||
|
||||
## Overview
|
||||
|
||||
6 个定点修复,修正 P4 核心业务层与 Spec 的实现偏差。T1/T2 已在之前修复中完成,仅需属性测试验证;T3/T4/T5/T6 需要代码变更。所有修改限于现有服务层内部逻辑,无新表、无新接口。
|
||||
|
||||
测试框架:Hypothesis(项目已有依赖),测试文件位于 `tests/` 目录。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. 验证 T1(任务列表)和 T2(召回检测器)已有实现 + 属性测试
|
||||
- [x] 1.1 编写 T1 属性测试:任务列表状态过滤、排序、abandon_reason 一致性
|
||||
- 创建 `tests/test_p52_task_list_properties.py`
|
||||
- 提取 `get_task_list` 的过滤和排序逻辑为纯函数(或 mock DB 层),用 Hypothesis 生成随机任务集合
|
||||
- **Property 1: 任务列表状态过滤** — 仅返回 active/abandoned,不含 completed/inactive
|
||||
- **Property 2: 任务列表排序正确性** — abandoned 排在 active 之后,active 内部按 is_pinned DESC, priority_score DESC NULLS LAST, created_at ASC
|
||||
- **Property 3: abandon_reason 与 status 一致性不变量** — active → null, abandoned → 非空字符串
|
||||
- **Validates: Requirements 1.1, 1.2, 1.3, 1.4**
|
||||
|
||||
- [x] 1.2 编写 T2 属性测试:召回检测器仅完成 recall 类型任务
|
||||
- 在 `tests/test_p52_recall_detector_properties.py` 中编写
|
||||
- 用 Hypothesis 生成包含四种 task_type 的任务集合和服务记录
|
||||
- **Property 4: 召回检测器仅完成 recall 类型任务** — high_priority_recall/priority_recall 被完成,follow_up_visit/relationship_building 状态不变
|
||||
- **Validates: Requirements 2.1, 2.2, 2.3**
|
||||
|
||||
- [x] 2. Checkpoint — 验证 T1/T2 属性测试通过
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 3. 实现 T3:备注回溯重分类器冲突处理
|
||||
- [x] 3.1 修改 `apps/backend/app/services/note_reclassifier.py` 的 `run()` 方法
|
||||
- 在创建 follow_up_visit 任务前,查询同 (site_id, assistant_id, member_id) 是否已有 follow_up_visit 任务
|
||||
- 已有 `completed` → 跳过创建(回访完成语义已满足)
|
||||
- 已有 `active` → 旧任务标记 `inactive`,记录 `superseded` 历史,创建新任务
|
||||
- 不存在(或仅有 inactive/abandoned)→ 正常创建
|
||||
- 确保重复触发相同 payload 不产生唯一约束冲突
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4_
|
||||
|
||||
- [x] 3.2 编写 T3 属性测试:冲突处理三分支
|
||||
- 在 `tests/test_p52_note_reclassifier_properties.py` 中编写
|
||||
- **Property 5: 已完成回访任务阻止新建** — 存在 completed 的 follow_up_visit 时跳过创建
|
||||
- **Property 6: active 回访任务被顶替** — 旧任务 → inactive + superseded 历史,新任务创建
|
||||
- **Property 7: 无冲突时正常创建** — 不存在 follow_up_visit(或仅 inactive/abandoned)时正常创建
|
||||
- **Validates: Requirements 3.1, 3.2, 3.3, 3.4**
|
||||
|
||||
- [x] 4. 实现 T4:回访完成条件改为「有备注即完成」
|
||||
- [x] 4.1 修改 `apps/backend/app/services/note_service.py` 的 `create_note()` 方法
|
||||
- 当 `note_type == "follow_up"` 且 `task_id is not None` 时:
|
||||
- 保留 `ai_analyze_note()` 占位调用,返回值仅用于更新 `ai_score` 字段
|
||||
- 不论 `ai_score` 如何,有备注即标记关联的 active follow_up_visit 任务为 `completed`
|
||||
- 记录 `completed_by_note` 历史
|
||||
- _Requirements: 4.1, 4.2, 4.3_
|
||||
|
||||
- [x] 4.2 修改 `apps/backend/app/services/note_reclassifier.py` 的 `run()` 方法(T4 部分)
|
||||
- 去掉 AI 评分判定逻辑(`ai_score >= 6` 条件)
|
||||
- 保留 `ai_analyze_note()` 占位调用
|
||||
- 找到备注(`note_id is not None`)→ 创建 `status='completed'` 的回访任务(回溯完成)
|
||||
- 未找到备注(`note_id is None`)→ 创建 `status='active'` 的回访任务(等待备注)
|
||||
- 注意:此步骤需与 3.1 的冲突处理逻辑协同,冲突检查在任务创建前执行
|
||||
- _Requirements: 4.4, 4.5_
|
||||
|
||||
- [x] 4.3 编写 T4 属性测试:有备注即完成
|
||||
- 在 `tests/test_p52_note_service_properties.py` 中编写
|
||||
- **Property 8: 有备注即完成回访任务,不依赖 AI 评分** — 对任意 ai_score(None/0-5/6-10),提交备注后 active follow_up_visit 任务都标记 completed
|
||||
- **Property 9: 回溯有备注时创建 completed 回访任务** — note_reclassifier 找到备注 → status='completed'
|
||||
- **Property 10: 回溯无备注时创建 active 回访任务** — note_reclassifier 未找到备注 → status='active'
|
||||
- **Validates: Requirements 4.2, 4.3, 4.4, 4.5**
|
||||
|
||||
- [x] 5. Checkpoint — 验证 T3/T4 实现和测试通过
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 6. 实现 T5:trigger_scheduler last_run_at 事务安全
|
||||
- [x] 6.1 修改 `apps/backend/app/services/trigger_scheduler.py`
|
||||
- `fire_event()`:handler 接受可选 `conn` 参数,在 handler 最终 commit 前附带更新 `last_run_at`;handler 失败时整个事务回滚(last_run_at 不更新)
|
||||
- `check_scheduled_jobs()`:同理,将 `last_run_at` 和 `next_run_at` 更新纳入 handler 事务范围
|
||||
- 需同步修改 `recall_detector.run()` 和 `note_reclassifier.run()` 的签名,接受可选 `conn` 参数
|
||||
- _Requirements: 5.1, 5.2, 5.3_
|
||||
|
||||
- [x] 6.2 编写 T5 属性测试:事务一致性
|
||||
- 在 `tests/test_p52_trigger_scheduler_properties.py` 中编写
|
||||
- **Property 11: 触发器 last_run_at 事务一致性** — handler 成功 → last_run_at 更新;handler 异常 → last_run_at 不变(整个事务回滚)
|
||||
- **Validates: Requirements 5.1, 5.2**
|
||||
|
||||
- [x] 7. 实现 T6:cron 默认值改为 07:00 + 迁移脚本
|
||||
- [x] 7.1 修改 `apps/backend/app/services/trigger_scheduler.py` 的 `_calculate_next_run()`
|
||||
- 将 `trigger_config.get("cron_expression", "0 4 * * *")` 改为 `"0 7 * * *"`
|
||||
- _Requirements: 6.2, 6.3_
|
||||
|
||||
- [x] 7.2 创建迁移脚本 `db/zqyy_app/migrations/2026-03-15__p52_update_cron_0700.sql`
|
||||
- 幂等 UPDATE:`UPDATE biz.trigger_jobs SET trigger_config = jsonb_set(trigger_config, '{cron_expression}', '"0 7 * * *"') WHERE job_name = 'task_generator'`
|
||||
- 包含回滚注释
|
||||
- _Requirements: 6.1_
|
||||
|
||||
- [x] 8. Final checkpoint — 全部测试通过
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
- 运行 `pytest tests/test_p52_*.py -v` 验证所有 P5.2 属性测试通过
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- T1/T2 已在之前修复中实现,任务 1 仅编写属性测试验证覆盖
|
||||
- T3 和 T4 对 `note_reclassifier.py` 有交叉修改,任务 3.1 和 4.2 需协同实施
|
||||
- T5 涉及 handler 签名变更,需同步修改 recall_detector 和 note_reclassifier
|
||||
- T6 种子数据已是 `0 7 * * *`,迁移脚本为幂等更新确保生产环境一致
|
||||
- 属性测试使用 Hypothesis,每个属性最少 100 次迭代
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "a3f7c2d1-8e4b-4f6a-9c5d-2b1e8f3a7d9c", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -1,840 +0,0 @@
|
||||
# 设计文档 — RNS1.3:三看板接口
|
||||
|
||||
## 概述
|
||||
|
||||
本设计覆盖 RNS1.3 的全部后端接口(BOARD-1/2/3、CONFIG-1)和前端筛选修复。核心挑战在于 BOARD-3 财务看板的复杂度(6 板块、200+ 字段、60+ 环比数据点)以及跨多个 ETL RLS 视图的数据聚合。
|
||||
|
||||
设计遵循已有架构模式:
|
||||
- 路由层(`routers/`):参数校验 + 权限检查,委托 service 层
|
||||
- 服务层(`services/`):业务逻辑编排,调用 `fdw_queries` 查询 ETL 数据
|
||||
- Schema 层(`schemas/`):`CamelModel` 基类,自动 camelCase 转换
|
||||
- 中间件:`ResponseWrapperMiddleware` 统一包装 `{ code: 0, data: ... }`
|
||||
- FDW 查询:`_fdw_context` 上下文管理器,直连 ETL 库 + `SET LOCAL app.current_site_id`
|
||||
|
||||
### 设计决策
|
||||
|
||||
1. **BOARD-1 扁平返回 vs 按维度返回**:选择扁平返回(所有维度字段一次性返回),前端切换维度无需重新请求,减少网络往返。代价是单次响应略大,但助教数量有限(通常 < 50),可接受。
|
||||
2. **BOARD-2 按维度返回**:选择按 `dimension` 参数仅返回对应维度字段。客户数量可达数千,分页 + 维度专属字段可显著减少传输量和查询开销。
|
||||
3. **BOARD-3 单接口 6 板块**:单个 `GET /api/xcx/board/finance` 返回全部 6 个板块。各板块独立查询、独立降级,某板块失败不影响其他板块。`recharge` 板块在 `area≠all` 时返回 `null`。
|
||||
4. **环比计算后端统一处理**:`compare=1` 时后端计算所有环比字段,`compare=0` 时完全不返回环比字段(非返回 null,而是字段不存在),减少 JSON 体积。
|
||||
5. **FDW 查询集中封装**:所有新增 ETL 查询函数统一添加到 `fdw_queries.py`,保持 DWD-DOC 规则在单一模块实施。
|
||||
|
||||
## 架构
|
||||
|
||||
### 请求流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MP as 小程序
|
||||
participant MW as ResponseWrapperMiddleware
|
||||
participant R as Router (xcx_board / xcx_config)
|
||||
participant P as Permission (require_permission)
|
||||
participant S as Service (board_service / config_service)
|
||||
participant FDW as fdw_queries (_fdw_context)
|
||||
participant ETL as ETL DB (app.v_*)
|
||||
participant BIZ as App DB (biz.*)
|
||||
|
||||
MP->>MW: GET /api/xcx/board/coaches?sort=perf_desc&skill=all&time=month
|
||||
MW->>R: 透传请求
|
||||
R->>P: require_permission("view_board_coach")
|
||||
P-->>R: CurrentUser (site_id)
|
||||
R->>S: get_coach_board(params, site_id)
|
||||
S->>FDW: 查询助教列表 + 绩效 + 客户
|
||||
FDW->>ETL: SET LOCAL app.current_site_id; SELECT FROM app.v_*
|
||||
ETL-->>FDW: 结果集
|
||||
S->>BIZ: 查询任务完成数 (biz.coach_tasks)
|
||||
BIZ-->>S: 结果集
|
||||
S-->>R: 组装响应 dict
|
||||
R-->>MW: JSON 响应
|
||||
MW-->>MP: { code: 0, data: {...} }
|
||||
```
|
||||
|
||||
### 模块结构
|
||||
|
||||
```
|
||||
apps/backend/app/
|
||||
├── routers/
|
||||
│ ├── xcx_board.py # 新增:BOARD-1/2/3 三个看板端点
|
||||
│ └── xcx_config.py # 新增:CONFIG-1 技能类型端点
|
||||
├── schemas/
|
||||
│ ├── xcx_board.py # 新增:三看板请求参数 + 响应 schema
|
||||
│ └── xcx_config.py # 新增:技能类型响应 schema
|
||||
├── services/
|
||||
│ ├── board_service.py # 新增:看板业务逻辑(3 个看板的编排函数)
|
||||
│ └── fdw_queries.py # 扩展:新增看板相关 FDW 查询函数
|
||||
└── main.py # 扩展:注册 xcx_board / xcx_config 路由
|
||||
|
||||
apps/miniprogram/miniprogram/
|
||||
├── pages/board-coach/ # 修改:筛选事件 → loadData()
|
||||
├── pages/board-customer/ # 修改:筛选事件 → loadData() + 分页
|
||||
├── pages/board-finance/ # 修改:筛选事件 → loadData() + 环比开关
|
||||
└── services/api.ts # 修改:函数签名扩展
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 3.1 路由层 — `xcx_board.py`
|
||||
|
||||
三个端点共用一个路由文件,前缀 `/api/xcx/board`。
|
||||
|
||||
```python
|
||||
# GET /api/xcx/board/coaches
|
||||
@router.get("/coaches", response_model=CoachBoardResponse)
|
||||
async def get_coach_board(
|
||||
sort: CoachSortEnum = Query(default="perf_desc"),
|
||||
skill: SkillFilterEnum = Query(default="all"),
|
||||
time: BoardTimeEnum = Query(default="month"),
|
||||
user: CurrentUser = Depends(require_permission("view_board_coach")),
|
||||
): ...
|
||||
|
||||
# GET /api/xcx/board/customers
|
||||
@router.get("/customers", response_model=CustomerBoardResponse)
|
||||
async def get_customer_board(
|
||||
dimension: CustomerDimensionEnum = Query(default="recall"),
|
||||
project: ProjectFilterEnum = Query(default="all"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_permission("view_board_customer")),
|
||||
): ...
|
||||
|
||||
# GET /api/xcx/board/finance
|
||||
@router.get("/finance", response_model=FinanceBoardResponse)
|
||||
async def get_finance_board(
|
||||
time: FinanceTimeEnum = Query(default="month"),
|
||||
area: AreaFilterEnum = Query(default="all"),
|
||||
compare: int = Query(default=0, ge=0, le=1),
|
||||
user: CurrentUser = Depends(require_permission("view_board_finance")),
|
||||
): ...
|
||||
```
|
||||
|
||||
### 3.2 路由层 — `xcx_config.py`
|
||||
|
||||
```python
|
||||
# GET /api/xcx/config/skill-types
|
||||
@router.get("/skill-types", response_model=list[SkillTypeItem])
|
||||
async def get_skill_types(
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
): ...
|
||||
```
|
||||
|
||||
### 3.3 服务层 — `board_service.py`
|
||||
|
||||
三个核心编排函数,各自独立处理参数解析、日期范围计算、数据查询和响应组装:
|
||||
|
||||
```python
|
||||
async def get_coach_board(sort, skill, time, site_id) -> dict:
|
||||
"""BOARD-1:助教看板。扁平返回所有维度字段。"""
|
||||
|
||||
async def get_customer_board(dimension, project, page, page_size, site_id) -> dict:
|
||||
"""BOARD-2:客户看板。按维度返回专属字段 + 分页。"""
|
||||
|
||||
async def get_finance_board(time, area, compare, site_id) -> dict:
|
||||
"""BOARD-3:财务看板。6 板块独立查询、独立降级。"""
|
||||
```
|
||||
|
||||
### 3.4 FDW 查询层扩展 — `fdw_queries.py`
|
||||
|
||||
新增函数(遵循已有 `_fdw_context` 模式):
|
||||
|
||||
| 函数 | 数据源视图 | 用途 |
|
||||
|------|-----------|------|
|
||||
| `get_all_assistants` | `v_dim_assistant` | BOARD-1 助教列表 |
|
||||
| `get_salary_calc_batch` | `v_dws_assistant_salary_calc` | BOARD-1 批量绩效(基于已有 `get_salary_calc` SQL 模式扩展为批量查询) |
|
||||
| `get_top_customers_for_coaches` | `v_dws_member_assistant_relation_index` + `v_dim_member` | BOARD-1 Top 客户(基于已有 `get_relation_index` 扩展为按助教批量查询) |
|
||||
| `get_coach_sv_data` | `v_dws_assistant_recharge_commission` | BOARD-1 sv 维度(助教储值提成明细,取 recharge_amount + commission_amount) |
|
||||
| `get_customer_board_recall` | `v_dws_member_winback_index` + `v_dim_member` | BOARD-2 recall 维度(ETL 已计算 WBI 指数) |
|
||||
| `get_customer_board_potential` | `v_dws_member_spending_power_index` | BOARD-2 potential 维度(ETL 已计算 SPI 指数) |
|
||||
| `get_customer_board_balance` | `v_dim_member_card_account` + `v_dim_member` | BOARD-2 balance 维度 |
|
||||
| `get_customer_board_recharge` | `v_dwd_recharge_order` + `v_dim_member_card_account` | BOARD-2 recharge 维度(充值记录 + 当前余额) |
|
||||
| `get_customer_board_recent` | `v_dws_member_visit_detail` + `v_dim_member` | BOARD-2 recent 维度(ETL 已计算到店明细) |
|
||||
| `get_customer_board_spend60` | `v_dws_member_consumption_summary` | BOARD-2 spend60 维度(items_sum_60d 已在汇总表中) |
|
||||
| `get_customer_board_freq60` | `v_dws_member_consumption_summary` | BOARD-2 freq60 维度(visit_count_60d 已在汇总表中) |
|
||||
| `get_customer_board_loyal` | `v_dws_member_assistant_relation_index` | BOARD-2 loyal 维度 |
|
||||
| `get_finance_overview` | `v_dws_finance_daily_summary` | BOARD-3 经营一览(按日期范围聚合财务日报) |
|
||||
| `get_finance_recharge` | `v_dws_finance_recharge_summary` | BOARD-3 预收资产 |
|
||||
| `get_finance_revenue` | `v_dws_finance_income_structure` + `v_dws_finance_discount_detail` | BOARD-3 应计收入 |
|
||||
| `get_finance_cashflow` | `v_dws_finance_daily_summary` | BOARD-3 现金流入(复用财务日报中的收款字段) |
|
||||
| `get_finance_expense` | `v_dws_finance_expense_summary` + `v_dws_platform_settlement` | BOARD-3 现金流出 |
|
||||
| `get_finance_coach_analysis` | `v_dws_assistant_salary_calc` | BOARD-3 助教分析 |
|
||||
| `get_skill_types` | ETL cfg 表 | CONFIG-1 技能类型 |
|
||||
|
||||
### 3.5 日期范围计算
|
||||
|
||||
统一工具函数 `_calc_date_range(time_enum) -> (start_date, end_date)`,返回 `date` 对象:
|
||||
|
||||
| time 参数 | 当期范围 | 上期范围(compare=1 时) |
|
||||
|-----------|---------|------------------------|
|
||||
| `month` | 当月 1 日 ~ 末日 | 上月 1 日 ~ 末日 |
|
||||
| `lastMonth` / `last_month` | 上月 1 日 ~ 末日 | 上上月 |
|
||||
| `week` | 本周一 ~ 本周日 | 上周一 ~ 上周日 |
|
||||
| `lastWeek` | 上周一 ~ 上周日 | 上上周 |
|
||||
| `quarter` | 本季度首日 ~ 末日 | 上季度 |
|
||||
| `lastQuarter` / `last_quarter` | 上季度 | 上上季度 |
|
||||
| `quarter3` / `last_3m` | 前 3 个月(不含本月) | 再前 3 个月 |
|
||||
| `half6` / `last_6m` | 前 6 个月(不含本月) | 再前 6 个月 |
|
||||
|
||||
### 3.6 环比计算工具
|
||||
|
||||
```python
|
||||
def calc_compare(current: Decimal, previous: Decimal) -> dict:
|
||||
"""
|
||||
统一环比计算。
|
||||
|
||||
返回:
|
||||
- compare: str — "12.5%" / "新增" / "持平"
|
||||
- is_down: bool — 是否下降
|
||||
- is_flat: bool — 是否持平
|
||||
|
||||
规则:
|
||||
- previous=0, current≠0 → "新增", is_down=False, is_flat=False
|
||||
- previous=0, current=0 → "持平", is_down=False, is_flat=True
|
||||
- 正常计算: (current - previous) / previous × 100%
|
||||
- 正值 → is_down=False; 负值 → is_down=True; 零 → is_flat=True
|
||||
"""
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 4.1 请求参数枚举
|
||||
|
||||
```python
|
||||
# BOARD-1 排序维度
|
||||
class CoachSortEnum(str, Enum):
|
||||
perf_desc = "perf_desc"
|
||||
perf_asc = "perf_asc"
|
||||
salary_desc = "salary_desc"
|
||||
salary_asc = "salary_asc"
|
||||
sv_desc = "sv_desc"
|
||||
task_desc = "task_desc"
|
||||
|
||||
# BOARD-1 技能筛选
|
||||
class SkillFilterEnum(str, Enum):
|
||||
all = "all"
|
||||
chinese = "chinese"
|
||||
snooker = "snooker"
|
||||
mahjong = "mahjong"
|
||||
karaoke = "karaoke"
|
||||
|
||||
# BOARD-1 时间范围
|
||||
class BoardTimeEnum(str, Enum):
|
||||
month = "month"
|
||||
quarter = "quarter"
|
||||
last_month = "last_month"
|
||||
last_3m = "last_3m"
|
||||
last_quarter = "last_quarter"
|
||||
last_6m = "last_6m"
|
||||
|
||||
# BOARD-2 客户维度
|
||||
class CustomerDimensionEnum(str, Enum):
|
||||
recall = "recall"
|
||||
potential = "potential"
|
||||
balance = "balance"
|
||||
recharge = "recharge"
|
||||
recent = "recent"
|
||||
spend60 = "spend60"
|
||||
freq60 = "freq60"
|
||||
loyal = "loyal"
|
||||
|
||||
# BOARD-2 项目筛选
|
||||
class ProjectFilterEnum(str, Enum):
|
||||
all = "all"
|
||||
chinese = "chinese"
|
||||
snooker = "snooker"
|
||||
mahjong = "mahjong"
|
||||
karaoke = "karaoke"
|
||||
|
||||
# BOARD-3 时间范围
|
||||
class FinanceTimeEnum(str, Enum):
|
||||
month = "month"
|
||||
lastMonth = "lastMonth"
|
||||
week = "week"
|
||||
lastWeek = "lastWeek"
|
||||
quarter3 = "quarter3"
|
||||
quarter = "quarter"
|
||||
lastQuarter = "lastQuarter"
|
||||
half6 = "half6"
|
||||
|
||||
# BOARD-3 区域筛选
|
||||
class AreaFilterEnum(str, Enum):
|
||||
all = "all"
|
||||
hall = "hall"
|
||||
hallA = "hallA"
|
||||
hallB = "hallB"
|
||||
hallC = "hallC"
|
||||
mahjong = "mahjong"
|
||||
teamBuilding = "teamBuilding"
|
||||
```
|
||||
|
||||
### 4.2 BOARD-1 响应 Schema
|
||||
|
||||
```python
|
||||
class CoachSkillItem(CamelModel):
|
||||
text: str
|
||||
cls: str
|
||||
|
||||
class CoachBoardItem(CamelModel):
|
||||
"""助教看板单条记录(扁平结构,包含所有维度字段)。"""
|
||||
# 基础字段(所有维度共享)
|
||||
id: int
|
||||
name: str
|
||||
initial: str
|
||||
avatar_gradient: str
|
||||
level: str # star/senior/middle/junior
|
||||
skills: list[CoachSkillItem]
|
||||
top_customers: list[str] # ["💖 王先生", "💛 李女士"]
|
||||
|
||||
# perf 维度
|
||||
perf_hours: float = 0.0
|
||||
perf_hours_before: float | None = None
|
||||
perf_gap: str | None = None # "距升档 13.8h" 或 None
|
||||
perf_reached: bool = False
|
||||
|
||||
# salary 维度
|
||||
salary: float = 0.0
|
||||
salary_perf_hours: float = 0.0
|
||||
salary_perf_before: float | None = None
|
||||
|
||||
# sv 维度
|
||||
sv_amount: float = 0.0
|
||||
sv_customer_count: int = 0
|
||||
sv_consume: float = 0.0
|
||||
|
||||
# task 维度
|
||||
task_recall: int = 0
|
||||
task_callback: int = 0
|
||||
|
||||
class CoachBoardResponse(CamelModel):
|
||||
items: list[CoachBoardItem]
|
||||
dim_type: str # perf/salary/sv/task
|
||||
```
|
||||
|
||||
### 4.3 BOARD-2 响应 Schema
|
||||
|
||||
```python
|
||||
class CustomerAssistant(CamelModel):
|
||||
name: str
|
||||
cls: str
|
||||
heart_score: float
|
||||
badge: str | None = None
|
||||
badge_cls: str | None = None
|
||||
|
||||
class CustomerBoardItemBase(CamelModel):
|
||||
"""客户看板基础字段(所有维度共享)。"""
|
||||
id: int
|
||||
name: str
|
||||
initial: str
|
||||
avatar_cls: str
|
||||
assistants: list[CustomerAssistant]
|
||||
|
||||
# 各维度专属字段通过继承扩展
|
||||
class RecallItem(CustomerBoardItemBase):
|
||||
ideal_days: int
|
||||
elapsed_days: int
|
||||
overdue_days: int
|
||||
visits_30d: int
|
||||
balance: str
|
||||
recall_index: float
|
||||
|
||||
class PotentialTag(CamelModel):
|
||||
text: str
|
||||
theme: str
|
||||
|
||||
class PotentialItem(CustomerBoardItemBase):
|
||||
potential_tags: list[PotentialTag]
|
||||
spend_30d: float
|
||||
avg_visits: float
|
||||
avg_spend: float
|
||||
|
||||
class BalanceItem(CustomerBoardItemBase):
|
||||
balance: str
|
||||
last_visit: str # "3天前"
|
||||
monthly_consume: float
|
||||
available_months: str # "约0.8个月"
|
||||
|
||||
class RechargeItem(CustomerBoardItemBase):
|
||||
last_recharge: str
|
||||
recharge_amount: float
|
||||
recharges_60d: int
|
||||
current_balance: str
|
||||
|
||||
class RecentItem(CustomerBoardItemBase):
|
||||
days_ago: int
|
||||
visit_freq: str # "6.2次/月"
|
||||
ideal_days: int
|
||||
visits_30d: int
|
||||
avg_spend: float
|
||||
|
||||
class Spend60Item(CustomerBoardItemBase):
|
||||
spend_60d: float
|
||||
visits_60d: int
|
||||
high_spend_tag: bool
|
||||
avg_spend: float
|
||||
|
||||
class WeeklyVisit(CamelModel):
|
||||
val: int
|
||||
pct: int # 0-100
|
||||
|
||||
class Freq60Item(CustomerBoardItemBase):
|
||||
visits_60d: int
|
||||
avg_interval: str # "5.0天"
|
||||
weekly_visits: list[WeeklyVisit] # 固定长度 8
|
||||
spend_60d: float
|
||||
|
||||
class CoachDetail(CamelModel):
|
||||
name: str
|
||||
cls: str
|
||||
heart_score: float
|
||||
badge: str | None = None
|
||||
avg_duration: str
|
||||
service_count: int
|
||||
coach_spend: float
|
||||
relation_idx: float
|
||||
|
||||
class LoyalItem(CustomerBoardItemBase):
|
||||
intimacy: float
|
||||
top_coach_name: str
|
||||
top_coach_heart: float
|
||||
top_coach_score: float
|
||||
coach_name: str
|
||||
coach_ratio: str # "78%"
|
||||
coach_details: list[CoachDetail]
|
||||
|
||||
class CustomerBoardResponse(CamelModel):
|
||||
items: list[dict] # 实际类型取决于 dimension
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
```
|
||||
|
||||
### 4.4 BOARD-3 响应 Schema
|
||||
|
||||
```python
|
||||
class CompareField(CamelModel):
|
||||
"""环比字段三元组(仅 compare=1 时出现)。"""
|
||||
compare: str # "12.5%" / "新增" / "持平"
|
||||
down: bool
|
||||
flat: bool
|
||||
|
||||
class OverviewPanel(CamelModel):
|
||||
occurrence: float
|
||||
discount: float # 负值
|
||||
discount_rate: float
|
||||
confirmed_revenue: float
|
||||
cash_in: float
|
||||
cash_out: float
|
||||
cash_balance: float
|
||||
balance_rate: float
|
||||
# 环比字段(compare=1 时存在,compare=0 时整个字段不出现)
|
||||
occurrence_compare: str | None = None
|
||||
occurrence_down: bool | None = None
|
||||
occurrence_flat: bool | None = None
|
||||
# ... 其余 7 项指标各 3 个环比字段,结构相同
|
||||
|
||||
class GiftCell(CamelModel):
|
||||
value: float
|
||||
compare: str | None = None
|
||||
down: bool | None = None
|
||||
flat: bool | None = None
|
||||
|
||||
class GiftRow(CamelModel):
|
||||
"""赠送卡矩阵一行:合计 / 酒水卡 / 台费卡 / 抵用券。"""
|
||||
label: str # "新增" / "消费" / "余额"
|
||||
total: GiftCell
|
||||
liquor: GiftCell
|
||||
table_fee: GiftCell
|
||||
voucher: GiftCell
|
||||
|
||||
class RechargePanel(CamelModel):
|
||||
actual_income: float
|
||||
first_charge: float
|
||||
renew_charge: float
|
||||
consumed: float
|
||||
card_balance: float
|
||||
gift_rows: list[GiftRow] # 3 行
|
||||
all_card_balance: float
|
||||
# 各项环比字段(同 overview 模式)
|
||||
|
||||
class RevenueStructureRow(CamelModel):
|
||||
id: str
|
||||
name: str
|
||||
desc: str | None = None
|
||||
is_sub: bool = False
|
||||
amount: float
|
||||
discount: float
|
||||
booked: float
|
||||
booked_compare: str | None = None
|
||||
|
||||
class RevenueItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
|
||||
class ChannelItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
|
||||
class RevenuePanel(CamelModel):
|
||||
structure_rows: list[RevenueStructureRow]
|
||||
price_items: list[RevenueItem] # 4 项
|
||||
total_occurrence: float
|
||||
discount_items: list[RevenueItem] # 4 项
|
||||
confirmed_total: float
|
||||
channel_items: list[ChannelItem] # 3 项
|
||||
|
||||
class CashflowItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
|
||||
class CashflowPanel(CamelModel):
|
||||
consume_items: list[CashflowItem] # 3 项
|
||||
recharge_items: list[CashflowItem] # 1 项
|
||||
total: float
|
||||
|
||||
class ExpenseItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
compare: str | None = None
|
||||
down: bool | None = None
|
||||
flat: bool | None = None
|
||||
|
||||
class ExpensePanel(CamelModel):
|
||||
operation_items: list[ExpenseItem] # 3 项
|
||||
fixed_items: list[ExpenseItem] # 4 项
|
||||
coach_items: list[ExpenseItem] # 4 项
|
||||
platform_items: list[ExpenseItem] # 3 项
|
||||
total: float
|
||||
total_compare: str | None = None
|
||||
total_down: bool | None = None
|
||||
total_flat: bool | None = None
|
||||
|
||||
class CoachAnalysisRow(CamelModel):
|
||||
level: str
|
||||
pay: float
|
||||
share: float
|
||||
hourly: float
|
||||
pay_compare: str | None = None
|
||||
pay_down: bool | None = None
|
||||
share_compare: str | None = None
|
||||
share_down: bool | None = None
|
||||
hourly_compare: str | None = None
|
||||
hourly_flat: bool | None = None
|
||||
|
||||
class CoachAnalysisTable(CamelModel):
|
||||
total_pay: float
|
||||
total_share: float
|
||||
avg_hourly: float
|
||||
total_pay_compare: str | None = None
|
||||
total_pay_down: bool | None = None
|
||||
total_share_compare: str | None = None
|
||||
total_share_down: bool | None = None
|
||||
avg_hourly_compare: str | None = None
|
||||
avg_hourly_flat: bool | None = None
|
||||
rows: list[CoachAnalysisRow] # 4 行:初级/中级/高级/星级
|
||||
|
||||
class CoachAnalysisPanel(CamelModel):
|
||||
basic: CoachAnalysisTable # 基础课/陪打
|
||||
incentive: CoachAnalysisTable # 激励课/超休
|
||||
|
||||
class FinanceBoardResponse(CamelModel):
|
||||
overview: OverviewPanel
|
||||
recharge: RechargePanel | None # area≠all 时为 null
|
||||
revenue: RevenuePanel
|
||||
cashflow: CashflowPanel
|
||||
expense: ExpensePanel
|
||||
coach_analysis: CoachAnalysisPanel
|
||||
```
|
||||
|
||||
### 4.5 CONFIG-1 响应 Schema
|
||||
|
||||
```python
|
||||
class SkillTypeItem(CamelModel):
|
||||
key: str # chinese/snooker/mahjong/karaoke
|
||||
label: str # 中文标签
|
||||
emoji: str # 表情符号
|
||||
cls: str # 前端样式类
|
||||
```
|
||||
|
||||
### 4.6 数据库查询模式
|
||||
|
||||
所有 FDW 查询遵循已有模式:
|
||||
|
||||
```python
|
||||
def get_finance_overview(conn, site_id, start_date, end_date):
|
||||
"""查询经营一览 8 指标(从财务日报聚合)。"""
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute("""
|
||||
SELECT SUM(occurrence) AS occurrence,
|
||||
SUM(discount) AS discount,
|
||||
...
|
||||
FROM app.v_dws_finance_daily_summary
|
||||
WHERE stat_date >= %s AND stat_date < %s
|
||||
""", (start_date, end_date))
|
||||
...
|
||||
```
|
||||
|
||||
⚠️ 已有函数复用说明:
|
||||
- `get_salary_calc_batch` 基于已有 `get_salary_calc()` 的 SQL 模式,扩展为 `WHERE assistant_id = ANY(%s)` 批量查询
|
||||
- `get_top_customers_for_coaches` 基于已有 `get_relation_index()` 的 SQL 模式,扩展为按助教维度批量查询 + JOIN v_dim_member
|
||||
- `get_coach_sv_data` 使用 `v_dws_assistant_recharge_commission`(助教储值提成明细表,取 recharge_amount + commission_amount),原 `v_dws_assistant_monthly_summary` 口径错误(M15 修复)
|
||||
- BOARD-2 recall/potential/recent/recharge 维度使用 ETL 已计算好的专用指数视图,避免在后端重复计算
|
||||
|
||||
关键 SQL 模式:
|
||||
- **items_sum 口径**:所有金额字段使用 `ledger_amount`(对应 `items_sum`),禁止 `consume_money`
|
||||
- **助教费用拆分**:`base_income`(对应 `assistant_pd_money`)+ `bonus_income`(对应 `assistant_cx_money`),禁止 `service_fee`
|
||||
- **会员信息 DQ-6**:`LEFT JOIN app.v_dim_member ON tenant_member_id = member_id AND scd2_is_current = 1`
|
||||
- **会员卡 DQ-7**:`LEFT JOIN app.v_dim_member_card_account ON tenant_member_id AND scd2_is_current = 1`
|
||||
- **废单排除**:`WHERE is_delete = 0`(RLS 视图使用 `is_delete`)
|
||||
- **正向交易**:`WHERE settle_type IN (1, 3)`
|
||||
- **支付渠道恒等式**:`balance_amount = recharge_card_amount + gift_card_amount`
|
||||
- **现金流互斥**:`platform_settlement_amount` 和 `groupbuy_pay_amount` 互斥
|
||||
|
||||
### 4.7 BOARD-2 分页策略
|
||||
|
||||
```python
|
||||
# SQL 层面使用 LIMIT/OFFSET
|
||||
# 先执行 COUNT(*) 获取 total,再执行分页查询
|
||||
# pageSize 上限 100,默认 20
|
||||
|
||||
def get_customer_board_recall(conn, site_id, project, page, page_size):
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
# 1. 总数
|
||||
cur.execute("SELECT COUNT(*) FROM ... WHERE ...", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# 2. 分页数据
|
||||
offset = (page - 1) * page_size
|
||||
cur.execute("SELECT ... LIMIT %s OFFSET %s",
|
||||
(*params, page_size, offset))
|
||||
items = cur.fetchall()
|
||||
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
```
|
||||
|
||||
### 4.8 BOARD-3 环比字段条件输出
|
||||
|
||||
`compare=0` 时,响应 JSON 中不包含任何环比字段。实现方式:Schema 中环比字段设为 `Optional`,`model_dump(exclude_none=True)` 输出时自动排除 `None` 值。
|
||||
|
||||
```python
|
||||
class OverviewPanel(CamelModel):
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
populate_by_name=True,
|
||||
# exclude_none 在序列化时排除 None 字段
|
||||
)
|
||||
|
||||
occurrence: float
|
||||
occurrence_compare: str | None = None # compare=0 时为 None → 不输出
|
||||
occurrence_down: bool | None = None
|
||||
occurrence_flat: bool | None = None
|
||||
```
|
||||
|
||||
路由层序列化时使用 `response_model_exclude_none=True`:
|
||||
|
||||
```python
|
||||
@router.get("/finance", response_model=FinanceBoardResponse,
|
||||
response_model_exclude_none=True)
|
||||
async def get_finance_board(...): ...
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是系统在所有合法执行路径上都应保持为真的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: 日期范围计算正确性
|
||||
|
||||
*对于任意*当前日期和任意 `time` 枚举值(BOARD-1 的 6 种 + BOARD-3 的 8 种),`_calc_date_range(time)` 返回的 `(start_date, end_date)` 应满足:`start_date <= end_date`,且当 `compare=1` 时计算的上期范围 `(prev_start, prev_end)` 应满足 `prev_end <= start_date` 且上期长度等于当期长度。
|
||||
|
||||
**Validates: Requirements 1.3, 3.2, 3.3**
|
||||
|
||||
### Property 2: BOARD-1 排序不变量
|
||||
|
||||
*对于任意* BOARD-1 响应列表和任意 `sort` 参数,列表中相邻两项的排序字段值应满足排序方向约束:`perf_desc` → `items[i].perfHours >= items[i+1].perfHours`,`salary_asc` → `items[i].salary <= items[i+1].salary`,以此类推。
|
||||
|
||||
**Validates: Requirements 1.15, 9.1, 9.2**
|
||||
|
||||
### Property 3: BOARD-2 分页不变量
|
||||
|
||||
*对于任意*相同筛选参数(`dimension`、`project`)的 BOARD-2 请求,(a) `items.length <= pageSize`,(b) 不同 `page` 值返回的 `total` 相同,(c) `page=1` 和 `page=2` 返回的 `items` 无交集(按 `id` 判断)。
|
||||
|
||||
**Validates: Requirements 2.2, 9.3, 9.4**
|
||||
|
||||
### Property 4: 亲密度 emoji 四级映射
|
||||
|
||||
*对于任意* `rs_display` 浮点数值(0-10 范围),亲密度 emoji 映射函数应返回确定的结果:`> 8.5` → 💖,`> 7` → 🧡,`> 5` → 💛,`≤ 5` → 💙,且映射结果与阈值边界一致(如 `rs_display=8.5` 应返回 🧡 而非 💖)。
|
||||
|
||||
**Validates: Requirements 1.6**
|
||||
|
||||
### Property 5: 环比计算公式正确性
|
||||
|
||||
*对于任意*两个非负 `Decimal` 值 `(current, previous)`,`calc_compare(current, previous)` 应满足:(a) `previous > 0` 时 `compare` 字符串等于 `abs((current - previous) / previous * 100)` 格式化为百分比,(b) `current > previous` 时 `is_down=False`,(c) `current < previous` 时 `is_down=True`,(d) `current == previous` 时 `is_flat=True`,(e) `previous=0, current>0` 时返回 `"新增"`,(f) `previous=0, current=0` 时返回 `"持平"`。
|
||||
|
||||
**Validates: Requirements 8.11, 8.12, 8.13, 8.14**
|
||||
|
||||
### Property 6: 环比开关一致性
|
||||
|
||||
*对于任意* BOARD-3 请求参数,当 `compare=0` 时,响应 JSON 序列化后的字符串中不应包含任何以 `Compare`、`Down`、`Flat` 结尾的 key(camelCase 格式)。
|
||||
|
||||
**Validates: Requirements 3.4, 9.8**
|
||||
|
||||
### Property 7: 预收资产区域约束
|
||||
|
||||
*对于任意* BOARD-3 请求,当 `area` 不等于 `all` 时,响应中 `recharge` 字段应为 `null`。
|
||||
|
||||
**Validates: Requirements 3.11, 9.7**
|
||||
|
||||
### Property 8: 经营一览收入确认恒等式
|
||||
|
||||
*对于任意* BOARD-3 响应中的 `overview` 数据,`confirmedRevenue` 应近似等于 `occurrence - abs(discount)`(在 ±0.01 浮点精度范围内)。
|
||||
|
||||
**Validates: Requirements 9.5**
|
||||
|
||||
### Property 9: 经营一览现金结余恒等式
|
||||
|
||||
*对于任意* BOARD-3 响应中的 `overview` 数据,`cashBalance` 应近似等于 `cashIn - cashOut`(在 ±0.01 浮点精度范围内)。
|
||||
|
||||
**Validates: Requirements 9.6**
|
||||
|
||||
### Property 10: 支付渠道恒等式
|
||||
|
||||
*对于任意*涉及支付渠道的数据记录,`balance_amount` 应精确等于 `recharge_card_amount + gift_card_amount`(DWD-DOC 强制规则 3)。
|
||||
|
||||
**Validates: Requirements 8.7, 9.9**
|
||||
|
||||
### Property 11: 参数互斥约束
|
||||
|
||||
*对于任意* BOARD-1 请求,当 `time=last_6m` 且 `sort=sv_desc` 时,API 应返回 HTTP 400 状态码,响应体包含错误信息。
|
||||
|
||||
**Validates: Requirements 1.2, 9.11**
|
||||
|
||||
### Property 12: BOARD-3 幂等性
|
||||
|
||||
*对于任意*相同参数的 BOARD-3 请求(相同 `time`、`area`、`compare`),在底层数据未变更的情况下,两次请求返回的 `overview.occurrence` 和 `overview.cashBalance` 值应完全相同。
|
||||
|
||||
**Validates: Requirements 9.10**
|
||||
|
||||
### Property 13: weeklyVisits 百分比范围
|
||||
|
||||
*对于任意* BOARD-2 `freq60` 维度响应中的 `weeklyVisits` 数组,(a) 数组长度固定为 8,(b) 每个元素的 `pct` 值在 0-100 范围内,(c) 如果存在非零 `val`,则 `max(pct)` 应等于 100。
|
||||
|
||||
**Validates: Requirements 2.20**
|
||||
|
||||
### Property 14: 优雅降级不变量
|
||||
|
||||
*对于任意* BOARD-3 请求,当某个板块(overview/recharge/revenue/cashflow/expense/coachAnalysis)的数据源查询抛出异常时,整体响应仍应返回 HTTP 200,失败板块返回空默认值(空对象或空数组),其他板块数据不受影响。
|
||||
|
||||
**Validates: Requirements 8.9, 8.10**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 6.1 HTTP 错误码
|
||||
|
||||
| 场景 | 状态码 | 响应体 |
|
||||
|------|--------|--------|
|
||||
| 未认证(无 JWT / JWT 过期) | 401 | `{ code: 401, message: "无效的令牌" }` |
|
||||
| 未审核(status ≠ approved) | 403 | `{ code: 403, message: "用户未通过审核" }` |
|
||||
| 权限不足 | 403 | `{ code: 403, message: "权限不足" }` |
|
||||
| 参数互斥(last_6m + sv_desc) | 400 | `{ code: 400, message: "最近6个月不支持客源储值排序" }` |
|
||||
| 无效枚举值 | 422 | FastAPI 自动验证 |
|
||||
| 服务端异常 | 500 | `{ code: 500, message: "Internal Server Error" }` |
|
||||
|
||||
### 6.2 优雅降级策略
|
||||
|
||||
BOARD-3 财务看板采用板块级降级:
|
||||
|
||||
```python
|
||||
async def get_finance_board(time, area, compare, site_id):
|
||||
conn = _get_biz_connection()
|
||||
try:
|
||||
# 每个板块独立 try/except
|
||||
try:
|
||||
overview = _build_overview(conn, site_id, date_range, compare)
|
||||
except Exception:
|
||||
logger.warning("overview 查询失败,降级为空", exc_info=True)
|
||||
overview = _empty_overview()
|
||||
|
||||
try:
|
||||
recharge = _build_recharge(conn, site_id, date_range, compare) if area == "all" else None
|
||||
except Exception:
|
||||
logger.warning("recharge 查询失败,降级为 null", exc_info=True)
|
||||
recharge = None
|
||||
|
||||
# ... 其余板块同理
|
||||
|
||||
return { "overview": overview, "recharge": recharge, ... }
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
BOARD-1 和 BOARD-2 采用整体降级:核心查询失败直接返回 500,扩展字段(如 topCustomers)失败降级为空。
|
||||
|
||||
CONFIG-1 采用空数组降级:ETL cfg 表查询失败返回 `[]`,前端使用硬编码回退。
|
||||
|
||||
### 6.3 FDW 查询异常处理
|
||||
|
||||
所有 `_fdw_context` 内的查询异常由调用方捕获。`fdw_queries.py` 中的函数不做异常吞没,让 service 层决定降级策略。
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 7.1 测试框架
|
||||
|
||||
- **属性测试**:`hypothesis`(Python),已在项目中使用(见 `.hypothesis/` 目录和 `test_site_isolation_properties.py`)
|
||||
- **单元测试**:`pytest`,mock 数据库交互
|
||||
- **集成测试**:`FastAPI TestClient`,mock FDW 连接
|
||||
|
||||
### 7.2 属性测试配置
|
||||
|
||||
每个属性测试使用 `@settings(max_examples=100)` 配置最少 100 次迭代。
|
||||
|
||||
每个测试函数的 docstring 中标注对应的设计属性:
|
||||
|
||||
```python
|
||||
@settings(max_examples=100)
|
||||
@given(...)
|
||||
def test_compare_formula(current, previous):
|
||||
"""Feature: rns1-board-apis, Property 5: 环比计算公式正确性"""
|
||||
...
|
||||
```
|
||||
|
||||
### 7.3 属性测试清单
|
||||
|
||||
| Property | 测试文件 | 测试策略 |
|
||||
|----------|---------|---------|
|
||||
| P1 日期范围 | `test_board_properties.py` | 生成随机日期 + time 枚举,验证 start <= end 和上下期关系 |
|
||||
| P2 排序不变量 | `test_board_properties.py` | 生成随机助教列表,调用排序函数,验证相邻元素顺序 |
|
||||
| P3 分页不变量 | `test_board_properties.py` | 生成随机客户列表 + page/pageSize,验证分页约束 |
|
||||
| P4 emoji 映射 | `test_board_properties.py` | 生成 0-10 范围浮点数,验证映射结果 |
|
||||
| P5 环比公式 | `test_board_properties.py` | 生成非负 Decimal 对,验证公式和边界条件 |
|
||||
| P6 环比开关 | `test_board_properties.py` | 生成 BOARD-3 mock 数据 + compare=0,验证 JSON 无环比 key |
|
||||
| P7 区域约束 | `test_board_properties.py` | 生成 area≠all 的请求,验证 recharge=null |
|
||||
| P8 收入恒等式 | `test_board_properties.py` | 生成 overview 数据,验证 confirmedRevenue ≈ occurrence - |discount| |
|
||||
| P9 现金结余 | `test_board_properties.py` | 生成 overview 数据,验证 cashBalance ≈ cashIn - cashOut |
|
||||
| P10 支付渠道 | `test_board_properties.py` | 生成支付渠道数据,验证恒等式 |
|
||||
| P11 参数互斥 | `test_board_properties.py` | 固定 time=last_6m + sort=sv_desc,验证 400 |
|
||||
| P12 幂等性 | `test_board_properties.py` | 相同参数两次调用,验证结果一致 |
|
||||
| P13 pct 范围 | `test_board_properties.py` | 生成 8 周到店数据,验证 pct 范围和最大值 |
|
||||
| P14 优雅降级 | `test_board_properties.py` | mock 板块查询抛异常,验证整体 200 + 空默认值 |
|
||||
|
||||
### 7.4 单元测试清单
|
||||
|
||||
| 测试目标 | 测试文件 | 覆盖内容 |
|
||||
|----------|---------|---------|
|
||||
| 日期范围边界 | `test_board_unit.py` | 月末、跨年、闰年等边界 |
|
||||
| 环比 "新增"/"持平" | `test_board_unit.py` | previous=0 的两种情况 |
|
||||
| BOARD-1 扁平结构 | `test_board_unit.py` | 验证所有维度字段都存在 |
|
||||
| BOARD-2 各维度排序 | `test_board_unit.py` | 8 个维度各一个排序示例 |
|
||||
| BOARD-3 recharge null | `test_board_unit.py` | area=hallA 时 recharge=null |
|
||||
| CONFIG-1 空数组降级 | `test_board_unit.py` | cfg 表查询失败返回 [] |
|
||||
| 权限检查 | `test_board_unit.py` | 无权限用户访问各看板返回 403 |
|
||||
|
||||
### 7.5 测试文件位置
|
||||
|
||||
```
|
||||
apps/backend/tests/
|
||||
├── test_board_properties.py # 属性测试(14 个 property)
|
||||
└── unit/
|
||||
└── test_board_unit.py # 单元测试
|
||||
```
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
# 需求文档 — RNS1.3:三看板接口
|
||||
|
||||
## 简介
|
||||
|
||||
RNS1.3 是 NS1 小程序后端 API 补全项目的第四个子 spec,负责实现 3 个看板接口(BOARD-1 助教看板、BOARD-2 客户看板、BOARD-3 财务看板)、CONFIG-1 技能配置接口、以及前端看板筛选修复。看板是管理层视角的核心功能,其中 BOARD-3 财务看板是全项目最复杂的单个接口(6 个独立板块、200+ 字段、60+ 环比数据点)。
|
||||
|
||||
### 依赖
|
||||
|
||||
- RNS1.0(基础设施与契约重写)必须先完成:全局响应包装中间件(`ResponseWrapperMiddleware`)、camelCase 转换(`CamelModel`)、重写后的 API 契约
|
||||
- RNS1.1 / RNS1.2 可并行开发,无直接依赖
|
||||
- 后端已有 `fdw_queries.py`(FDW 查询集中封装)、`task_manager.py`、`note_service.py`
|
||||
- 前端已有 13 个页面(P5.2 交付),当前使用 mock 数据
|
||||
|
||||
### 来源文档
|
||||
|
||||
- `docs/prd/Neo_Specs/RNS1-split-plan.md` — 拆分计划主文档(RNS1.3 章节,T3-1 ~ T3-7)
|
||||
- `docs/miniprogram-dev/API-contract.md` — API 契约(BOARD-1/2/3、CONFIG-1 完整定义)
|
||||
- `docs/prd/Neo_Specs/NS1-xcx-backend-api.md` — 原始 spec(八¾ 看板筛选交叉矩阵为权威参考)
|
||||
- `docs/prd/Neo_Specs/miniprogram-storyboard-walkthrough-gaps.md` — 管理层视角走查报告(G1~G10 看板相关 Gap)
|
||||
- `docs/prd/Neo_Specs/storyboard-walkthrough-assistant-view.md` — 助教视角走查报告
|
||||
- `docs/reports/DWD-DOC/` — 金额口径与字段语义权威标杆文档
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Backend**:FastAPI 后端应用,位于 `apps/backend/`
|
||||
- **Miniprogram**:微信小程序前端应用,位于 `apps/miniprogram/`
|
||||
- **BOARD_1_API**:助教看板接口 `GET /api/xcx/board/coaches`,返回助教排行列表(4 维度专属字段)
|
||||
- **BOARD_2_API**:客户看板接口 `GET /api/xcx/board/customers`,返回客户排行列表(8 维度专属字段)
|
||||
- **BOARD_3_API**:财务看板接口 `GET /api/xcx/board/finance`,返回 6 个板块的财务数据(overview/recharge/revenue/cashflow/expense/coachAnalysis)
|
||||
- **CONFIG_1_API**:技能类型列表接口 `GET /api/xcx/config/skill-types`,返回助教技能类型配置
|
||||
- **FDW**:PostgreSQL Foreign Data Wrapper,后端通过直连 ETL 库查询 `app.v_*` RLS 视图
|
||||
- **items_sum**:DWD-DOC 强制使用的消费金额口径,= `table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money`
|
||||
- **assistant_pd_money**:助教陪打费用(基础课),DWD-DOC 强制规则 2 要求的拆分字段
|
||||
- **assistant_cx_money**:助教超休费用(激励课),DWD-DOC 强制规则 2 要求的拆分字段
|
||||
- **v_dws_assistant_salary_calc**:ETL RLS 视图,提供助教绩效/档位/收入/工资数据
|
||||
- **v_dws_assistant_monthly_summary**:ETL RLS 视图,提供助教月度汇总(客户数、储值额等)
|
||||
- **v_dim_assistant**:ETL RLS 视图,提供助教基本信息(姓名、技能、入职日期等)
|
||||
- **v_dim_member**:ETL RLS 视图,提供会员基本信息(nickname、mobile),通过 `member_id` 关联,取 `scd2_is_current=1`
|
||||
- **v_dim_member_card_account**:ETL RLS 视图,提供会员卡余额,通过 `tenant_member_id` 关联,取 `scd2_is_current=1`
|
||||
- **v_dws_member_assistant_relation_index**:ETL RLS 视图,提供会员与助教的关系指数
|
||||
- **v_dws_member_consumption_summary**:ETL RLS 视图,提供会员消费汇总
|
||||
- **v_dws_finance_daily_summary**:ETL RLS 视图,提供财务日报汇总数据(经营一览 8 指标 + 现金流入/流出),BOARD-3 overview/cashflow/expense 板块的主数据源
|
||||
- **v_dws_finance_income_structure**:ETL RLS 视图,提供收入结构表 + 正价/优惠/渠道明细,BOARD-3 revenue 板块数据源
|
||||
- **v_dws_finance_recharge_summary**:ETL RLS 视图,提供储值卡 + 赠送卡矩阵数据,BOARD-3 recharge 板块数据源
|
||||
- **v_dws_finance_discount_detail**:ETL RLS 视图,提供优惠明细(大客户优惠/其他优惠拆分),BOARD-3 revenue 板块辅助数据源
|
||||
- **v_dws_finance_expense_summary**:ETL RLS 视图,提供现金流出 4 子分组明细,BOARD-3 expense 板块数据源
|
||||
- **v_dws_platform_settlement**:ETL RLS 视图,提供平台结算数据(汇来米/美团/抖音),BOARD-3 expense.platformItems 数据源
|
||||
- **coach_tasks**:业务库 `biz.coach_tasks` 表,存储助教任务分配与状态
|
||||
- **user_assistant_binding**:认证库 `auth.user_assistant_binding` 表,映射小程序用户与助教身份
|
||||
- **dimType**:BOARD-1 中根据 `sort` 参数映射的维度类型(`perf`/`salary`/`sv`/`task`),决定卡片展示模板
|
||||
- **环比**:月环比,与上一个相同时间周期对比(本月 vs 上月、本周 vs 上周等),返回百分比字符串 + 方向标记
|
||||
- **GiftRow**:赠送卡 3×4 矩阵中的一行(新增/消费/余额),每行含 4 列(合计/酒水卡/台费卡/抵用券)
|
||||
- **RevenueStructureRow**:收入结构表中的一行(9 行含子行),含发生额、优惠、入账金额
|
||||
- **CoachAnalysisTable**:助教分析子表(基础课或激励课),含汇总行 + 按等级分行(初级/中级/高级/星级)
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:实现 BOARD-1 助教看板(T3-1)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在助教看板中按不同维度(定档业绩/工资/客源储值/任务完成)查看助教排行,并支持技能筛选和时间范围切换,以便评估和对比各助教的工作表现。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 1.1 请求参数与筛选
|
||||
|
||||
1. THE BOARD_1_API SHALL 接受 3 个查询参数:`sort`(排序维度,6 种枚举:`perf_desc`/`perf_asc`/`salary_desc`/`salary_asc`/`sv_desc`/`task_desc`)、`skill`(技能筛选,5 种枚举:`all`/`chinese`/`snooker`/`mahjong`/`karaoke`)、`time`(时间范围,6 种枚举:`month`/`quarter`/`last_month`/`last_3m`/`last_quarter`/`last_6m`)
|
||||
2. IF `time=last_6m` 且 `sort=sv_desc`,THEN THE BOARD_1_API SHALL 返回 HTTP 400 `{ code: 400, message: "最近6个月不支持客源储值排序" }`
|
||||
3. THE BOARD_1_API SHALL 根据 `time` 参数计算对应的日期范围(本月=当月1日~末日、上月=上月1日~末日、本季度=季度首日~末日、前3个月=不含本月的前3个月、上季度=上季度、最近6个月=不含本月的前6个月)
|
||||
|
||||
#### 1.2 基础字段(所有维度共享)
|
||||
|
||||
4. THE BOARD_1_API SHALL 为每个助教 item 返回基础字段:`id`(助教 ID)、`name`(助教姓名)、`initial`(姓名首字)、`avatarGradient`(头像渐变色标识)、`level`(等级 key:`star`/`senior`/`middle`/`junior`)、`skills`(技能列表,`Array<{ text: string, cls: string }>`)、`topCustomers`(Top 客户列表,含亲密度 emoji 前缀,如 `['💖 王先生', '💛 李女士']`)
|
||||
5. THE BOARD_1_API SHALL 从 `v_dim_assistant` 获取助教基本信息,从 `v_dws_assistant_salary_calc` 获取等级(`assistant_level_name`)
|
||||
6. THE BOARD_1_API SHALL 从 `v_dws_member_assistant_relation_index` 按亲密度降序取 Top 3 客户,拼接亲密度 emoji(P6 AC3 四级映射:`rs_display > 8.5` → 💖,`> 7` → 🧡,`> 5` → 💛,`≤ 5` → 💙)+ 客户姓名作为 `topCustomers`
|
||||
|
||||
#### 1.3 perf 维度专属字段
|
||||
|
||||
7. WHEN `sort` 为 `perf_desc` 或 `perf_asc` 时,THE BOARD_1_API SHALL 返回 perf 维度字段:`perfHours`(当期定档工时)、`perfHoursBefore`(上期定档工时,可选)、`perfGap`(距升档差距描述,如 `"距升档 13.8h"`,已达标时不返回)、`perfReached`(是否已达标)
|
||||
8. THE BOARD_1_API SHALL 从 `v_dws_assistant_salary_calc` 查询当期和上期的定档工时数据,根据档位阈值计算 `perfGap` 和 `perfReached`
|
||||
|
||||
#### 1.4 salary 维度专属字段
|
||||
|
||||
9. WHEN `sort` 为 `salary_desc` 或 `salary_asc` 时,THE BOARD_1_API SHALL 返回 salary 维度字段:`salary`(预估工资总额,元)、`salaryPerfHours`(定档工时)、`salaryPerfBefore`(上期定档工时,可选)
|
||||
10. THE BOARD_1_API SHALL 使用 `items_sum` 口径计算 `salary` 字段(DWD-DOC 强制规则 1)
|
||||
|
||||
#### 1.5 sv 维度专属字段
|
||||
|
||||
11. WHEN `sort` 为 `sv_desc` 时,THE BOARD_1_API SHALL 返回 sv 维度字段:`svAmount`(客源储值总额,元)、`svCustomerCount`(储值客户数)、`svConsume`(储值消耗额,元)
|
||||
12. THE BOARD_1_API SHALL 从 `v_dws_assistant_monthly_summary` 获取助教月度储值汇总数据(客源储值额、储值客户数、储值消耗额),该视图已按助教维度预聚合
|
||||
|
||||
#### 1.6 task 维度专属字段
|
||||
|
||||
13. WHEN `sort` 为 `task_desc` 时,THE BOARD_1_API SHALL 返回 task 维度字段:`taskRecall`(召回任务完成数)、`taskCallback`(回访任务完成数)
|
||||
14. THE BOARD_1_API SHALL 从 `biz.coach_tasks` 查询指定时间范围内 `status='completed'` 的任务,按 `task_type` 分类统计
|
||||
|
||||
#### 1.7 排序与返回策略
|
||||
|
||||
15. THE BOARD_1_API SHALL 根据 `sort` 参数对结果排序(`perf_desc` 按定档工时降序、`perf_asc` 按定档工时升序、`salary_desc` 按工资降序、`salary_asc` 按工资升序、`sv_desc` 按储值额降序、`task_desc` 按任务完成总数降序)
|
||||
16. THE BOARD_1_API SHALL 始终返回所有维度的字段(扁平结构),前端根据当前 `sort` 选择性渲染对应卡片模板,切换维度时无需重新请求
|
||||
|
||||
### 需求 2:实现 BOARD-2 客户看板(T3-2)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在客户看板中按 8 个维度(最应召回/最大消费潜力/最高余额/最近充值/最近到店/最高消费60天/最频繁60天/最专一60天)查看客户排行,每个维度展示不同的专属字段卡片和关联助教信息,以便从多角度评估客户价值和服务需求。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 2.1 请求参数与分页
|
||||
|
||||
1. THE BOARD_2_API SHALL 接受 4 个查询参数:`dimension`(维度,8 种枚举:`recall`/`potential`/`balance`/`recharge`/`recent`/`spend60`/`freq60`/`loyal`)、`project`(项目筛选,5 种枚举:`all`/`chinese`/`snooker`/`mahjong`/`karaoke`)、`page`(页码,默认 1)、`pageSize`(每页条数,默认 20,上限 100)
|
||||
2. THE BOARD_2_API SHALL 返回分页结构:`items`(客户列表)、`total`(总数)、`page`(当前页)、`pageSize`(每页条数),支持前端 20 条懒加载
|
||||
|
||||
#### 2.2 基础字段(所有维度共享)
|
||||
|
||||
3. THE BOARD_2_API SHALL 为每个客户 item 返回基础字段:`id`(客户 member_id)、`name`(客户姓名)、`initial`(姓名首字)、`avatarCls`(头像样式类)、`assistants`(关联助教列表,`Array<{ name, cls, heartScore, badge?, badgeCls? }>`)
|
||||
4. THE BOARD_2_API SHALL 通过 `member_id` LEFT JOIN `v_dim_member`(取 `scd2_is_current=1`)获取客户姓名(DQ-6)
|
||||
5. THE BOARD_2_API SHALL 从 `biz.coach_tasks` + 亲密度计算获取 `assistants` 列表,按亲密度降序排列,当前跟进助教(`cls='assistant--assignee'`)置顶
|
||||
|
||||
#### 2.3 recall 维度专属字段
|
||||
|
||||
6. WHEN `dimension=recall` 时,THE BOARD_2_API SHALL 返回:`idealDays`(理想到店间隔天数)、`elapsedDays`(已过天数)、`overdueDays`(超期天数 = elapsedDays - idealDays)、`visits30d`(近30天到店次数)、`balance`(余额,格式化字符串)、`recallIndex`(召回指数)
|
||||
7. THE BOARD_2_API SHALL 按 WBI(召回指数)降序排列 recall 维度结果
|
||||
|
||||
#### 2.4 potential 维度专属字段
|
||||
|
||||
8. WHEN `dimension=potential` 时,THE BOARD_2_API SHALL 返回:`potentialTags`(潜力标签列表,`Array<{ text, theme }>`)、`spend30d`(近30天消费)、`avgVisits`(月均到店)、`avgSpend`(次均消费)
|
||||
9. THE BOARD_2_API SHALL 按 SPI(消费潜力指数)降序排列 potential 维度结果
|
||||
|
||||
#### 2.5 balance 维度专属字段
|
||||
|
||||
10. WHEN `dimension=balance` 时,THE BOARD_2_API SHALL 返回:`balance`(当前余额)、`lastVisit`(最近到店描述,如 `"3天前"`)、`monthlyConsume`(月均消耗)、`availableMonths`(可用月数,如 `"约0.8个月"`)
|
||||
11. THE BOARD_2_API SHALL 按 `balance_amount` 降序排列 balance 维度结果
|
||||
|
||||
#### 2.6 recharge 维度专属字段
|
||||
|
||||
12. WHEN `dimension=recharge` 时,THE BOARD_2_API SHALL 返回:`lastRecharge`(最后充值日期)、`rechargeAmount`(充值金额)、`recharges60d`(近60天充值次数)、`currentBalance`(当前余额)
|
||||
13. THE BOARD_2_API SHALL 按 `last_recharge_date` 降序排列 recharge 维度结果
|
||||
|
||||
#### 2.7 recent 维度专属字段
|
||||
|
||||
14. WHEN `dimension=recent` 时,THE BOARD_2_API SHALL 返回:`daysAgo`(距今天数)、`visitFreq`(到店频率,如 `"6.2次/月"`)、`idealDays`(理想间隔)、`visits30d`(近30天到店)、`avgSpend`(次均消费)
|
||||
15. THE BOARD_2_API SHALL 按 `last_visit_date` 降序排列 recent 维度结果
|
||||
|
||||
#### 2.8 spend60 维度专属字段
|
||||
|
||||
16. WHEN `dimension=spend60` 时,THE BOARD_2_API SHALL 返回:`spend60d`(近60天消费总额)、`visits60d`(近60天到店次数)、`highSpendTag`(是否高消费标记)、`avgSpend`(次均消费)
|
||||
17. THE BOARD_2_API SHALL 使用 `items_sum` 口径计算 `spend60d`(DWD-DOC 强制规则 1),按 `items_sum_60d` 降序排列
|
||||
|
||||
#### 2.9 freq60 维度专属字段
|
||||
|
||||
18. WHEN `dimension=freq60` 时,THE BOARD_2_API SHALL 返回:`visits60d`(近60天到店次数)、`avgInterval`(平均到店间隔,如 `"5.0天"`)、`weeklyVisits`(最近8周到店柱状图,`Array<{ val: number, pct: number }>`,固定长度 8)、`spend60d`(近60天消费)
|
||||
19. THE BOARD_2_API SHALL 按 `visit_count_60d` 降序排列 freq60 维度结果
|
||||
20. THE BOARD_2_API SHALL 计算 `weeklyVisits` 中每周的 `pct` 为相对于 8 周最大值的百分比(0-100)
|
||||
|
||||
#### 2.10 loyal 维度专属字段
|
||||
|
||||
21. WHEN `dimension=loyal` 时,THE BOARD_2_API SHALL 返回:`intimacy`(专一度指数)、`topCoachName`(最亲密助教姓名)、`topCoachHeart`(最亲密助教亲密度分数)、`topCoachScore`(最亲密助教关系指数)、`coachName`(主助教姓名)、`coachRatio`(主助教占比,如 `"78%"`)、`coachDetails`(助教明细列表,`Array<{ name, cls, heartScore, badge?, avgDuration, serviceCount, coachSpend, relationIdx }>`)
|
||||
22. THE BOARD_2_API SHALL 按 `max_rs`(最大关系指数)降序排列 loyal 维度结果
|
||||
|
||||
#### 2.11 维度切换策略
|
||||
|
||||
23. THE BOARD_2_API SHALL 按 `dimension` 参数仅返回对应维度的专属字段(减少传输量和查询开销),切换维度时前端重新请求
|
||||
|
||||
### 需求 3:实现 BOARD-3 经营一览 + 预收资产(T3-3)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在财务看板中查看经营一览(8 项核心指标及环比)和预收资产(储值卡 + 赠送卡矩阵),并支持时间范围、区域筛选和环比开关,以便全面掌握门店的经营状况和预收资产变动。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 3.1 请求参数
|
||||
|
||||
1. THE BOARD_3_API SHALL 接受 3 个查询参数:`time`(时间范围,8 种枚举:`month`/`lastMonth`/`week`/`lastWeek`/`quarter3`/`quarter`/`lastQuarter`/`half6`)、`area`(区域筛选,7 种枚举:`all`/`hall`/`hallA`/`hallB`/`hallC`/`mahjong`/`teamBuilding`)、`compare`(环比开关,`0` 或 `1`,默认 `0`)
|
||||
2. THE BOARD_3_API SHALL 根据 `time` 参数计算当期日期范围(`month`=当月1日~末日、`lastMonth`=上月1日~末日、`week`=本周一~本周日、`lastWeek`=上周一~上周日、`quarter3`=前3个月不含本月、`quarter`=本季度首日~末日、`lastQuarter`=上季度、`half6`=最近6个月不含本月)
|
||||
3. WHEN `compare=1` 时,THE BOARD_3_API SHALL 计算上期日期范围(与当期相同长度的前一个周期),分别查询当期和上期数据后计算环比百分比
|
||||
4. WHEN `compare=0` 时,THE BOARD_3_API SHALL 不返回任何环比字段(`xxxCompare`/`isDown`/`isFlat`),减少查询开销
|
||||
|
||||
#### 3.2 经营一览 overview(8 指标 + 8 环比)
|
||||
|
||||
5. THE BOARD_3_API SHALL 返回 `overview` 板块,包含 8 项核心指标:`occurrence`(发生额/正价)、`discount`(总优惠,负值)、`discountRate`(折扣率)、`confirmedRevenue`(成交/确认收入)、`cashIn`(实收/现金流入)、`cashOut`(现金支出)、`cashBalance`(现金结余)、`balanceRate`(结余率)
|
||||
6. WHEN `compare=1` 时,THE BOARD_3_API SHALL 为 overview 每项指标返回 3 个环比字段:`xxxCompare`(环比百分比字符串,如 `"12.5%"` 或 `"持平"`)、`xxxDown`(是否下降,boolean)、`xxxFlat`(是否持平,boolean)
|
||||
7. THE BOARD_3_API SHALL 从 `v_dws_finance_daily_summary` 查询经营一览数据(按日期范围聚合),使用 `items_sum` 口径计算所有金额(DWD-DOC 强制规则 1)
|
||||
|
||||
#### 3.3 预收资产 recharge(储值卡 + 赠送卡矩阵)
|
||||
|
||||
8. WHEN `area=all` 时,THE BOARD_3_API SHALL 返回 `recharge` 板块,包含储值卡 5 项指标:`actualIncome`(充值实收)、`firstCharge`(首充)、`renewCharge`(续费)、`consumed`(消耗)、`cardBalance`(储值卡总余额)
|
||||
9. THE BOARD_3_API SHALL 返回 `recharge.giftRows`(赠送卡 3×4 矩阵),3 行(新增/消费/余额)× 4 列(合计/酒水卡/台费卡/抵用券),每个单元格含值和环比字段,共 24 个数据字段 + 24 个环比字段
|
||||
10. THE BOARD_3_API SHALL 返回 `recharge.allCardBalance`(全类别会员卡余额合计 = 储值卡 + 赠送卡)
|
||||
11. WHEN `area` 不等于 `all` 时,THE BOARD_3_API SHALL 将 `recharge` 板块返回 `null`(储值卡数据不按区域拆分,选中具体区域时无意义)
|
||||
12. THE BOARD_3_API SHALL 从 `v_dws_finance_recharge_summary` 查询预收资产数据
|
||||
|
||||
### 需求 4:实现 BOARD-3 应计收入 + 现金流入(T3-4)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在财务看板中查看应计收入确认(收入结构表含区域子行、正价/优惠/渠道明细)和现金流入(消费收款 + 充值收款),以便分析收入构成和现金来源。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 4.1 应计收入确认 revenue
|
||||
|
||||
1. THE BOARD_3_API SHALL 返回 `revenue.structureRows`(收入结构表),包含 9 行含子行标记:主行(开台与包厢、助教基础课、助教激励课、食品酒水)+ 子行(A区/B区/C区/团建区/麻将区,属于"开台与包厢"的子行,`isSub=true`),每行含 `id`、`name`、`desc`(可选)、`amount`(发生额)、`discount`(优惠金额)、`booked`(入账金额)、`bookedCompare`(入账环比,可选)
|
||||
2. THE BOARD_3_API SHALL 对收入结构表中助教行使用 `assistant_pd_money`(基础课/陪打)和 `assistant_cx_money`(激励课/超休)拆分(DWD-DOC 强制规则 2),禁止使用 `service_fee`
|
||||
3. THE BOARD_3_API SHALL 返回 `revenue.priceItems`(正价明细,4 项)、`revenue.totalOccurrence`(发生额合计)、`revenue.discountItems`(优惠明细,4 项)、`revenue.confirmedTotal`(确认收入合计)、`revenue.channelItems`(渠道明细,3 项:储值卡结算冲销/现金线上支付/团购核销确认收入)
|
||||
4. THE BOARD_3_API SHALL 从 `v_dws_finance_income_structure`(收入结构主表)+ `v_dws_finance_discount_detail`(优惠明细辅助)查询应计收入数据
|
||||
|
||||
#### 4.2 现金流入 cashflow
|
||||
|
||||
5. THE BOARD_3_API SHALL 返回 `cashflow` 板块,包含 `consumeItems`(消费收款,3 项:纸币现金/线上收款/团购平台)、`rechargeItems`(充值收款,1 项:会员充值到账)、`total`(现金流入合计)
|
||||
6. THE BOARD_3_API SHALL 确保 `consumeItems` 中 `platform_settlement_amount` 和 `groupbuy_pay_amount` 互斥(DWD-DOC 强制规则 8:现金流互斥)
|
||||
7. THE BOARD_3_API SHALL 从 `v_dws_finance_daily_summary` 查询现金流入数据(消费收款 + 充值收款字段均在财务日报中)
|
||||
|
||||
### 需求 5:实现 BOARD-3 现金流出 + 助教分析(T3-5)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在财务看板中查看现金流出(经营/固定/助教分成/平台服务费 4 个子分组)和助教分析(基础课 + 激励课各按 4 等级分行),以便分析支出结构和助教成本。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 5.1 现金流出 expense
|
||||
|
||||
1. THE BOARD_3_API SHALL 返回 `expense` 板块,包含 4 个子分组:`operationItems`(经营支出,3 项:食品饮料/耗材/报销)、`fixedItems`(固定支出,4 项:房租/水电/物业/人员工资)、`coachItems`(助教分成,4 项:基础课分成/激励课分成/充值提成/额外奖金)、`platformItems`(平台服务费,3 项:汇来米/美团/抖音)
|
||||
2. THE BOARD_3_API SHALL 返回 `expense.total`(现金流出合计)及其环比字段
|
||||
3. THE BOARD_3_API SHALL 对 `coachItems` 中基础课分成使用 `assistant_pd_money`,激励课分成使用 `assistant_cx_money`(DWD-DOC 强制规则 2)
|
||||
4. THE BOARD_3_API SHALL 从 `v_dws_finance_expense_summary`(支出明细 4 子分组,含助教分成)+ `v_dws_platform_settlement`(平台服务费:汇来米/美团/抖音)查询现金流出数据
|
||||
|
||||
#### 5.2 助教分析 coachAnalysis
|
||||
|
||||
5. THE BOARD_3_API SHALL 返回 `coachAnalysis` 板块,包含 `basic`(基础课/陪打)和 `incentive`(激励课/超休)两个子表,结构完全相同
|
||||
6. THE BOARD_3_API SHALL 为每个子表返回汇总行:`totalPay`(总课时费)、`totalShare`(总分成)、`avgHourly`(平均时薪),各含环比字段
|
||||
7. THE BOARD_3_API SHALL 为每个子表返回 `rows`(按等级分行,4 行:初级/中级/高级/星级),每行含 `level`(等级名)、`pay`(课时费)、`share`(分成)、`hourly`(时薪),各含环比字段和方向标记(`payDown`/`shareDown`/`hourlyFlat`)
|
||||
8. THE BOARD_3_API SHALL 从 `v_dws_assistant_salary_calc` 按 `assistant_level_name` 分组聚合助教分析数据
|
||||
|
||||
### 需求 6:实现 CONFIG-1 技能类型列表(T3-6)
|
||||
|
||||
**用户故事:** 作为管理者,我希望助教看板的技能筛选选项从后端配置表动态获取(而非前端硬编码),以便在新增技能类型时无需发版更新前端。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CONFIG_1_API SHALL 实现 `GET /api/xcx/config/skill-types` 端点,返回技能类型列表,每项含 `key`(枚举值,如 `chinese`/`snooker`/`mahjong`/`karaoke`)、`label`(中文标签)、`emoji`(表情符号)、`cls`(前端样式类)
|
||||
2. THE CONFIG_1_API SHALL 从 ETL cfg 表读取技能类型配置数据
|
||||
3. IF ETL cfg 表查询失败或无数据,THEN THE CONFIG_1_API SHALL 返回空数组,前端 `api.ts` 中的硬编码列表作为 mock 回退
|
||||
4. THE CONFIG_1_API SHALL 对响应设置合理的缓存策略(技能类型变更频率极低)
|
||||
|
||||
### 需求 7:前端看板筛选修复(T3-7)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在看板页面切换筛选条件时能立即看到更新后的数据(而非停留在旧数据),以便实时对比不同维度和时间范围的数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 7.1 BOARD-1 筛选修复(F1, F6)
|
||||
|
||||
1. THE Miniprogram SHALL 修复 `board-coach` 页面的 `onSortChange`、`onSkillChange`、`onTimeChange` 事件处理函数,在更新 data 状态后调用 `this.loadData()` 重新请求 API
|
||||
2. THE Miniprogram SHALL 在 `board-coach` 页面实现 `time=last_6m` + `sort=sv_desc` 的互斥约束:选择 `last_6m` 时禁用 `sv_desc` 选项,或选择 `sv_desc` 时禁用 `last_6m` 选项
|
||||
|
||||
#### 7.2 BOARD-2 筛选修复 + 分页补充(F2, F3)
|
||||
|
||||
3. THE Miniprogram SHALL 修复 `board-customer` 页面的 `onDimensionChange`、`onProjectChange` 事件处理函数,在更新 data 状态后调用 `this.loadData()` 重新请求 API
|
||||
4. THE Miniprogram SHALL 为 `board-customer` 页面补充分页参数(`page`/`pageSize`)和"加载更多"懒加载逻辑(`onReachBottom` 触发加载下一页,`pageSize=20`)
|
||||
5. THE Miniprogram SHALL 修改 `services/api.ts` 中 `fetchBoardCustomers` 函数签名,增加 `page` 和 `pageSize` 参数
|
||||
|
||||
#### 7.3 BOARD-3 筛选修复 + 签名扩展(F4, F5)
|
||||
|
||||
6. THE Miniprogram SHALL 修改 `services/api.ts` 中 `fetchBoardFinance` 函数签名,从 `{ date?: string }` 扩展为 `{ time: string, area: string, compare: number }`
|
||||
7. THE Miniprogram SHALL 修复 `board-finance` 页面的 `onTimeChange`、`onAreaChange` 事件处理函数,在更新 data 状态后使用新参数调用 `fetchBoardFinance` 重新请求 API
|
||||
8. THE Miniprogram SHALL 修复 `board-finance` 页面的 `toggleCompare` 函数,切换环比开关后使用 `compare=0` 或 `compare=1` 参数重新请求 API
|
||||
9. WHEN `area` 不等于 `all` 时,THE Miniprogram SHALL 隐藏预收资产板块(`recharge` 为 `null` 时不渲染该 section)
|
||||
|
||||
### 需求 8:全局约束与数据隔离
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望所有看板和配置接口都遵循统一的权限控制、数据隔离和数据质量规则,以确保数据安全和口径一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 8.1 权限与认证
|
||||
|
||||
1. THE Backend SHALL 对所有 RNS1.3 接口(BOARD_1_API、BOARD_2_API、BOARD_3_API、CONFIG_1_API)执行 `require_approved()` 权限检查,确保用户状态为 `approved`
|
||||
2. THE Backend SHALL 对 BOARD_1_API 验证用户具有 `view_board_coach` 权限,对 BOARD_2_API 验证用户具有 `view_board_customer` 权限,对 BOARD_3_API 验证用户具有 `view_board_finance` 权限
|
||||
3. THE Backend SHALL 对所有 RNS1.3 接口通过 `SET LOCAL app.current_site_id` 实现门店级数据隔离(FDW 查询通过 `_fdw_context` 上下文管理器统一执行)
|
||||
|
||||
#### 8.2 DWD-DOC 强制规则
|
||||
|
||||
4. THE Backend SHALL 对所有涉及金额的字段统一使用 `items_sum` 口径(DWD-DOC 强制规则 1),禁止使用 `consume_money`
|
||||
5. THE Backend SHALL 对所有涉及助教费用的字段使用 `assistant_pd_money`(陪打/基础课)+ `assistant_cx_money`(超休/激励课)拆分(DWD-DOC 强制规则 2),禁止使用 `service_fee`
|
||||
6. THE Backend SHALL 对所有涉及会员信息的查询通过 `member_id` LEFT JOIN `v_dim_member`(取 `scd2_is_current=1`)获取姓名和手机号(DWD-DOC 强制规则 DQ-6),禁止直接使用 `settlement_head.member_phone` 或 `member_name`
|
||||
7. THE Backend SHALL 确保支付渠道恒等式 `balance_amount = recharge_card_amount + gift_card_amount`(DWD-DOC 强制规则 3),三者不可重复计算
|
||||
8. THE Backend SHALL 确保现金流互斥:`platform_settlement_amount` 和 `groupbuy_pay_amount` 互斥(DWD-DOC 强制规则 8)
|
||||
|
||||
#### 8.3 优雅降级
|
||||
|
||||
9. IF 某个看板板块的数据源查询失败,THEN THE Backend SHALL 对该板块返回空默认值(空对象或空数组),不影响其他板块和整体响应
|
||||
10. THE Backend SHALL 对所有 FDW 查询异常进行捕获和日志记录,返回降级响应而非 HTTP 500
|
||||
|
||||
#### 8.4 环比计算通用规则
|
||||
|
||||
11. THE Backend SHALL 对所有环比计算采用统一公式:`compareValue = (当期值 - 上期值) / 上期值 × 100%`,格式化为百分比字符串(如 `"12.5%"`)
|
||||
12. WHEN 上期值为 0 且当期值不为 0 时,THE Backend SHALL 返回 `xxxCompare: "新增"`,`xxxDown: false`,`xxxFlat: false`
|
||||
13. WHEN 上期值和当期值均为 0 时,THE Backend SHALL 返回 `xxxCompare: "持平"`,`xxxDown: false`,`xxxFlat: true`
|
||||
14. THE Backend SHALL 根据环比值设置方向标记:正值 → `isDown=false`,负值 → `isDown=true`,零值 → `isFlat=true`
|
||||
|
||||
### 需求 9:正确性属性(Property-Based Testing)
|
||||
|
||||
**用户故事:** 作为开发者,我希望通过属性测试验证看板接口的数据一致性和业务规则正确性,以便在开发阶段发现口径错误和数据异常。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 9.1 BOARD-1 排序不变量
|
||||
|
||||
1. FOR ALL BOARD_1_API 响应(`sort=perf_desc`),列表 SHALL 按 `perfHours` 降序排列(前一项的 `perfHours` ≥ 后一项的 `perfHours`)
|
||||
2. FOR ALL BOARD_1_API 响应(`sort=salary_asc`),列表 SHALL 按 `salary` 升序排列(前一项的 `salary` ≤ 后一项的 `salary`)
|
||||
|
||||
#### 9.2 BOARD-2 分页不变量
|
||||
|
||||
3. FOR ALL 相同参数的 BOARD_2_API 请求(相同 `dimension`、`project`),`page=1` 返回的 `total` SHALL 等于 `page=2` 返回的 `total`(总数在分页间保持一致)
|
||||
4. FOR ALL BOARD_2_API 响应,`items.length` SHALL 小于等于 `pageSize`
|
||||
|
||||
#### 9.3 BOARD-3 经营一览恒等式
|
||||
|
||||
5. FOR ALL BOARD_3_API 响应中的 `overview`,`confirmedRevenue` SHALL 近似等于 `occurrence` 减去 `discount` 的绝对值(在浮点精度范围内),验证收入确认公式
|
||||
6. FOR ALL BOARD_3_API 响应中的 `overview`,`cashBalance` SHALL 近似等于 `cashIn` 减去 `cashOut`(在浮点精度范围内),验证现金结余公式
|
||||
|
||||
#### 9.4 BOARD-3 预收资产区域约束
|
||||
|
||||
7. FOR ALL BOARD_3_API 响应(`area` 不等于 `all`),`recharge` SHALL 为 `null`,验证预收资产区域隐藏规则
|
||||
|
||||
#### 9.5 BOARD-3 环比开关一致性
|
||||
|
||||
8. FOR ALL BOARD_3_API 响应(`compare=0`),响应 JSON 中 SHALL 不包含任何以 `Compare`、`Down`、`Flat` 结尾的字段,验证环比开关关闭时不返回环比数据
|
||||
|
||||
#### 9.6 BOARD-3 支付渠道恒等式
|
||||
|
||||
9. FOR ALL BOARD_3_API 响应中涉及支付渠道的数据,`balance_amount` SHALL 等于 `recharge_card_amount + gift_card_amount`(DWD-DOC 强制规则 3),验证支付渠道恒等式
|
||||
|
||||
#### 9.7 幂等性
|
||||
|
||||
10. FOR ALL 相同参数的 BOARD_3_API 请求(相同 `time`、`area`、`compare`),在数据未变更的情况下,两次请求 SHALL 返回相同的 `overview.occurrence` 和 `overview.cashBalance` 值
|
||||
|
||||
#### 9.8 BOARD-1 交叉约束
|
||||
|
||||
11. FOR ALL BOARD_1_API 请求(`time=last_6m` 且 `sort=sv_desc`),响应 SHALL 为 HTTP 400,验证不兼容参数组合的拒绝规则
|
||||
@@ -1,319 +0,0 @@
|
||||
# Implementation Plan: RNS1.3 三看板接口
|
||||
|
||||
## Overview
|
||||
|
||||
基于 design.md 的模块结构,增量扩展后端路由、服务层和 FDW 查询层,新增 3 个看板端点 + 1 个配置端点,并完成前端筛选修复。BOARD-3 财务看板是最复杂的单个接口(6 板块、200+ 字段、60+ 环比数据点),采用板块级独立查询和独立降级策略。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. 通用工具函数(日期范围 + 环比计算)
|
||||
- [x] 1.1 在 `apps/backend/app/services/board_service.py` 中实现 `_calc_date_range(time_enum, ref_date=None)` 工具函数
|
||||
- 支持 BOARD-1 的 6 种时间枚举(`month`/`quarter`/`last_month`/`last_3m`/`last_quarter`/`last_6m`)和 BOARD-3 的 8 种时间枚举(`month`/`lastMonth`/`week`/`lastWeek`/`quarter3`/`quarter`/`lastQuarter`/`half6`)
|
||||
- 返回 `(start_date, end_date)` 元组,`date` 类型
|
||||
- _Requirements: 1.3, 3.2_
|
||||
- [x] 1.2 在 `board_service.py` 中实现 `_calc_prev_range(start_date, end_date)` 计算上期日期范围
|
||||
- 上期长度等于当期长度,`prev_end <= start_date`
|
||||
- _Requirements: 3.3_
|
||||
- [x] 1.3 在 `board_service.py` 中实现 `calc_compare(current: Decimal, previous: Decimal) -> dict` 环比计算工具
|
||||
- 返回 `{ compare: str, is_down: bool, is_flat: bool }`
|
||||
- 边界:`previous=0, current≠0` → `"新增"`;`previous=0, current=0` → `"持平"`
|
||||
- _Requirements: 8.11, 8.12, 8.13, 8.14_
|
||||
|
||||
- [x] 2. Pydantic Schema 定义
|
||||
- [x] 2.1 新建 `apps/backend/app/schemas/xcx_board.py`,定义请求参数枚举
|
||||
- `CoachSortEnum`(6 值)、`SkillFilterEnum`(5 值)、`BoardTimeEnum`(6 值)
|
||||
- `CustomerDimensionEnum`(8 值)、`ProjectFilterEnum`(5 值)
|
||||
- `FinanceTimeEnum`(8 值)、`AreaFilterEnum`(7 值)
|
||||
- _Requirements: 1.1, 2.1, 3.1_
|
||||
- [x] 2.2 在 `xcx_board.py` 中定义 BOARD-1 响应 Schema
|
||||
- `CoachSkillItem`、`CoachBoardItem`(扁平结构,含 perf/salary/sv/task 全部维度字段)、`CoachBoardResponse`(items + dimType)
|
||||
- _Requirements: 1.4~1.14, 1.16_
|
||||
- [x] 2.3 在 `xcx_board.py` 中定义 BOARD-2 响应 Schema
|
||||
- `CustomerAssistant`、`CustomerBoardItemBase`(基础字段)
|
||||
- 8 个维度专属 Schema:`RecallItem`、`PotentialItem`、`BalanceItem`、`RechargeItem`、`RecentItem`、`Spend60Item`、`Freq60Item`、`LoyalItem`
|
||||
- `WeeklyVisit`(val + pct)、`PotentialTag`、`CoachDetail`
|
||||
- `CustomerBoardResponse`(items + total + page + pageSize)
|
||||
- _Requirements: 2.3~2.22_
|
||||
- [x] 2.4 在 `xcx_board.py` 中定义 BOARD-3 响应 Schema
|
||||
- `OverviewPanel`(8 指标 + 各 3 个环比字段,Optional)
|
||||
- `GiftCell`、`GiftRow`、`RechargePanel`(储值卡 5 指标 + 赠送卡 3×4 矩阵 + allCardBalance)
|
||||
- `RevenueStructureRow`、`RevenueItem`、`ChannelItem`、`RevenuePanel`
|
||||
- `CashflowItem`、`CashflowPanel`
|
||||
- `ExpenseItem`、`ExpensePanel`(4 子分组 + total)
|
||||
- `CoachAnalysisRow`、`CoachAnalysisTable`、`CoachAnalysisPanel`(basic + incentive)
|
||||
- `FinanceBoardResponse`(overview + recharge|null + revenue + cashflow + expense + coachAnalysis)
|
||||
- _Requirements: 3.5~3.12, 4.1~4.7, 5.1~5.8_
|
||||
- [x] 2.5 新建 `apps/backend/app/schemas/xcx_config.py`,定义 `SkillTypeItem`(key/label/emoji/cls)
|
||||
- _Requirements: 6.1_
|
||||
|
||||
- [x] 3. FDW 查询层扩展 — BOARD-1
|
||||
- [x] 3.1 在 `apps/backend/app/services/fdw_queries.py` 中实现 `get_all_assistants(conn, site_id, skill_filter)`
|
||||
- 数据源:`app.v_dim_assistant`,按 `skill` 筛选
|
||||
- _Requirements: 1.5_
|
||||
- [x] 3.2 实现 `get_salary_calc_batch(conn, site_id, assistant_ids, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_assistant_salary_calc`,批量查询当期和上期绩效
|
||||
- ⚠️ 基于已有 `get_salary_calc()` 的 SQL 模式扩展,复用列名映射(salary_month/effective_hours/gross_salary/base_income/bonus_income)
|
||||
- ⚠️ DWD-DOC 规则 1:收入使用 items_sum 口径
|
||||
- ⚠️ DWD-DOC 规则 2:费用使用 assistant_pd_money + assistant_cx_money
|
||||
- _Requirements: 1.8, 1.10_
|
||||
- [x] 3.3 实现 `get_top_customers_for_coaches(conn, site_id, assistant_ids)`
|
||||
- 数据源:`app.v_dws_member_assistant_relation_index` + `app.v_dim_member`
|
||||
- ⚠️ 基于已有 `get_relation_index()` 的 SQL 模式扩展为按助教维度批量查询
|
||||
- 按亲密度降序取 Top 3,拼接 P6 AC3 四级 emoji(`> 8.5` → 💖,`> 7` → 🧡,`> 5` → 💛,`≤ 5` → 💙)
|
||||
- ⚠️ DQ-6:客户姓名通过 member_id JOIN v_dim_member,取 scd2_is_current=1
|
||||
- ⚠️ 注意:已有 `get_coach_top_customers()` 按服务次数排序(来自 v_dwd_assistant_service_log),本函数按亲密度排序(来自 v_dws_member_assistant_relation_index),语义不同,不可复用
|
||||
- _Requirements: 1.6_
|
||||
- [x] 3.4 实现 `get_coach_sv_data(conn, site_id, assistant_ids, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_assistant_monthly_summary`(已按助教维度预聚合,含客源储值额/储值客户数/储值消耗额)
|
||||
- ⚠️ 不使用 `v_dws_member_consumption_summary`(那是按客户维度的汇总表,需要额外关联助教再聚合,效率低且语义不匹配)
|
||||
- _Requirements: 1.12_
|
||||
|
||||
- [x] 4. FDW 查询层扩展 — BOARD-2(8 维度)
|
||||
- [x] 4.1 实现 `get_customer_board_recall(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_winback_index` + `app.v_dim_member`(ETL 已计算 WBI 召回指数,含 ideal_days/elapsed_days/overdue_days/visits_30d/wbi_score)
|
||||
- 按 WBI(wbi_score)降序,LIMIT/OFFSET 分页
|
||||
- ⚠️ DQ-6:客户姓名通过 member_id JOIN v_dim_member
|
||||
- ⚠️ 余额通过 JOIN v_dim_member_card_account 获取
|
||||
- _Requirements: 2.6, 2.7_
|
||||
- [x] 4.2 实现 `get_customer_board_potential(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_spending_power_index`(ETL 已计算 SPI 消费潜力指数,含 potential_tags/spend_30d/avg_visits/avg_spend/spi_score)
|
||||
- 按 SPI(spi_score)降序
|
||||
- _Requirements: 2.8, 2.9_
|
||||
- [x] 4.3 实现 `get_customer_board_balance(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dim_member_card_account` + `app.v_dim_member`
|
||||
- 按 balance_amount 降序
|
||||
- ⚠️ DQ-7:余额通过 tenant_member_id JOIN,取 scd2_is_current=1
|
||||
- _Requirements: 2.10, 2.11_
|
||||
- [x] 4.4 实现 `get_customer_board_recharge(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dwd_recharge_order` + `app.v_dim_member_card_account`(充值记录 + 当前余额)
|
||||
- 按 last_recharge_date 降序
|
||||
- _Requirements: 2.12, 2.13_
|
||||
- [x] 4.5 实现 `get_customer_board_recent(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_visit_detail` + `app.v_dim_member`(ETL 已计算到店明细,含 last_visit_date/visit_freq/ideal_days)
|
||||
- 按 last_visit_date 降序
|
||||
- _Requirements: 2.14, 2.15_
|
||||
- [x] 4.6 实现 `get_customer_board_spend60(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_consumption_summary`(items_sum_60d 已在汇总表中预计算)
|
||||
- ⚠️ DWD-DOC 规则 1:使用 items_sum 口径计算 spend60d
|
||||
- 按 items_sum_60d 降序
|
||||
- _Requirements: 2.16, 2.17_
|
||||
- [x] 4.7 实现 `get_customer_board_freq60(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_consumption_summary`(visit_count_60d 已在汇总表中预计算)
|
||||
- 含 weeklyVisits 8 周柱状图计算(pct 相对最大值百分比 0-100)
|
||||
- ⚠️ weeklyVisits 需从 `app.v_dwd_assistant_service_log` 按周分组统计(汇总表无周粒度数据)
|
||||
- 按 visit_count_60d 降序
|
||||
- _Requirements: 2.18, 2.19, 2.20_
|
||||
- [x] 4.8 实现 `get_customer_board_loyal(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_assistant_relation_index`
|
||||
- 按 max_rs 降序
|
||||
- _Requirements: 2.21, 2.22_
|
||||
- [x] 4.9 实现 `get_customer_assistants(conn, site_id, member_ids)` 批量查询客户关联助教列表
|
||||
- 含亲密度计算,当前跟进助教置顶
|
||||
- _Requirements: 2.5_
|
||||
|
||||
- [x] 5. FDW 查询层扩展 — BOARD-3(6 板块)
|
||||
- [x] 5.1 实现 `get_finance_overview(conn, site_id, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_finance_daily_summary`(按日期范围 SUM 聚合),返回 8 项核心指标
|
||||
- ⚠️ DWD-DOC 规则 1:使用 items_sum 口径
|
||||
- ⚠️ 注意:ETL 中不存在名为 `v_dws_finance_overview` 的视图,实际视图为 `v_dws_finance_daily_summary`
|
||||
- _Requirements: 3.5, 3.7_
|
||||
- [x] 5.2 实现 `get_finance_recharge(conn, site_id, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_finance_recharge_summary`,返回储值卡 5 指标 + 赠送卡 3×4 矩阵
|
||||
- ⚠️ 注意:ETL 中不存在名为 `v_dws_finance_recharge` 的视图,实际视图为 `v_dws_finance_recharge_summary`
|
||||
- _Requirements: 3.8, 3.9, 3.10, 3.12_
|
||||
- [x] 5.3 实现 `get_finance_revenue(conn, site_id, start_date, end_date, area)`
|
||||
- 数据源:`app.v_dws_finance_income_structure`(收入结构主表)+ `app.v_dws_finance_discount_detail`(优惠明细辅助)
|
||||
- ⚠️ DWD-DOC 规则 2:助教行使用 assistant_pd_money(基础课)+ assistant_cx_money(激励课)
|
||||
- ⚠️ 注意:ETL 中不存在名为 `v_dws_finance_revenue` 的视图,需组合两个实际视图
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4_
|
||||
- [x] 5.4 实现 `get_finance_cashflow(conn, site_id, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_finance_daily_summary`(消费收款 + 充值收款字段均在财务日报中)
|
||||
- ⚠️ DWD-DOC 规则 7:platform_settlement_amount 和 groupbuy_pay_amount 互斥
|
||||
- ⚠️ 注意:ETL 中不存在名为 `v_dws_finance_cashflow` 的独立视图,复用财务日报
|
||||
- _Requirements: 4.5, 4.6, 4.7_
|
||||
- [x] 5.5 实现 `get_finance_expense(conn, site_id, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_finance_expense_summary`(支出明细 4 子分组)+ `app.v_dws_platform_settlement`(平台服务费:汇来米/美团/抖音)
|
||||
- ⚠️ DWD-DOC 规则 2:coachItems 中基础课使用 assistant_pd_money,激励课使用 assistant_cx_money
|
||||
- ⚠️ 注意:ETL 中不存在名为 `v_dws_finance_expense` 的视图,需组合两个实际视图
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4_
|
||||
- [x] 5.6 实现 `get_finance_coach_analysis(conn, site_id, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_assistant_salary_calc`,按 assistant_level_name 分组聚合
|
||||
- 返回 basic(基础课/陪打)+ incentive(激励课/超休)两个子表
|
||||
- _Requirements: 5.5, 5.6, 5.7, 5.8_
|
||||
- [x] 5.7 实现 `get_skill_types(conn, site_id)` 查询技能类型配置
|
||||
- 数据源:ETL cfg 表
|
||||
- _Requirements: 6.2_
|
||||
|
||||
- [x] 6. Checkpoint — FDW 查询层验证
|
||||
- All FDW query functions compile and type-check correctly (getDiagnostics: 0 errors).
|
||||
|
||||
- [x] 7. 服务层 — BOARD-1 助教看板
|
||||
- [x] 7.1 在 `board_service.py` 中实现 `get_coach_board(sort, skill, time, site_id) -> dict`
|
||||
- 参数互斥校验:`time=last_6m` + `sort=sv_desc` → HTTP 400
|
||||
- 日期范围计算 → 查询助教列表 → 批量查询绩效/Top 客户/储值/任务 → 排序 → 组装扁平响应
|
||||
- topCustomers 查询失败降级为空列表
|
||||
- _Requirements: 1.1~1.16_
|
||||
- [x] 7.2 在 `board_service.py` 中实现 `_query_coach_tasks(site_id, assistant_ids, start_date, end_date)` 查询任务完成数
|
||||
- 数据源:`biz.coach_tasks`,按 task_type 分类统计 recall/callback
|
||||
- _Requirements: 1.13, 1.14_
|
||||
|
||||
- [x] 8. 服务层 — BOARD-2 客户看板
|
||||
- [x] 8.1 在 `board_service.py` 中实现 `get_customer_board(dimension, project, page, page_size, site_id) -> dict`
|
||||
- 按 dimension 参数路由到对应 FDW 查询函数
|
||||
- 批量查询客户关联助教列表
|
||||
- 组装分页响应(items + total + page + pageSize)
|
||||
- _Requirements: 2.1~2.23_
|
||||
|
||||
- [x] 9. 服务层 — BOARD-3 财务看板
|
||||
- [x] 9.1 在 `board_service.py` 中实现 `get_finance_board(time, area, compare, site_id) -> dict`
|
||||
- 日期范围计算 → 6 板块独立查询、独立 try/except 降级
|
||||
- `area≠all` 时 recharge 返回 null
|
||||
- `compare=1` 时计算上期范围并调用 calc_compare
|
||||
- `compare=0` 时环比字段为 None(序列化时排除)
|
||||
- _Requirements: 3.1~3.12, 4.1~4.7, 5.1~5.8, 8.9, 8.10_
|
||||
- [x] 9.2 实现 `_build_overview(conn, site_id, date_range, prev_range, compare)` 经营一览板块构建
|
||||
- _Requirements: 3.5, 3.6, 3.7_
|
||||
- [x] 9.3 实现 `_build_recharge(conn, site_id, date_range, prev_range, compare)` 预收资产板块构建
|
||||
- _Requirements: 3.8~3.12_
|
||||
- [x] 9.4 实现 `_build_revenue(conn, site_id, date_range, area, prev_range, compare)` 应计收入板块构建
|
||||
- _Requirements: 4.1~4.4_
|
||||
- [x] 9.5 实现 `_build_cashflow(conn, site_id, date_range, prev_range, compare)` 现金流入板块构建
|
||||
- _Requirements: 4.5~4.7_
|
||||
- [x] 9.6 实现 `_build_expense(conn, site_id, date_range, prev_range, compare)` 现金流出板块构建
|
||||
- _Requirements: 5.1~5.4_
|
||||
- [x] 9.7 实现 `_build_coach_analysis(conn, site_id, date_range, prev_range, compare)` 助教分析板块构建
|
||||
- _Requirements: 5.5~5.8_
|
||||
- [x] 9.8 实现各板块的 `_empty_*()` 空默认值工厂函数(优雅降级用)
|
||||
- _Requirements: 8.9, 8.10_
|
||||
|
||||
- [x] 10. 路由层 + 路由注册
|
||||
- [x] 10.1 新建 `apps/backend/app/routers/xcx_board.py`,实现 3 个看板端点
|
||||
- `GET /api/xcx/board/coaches` — require_permission("view_board_coach")
|
||||
- `GET /api/xcx/board/customers` — require_permission("view_board_customer")
|
||||
- `GET /api/xcx/board/finance` — require_permission("view_board_finance"),`response_model_exclude_none=True`
|
||||
- _Requirements: 8.1, 8.2, 8.3_
|
||||
- [x] 10.2 新建 `apps/backend/app/routers/xcx_config.py`,实现 CONFIG-1 端点
|
||||
- `GET /api/xcx/config/skill-types` — require_approved()
|
||||
- 查询失败降级返回空数组
|
||||
- _Requirements: 6.1~6.4_
|
||||
- [x] 10.3 在 `apps/backend/app/main.py` 中注册 `xcx_board` 和 `xcx_config` 路由
|
||||
- _Requirements: 8.1_
|
||||
|
||||
- [x] 11. Checkpoint — 后端接口验证
|
||||
- All backend endpoints compile and type-check correctly (getDiagnostics: 0 errors on all router files and main.py).
|
||||
|
||||
- [x] 12. 前端筛选修复 — BOARD-1(T3-7 F1, F6)
|
||||
- [x] 12.1 修复 `apps/miniprogram/miniprogram/pages/board-coach/` 页面的 `onSortChange`、`onSkillChange`、`onTimeChange` 事件处理函数
|
||||
- 更新 data 状态后调用 `this.loadData()` 重新请求 API
|
||||
- _Requirements: 7.1_
|
||||
- [x] 12.2 实现 `time=last_6m` + `sort=sv_desc` 互斥约束
|
||||
- 选择 `last_6m` 时禁用 `sv_desc` 选项,或选择 `sv_desc` 时禁用 `last_6m` 选项
|
||||
- _Requirements: 7.2_
|
||||
|
||||
- [x] 13. 前端筛选修复 — BOARD-2(T3-7 F2, F3)
|
||||
- [x] 13.1 修复 `apps/miniprogram/miniprogram/pages/board-customer/` 页面的 `onDimensionChange`、`onProjectChange` 事件处理函数
|
||||
- 更新 data 状态后调用 `this.loadData()` 重新请求 API
|
||||
- _Requirements: 7.3_
|
||||
- [x] 13.2 补充分页参数和懒加载逻辑
|
||||
- `onReachBottom` 触发加载下一页,`pageSize=20`
|
||||
- _Requirements: 7.4_
|
||||
- [x] 13.3 修改 `services/api.ts` 中 `fetchBoardCustomers` 函数签名,增加 `page` 和 `pageSize` 参数
|
||||
- _Requirements: 7.5_
|
||||
|
||||
- [x] 14. 前端筛选修复 — BOARD-3(T3-7 F4, F5)
|
||||
- [x] 14.1 修改 `services/api.ts` 中 `fetchBoardFinance` 函数签名
|
||||
- 从 `{ date?: string }` 扩展为 `{ time: string, area: string, compare: number }`
|
||||
- _Requirements: 7.6_
|
||||
- [x] 14.2 修复 `apps/miniprogram/miniprogram/pages/board-finance/` 页面的 `onTimeChange`、`onAreaChange` 事件处理函数
|
||||
- 更新 data 状态后使用新参数调用 `fetchBoardFinance`
|
||||
- _Requirements: 7.7_
|
||||
- [x] 14.3 修复 `toggleCompare` 函数,切换环比开关后使用 `compare=0/1` 参数重新请求
|
||||
- _Requirements: 7.8_
|
||||
- [x] 14.4 `area≠all` 时隐藏预收资产板块(`recharge` 为 null 时不渲染该 section)
|
||||
- _Requirements: 7.9_
|
||||
|
||||
- [x] 15. Checkpoint — 前端筛选修复验证
|
||||
- All frontend filter fixes implemented: event handlers call loadData(), API signatures extended, pagination added to BOARD-2, mutual exclusion constraint for BOARD-1.
|
||||
|
||||
- [x] 16. 属性测试(Property-Based Testing)
|
||||
- [x] 16.1 新建 `tests/test_board_properties.py`,实现 Property 1: 日期范围计算正确性
|
||||
- 生成器:`st.dates()` + `st.sampled_from(BoardTimeEnum/FinanceTimeEnum)`
|
||||
- 验证:`start_date <= end_date`,上期 `prev_end <= start_date`,上期长度 = 当期长度
|
||||
- **Validates: Requirements 1.3, 3.2, 3.3 — Design Property 1**
|
||||
- [x] 16.2 实现 Property 2: BOARD-1 排序不变量
|
||||
- 生成器:随机助教列表 + `st.sampled_from(CoachSortEnum)`
|
||||
- 验证:相邻元素排序字段满足方向约束
|
||||
- **Validates: Requirements 1.15, 9.1, 9.2 — Design Property 2**
|
||||
- [x] 16.3 实现 Property 3: BOARD-2 分页不变量
|
||||
- 生成器:随机客户列表 + page/pageSize
|
||||
- 验证:`items.length <= pageSize`,total 跨页一致,无交集
|
||||
- **Validates: Requirements 2.2, 9.3, 9.4 — Design Property 3**
|
||||
- [x] 16.4 实现 Property 4: 亲密度 emoji 四级映射
|
||||
- 生成器:`st.floats(min_value=0, max_value=10)`
|
||||
- 验证:`> 8.5` → 💖,`> 7` → 🧡,`> 5` → 💛,`≤ 5` → 💙;边界 `8.5` → 🧡
|
||||
- **Validates: Requirements 1.6 — Design Property 4**
|
||||
- [x] 16.5 实现 Property 5: 环比计算公式正确性
|
||||
- 生成器:`st.decimals(min_value=0, max_value=1e8)` × 2
|
||||
- 验证:公式正确、方向标记正确、"新增"/"持平" 边界
|
||||
- **Validates: Requirements 8.11~8.14 — Design Property 5**
|
||||
- [x] 16.6 实现 Property 6: 环比开关一致性
|
||||
- 生成 BOARD-3 mock 数据 + compare=0,序列化后验证 JSON 无 Compare/Down/Flat key
|
||||
- **Validates: Requirements 3.4, 9.8 — Design Property 6**
|
||||
- [x] 16.7 实现 Property 7: 预收资产区域约束
|
||||
- 生成 area≠all 的请求,验证 recharge=null
|
||||
- **Validates: Requirements 3.11, 9.7 — Design Property 7**
|
||||
- [x] 16.8 实现 Property 8+9: 经营一览恒等式
|
||||
- 验证 `confirmedRevenue ≈ occurrence - abs(discount)`(±0.01)
|
||||
- 验证 `cashBalance ≈ cashIn - cashOut`(±0.01)
|
||||
- **Validates: Requirements 9.5, 9.6 — Design Property 8, 9**
|
||||
- [x] 16.9 实现 Property 10: 支付渠道恒等式
|
||||
- 验证 `balance_amount = recharge_card_amount + gift_card_amount`
|
||||
- **Validates: Requirements 8.7, 9.9 — Design Property 10**
|
||||
- [x] 16.10 实现 Property 11: 参数互斥约束
|
||||
- 固定 `time=last_6m` + `sort=sv_desc`,验证 HTTP 400
|
||||
- **Validates: Requirements 1.2, 9.11 — Design Property 11**
|
||||
- [x] 16.11 实现 Property 13: weeklyVisits 百分比范围
|
||||
- 生成 8 周到店数据,验证长度=8、pct 0-100、max(pct)=100
|
||||
- **Validates: Requirements 2.20 — Design Property 13**
|
||||
- [x] 16.12 实现 Property 14: 优雅降级不变量
|
||||
- mock 板块查询抛异常,验证整体 HTTP 200 + 失败板块空默认值
|
||||
- **Validates: Requirements 8.9, 8.10 — Design Property 14**
|
||||
|
||||
- [x] 17. Final Checkpoint — 全量验证
|
||||
- Run all property tests: `cd C:\NeoZQYY && pytest tests/test_board_properties.py -v`
|
||||
- Ensure all 12 property tests pass. Ask the user if questions arise.
|
||||
|
||||
- [x] 18. 前端到数据库全链路测试
|
||||
- [x] 18.1 启动后端服务,使用测试库(`test_zqyy_app`)验证 BOARD-1、BOARD-2、BOARD-3、CONFIG-1 四个端点的完整请求-响应链路
|
||||
- 使用真实 FDW 连接(`test_etl_feiqiu`)验证 SQL 查询正确性
|
||||
- 验证 JSON 响应结构与 Schema 定义一致(camelCase 序列化)
|
||||
- 验证权限校验(`require_permission()` / `require_approved()`)在真实请求中生效
|
||||
- 验证 `SET LOCAL app.current_site_id` 数据隔离在真实请求中生效
|
||||
- [x] 18.2 验证 BOARD-3 环比开关行为
|
||||
- `compare=0` 时响应 JSON 中无 Compare/Down/Flat 字段 ✅
|
||||
- `compare=1` 时响应 JSON 中包含完整环比数据 ✅
|
||||
- `area≠all` 时 `recharge` 为 null ✅
|
||||
- [x] 18.3 验证 BOARD-1 参数互斥
|
||||
- `time=last_6m` + `sort=sv_desc` 返回 HTTP 400 ✅
|
||||
- [x] 18.4 验证 BOARD-2 分页行为
|
||||
- `page=1, pageSize=20` 返回正确分页结构 ✅
|
||||
- 不同 page 返回的 total 一致 ✅
|
||||
- [x] 18.5 小程序前端联调验证
|
||||
- 前端筛选修复代码已正确接入 API(代码审查确认)
|
||||
- 待联调清单记录在测试文件注释中(FDW 列名已修复,可联调)
|
||||
|
||||
- [x] 19. 项目文档更新与落地
|
||||
- [x] 19.1 更新 `docs/contracts/openapi/backend-api.json`,补充 BOARD-1、BOARD-2、BOARD-3、CONFIG-1 四个端点的 OpenAPI 定义
|
||||
- [x] 19.2 更新 `docs/architecture/backend-architecture.md`,补充新增的 `board_service` 模块、`xcx_board` / `xcx_config` 路由注册说明
|
||||
- [x] 19.3 更新 `docs/database/BD_Manual_biz_tables.md`,补充本次引用的 `biz.coach_tasks` 表在看板场景下的使用说明(BOARD-1 task 维度查询)
|
||||
- [x] 19.4 更新 `docs/DOCUMENTATION-MAP.md`,确保新增文档条目已索引
|
||||
- [x] 19.5 更新 `docs/miniprogram-dev/API-contract.md`,补充 BOARD-1、BOARD-2、BOARD-3、CONFIG-1 的接口契约(请求参数/响应示例)
|
||||
|
||||
- [x] 20. 数据库变更审计与 DDL 合并
|
||||
- [x] 20.1 审计本次实现中对数据库的改动(新建表、新增字段、新增索引、FDW 映射变更等)
|
||||
- 结论:**无 DDL 变更**。全部基于已有 `app.v_*` RLS 视图的 SELECT 查询,`IMPORT FOREIGN SCHEMA app` 已自动导入所有视图。`biz.coach_tasks` 看板查询走已有 `idx_coach_tasks_assistant_status` 索引,无需新增。
|
||||
- [x] 20.2 将所有数据库变更合并到主 DDL 文件
|
||||
- 结论:无 DDL 变更需合并。
|
||||
- [x] 20.3 更新 BD 手册记录变更
|
||||
- `docs/database/BD_Manual_biz_tables.md` 已补充 RNS1.3 看板引用说明(§2.1)
|
||||
- 审计记录:`docs/audit/changes/2026-03-20__rns13-board-apis-e2e-fix.md`
|
||||
@@ -1 +0,0 @@
|
||||
{"specId": "9eccc890-b6c3-41a3-8ba1-bb2f0e09f653", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -1,704 +0,0 @@
|
||||
# 技术设计文档 — RNS1.4:CHAT 对齐与联调收尾
|
||||
|
||||
## 概述
|
||||
|
||||
RNS1.4 是 RNS1 系列的收尾 spec,覆盖三大块工作:
|
||||
|
||||
1. **CHAT 模块路径迁移与功能补全**(T4-1 ~ T4-3):将现有 `/api/ai/*` 路由迁移到 `/api/xcx/chat/*`,实现 CHAT-1/2/3/4 四个端点,支持 referenceCard 和多入口参数路由
|
||||
2. **FDW 端到端验证**(T4-4):验证 `test_zqyy_app` → `test_etl_feiqiu` 链路上所有 FDW 视图可访问、性能达标、索引完备
|
||||
3. **全量前后端联调**(T4-5):13 个页面移除 mock 数据,连接真实后端,修复 notes 触底加载和 customer-service-records 按月请求
|
||||
|
||||
### 设计原则
|
||||
|
||||
- **复用优先**:现有 `biz.ai_conversations` / `biz.ai_messages` 表结构通过 DDL 迁移扩展,新增 CHAT 所需字段(`customer_id`、`title`、`last_message`、`reference_card`),不新建表
|
||||
- **契约驱动**:所有端点严格遵循 `API-contract.md` 中 CHAT-1/2/3/4 的定义
|
||||
- **权限一致**:所有 CHAT 端点使用 `require_approved()` 依赖,与 RNS1.1-1.3 保持一致
|
||||
- **DWD-DOC 强制规则**:referenceCard 中涉及金额使用 `items_sum` 口径,会员信息通过 `member_id` JOIN `dim_member`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
RNS10[RNS1.0<br/>基础设施] --> RNS14[RNS1.4<br/>CHAT + 联调]
|
||||
RNS11[RNS1.1<br/>任务/绩效] --> RNS14
|
||||
RNS12[RNS1.2<br/>客户/助教] --> RNS14
|
||||
RNS13[RNS1.3<br/>三看板] --> RNS14
|
||||
style RNS14 fill:#f9f,stroke:#333
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
### 整体架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "微信小程序 (apps/miniprogram/)"
|
||||
FE_CHAT[pages/chat/chat.ts]
|
||||
FE_HIST[pages/chat-history/chat-history.ts]
|
||||
FE_API[services/api.ts]
|
||||
FE_REQ[utils/request.ts]
|
||||
FE_CHAT --> FE_API
|
||||
FE_HIST --> FE_API
|
||||
FE_API --> FE_REQ
|
||||
end
|
||||
|
||||
subgraph "FastAPI 后端 (apps/backend/app/)"
|
||||
MW[ResponseWrapperMiddleware<br/>SSE 自动跳过]
|
||||
ROUTER[routers/xcx_chat.py<br/>CHAT-1/2/3/4]
|
||||
SCHEMA[schemas/xcx_chat.py<br/>Pydantic CamelModel]
|
||||
SVC[services/chat_service.py<br/>对话业务逻辑]
|
||||
AI_SVC[ai/conversation_service.py<br/>AI 调用 + 持久化]
|
||||
BAILIAN[ai/bailian_client.py<br/>百炼 API]
|
||||
FDW_Q[services/fdw_queries.py<br/>FDW 查询]
|
||||
DB[database.py]
|
||||
|
||||
MW --> ROUTER
|
||||
ROUTER --> SCHEMA
|
||||
ROUTER --> SVC
|
||||
SVC --> AI_SVC
|
||||
SVC --> FDW_Q
|
||||
AI_SVC --> BAILIAN
|
||||
SVC --> DB
|
||||
end
|
||||
|
||||
subgraph "数据库"
|
||||
APP_DB[(zqyy_app<br/>biz.ai_conversations<br/>biz.ai_messages)]
|
||||
ETL_DB[(etl_feiqiu via FDW<br/>fdw_etl.v_dim_member<br/>fdw_etl.v_dws_member_*)]
|
||||
end
|
||||
|
||||
FE_REQ -->|HTTP JSON / SSE| MW
|
||||
DB --> APP_DB
|
||||
FDW_Q --> ETL_DB
|
||||
|
||||
style ROUTER fill:#f9f,stroke:#333
|
||||
style SVC fill:#f9f,stroke:#333
|
||||
```
|
||||
|
||||
### 请求-响应流程(SSE 流式)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MP as 小程序 chat.ts
|
||||
participant MW as ResponseWrapper
|
||||
participant R as xcx_chat.py
|
||||
participant S as chat_service.py
|
||||
participant AI as BailianClient
|
||||
participant DB as zqyy_app
|
||||
|
||||
MP->>MW: POST /api/xcx/chat/stream<br/>{chatId, content}
|
||||
MW->>R: 透传(SSE 跳过包装)
|
||||
R->>R: require_approved() + 验证 chatId 归属
|
||||
R->>S: stream_chat(chatId, content, user)
|
||||
S->>DB: INSERT user message → ai_messages
|
||||
S->>AI: 流式调用百炼 API
|
||||
|
||||
loop 逐 token
|
||||
AI-->>S: token 片段
|
||||
S-->>R: SSEEvent(type=message, token=...)
|
||||
R-->>MW: data: {"token": "..."}
|
||||
MW-->>MP: 透传 SSE 事件
|
||||
end
|
||||
|
||||
S->>DB: INSERT AI reply → ai_messages
|
||||
S->>DB: UPDATE ai_conversations.last_message
|
||||
S-->>R: SSEEvent(type=done, messageId, createdAt)
|
||||
R-->>MW: data: {"messageId": "...", "createdAt": "..."}
|
||||
MW-->>MP: 透传 done 事件
|
||||
```
|
||||
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 组件 1:xcx_chat 路由模块(路径迁移 + CHAT-1/2/3/4)
|
||||
|
||||
**位置**:`apps/backend/app/routers/xcx_chat.py`(新文件,替代 `xcx_ai_chat.py`)
|
||||
|
||||
**职责**:将现有 `/api/ai/*` 路由迁移到 `/api/xcx/chat/*`,并实现 CHAT-1/2/3/4 四个端点。
|
||||
|
||||
**迁移策略**:
|
||||
- 新建 `xcx_chat.py`,prefix 为 `/api/xcx/chat`
|
||||
- 从 `xcx_ai_chat.py` 迁移 SSE 流式对话、历史列表、消息查询三个端点
|
||||
- 在 `main.py` 中替换路由注册:移除 `xcx_ai_chat.router`,注册 `xcx_chat.router`
|
||||
- 删除 `xcx_ai_chat.py`(不保留旧路径兼容)
|
||||
|
||||
**端点定义**:
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/api/xcx/chat", tags=["小程序 CHAT"])
|
||||
|
||||
# CHAT-1: 对话历史列表
|
||||
@router.get("/history")
|
||||
async def list_chat_history(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
) -> ChatHistoryResponse: ...
|
||||
|
||||
# CHAT-2a: 通过 chatId 查询消息
|
||||
@router.get("/{chat_id}/messages")
|
||||
async def get_chat_messages(
|
||||
chat_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
) -> ChatMessagesResponse: ...
|
||||
|
||||
# CHAT-2b: 通过上下文查询消息(自动查找/创建对话)
|
||||
@router.get("/messages")
|
||||
async def get_chat_messages_by_context(
|
||||
context_type: str = Query(..., alias="contextType"), # task / customer / coach
|
||||
context_id: str = Query(..., alias="contextId"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
) -> ChatMessagesResponse: ...
|
||||
|
||||
# CHAT-3: 发送消息(同步回复)
|
||||
@router.post("/{chat_id}/messages")
|
||||
async def send_message(
|
||||
chat_id: int,
|
||||
body: SendMessageRequest,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
) -> SendMessageResponse: ...
|
||||
|
||||
# CHAT-4: SSE 流式端点
|
||||
@router.post("/stream")
|
||||
async def chat_stream(
|
||||
body: ChatStreamRequest,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
) -> StreamingResponse: ...
|
||||
```
|
||||
|
||||
### 组件 2:chat_service 业务逻辑层
|
||||
|
||||
**位置**:`apps/backend/app/services/chat_service.py`(新文件)
|
||||
|
||||
**职责**:封装 CHAT 模块的核心业务逻辑,包括对话管理、消息持久化、referenceCard 组装。
|
||||
|
||||
**关键方法**:
|
||||
|
||||
```python
|
||||
class ChatService:
|
||||
"""CHAT 模块业务逻辑。"""
|
||||
|
||||
def get_chat_history(
|
||||
self, user_id: int, site_id: int, page: int, page_size: int
|
||||
) -> tuple[list[dict], int]:
|
||||
"""CHAT-1: 查询对话历史列表,返回 (items, total)。"""
|
||||
|
||||
def get_or_create_session(
|
||||
self, user_id: int, site_id: int,
|
||||
context_type: str, context_id: str | None
|
||||
) -> int:
|
||||
"""按入口上下文查找或创建对话,返回 chat_id。
|
||||
复用规则:
|
||||
- context_type='task': 同一 taskId 始终复用(无时限)
|
||||
- context_type='customer'/'coach': 最后消息 ≤ 3 天复用,> 3 天新建
|
||||
- context_type='general': 始终新建
|
||||
"""
|
||||
|
||||
def get_messages(
|
||||
self, chat_id: int, user_id: int, site_id: int,
|
||||
page: int, page_size: int
|
||||
) -> tuple[list[dict], int, int]:
|
||||
"""CHAT-2: 查询消息列表,返回 (messages, total, chat_id)。
|
||||
验证 chat_id 归属当前用户。"""
|
||||
|
||||
def send_message_sync(
|
||||
self, chat_id: int, content: str, user_id: int, site_id: int
|
||||
) -> dict:
|
||||
"""CHAT-3: 发送消息并获取同步 AI 回复。
|
||||
1. 验证 chatId 归属
|
||||
2. 存入用户消息
|
||||
3. 调用 AI 获取回复
|
||||
4. 存入 AI 回复
|
||||
5. 更新 session 的 last_message / last_message_at
|
||||
6. AI 失败时返回错误提示消息(HTTP 200)"""
|
||||
|
||||
def build_reference_card(
|
||||
self, customer_id: int, site_id: int
|
||||
) -> dict | None:
|
||||
"""组装 referenceCard:从 FDW 查询客户关键指标。
|
||||
遵循 DWD-DOC 规则:金额用 items_sum,会员信息通过 member_id JOIN dim_member。"""
|
||||
|
||||
def generate_title(self, session: dict) -> str:
|
||||
"""生成对话标题:自定义标题 > 上下文名称 > 首条消息前20字。"""
|
||||
```
|
||||
|
||||
**referenceCard 组装逻辑**:
|
||||
|
||||
```python
|
||||
def build_reference_card(self, customer_id: int, site_id: int) -> dict | None:
|
||||
"""从 FDW 查询客户指标,组装为 referenceCard 结构。"""
|
||||
# 1. 通过 member_id JOIN fdw_etl.v_dim_member 获取客户姓名
|
||||
# 2. 通过 fdw_etl.v_dws_member_consumption_summary 获取:
|
||||
# - 余额(balance)
|
||||
# - 近30天消费(items_sum 口径,非 consume_money)
|
||||
# - 到店次数
|
||||
# 3. 组装为 referenceCard 结构
|
||||
return {
|
||||
"type": "customer",
|
||||
"title": f"{member_name} — 消费概览",
|
||||
"summary": f"余额 ¥{balance},近30天消费 ¥{consume_30d}",
|
||||
"data": {
|
||||
"余额": f"¥{balance}",
|
||||
"近30天消费": f"¥{consume_30d}",
|
||||
"到店次数": f"{visit_count}次",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 组件 3:Pydantic Schema(CamelModel)
|
||||
|
||||
**位置**:`apps/backend/app/schemas/xcx_chat.py`(新文件)
|
||||
|
||||
**职责**:定义 CHAT 模块所有请求/响应的 Pydantic schema,继承 `CamelModel` 统一 camelCase 输出。
|
||||
|
||||
```python
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
class ChatHistoryItem(CamelModel):
|
||||
id: int
|
||||
title: str
|
||||
customer_name: str | None = None
|
||||
last_message: str | None = None
|
||||
timestamp: str # ISO 8601,最后消息时间
|
||||
unread_count: int = 0
|
||||
|
||||
class ChatHistoryResponse(CamelModel):
|
||||
items: list[ChatHistoryItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
class ReferenceCard(CamelModel):
|
||||
type: str # 'customer' | 'record'
|
||||
title: str
|
||||
summary: str
|
||||
data: dict[str, str] # 键值对详情
|
||||
|
||||
class ChatMessageItem(CamelModel):
|
||||
id: int
|
||||
role: str # 'user' | 'assistant'
|
||||
content: str
|
||||
created_at: str # ISO 8601(统一字段名,替代 timestamp / created_at)
|
||||
reference_card: ReferenceCard | None = None
|
||||
|
||||
class ChatMessagesResponse(CamelModel):
|
||||
chat_id: int
|
||||
items: list[ChatMessageItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
class SendMessageRequest(CamelModel):
|
||||
content: str
|
||||
|
||||
class SendMessageResponse(CamelModel):
|
||||
user_message: MessageBrief
|
||||
ai_reply: MessageBrief
|
||||
|
||||
class MessageBrief(CamelModel):
|
||||
id: int
|
||||
content: str
|
||||
created_at: str
|
||||
|
||||
class ChatStreamRequest(CamelModel):
|
||||
chat_id: int
|
||||
content: str
|
||||
```
|
||||
|
||||
### 组件 4:前端 chat 页面改造
|
||||
|
||||
**位置**:`apps/miniprogram/miniprogram/pages/chat/chat.ts`
|
||||
|
||||
**改造要点**:
|
||||
|
||||
1. **多入口参数路由**(GAP-49/50):
|
||||
```typescript
|
||||
onLoad(options) {
|
||||
if (options.historyId) {
|
||||
// 从 chat-history 跳转:直接用 historyId 作为 chatId
|
||||
this.chatId = options.historyId
|
||||
this.loadMessages(this.chatId)
|
||||
} else if (options.taskId) {
|
||||
// 从 task-detail 跳转:同一 taskId 始终复用同一对话
|
||||
this.loadMessagesByContext('task', options.taskId)
|
||||
} else if (options.customerId) {
|
||||
// 从 customer-detail 跳转:3 天内复用,超过 3 天新建
|
||||
this.loadMessagesByContext('customer', options.customerId)
|
||||
} else if (options.coachId) {
|
||||
// 从 coach-detail 跳转:3 天内复用,超过 3 天新建
|
||||
this.loadMessagesByContext('coach', options.coachId)
|
||||
} else {
|
||||
// 无参数:始终新建通用对话
|
||||
this.loadMessagesByContext('general', '')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **SSE 替换 mock 流式输出**:
|
||||
- 移除 `simulateStreamOutput()` 调用和 `mockAIReplies`
|
||||
- 使用 `wx.request` + `enableChunked: true`(微信基础库 2.20.2+)接收 SSE
|
||||
- 备选方案:轮询 CHAT-3 同步端点(不支持 chunked 的低版本基础库)
|
||||
|
||||
3. **referenceCard 渲染**:
|
||||
- 消息列表中检测 `referenceCard` 字段,已有 `toDataList()` 和 WXML 模板,无需大改
|
||||
- 确保从真实 API 返回的 `referenceCard` 结构与 mock 一致
|
||||
|
||||
### 组件 5:前端 chat-history 页面改造
|
||||
|
||||
**位置**:`apps/miniprogram/miniprogram/pages/chat-history/chat-history.ts`
|
||||
|
||||
**改造要点**:
|
||||
- 移除 `mockChatHistory` 导入
|
||||
- 调用 `fetchChatHistory()` 获取真实数据
|
||||
- 响应字段映射:后端返回 `timestamp`(ISO 8601)→ 前端 `formatRelativeTime()` 处理
|
||||
|
||||
### 组件 6:前端 services/api.ts CHAT 模块对接
|
||||
|
||||
**位置**:`apps/miniprogram/miniprogram/services/api.ts`
|
||||
|
||||
**改造要点**:
|
||||
- `fetchChatHistory()`:调用 `GET /api/xcx/chat/history`
|
||||
- `fetchChatMessages()`:调用 `GET /api/xcx/chat/{chatId}/messages`
|
||||
- `fetchChatMessagesByContext(contextType, contextId)`:新增,调用 `GET /api/xcx/chat/messages?contextType={type}&contextId={id}`
|
||||
- `sendChatMessage()`:调用 `POST /api/xcx/chat/{chatId}/messages`
|
||||
- 移除所有 CHAT 相关 mock 数据导入
|
||||
- `USE_REAL_API` 开关对 CHAT 模块设为 `true`
|
||||
|
||||
### 组件 7:FDW 验证脚本
|
||||
|
||||
**位置**:`scripts/ops/verify_fdw_e2e.py`(新文件)
|
||||
|
||||
**职责**:一次性验证脚本,检查 `test_zqyy_app` → `test_etl_feiqiu` FDW 链路。
|
||||
|
||||
**验证项**:
|
||||
1. 所有 `fdw_etl.*` 视图可访问(SELECT 1 FROM ... LIMIT 1)
|
||||
2. 带典型过滤条件的查询响应时间 < 3s
|
||||
3. 关键索引存在检查(`chat_sessions` 的 `(assistant_id, customer_id)` 等)
|
||||
4. 结果输出为结构化报告(JSON),失败项标注需 DBA 介入
|
||||
|
||||
### 组件 8:联调修复 — notes 触底加载 & customer-service-records 按月请求
|
||||
|
||||
**notes 页面**(`pages/notes/notes.ts`):
|
||||
- 实现 `onReachBottom()` 生命周期函数
|
||||
- 维护 `page` 状态,触底时 `page++` 调用 `fetchNotes({ page, pageSize })`
|
||||
- 追加数据到已有列表,`hasMore === false` 时停止加载
|
||||
|
||||
**customer-service-records 页面**(`pages/customer-service-records/customer-service-records.ts`):
|
||||
- 月份切换时调用 `fetchCustomerRecords({ customerId, year, month })`
|
||||
- 清空已有列表 → 显示 loading → 渲染新数据
|
||||
- 首次加载默认当前月份
|
||||
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 表结构变更:扩展 `biz.ai_conversations`
|
||||
|
||||
现有 `biz.ai_conversations` 表需新增字段以支持 CHAT API 契约:
|
||||
|
||||
```sql
|
||||
-- 迁移脚本:扩展 ai_conversations 支持 CHAT 模块
|
||||
ALTER TABLE biz.ai_conversations
|
||||
ADD COLUMN IF NOT EXISTS context_type varchar(20), -- 关联上下文类型:task / customer / coach / general
|
||||
ADD COLUMN IF NOT EXISTS context_id varchar(50), -- 关联上下文 ID(taskId / customerId / coachId)
|
||||
ADD COLUMN IF NOT EXISTS title varchar(200), -- 对话标题
|
||||
ADD COLUMN IF NOT EXISTS last_message text, -- 最后一条消息摘要
|
||||
ADD COLUMN IF NOT EXISTS last_message_at timestamptz; -- 最后消息时间
|
||||
|
||||
COMMENT ON COLUMN biz.ai_conversations.context_type IS '对话关联上下文类型:task(任务)/ customer(客户)/ coach(助教)/ general(通用)';
|
||||
COMMENT ON COLUMN biz.ai_conversations.context_id IS '关联上下文 ID:task 入口为 taskId,customer 入口为 customerId,coach 入口为 coachId,general 为 NULL';
|
||||
COMMENT ON COLUMN biz.ai_conversations.title IS '对话标题:自定义 > 上下文名称 > 首条消息前20字';
|
||||
COMMENT ON COLUMN biz.ai_conversations.last_message IS '最后一条消息内容摘要(截断至100字)';
|
||||
COMMENT ON COLUMN biz.ai_conversations.last_message_at IS '最后消息时间,用于历史列表排序和对话复用时限判断';
|
||||
```
|
||||
|
||||
**新增索引**:
|
||||
|
||||
```sql
|
||||
-- 上下文对话查找(按 context_type + context_id 查找可复用对话)
|
||||
CREATE INDEX idx_ai_conv_context
|
||||
ON biz.ai_conversations (user_id, site_id, context_type, context_id, last_message_at DESC NULLS LAST)
|
||||
WHERE context_type IS NOT NULL;
|
||||
|
||||
-- 历史列表排序优化(CHAT-1: 按 last_message_at 倒序)
|
||||
CREATE INDEX idx_ai_conv_last_msg
|
||||
ON biz.ai_conversations (user_id, site_id, last_message_at DESC NULLS LAST);
|
||||
```
|
||||
|
||||
### 对话复用规则
|
||||
|
||||
不同入口的对话创建/复用策略:
|
||||
|
||||
| 入口 | context_type | context_id | 复用规则 |
|
||||
|------|-------------|-----------|---------|
|
||||
| task-detail | `task` | taskId | **始终复用**:同一 taskId 归为同一个对话,无时限 |
|
||||
| customer-detail | `customer` | customerId | **3 天时限**:最后一条消息 ≤ 3 天则复用,> 3 天则新建 |
|
||||
| coach-detail | `coach` | coachId | **3 天时限**:最后一条消息 ≤ 3 天则复用,> 3 天则新建 |
|
||||
| chat-history | — | — | **直接打开**:用 historyId 作为 chatId 加载已有对话 |
|
||||
| 无参数(AI 按钮等) | `general` | NULL | **始终新建**:每次创建新对话 |
|
||||
|
||||
复用查找 SQL 模式:
|
||||
```sql
|
||||
-- task 入口:始终复用(无时限)
|
||||
SELECT id FROM biz.ai_conversations
|
||||
WHERE user_id = :user_id AND site_id = :site_id
|
||||
AND context_type = 'task' AND context_id = :task_id
|
||||
ORDER BY created_at DESC LIMIT 1;
|
||||
|
||||
-- customer / coach 入口:3 天时限复用
|
||||
SELECT id FROM biz.ai_conversations
|
||||
WHERE user_id = :user_id AND site_id = :site_id
|
||||
AND context_type = :type AND context_id = :context_id
|
||||
AND last_message_at > NOW() - INTERVAL '3 days'
|
||||
ORDER BY last_message_at DESC LIMIT 1;
|
||||
```
|
||||
|
||||
### 表结构变更:扩展 `biz.ai_messages`
|
||||
|
||||
```sql
|
||||
-- 迁移脚本:扩展 ai_messages 支持 referenceCard
|
||||
ALTER TABLE biz.ai_messages
|
||||
ADD COLUMN IF NOT EXISTS reference_card jsonb; -- 引用卡片 JSON
|
||||
|
||||
COMMENT ON COLUMN biz.ai_messages.reference_card IS 'referenceCard JSON:{type, title, summary, data}';
|
||||
```
|
||||
|
||||
### 变更后完整表结构
|
||||
|
||||
#### biz.ai_conversations(扩展后)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | bigint PK | 对话 ID(即 chatId) |
|
||||
| `user_id` | varchar(50) | 用户 ID(助教) |
|
||||
| `nickname` | varchar(100) | 用户昵称 |
|
||||
| `app_id` | varchar(30) | AI 应用 ID(CHAT 模块固定为 `app1_chat`) |
|
||||
| `site_id` | bigint | 门店 ID |
|
||||
| `source_page` | varchar(100) | 来源页面 |
|
||||
| `source_context` | jsonb | 来源上下文 |
|
||||
| `created_at` | timestamptz | 创建时间 |
|
||||
| `customer_id` | — | **已移除** — 改用 `context_type` + `context_id` 通用方案 |
|
||||
| `context_type` | varchar(20) | **新增** — 对话关联上下文类型:task/customer/coach/general |
|
||||
| `context_id` | varchar(50) | **新增** — 关联上下文 ID(taskId/customerId/coachId) |
|
||||
| `title` | varchar(200) | **新增** — 对话标题 |
|
||||
| `last_message` | text | **新增** — 最后消息摘要 |
|
||||
| `last_message_at` | timestamptz | **新增** — 最后消息时间 |
|
||||
|
||||
#### biz.ai_messages(扩展后)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | bigint PK | 消息 ID |
|
||||
| `conversation_id` | bigint FK | 对话 ID |
|
||||
| `role` | varchar(10) | 角色:`user` / `assistant` |
|
||||
| `content` | text | 消息内容 |
|
||||
| `tokens_used` | integer | token 消耗量 |
|
||||
| `created_at` | timestamptz | 创建时间 |
|
||||
| `reference_card` | jsonb | **新增** — 引用卡片 |
|
||||
|
||||
### 字段映射:数据库 → API 响应
|
||||
|
||||
| 数据库字段 | API 响应字段(camelCase) | 说明 |
|
||||
|-----------|------------------------|------|
|
||||
| `ai_conversations.id` | `id` / `chatId` | 对话 ID |
|
||||
| `ai_conversations.title` | `title` | 对话标题 |
|
||||
| `ai_conversations.last_message` | `lastMessage` | 最后消息摘要 |
|
||||
| `ai_conversations.last_message_at` | `timestamp` | CHAT-1 历史列表时间 |
|
||||
| `ai_conversations.context_id` → 当 `context_type=customer` 时 JOIN `v_dim_member` | `customerName` | 客户姓名(仅 context_type=customer 时有值) |
|
||||
| `ai_messages.id` | `id` | 消息 ID |
|
||||
| `ai_messages.role` | `role` | 消息角色 |
|
||||
| `ai_messages.content` | `content` | 消息内容 |
|
||||
| `ai_messages.created_at` | `createdAt` | 统一时间字段名 |
|
||||
| `ai_messages.reference_card` | `referenceCard` | 引用卡片 JSON |
|
||||
|
||||
### referenceCard JSON 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "customer",
|
||||
"title": "张伟 — 消费概览",
|
||||
"summary": "余额 ¥5,200,近30天消费 ¥2,380",
|
||||
"data": {
|
||||
"余额": "¥5,200",
|
||||
"近30天消费": "¥2,380",
|
||||
"到店次数": "8次",
|
||||
"最近到店": "3天前"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### DWD-DOC 强制规则在数据模型中的体现
|
||||
|
||||
| 规则 | 影响范围 | 实施方式 |
|
||||
|------|---------|---------|
|
||||
| `items_sum` 口径 | referenceCard 中"近30天消费" | SQL 使用 `items_sum` 字段,禁用 `consume_money` |
|
||||
| 助教费用拆分 | referenceCard 中如涉及助教费用 | 使用 `assistant_pd_money` + `assistant_cx_money` |
|
||||
| 会员信息 JOIN | referenceCard 中客户姓名 | 通过 `member_id` JOIN `fdw_etl.v_dim_member`(`scd2_is_current=1`),禁用结算单冗余字段 |
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是一个在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: 路由迁移完整性
|
||||
|
||||
*For any* CHAT 端点路径(`/history`、`/{chatId}/messages`、`/messages`、`/{chatId}/messages` POST、`/stream`),以 `/api/xcx/chat` 为前缀的请求应返回非 404 响应(需认证),而以 `/api/ai` 为前缀的对应旧路径应返回 404。
|
||||
|
||||
**Validates: Requirements 1.1, 1.2**
|
||||
|
||||
### Property 2: CHAT API 响应结构完整性
|
||||
|
||||
*For any* CHAT-1 历史列表项,响应必须包含 `id`、`title`、`lastMessage`、`timestamp` 字段;*For any* CHAT-2 消息项,响应必须包含 `id`、`role`、`content`、`createdAt` 字段;*For any* CHAT-3 发送消息响应,必须包含 `userMessage` 和 `aiReply`,各含 `id`、`content`、`createdAt` 字段。
|
||||
|
||||
**Validates: Requirements 2.2, 3.3, 5.3**
|
||||
|
||||
### Property 3: 列表排序不变量
|
||||
|
||||
*For any* CHAT-1 返回的对话历史列表,相邻两项的 `timestamp` 应满足前项 ≥ 后项(按时间倒序);*For any* CHAT-2 返回的消息列表,相邻两项的 `createdAt` 应满足前项 ≤ 后项(按时间正序)。
|
||||
|
||||
**Validates: Requirements 2.3, 3.5**
|
||||
|
||||
### Property 4: 对话标题生成优先级
|
||||
|
||||
*For any* 对话记录,标题生成应遵循优先级链:若 `title` 字段非空则使用 `title`;否则若 `customer_id` 关联的客户姓名非空则使用客户姓名;否则使用首条消息内容的前 20 个字符。生成结果应始终为非空字符串。
|
||||
|
||||
**Validates: Requirements 2.4**
|
||||
|
||||
### Property 5: 权限控制与数据隔离
|
||||
|
||||
*For any* CHAT 端点(CHAT-1/2/3/4),未通过审核的用户(status ≠ approved)应收到 HTTP 403 响应;*For any* 已认证用户请求的对话数据,返回的所有对话记录的 `user_id` 应等于当前用户 ID;*For any* 不属于当前用户的 `chatId`,CHAT-3 和 CHAT-4 应返回 HTTP 403。
|
||||
|
||||
**Validates: Requirements 2.6, 5.6, 11.1, 11.2, 11.3**
|
||||
|
||||
### Property 6: 对话复用规则正确性
|
||||
|
||||
*For any* `context_type='task'` 的入口,同一 `(user_id, site_id, context_id)` 多次调用 `get_or_create_session` 应始终返回同一个 `chatId`;*For any* `context_type='customer'` 或 `context_type='coach'` 的入口,若最后消息时间 ≤ 3 天则返回已有 `chatId`,若 > 3 天则返回新的 `chatId`;*For any* `context_type='general'` 的入口,每次调用应返回不同的 `chatId`。
|
||||
|
||||
**Validates: Requirements 3.8, 3.9, 3.10**
|
||||
|
||||
### Property 7: referenceCard 持久化 Round Trip
|
||||
|
||||
*For any* 合法的 referenceCard JSON 对象(包含 `type`、`title`、`summary`、`data` 字段),存入 `ai_messages.reference_card` 后再读取,应得到与原始对象结构等价的 JSON。
|
||||
|
||||
**Validates: Requirements 4.1, 4.3**
|
||||
|
||||
### Property 8: 消息持久化与会话元数据更新
|
||||
|
||||
*For any* 通过 CHAT-3 或 CHAT-4 发送的消息,用户消息和 AI 回复均应被持久化到 `ai_messages` 表;发送后对应 `ai_conversations` 记录的 `last_message` 应更新为最新消息内容,`last_message_at` 应更新为最新消息时间。
|
||||
|
||||
**Validates: Requirements 5.2, 5.4, 6.3, 6.4**
|
||||
|
||||
### Property 9: SSE 事件类型有效性
|
||||
|
||||
*For any* CHAT-4 SSE 流中的事件,其 `event` 字段应为 `message`、`done`、`error` 三者之一;`message` 事件的 `data` 应包含 `token` 字段;`done` 事件的 `data` 应包含 `messageId` 和 `createdAt` 字段;`error` 事件的 `data` 应包含 `message` 字段。
|
||||
|
||||
**Validates: Requirements 6.2**
|
||||
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 后端错误处理
|
||||
|
||||
所有 CHAT 端点的错误响应遵循 RNS1.0 全局异常处理器格式:`{ code: <HTTP状态码>, message: <错误详情> }`。
|
||||
|
||||
| 场景 | HTTP 状态码 | 响应 | 处理方式 |
|
||||
|------|-----------|------|---------|
|
||||
| 未认证(无 token / token 过期) | 401 | `{ code: 401, message: "无效的令牌" }` | `get_current_user` 依赖抛出 |
|
||||
| 未通过审核(status ≠ approved) | 403 | `{ code: 403, message: "用户未通过审核,无法访问此资源" }` | `require_approved()` 依赖抛出 |
|
||||
| chatId 不属于当前用户 | 403 | `{ code: 403, message: "无权访问此对话" }` | `chat_service` 验证后抛出 |
|
||||
| chatId 不存在 | 404 | `{ code: 404, message: "对话不存在" }` | `chat_service` 查询后抛出 |
|
||||
| 消息内容为空 | 422 | `{ code: 422, message: "消息内容不能为空" }` | 路由层 Pydantic 验证 |
|
||||
| AI 服务调用失败(CHAT-3) | 200 | `aiReply.content = "抱歉,AI 助手暂时无法回复,请稍后重试"` | 用户消息仍保存,AI 回复为错误提示 |
|
||||
| AI 服务调用失败(CHAT-4 SSE) | SSE error 事件 | `event: error\ndata: {"message": "AI 服务暂时不可用"}` | 流中发送 error 事件后关闭 |
|
||||
| 数据库连接失败 | 500 | `{ code: 500, message: "Internal Server Error" }` | 全局 `unhandled_exception_handler` |
|
||||
| FDW 查询失败(referenceCard) | 静默降级 | referenceCard 返回 `null` | 不影响消息本身,仅 referenceCard 缺失 |
|
||||
|
||||
### CHAT-3 AI 失败降级策略
|
||||
|
||||
```python
|
||||
async def send_message_sync(self, chat_id, content, user_id, site_id):
|
||||
# 1. 存入用户消息(无论 AI 是否成功)
|
||||
user_msg_id = self._save_message(chat_id, "user", content)
|
||||
|
||||
# 2. 调用 AI
|
||||
try:
|
||||
ai_reply = await self._call_ai(content, chat_id)
|
||||
except Exception as e:
|
||||
logger.error("AI 服务调用失败: %s", e)
|
||||
ai_reply = "抱歉,AI 助手暂时无法回复,请稍后重试"
|
||||
|
||||
# 3. 存入 AI 回复(包括错误提示)
|
||||
ai_msg_id = self._save_message(chat_id, "assistant", ai_reply)
|
||||
|
||||
# 4. 更新 session 元数据
|
||||
self._update_session_metadata(chat_id, ai_reply)
|
||||
|
||||
# 5. HTTP 200 返回(不抛异常)
|
||||
return { "userMessage": {...}, "aiReply": {...} }
|
||||
```
|
||||
|
||||
### 前端错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| CHAT API 返回 401 | 跳转登录页(`request()` 全局拦截) |
|
||||
| CHAT API 返回 403 | Toast 提示"权限不足" |
|
||||
| CHAT API 返回 404 | Toast 提示"对话不存在" |
|
||||
| CHAT API 返回 500 | Toast 提示"服务暂时不可用" |
|
||||
| SSE 连接中断 | 停止流式显示,显示"连接中断"提示,允许重试 |
|
||||
| 网络超时 | `wx.request` fail 回调,显示网络错误提示 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 双轨测试方法
|
||||
|
||||
RNS1.4 采用属性测试(Property-Based Testing)+ 单元测试(Unit Testing)双轨并行:
|
||||
|
||||
- **属性测试**:验证对话管理、消息持久化、权限控制、referenceCard round trip 等通用规则
|
||||
- **单元测试**:验证具体端点行为、边界条件、AI 失败降级等
|
||||
|
||||
### 属性测试配置
|
||||
|
||||
- **测试库**:[Hypothesis](https://hypothesis.readthedocs.io/)(Python,项目已使用)
|
||||
- **测试位置**:`tests/` 目录(Monorepo 级属性测试)
|
||||
- **最小迭代次数**:每个属性测试 100 次(`@settings(max_examples=100)`)
|
||||
- **标签格式**:每个测试函数的 docstring 中标注 `Feature: rns1-chat-integration, Property {N}: {property_text}`
|
||||
|
||||
### 属性测试清单
|
||||
|
||||
| Property | 测试函数 | 生成器 | 验证逻辑 |
|
||||
|----------|---------|--------|---------|
|
||||
| P3: 列表排序不变量 | `test_chat_list_ordering` | `st.lists(st.datetimes())` 生成随机时间戳列表 | 对话列表按时间倒序,消息列表按时间正序 |
|
||||
| P4: 标题生成优先级 | `test_title_generation_priority` | `st.fixed_dictionaries({"title": st.one_of(st.none(), st.text(min_size=1)), "customer_name": st.one_of(st.none(), st.text(min_size=1)), "first_message": st.text(min_size=1)})` | 标题遵循优先级链,结果非空 |
|
||||
| P6: customerId 幂等性 | `test_customer_id_get_or_create_idempotent` | `st.integers(min_value=1)` 生成随机 user_id 和 customer_id | 多次调用返回同一 chatId |
|
||||
| P7: referenceCard Round Trip | `test_reference_card_roundtrip` | `st.fixed_dictionaries({"type": st.sampled_from(["customer", "record"]), "title": st.text(min_size=1), "summary": st.text(), "data": st.dictionaries(st.text(min_size=1), st.text())})` | JSON 序列化→存储→读取→反序列化等于原始对象 |
|
||||
| P8: 消息持久化 | `test_message_persistence_after_send` | `st.text(min_size=1, max_size=500)` 生成随机消息内容 | 发送后 ai_messages 包含用户消息和 AI 回复,session 元数据已更新 |
|
||||
| P9: SSE 事件类型 | `test_sse_event_type_validity` | `st.sampled_from(["message", "done", "error"])` + 对应 data 结构 | 事件类型为三者之一,data 结构符合定义 |
|
||||
|
||||
### 单元测试清单
|
||||
|
||||
| 测试目标 | 测试文件 | 关键用例 |
|
||||
|---------|---------|---------|
|
||||
| 路由迁移(P1) | `apps/backend/tests/unit/test_xcx_chat_routes.py` | `/api/xcx/chat/history` 返回 200;`/api/ai/conversations` 返回 404 |
|
||||
| 响应结构(P2) | `apps/backend/tests/unit/test_xcx_chat_schema.py` | ChatHistoryItem / ChatMessageItem / SendMessageResponse 序列化验证 |
|
||||
| 权限控制(P5) | `apps/backend/tests/unit/test_xcx_chat_auth.py` | 未审核用户 403;chatId 不属于当前用户 403 |
|
||||
| AI 失败降级(edge case) | `apps/backend/tests/unit/test_xcx_chat_ai_fallback.py` | AI 超时时返回错误提示消息,HTTP 200 |
|
||||
| SSE 跳过包装 | 已由 RNS1.0 测试覆盖 | `text/event-stream` 不经过 ResponseWrapper |
|
||||
| FDW 验证 | `scripts/ops/verify_fdw_e2e.py` | 一次性运行,输出验证报告 |
|
||||
| 联调验证 | 手动测试 | 13 页面逐一验证真实数据渲染 |
|
||||
|
||||
### 测试执行命令
|
||||
|
||||
```bash
|
||||
# 属性测试(Hypothesis)
|
||||
cd C:\NeoZQYY && pytest tests/ -v -k "rns1_chat"
|
||||
|
||||
# 单元测试
|
||||
cd apps/backend && pytest tests/unit/ -v -k "xcx_chat"
|
||||
|
||||
# FDW 验证脚本
|
||||
cd C:\NeoZQYY && uv run python scripts/ops/verify_fdw_e2e.py
|
||||
```
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
# 需求文档 — RNS1.4:CHAT 对齐与联调收尾
|
||||
|
||||
## 简介
|
||||
|
||||
RNS1.4 是 NS1 小程序后端 API 补全项目的最后一个子 spec,负责 CHAT 模块路径迁移和功能补全、FDW 端到端验证、以及全量前后端联调。本 spec 依赖 RNS1.0-1.3 全部完成,是整个 RNS1 系列的收尾阶段,确保 13 个页面全部连接真实后端运行,无 mock 数据残留。
|
||||
|
||||
### 依赖
|
||||
|
||||
- RNS1.0(基础设施与契约重写)— 全局响应包装中间件、camelCase 转换、重写后的 API 契约
|
||||
- RNS1.1(任务与绩效接口)— TASK-1/2、PERF-1/2、PIN 接口已实现
|
||||
- RNS1.2(客户与助教接口)— CUST-1/2、COACH-1 接口已实现
|
||||
- RNS1.3(三看板接口)— BOARD-1/2/3、CONFIG-1 接口已实现
|
||||
- 后端已有 `xcx_ai.py`(现有 `/api/ai/*` 路由,需迁移)
|
||||
- 前端已有 chat.ts 和 chat-history.ts 页面(P5.2 交付),当前使用 mock 数据和模拟流式输出
|
||||
|
||||
### 来源文档
|
||||
|
||||
- `docs/prd/Neo_Specs/RNS1-split-plan.md` — 拆分计划主文档
|
||||
- `docs/miniprogram-dev/API-contract.md` — API 契约(RNS1.0 T0-5 重写后版本)
|
||||
- `docs/prd/Neo_Specs/storyboard-walkthrough-assistant-view.md` — 助教视角走查报告(GAP-45~51)
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Backend**:FastAPI 后端应用,位于 `apps/backend/`
|
||||
- **Miniprogram**:微信小程序前端应用,位于 `apps/miniprogram/`
|
||||
- **CHAT_1_API**:对话历史列表接口 `GET /api/xcx/chat/history`,返回分页的对话列表
|
||||
- **CHAT_2_API**:对话消息接口 `GET /api/xcx/chat/{chatId}/messages` 或 `GET /api/xcx/chat/messages?customerId={customerId}`,返回指定对话的消息列表
|
||||
- **CHAT_3_API**:发送消息接口 `POST /api/xcx/chat/{chatId}/messages`,发送用户消息并获取 AI 同步回复
|
||||
- **CHAT_4_SSE**:SSE 流式端点 `POST /api/xcx/chat/stream`,通过 Server-Sent Events 逐 token 返回 AI 回复
|
||||
- **SSE**:Server-Sent Events,服务端推送事件协议,用于 AI 流式回复的逐 token 输出
|
||||
- **referenceCard**:引用卡片,消息中附带的结构化上下文数据(类型/标题/摘要/键值对),用于展示客户概览等信息
|
||||
- **FDW**:PostgreSQL Foreign Data Wrapper,用于从业务库 `zqyy_app` 访问 ETL 库 `etl_feiqiu` 的数据
|
||||
- **chat_sessions**:业务库 `zqyy_app` 中的对话会话表,存储对话元数据
|
||||
- **chat_messages**:业务库 `zqyy_app` 中的消息表,存储对话消息内容
|
||||
- **Response_Wrapper**:RNS1.0 实现的全局响应包装中间件,`text/event-stream` 响应自动跳过包装
|
||||
- **items_sum**:DWD-DOC 强制使用的消费金额口径
|
||||
- **联调**:前后端联合调试,验证所有页面使用真实后端数据正常运行
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:CHAT 路径迁移(T4-1)
|
||||
|
||||
**用户故事:** 作为前后端开发者,我希望 CHAT 模块的 API 路径从 `/api/ai/*` 统一迁移到 `/api/xcx/chat/*`,以便与其他小程序接口保持一致的路径命名规范。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Backend SHALL 将现有 `/api/ai/*` 路由全部迁移到 `/api/xcx/chat/*` 路径下,包括同步端点和 SSE 流式端点
|
||||
2. THE Backend SHALL 在迁移后移除原 `/api/ai/*` 路径,不保留旧路径的兼容映射
|
||||
3. THE Backend SHALL 将迁移后的路由注册到 `xcx_chat` router(或等效命名),与其他 `xcx_*` 路由模块保持一致的组织结构
|
||||
4. THE Miniprogram SHALL 更新 `services/api.ts` 中所有 CHAT 相关的 API 调用路径,从 `/api/ai/*` 改为 `/api/xcx/chat/*`
|
||||
5. WHEN CHAT_4_SSE 端点迁移到 `/api/xcx/chat/stream` 后,THE Response_Wrapper SHALL 继续对 `text/event-stream` 响应跳过包装(RNS1.0 已实现的行为不受路径变更影响)
|
||||
|
||||
### 需求 2:实现 CHAT-1 对话历史列表(T4-2 历史列表部分)
|
||||
|
||||
**用户故事:** 作为助教,我希望在对话历史页面看到所有对话记录(含对话标题和最后消息摘要),以便快速找到并继续之前的对话。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CHAT_1_API SHALL 实现 `GET /api/xcx/chat/history` 端点,接受 `page`(默认 1)和 `pageSize`(默认 20)查询参数,返回分页的对话历史列表
|
||||
2. THE CHAT_1_API SHALL 为每条对话记录返回以下字段:`id`(对话 ID)、`title`(对话标题)、`customerName`(关联客户姓名,可选)、`lastMessage`(最后一条消息摘要)、`timestamp`(最后消息时间,ISO 8601 格式)、`unreadCount`(未读消息数)
|
||||
3. THE CHAT_1_API SHALL 从 `zqyy_app.chat_sessions` 查询对话列表,按最后消息时间倒序排列
|
||||
4. THE CHAT_1_API SHALL 为 `title` 字段生成对话标题:优先使用对话会话中存储的自定义标题,若无自定义标题则使用关联客户姓名,若均无则使用首条消息内容的前 20 个字符作为标题
|
||||
5. THE CHAT_1_API SHALL 返回标准分页字段:`total`(总记录数)、`page`(当前页码)、`pageSize`(每页条数)
|
||||
6. THE CHAT_1_API SHALL 通过当前登录用户的身份过滤对话列表,确保每位助教只能看到自己的对话记录
|
||||
|
||||
### 需求 3:实现 CHAT-2 对话消息查看(T4-2 消息查看部分)
|
||||
|
||||
**用户故事:** 作为助教,我希望查看指定对话的消息列表,以便回顾与 AI 助手的对话内容。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CHAT_2_API SHALL 实现两个等效的消息查询端点:`GET /api/xcx/chat/{chatId}/messages`(通过对话 ID 查询)和 `GET /api/xcx/chat/messages?contextType={type}&contextId={id}`(通过上下文类型和 ID 查询)
|
||||
2. THE CHAT_2_API SHALL 接受 `page`(默认 1)和 `pageSize`(默认 50)查询参数,返回分页的消息列表
|
||||
3. THE CHAT_2_API SHALL 为每条消息返回以下字段:`id`(消息 ID)、`role`(`user` 或 `assistant`)、`content`(消息内容)、`createdAt`(创建时间,ISO 8601 格式)、`referenceCard`(引用卡片,可选)
|
||||
4. THE CHAT_2_API SHALL 统一使用 `createdAt` 作为消息时间字段名(替代前端使用的 `timestamp` 和旧契约的 `created_at`,遵循 camelCase 规范)
|
||||
5. THE CHAT_2_API SHALL 从 `zqyy_app.chat_messages` 查询消息列表,按 `createdAt` 正序排列(最早的消息在前)
|
||||
6. THE CHAT_2_API SHALL 在响应中返回 `chatId` 字段,供前端后续发送消息时使用(尤其是通过上下文入口时,前端需要获取对应的 `chatId`)
|
||||
7. THE CHAT_2_API SHALL 返回标准分页字段:`total`(总记录数)、`page`(当前页码)、`pageSize`(每页条数)
|
||||
|
||||
#### 3.2 上下文对话复用规则
|
||||
|
||||
8. WHEN 通过 `contextType` 和 `contextId` 查询参数调用消息端点时,THE CHAT_2_API SHALL 按以下规则查找或创建对话:
|
||||
- `contextType='task'`:查找同一用户、同一 `contextId`(taskId)的已有对话,找到则复用(无时限),找不到则新建
|
||||
- `contextType='customer'` 或 `contextType='coach'`:查找同一用户、同一 `contextId` 的已有对话,若最后消息时间 ≤ 3 天则复用,> 3 天或不存在则新建
|
||||
- `contextType='general'`:始终新建对话
|
||||
9. THE CHAT_2_API SHALL 在 `ai_conversations` 中记录 `context_type` 和 `context_id` 字段,用于后续对话查找
|
||||
10. THE CHAT_2_API SHALL 确保对话复用查找基于 `(user_id, site_id, context_type, context_id)` 组合,不同用户的对话互不影响
|
||||
|
||||
|
||||
### 需求 4:CHAT referenceCard 支持(T4-3)
|
||||
|
||||
**用户故事:** 作为助教,我希望在与 AI 助手对话时,消息中能附带客户概览卡片(含余额、消费、到店频次等键值对数据),以便在对话上下文中快速查看客户关键信息。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CHAT_2_API SHALL 为消息返回可选的 `referenceCard` 字段,结构包含:`type`(引用类型,`customer` 或 `record` 枚举)、`title`(卡片标题,如 `"张伟 — 消费概览"`)、`summary`(摘要文字)、`data`(`Record<string, string>` 键值对详情,如 `{ "近30天消费": "¥2,380", "到店次数": "8次" }`)
|
||||
2. WHEN AI 助手回复消息涉及特定客户时,THE Backend SHALL 从 FDW 查询该客户的关键指标(余额、近期消费、到店频次等),组装为 `referenceCard` 附加到 AI 回复消息中
|
||||
3. THE Backend SHALL 将 `referenceCard` 数据持久化存储到 `chat_messages` 表中(作为 JSON 字段),以便历史消息查看时仍能展示引用卡片
|
||||
4. THE Miniprogram SHALL 在 chat 页面的消息列表中,检测消息的 `referenceCard` 字段,若存在则渲染为结构化卡片组件(标题 + 摘要 + 键值对列表)
|
||||
|
||||
#### 4.2 多入口参数路由(GAP-50)
|
||||
|
||||
5. THE Miniprogram SHALL 在 chat 页面的 `onLoad(options)` 中实现多入口参数路由逻辑,按以下优先级处理入口参数:
|
||||
- 若 `options.historyId` 存在(从 chat-history 跳转),使用 `historyId` 作为 `chatId` 直接加载历史消息
|
||||
- 若 `options.taskId` 存在(从 task-detail 跳转),使用 `contextType=task` + `contextId=taskId` 调用 CHAT_2_API,由后端查找同一任务的已有对话(始终复用,无时限)
|
||||
- 若 `options.customerId` 存在(从 customer-detail 跳转),使用 `contextType=customer` + `contextId=customerId` 调用 CHAT_2_API,由后端按 3 天时限判断复用或新建
|
||||
- 若 `options.coachId` 存在(从 coach-detail 跳转),使用 `contextType=coach` + `contextId=coachId` 调用 CHAT_2_API,由后端按 3 天时限判断复用或新建
|
||||
6. WHEN 通过上下文入口进入对话后,THE Miniprogram SHALL 将后端返回的 `chatId` 缓存到页面 data 中,后续发送消息和 SSE 流式请求均使用该 `chatId`
|
||||
7. IF chat 页面未收到任何入口参数(`historyId`/`taskId`/`customerId`/`coachId` 均为空),THEN THE Miniprogram SHALL 使用 `contextType=general` 调用 CHAT_2_API 创建一个通用对话
|
||||
|
||||
### 需求 5:CHAT-3 发送消息(T4-2 发送部分)
|
||||
|
||||
**用户故事:** 作为助教,我希望在对话页面发送消息后能立即收到 AI 的同步回复,以便在不支持 SSE 的场景下也能正常使用 AI 助手。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CHAT_3_API SHALL 实现 `POST /api/xcx/chat/{chatId}/messages` 端点,接受请求体 `{ content: string }`
|
||||
2. THE CHAT_3_API SHALL 将用户消息存入 `chat_messages` 表,调用 AI 服务获取回复,将 AI 回复也存入 `chat_messages` 表
|
||||
3. THE CHAT_3_API SHALL 返回包含用户消息和 AI 回复的响应:`userMessage`(含 `id`/`content`/`createdAt`)和 `aiReply`(含 `id`/`content`/`createdAt`)
|
||||
4. THE CHAT_3_API SHALL 在发送消息后更新 `chat_sessions` 表的 `lastMessage` 和最后消息时间字段
|
||||
5. IF AI 服务调用失败或超时,THEN THE CHAT_3_API SHALL 仍保存用户消息,并返回 AI 回复为错误提示消息(如 `{ content: "抱歉,AI 助手暂时无法回复,请稍后重试" }`),HTTP 状态码保持 200
|
||||
6. THE CHAT_3_API SHALL 验证请求的 `chatId` 属于当前登录助教,不属于时返回 HTTP 403
|
||||
|
||||
### 需求 6:CHAT-4 SSE 流式端点(T4-1 SSE 部分)
|
||||
|
||||
**用户故事:** 作为助教,我希望 AI 助手的回复能以流式方式逐字显示,以便获得更自然的对话体验,减少等待感。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CHAT_4_SSE SHALL 实现 `POST /api/xcx/chat/stream` 端点,接受请求体 `{ chatId: string, content: string }`,响应内容类型为 `text/event-stream`
|
||||
2. THE CHAT_4_SSE SHALL 发送以下三种 SSE 事件类型:
|
||||
- `event: message` — 逐 token 输出,`data` 为 `{"token": "<文本片段>"}`
|
||||
- `event: done` — 流结束,`data` 为 `{"messageId": "<完整消息ID>", "createdAt": "<ISO 8601>"}`
|
||||
- `event: error` — 错误,`data` 为 `{"message": "<错误描述>"}`
|
||||
3. THE CHAT_4_SSE SHALL 在流开始前将用户消息存入 `chat_messages` 表,在流结束后将完整的 AI 回复存入 `chat_messages` 表
|
||||
4. THE CHAT_4_SSE SHALL 在流结束后更新 `chat_sessions` 表的 `lastMessage` 和最后消息时间字段
|
||||
5. THE Response_Wrapper SHALL 对 `text/event-stream` 响应跳过全局包装,直接透传 SSE 事件流(RNS1.0 已实现)
|
||||
6. THE CHAT_4_SSE SHALL 验证请求的 `chatId` 属于当前登录助教,不属于时返回 HTTP 403(此时响应为普通 JSON 错误,非 SSE)
|
||||
7. THE Miniprogram SHALL 将 chat 页面现有的 `simulateStreamOutput()`(模拟逐字输出)替换为真实的 SSE 连接,通过 `wx.request` 或兼容方案接收 `text/event-stream` 响应
|
||||
|
||||
|
||||
### 需求 7:FDW 端到端验证(T4-4)
|
||||
|
||||
**用户故事:** 作为后端开发者,我希望验证所有 FDW 查询在测试环境链路(`test_zqyy_app` → `test_etl_feiqiu`)上正常工作,以便确保 RNS1.1-1.3 实现的所有接口在真实数据链路上不会因 FDW 连接、权限或性能问题而失败。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Backend SHALL 验证所有 `fdw_etl.*` 视图在 `test_zqyy_app` 数据库中可正常访问,包括但不限于:`v_dws_assistant_salary_calc`、`v_dwd_assistant_service_log`、`v_dim_member`、`v_dim_assistant`、`v_dws_member_consumption_summary`、`v_dws_member_assistant_relation_index`、`v_dws_finance_*` 系列视图
|
||||
2. THE Backend SHALL 验证每个 FDW 视图的查询响应时间在可接受范围内(单次查询不超过 3 秒),对超时的查询记录慢查询日志并评估是否需要添加索引
|
||||
3. THE Backend SHALL 验证 FDW 查询在带有典型过滤条件(如 `assistant_id`、`member_id`、日期范围)时能正确返回数据,且结果集与直接查询 `test_etl_feiqiu` 的结果一致
|
||||
4. IF 某个 FDW 视图不存在或权限不足,THEN THE Backend SHALL 记录具体的错误信息(视图名、错误类型),并在验证报告中标注需要 DBA 介入修复
|
||||
5. THE Backend SHALL 检查 FDW 链路上的关键索引是否存在:`chat_sessions` 表的 `(assistant_id, customer_id)` 索引、`chat_messages` 表的 `(session_id, created_at)` 索引
|
||||
|
||||
### 需求 8:前端联调修复 — notes 页触底加载(T4-5 F11)
|
||||
|
||||
**用户故事:** 作为助教,我希望在备注列表页面滚动到底部时自动加载更多备注,以便查看全部备注记录而不需要手动翻页。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Miniprogram SHALL 在 notes 页面实现 `onReachBottom()` 生命周期函数,当用户滚动到页面底部时自动请求下一页备注数据
|
||||
2. WHEN 触底加载触发时,THE Miniprogram SHALL 将 `page` 参数加 1,调用 `fetchNotes({ page, pageSize })` 接口,将返回的备注追加到已有列表末尾
|
||||
3. WHEN 后端返回的 `hasMore` 为 `false` 或返回的备注数量小于 `pageSize` 时,THE Miniprogram SHALL 停止触底加载,显示"没有更多了"提示
|
||||
4. THE Miniprogram SHALL 在触底加载过程中显示加载状态指示器,防止重复触发请求
|
||||
|
||||
### 需求 9:前端联调修复 — customer-service-records 按月请求(T4-5 F10)
|
||||
|
||||
**用户故事:** 作为助教,我希望客户服务记录页面在切换月份时向后端请求对应月份的数据,以便在数据量大时页面仍能快速响应,而不是全量加载后本地过滤。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Miniprogram SHALL 修改 customer-service-records 页面的月份切换逻辑,从当前的"全量加载后本地过滤"改为"按月请求 API"
|
||||
2. WHEN 用户切换月份时,THE Miniprogram SHALL 使用新的 `year`/`month` 参数调用 `fetchCustomerRecords({ customerId, year, month })` 接口,加载对应月份的服务记录
|
||||
3. THE Miniprogram SHALL 在月份切换时清空已有记录列表,显示加载状态,待新数据返回后渲染
|
||||
4. THE Miniprogram SHALL 在首次加载时默认请求当前月份的数据,而非全量数据
|
||||
5. THE Backend SHALL 确保 CUST-2 接口支持 `year` 和 `month` 查询参数,仅返回指定月份的服务记录(RNS1.2 T2-4 已实现按月查询能力)
|
||||
|
||||
### 需求 10:全量前后端联调(T4-5 联调部分)
|
||||
|
||||
**用户故事:** 作为开发团队,我们希望 13 个小程序页面全部连接真实后端运行,无 mock 数据残留,以便确认整个应用在真实数据环境下功能完整、交互正常。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Miniprogram SHALL 移除所有页面中的内联 mock 数据和 mock 数据导入(`import { mockXxx } from '../../utils/mock-data'`),全部替换为真实 API 调用
|
||||
2. THE Miniprogram SHALL 确保以下 13 个页面均能使用真实后端数据正常渲染:`task-list`、`task-detail`、`notes`、`performance`、`performance-records`、`customer-detail`、`customer-service-records`、`coach-detail`、`board-coach`、`board-customer`、`board-finance`、`chat-history`、`chat`
|
||||
3. WHEN 某个页面的 API 调用失败时,THE Miniprogram SHALL 显示友好的错误提示(如 Toast 或空状态占位),不出现白屏或未捕获异常
|
||||
4. THE Miniprogram SHALL 验证所有页面间的跳转参数传递正确(RNS1.0 T0-6 已修复的跨页面参数),确保目标页面能正确加载对应数据
|
||||
5. THE Miniprogram SHALL 验证 chat 页面从 4 个入口(task-detail、customer-detail、coach-detail、chat-history)进入时均能正确关联上下文并加载对应对话
|
||||
6. IF 联调过程中发现新的 Bug 或数据不一致问题,THEN THE Miniprogram 和 Backend SHALL 在本 spec 范围内修复,修复内容记录到联调问题清单中
|
||||
|
||||
### 需求 11:全局约束与权限控制
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望所有 CHAT 接口遵循统一的权限控制和数据隔离规则,以确保每位助教只能访问自己的对话数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Backend SHALL 对所有 RNS1.4 CHAT 接口(CHAT-1、CHAT-2、CHAT-3、CHAT-4)执行 `require_approved()` 权限检查,确保用户状态为 `approved`
|
||||
2. THE Backend SHALL 通过当前登录用户的身份信息过滤对话数据,确保每位助教只能访问自己创建的或与自己关联的对话
|
||||
3. IF 当前用户未通过审核(状态非 `approved`),THEN THE Backend SHALL 返回 HTTP 403 `{ code: 403, message: "用户未通过审核,无法访问此资源" }`
|
||||
4. THE Backend SHALL 对所有 CHAT 接口的响应字段名统一使用 camelCase 格式(与 RNS1.0 的 CamelCase_Converter 一致)
|
||||
5. THE Backend SHALL 确保 CHAT 模块的错误响应格式与全局异常处理器一致:`{ code: <HTTP状态码>, message: <错误详情> }`
|
||||
6. WHEN CHAT 模块查询 FDW 数据(如为 referenceCard 获取客户指标)时,THE Backend SHALL 遵循 DWD-DOC 强制规则:金额使用 `items_sum` 口径,会员信息通过 `member_id` JOIN `dim_member` 获取
|
||||
@@ -1,311 +0,0 @@
|
||||
# 实施计划:RNS1.4 CHAT 对齐与联调收尾
|
||||
|
||||
## 概述
|
||||
|
||||
按照设计文档的 8 个组件,将实施拆分为:DDL 迁移 → 后端 Schema/Service/Router → FDW 验证 → 前端改造 → 联调收尾。每个任务增量构建,确保无孤立代码。属性测试(Hypothesis)和单元测试作为可选子任务紧跟实现步骤。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. DDL 迁移:扩展 ai_conversations 和 ai_messages 表
|
||||
- [x] 1.1 创建 DDL 迁移脚本 `db/zqyy_app/migrations/2026-03-20__rns14_chat_module_extend.sql`
|
||||
- ALTER TABLE `biz.ai_conversations` 新增 `context_type varchar(20)`、`context_id varchar(50)`、`title varchar(200)`、`last_message text`、`last_message_at timestamptz` 五个字段
|
||||
- ALTER TABLE `biz.ai_messages` 新增 `reference_card` jsonb 字段
|
||||
- 创建索引 `idx_ai_conv_context` ON `(user_id, site_id, context_type, context_id, last_message_at DESC NULLS LAST) WHERE context_type IS NOT NULL`
|
||||
- 创建排序索引 `idx_ai_conv_last_msg` ON `(user_id, site_id, last_message_at DESC NULLS LAST)`
|
||||
- 添加 COMMENT ON COLUMN 注释
|
||||
- _需求: R2.3, R3.8, R3.10, R4.3, R7.5_
|
||||
|
||||
- [x] 2. 后端 Pydantic Schema 定义(组件 3)
|
||||
- [x] 2.1 创建 `apps/backend/app/schemas/xcx_chat.py`
|
||||
- 继承 `CamelModel` 基类,定义 `ChatHistoryItem`、`ChatHistoryResponse`、`ReferenceCard`、`ChatMessageItem`、`ChatMessagesResponse`、`SendMessageRequest`、`SendMessageResponse`、`MessageBrief`、`ChatStreamRequest`
|
||||
- 字段类型和可选性严格遵循设计文档组件 3 定义
|
||||
- _需求: R2.2, R3.3, R4.1, R5.1, R5.3, R6.1, R11.4_
|
||||
- [x] 2.2 编写 Schema 序列化单元测试 `apps/backend/tests/unit/test_xcx_chat_schema.py`
|
||||
- 验证 ChatHistoryItem / ChatMessageItem / SendMessageResponse 的 camelCase 序列化输出
|
||||
- 验证 ReferenceCard 可选字段为 None 时不报错
|
||||
- _需求: R2.2, R3.3, R5.3, R11.4_
|
||||
|
||||
- [x] 3. 后端 chat_service 业务逻辑层(组件 2)
|
||||
- [x] 3.1 创建 `apps/backend/app/services/chat_service.py`
|
||||
- 实现 `ChatService` 类,包含 `get_chat_history`、`get_or_create_session`、`get_messages`、`send_message_sync`、`build_reference_card`、`generate_title` 方法
|
||||
- `get_chat_history`:查询 `biz.ai_conversations`,按 `last_message_at` 倒序,JOIN `v_dim_member` 获取 `customerName`,分页返回
|
||||
- `get_or_create_session`:按 `(user_id, site_id, context_type, context_id)` 查找或创建对话。复用规则:task 入口始终复用(无时限);customer/coach 入口 ≤ 3 天复用、> 3 天新建;general 入口始终新建
|
||||
- `get_messages`:查询 `biz.ai_messages`,按 `created_at` 正序,验证 chatId 归属当前用户
|
||||
- `send_message_sync`:存入用户消息 → 调用 AI → 存入 AI 回复 → 更新 session 元数据;AI 失败时返回错误提示消息(HTTP 200)
|
||||
- ⚠️ **P5 PRD 合规**:对话落库必须遵循 `docs/prd/specs/P5-miniapp-ai-integration.md` 数据写入规则:
|
||||
- `app_id` 固定为 `app1_chat`
|
||||
- 用户消息发送时即写入 `ai_messages`(role=user)
|
||||
- 流式完成后完整 assistant 回复写入 `ai_messages`(role=assistant),含 `tokens_used`
|
||||
- 首条消息为页面上下文 JSON(`current_time`/`source_page`/`page_context`/`screen_content`)
|
||||
- `get_or_create_session` 仅用于 task/customer/coach 入口的对话复用(task 无时限,customer/coach 3 天时限);general 入口始终新建(保持 P5 PRD 兼容)
|
||||
- `build_reference_card`:从 FDW 查询客户指标(`items_sum` 口径),组装 referenceCard JSON
|
||||
- `generate_title`:自定义标题 > 客户姓名 > 首条消息前 20 字
|
||||
- _需求: R2.1-R2.6, R3.1-R3.10, R4.1-R4.3, R5.1-R5.6, R11.2, R11.6_
|
||||
|
||||
- [x] 3.2 编写属性测试:标题生成优先级
|
||||
- **Property 4: 对话标题生成优先级**
|
||||
- 使用 Hypothesis `st.fixed_dictionaries` 生成随机 title/customer_name/first_message 组合
|
||||
- 验证:有 title 用 title,否则用 customer_name,否则用首条消息前 20 字,结果始终非空
|
||||
- **验证: 需求 R2.4**
|
||||
|
||||
- [x] 3.3 编写属性测试:对话复用规则正确性
|
||||
- **Property 6: 对话复用规则正确性**
|
||||
- 使用 Hypothesis 生成随机 context_type/context_id/last_message_at 组合
|
||||
- 验证:task 入口同一 context_id 始终返回同一 chatId;customer/coach 入口 ≤ 3 天复用、> 3 天新建;general 入口每次返回不同 chatId
|
||||
- **验证: 需求 R3.8, R3.9, R3.10**
|
||||
|
||||
- [x] 3.4 编写属性测试:referenceCard Round Trip
|
||||
- **Property 7: referenceCard 持久化 Round Trip**
|
||||
- 使用 Hypothesis 生成随机 referenceCard JSON(type/title/summary/data)
|
||||
- 验证:JSON 序列化→存储→读取→反序列化等于原始对象
|
||||
- **验证: 需求 R4.1, R4.3**
|
||||
|
||||
- [x] 3.5 编写属性测试:消息持久化与会话元数据更新
|
||||
- **Property 8: 消息持久化与会话元数据更新**
|
||||
- 使用 Hypothesis `st.text(min_size=1, max_size=500)` 生成随机消息内容
|
||||
- 验证:发送后 ai_messages 包含用户消息和 AI 回复,session 的 last_message 和 last_message_at 已更新
|
||||
- **验证: 需求 R5.2, R5.4, R6.3, R6.4**
|
||||
|
||||
- [x] 3.6 编写单元测试:AI 失败降级
|
||||
- 测试文件 `apps/backend/tests/unit/test_xcx_chat_ai_fallback.py`
|
||||
- 验证 AI 服务超时/异常时,用户消息仍保存,AI 回复为错误提示消息,HTTP 200
|
||||
- _需求: R5.5_
|
||||
|
||||
- [x] 4. 后端路由迁移与 CHAT-1/2/3/4 端点实现(组件 1)
|
||||
- [x] 4.1 创建 `apps/backend/app/routers/xcx_chat.py`,实现 CHAT-1/2/3/4 五个端点
|
||||
- `GET /history` — CHAT-1 对话历史列表,调用 `chat_service.get_chat_history`
|
||||
- `GET /{chat_id}/messages` — CHAT-2a 通过 chatId 查询消息
|
||||
- `GET /messages?contextType=&contextId=` — CHAT-2b 通过上下文查询消息(按复用规则自动查找/创建对话)
|
||||
- `POST /{chat_id}/messages` — CHAT-3 发送消息(同步回复)
|
||||
- `POST /stream` — CHAT-4 SSE 流式端点,返回 `StreamingResponse(media_type="text/event-stream")`
|
||||
- 所有端点使用 `Depends(require_approved())` 权限检查
|
||||
- chatId 归属验证:CHAT-3/4 不属于当前用户返回 HTTP 403
|
||||
- _需求: R1.1, R1.3, R2.1, R3.1, R5.1, R6.1, R11.1, R11.2_
|
||||
|
||||
- [x] 4.2 在 `apps/backend/app/main.py` 中注册 `xcx_chat.router`,移除 `xcx_ai_chat.router`
|
||||
- 删除 `xcx_ai_chat.py` 文件(不保留旧路径兼容)
|
||||
- _需求: R1.2, R1.3_
|
||||
|
||||
- [x] 4.3 编写属性测试:SSE 事件类型有效性
|
||||
- **Property 9: SSE 事件类型有效性**
|
||||
- 使用 Hypothesis `st.sampled_from(["message", "done", "error"])` + 对应 data 结构
|
||||
- 验证:事件类型为三者之一,data 结构符合定义(message→token, done→messageId+createdAt, error→message)
|
||||
- **验证: 需求 R6.2**
|
||||
|
||||
- [x] 4.4 编写属性测试:列表排序不变量
|
||||
- **Property 3: 列表排序不变量**
|
||||
- 使用 Hypothesis `st.lists(st.datetimes())` 生成随机时间戳列表
|
||||
- 验证:CHAT-1 对话列表按时间倒序,CHAT-2 消息列表按时间正序
|
||||
- **验证: 需求 R2.3, R3.5**
|
||||
|
||||
- [x] 4.5 编写单元测试:路由迁移与权限控制
|
||||
- 测试文件 `apps/backend/tests/unit/test_xcx_chat_routes.py`
|
||||
- 验证 `/api/xcx/chat/history` 返回 200(需认证);`/api/ai/conversations` 返回 404
|
||||
- 验证未审核用户收到 403;chatId 不属于当前用户收到 403
|
||||
- **Property 1: 路由迁移完整性** / **Property 5: 权限控制与数据隔离**
|
||||
- **验证: 需求 R1.1, R1.2, R5.6, R6.6, R11.1, R11.3**
|
||||
|
||||
- [x] 5. 检查点 — 后端实现验证
|
||||
- 确保所有后端测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 6. FDW 端到端验证脚本(组件 7)
|
||||
- [x] 6.1 创建 `scripts/ops/verify_fdw_e2e.py`
|
||||
- 验证所有 `fdw_etl.*` 视图在 `test_zqyy_app` 中可访问(SELECT 1 FROM ... LIMIT 1)
|
||||
- 验证带典型过滤条件(assistant_id、member_id、日期范围)的查询响应时间 < 3s
|
||||
- 检查关键索引存在:`chat_sessions(assistant_id, customer_id)`、`chat_messages(session_id, created_at)`
|
||||
- 输出结构化 JSON 报告,失败项标注需 DBA 介入
|
||||
- 使用 `load_dotenv` 加载根 `.env`,连接 `test_zqyy_app`(遵循 testing-env.md 规范)
|
||||
- _需求: R7.1, R7.2, R7.3, R7.4, R7.5_
|
||||
|
||||
- [x] 7. 前端 services/api.ts CHAT 模块对接(组件 6)
|
||||
- [x] 7.1 修改 `apps/miniprogram/miniprogram/services/api.ts`
|
||||
- `fetchChatHistory()`:调用 `GET /api/xcx/chat/history`
|
||||
- `fetchChatMessages(chatId)`:调用 `GET /api/xcx/chat/{chatId}/messages`
|
||||
- 新增 `fetchChatMessagesByContext(contextType, contextId)`:调用 `GET /api/xcx/chat/messages?contextType={type}&contextId={id}`
|
||||
- `sendChatMessage(chatId, content)`:调用 `POST /api/xcx/chat/{chatId}/messages`
|
||||
- 移除所有 CHAT 相关 mock 数据导入,`USE_REAL_API` 对 CHAT 模块设为 `true`
|
||||
- _需求: R1.4, R10.1_
|
||||
|
||||
- [x] 8. 前端 chat 页面改造(组件 4)
|
||||
- [x] 8.1 修改 `apps/miniprogram/miniprogram/pages/chat/chat.ts` 实现多入口参数路由
|
||||
- `onLoad(options)` 中按优先级处理:`historyId` → `taskId` → `customerId` → `coachId` → 无参数(通用对话)
|
||||
- `historyId` 入口:直接用作 chatId 加载历史消息
|
||||
- `taskId` 入口:调用 `fetchChatMessagesByContext('task', taskId)`,同一 taskId 始终复用同一对话(无时限)
|
||||
- `customerId` 入口:调用 `fetchChatMessagesByContext('customer', customerId)`,≤ 3 天复用、> 3 天新建
|
||||
- `coachId` 入口:调用 `fetchChatMessagesByContext('coach', coachId)`,≤ 3 天复用、> 3 天新建
|
||||
- 无参数入口:调用 `fetchChatMessagesByContext('general', '')`,始终新建
|
||||
- _需求: R4.5, R4.6, R4.7_
|
||||
|
||||
- [x] 8.2 修改 chat.ts 将 `simulateStreamOutput()` 替换为真实 SSE 连接
|
||||
- 使用 `wx.request` + `enableChunked: true` 接收 `POST /api/xcx/chat/stream` 的 SSE 响应
|
||||
- 解析 `event: message`(逐 token 追加)、`event: done`(流结束)、`event: error`(错误处理)
|
||||
- 移除 `simulateStreamOutput()` 和 `mockAIReplies` 相关代码
|
||||
- SSE 连接中断时显示"连接中断"提示,允许重试
|
||||
- _需求: R6.7, R10.1_
|
||||
|
||||
- [x] 8.3 确保 chat 页面 referenceCard 渲染与真实 API 数据兼容
|
||||
- 验证 `toDataList()` 和 WXML 模板能正确渲染后端返回的 referenceCard 结构
|
||||
- _需求: R4.4_
|
||||
|
||||
- [x] 9. 前端 chat-history 页面改造(组件 5)
|
||||
- [x] 9.1 修改 `apps/miniprogram/miniprogram/pages/chat-history/chat-history.ts`
|
||||
- 移除 `mockChatHistory` 导入,调用 `fetchChatHistory()` 获取真实数据
|
||||
- 响应字段映射:后端 `timestamp`(ISO 8601)→ `formatRelativeTime()` 处理
|
||||
- 点击对话项跳转 chat 页面时传递 `historyId` 参数
|
||||
- _需求: R2.1, R10.1, R10.2_
|
||||
|
||||
- [x] 10. 前端联调修复(组件 8)
|
||||
- [x] 10.1 修改 notes 页面实现触底加载
|
||||
- 在 `apps/miniprogram/miniprogram/pages/notes/notes.ts` 实现 `onReachBottom()` 生命周期函数
|
||||
- 维护 `page` 状态,触底时 `page++` 调用 `fetchNotes({ page, pageSize })`
|
||||
- 追加数据到已有列表,`hasMore === false` 时停止加载并显示"没有更多了"
|
||||
- 加载过程中显示加载状态指示器,防止重复触发
|
||||
- _需求: R8.1, R8.2, R8.3, R8.4_
|
||||
|
||||
- [x] 10.2 修改 customer-service-records 页面实现按月请求
|
||||
- 在 `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.ts` 修改月份切换逻辑
|
||||
- 月份切换时调用 `fetchCustomerRecords({ customerId, year, month })`,清空列表 → loading → 渲染
|
||||
- 首次加载默认当前月份数据
|
||||
- _需求: R9.1, R9.2, R9.3, R9.4_
|
||||
|
||||
- [x] 11. 检查点 — 前端改造验证
|
||||
- 确保所有前端改造完成,chat 页面 4 个入口(task-detail、customer-detail、coach-detail、chat-history)均能正确进入并加载对话。ask the user if questions arise.
|
||||
|
||||
- [x] 12. Mock 数据移除与全量联调
|
||||
- [x] 12.1 移除所有页面中的 mock 数据残留
|
||||
- 搜索并移除所有 `import { mockXxx } from '../../utils/mock-data'` 引用
|
||||
- 确保 13 个页面(task-list、task-detail、notes、performance、performance-records、customer-detail、customer-service-records、coach-detail、board-coach、board-customer、board-finance、chat-history、chat)均使用真实 API
|
||||
- _需求: R10.1, R10.2_
|
||||
|
||||
- [x] 12.2 验证全量联调
|
||||
- 确保所有页面 API 调用失败时显示友好错误提示(Toast 或空状态占位),不出现白屏
|
||||
- 验证页面间跳转参数传递正确(RNS1.0 T0-6 已修复的跨页面参数)
|
||||
- 验证 chat 页面从 4 个入口进入时均能正确关联上下文
|
||||
- _需求: R10.2, R10.3, R10.4, R10.5_
|
||||
|
||||
- [x] 13. 全链路端到端测试(真实 AI 接口)
|
||||
- [x] 13.1 后端全链路测试:使用真实百炼 API 验证 CHAT-3(同步)和 CHAT-4(SSE 流式)
|
||||
- 启动后端服务连接 `test_zqyy_app`,使用真实测试用户 token
|
||||
- 调用 `POST /api/xcx/chat/{chatId}/messages` 发送真实消息,验证:
|
||||
- AI 回复内容质量(非乱码、语义相关、中文正常)
|
||||
- 用户消息和 AI 回复均已持久化到 `biz.ai_messages`
|
||||
- `biz.ai_conversations` 的 `last_message` 和 `last_message_at` 已更新
|
||||
- `tokens_used` 字段已记录
|
||||
- 调用 `POST /api/xcx/chat/stream` 验证 SSE 流式返回:
|
||||
- 逐 token 事件格式正确(`event: message`、`event: done`)
|
||||
- 完整回复拼接后语义通顺
|
||||
- 流结束后消息已落库
|
||||
- 手动评估 AI 返回内容质量(至少 3 轮对话),记录评估结果
|
||||
- _需求: R5.2, R5.4, R6.2, R6.3, R6.4, AC1, AC9_
|
||||
|
||||
- [x] 13.2 前端→后端→AI→数据库全链路验证
|
||||
- 在微信开发者工具中启动小程序,连接本地后端
|
||||
- 从 4 个入口(task-detail、customer-detail、coach-detail、chat-history)进入 chat 页面
|
||||
- 每个入口发送至少 1 条消息,验证:
|
||||
- SSE 流式逐字显示正常
|
||||
- 消息发送后页面状态正确(loading → 显示回复)
|
||||
- 返回 chat-history 页面能看到刚才的对话记录
|
||||
- referenceCard 在有客户关联时正确渲染(如有数据)
|
||||
- 验证错误场景:网络断开时的提示、空消息拦截
|
||||
- _需求: R6.7, R10.5, R4.4_
|
||||
|
||||
- [x] 13.3 AI 对话落库合规性验证(对照 P5 PRD + 用户确认的复用规则)
|
||||
- 验证对话复用规则:task 入口同一 taskId 始终复用;customer/coach 入口 ≤ 3 天复用、> 3 天新建;general 入口始终新建
|
||||
- 验证首条消息格式:应用 1 的首条消息应为页面上下文 JSON(`source_page`/`page_context`/`screen_content`/`current_time`),对照 P5 PRD 应用 1 Prompt 数据结构
|
||||
- 验证 `app_id` 字段:CHAT 模块对话的 `app_id` 应为 `app1_chat`
|
||||
- 验证 `ai_messages.role` 值:仅 `user`/`assistant`/`system` 三种(CHECK 约束)
|
||||
- 验证 `tokens_used` 记录:AI 回复消息应记录 token 消耗量
|
||||
- 验证 `source_page` 和 `source_context`:从不同入口进入时应正确记录来源页面和上下文
|
||||
- 验证 `context_type` 和 `context_id`:不同入口写入正确的上下文类型和 ID
|
||||
- _需求: AC9, P5-PRD 数据写入规则_
|
||||
|
||||
- [x] 14. DDL 迁移合并到主 DDL 基线
|
||||
- [x] 14.1 执行迁移脚本到 `test_zqyy_app`
|
||||
- 运行 `db/zqyy_app/migrations/2026-03-20__rns14_chat_module_extend.sql`(任务 1 创建的脚本)
|
||||
- 验证新字段和索引已正确创建(使用 BD 手册中的验证 SQL)
|
||||
- _需求: R2.3, R3.8, R3.10, R4.3, R7.5_
|
||||
|
||||
- [x] 14.2 合并到主 DDL 基线 `docs/database/ddl/zqyy_app__biz.sql`
|
||||
- 在 `biz.ai_conversations` 表定义中追加 5 个新字段:`context_type varchar(20)`、`context_id varchar(50)`、`title varchar(200)`、`last_message text`、`last_message_at timestamptz`
|
||||
- 在 `biz.ai_messages` 表定义中追加 1 个新字段:`reference_card jsonb`
|
||||
- 在索引区追加 2 个新索引:`idx_ai_conv_context`(条件索引,WHERE context_type IS NOT NULL)、`idx_ai_conv_last_msg`
|
||||
- 更新文件头部的"生成日期"注释
|
||||
- _需求: DDL 基线同步_
|
||||
|
||||
- [x] 15. 文档更新落地
|
||||
- [x] 15.1 更新 BD 手册 `docs/database/BD_Manual_ai_tables.md`
|
||||
- 在 `biz.ai_conversations` 字段明细中追加 5 个新字段(context_type/context_id/title/last_message/last_message_at)
|
||||
- 在 `biz.ai_messages` 字段明细中追加 reference_card 字段
|
||||
- 在约束与索引表中追加 2 个新索引(idx_ai_conv_context、idx_ai_conv_last_msg)
|
||||
- 更新兼容性影响:标注 RNS1.4 CHAT 模块依赖新字段
|
||||
- 更新验证 SQL:追加新字段和索引的验证查询
|
||||
- 更新回滚策略:追加 DROP INDEX 和 ALTER TABLE DROP COLUMN
|
||||
- _规范: db-docs.md 强制要求_
|
||||
|
||||
- [x] 15.2 更新 API 契约 `docs/miniprogram-dev/API-contract.md`
|
||||
- CHAT 部分路径从 `/api/ai/*` 更新为 `/api/xcx/chat/*`
|
||||
- 补充 CHAT-1(历史列表)、CHAT-2a/2b(消息查询,含 customerId 参数)、CHAT-3(发送消息)端点定义
|
||||
- 补充 referenceCard 结构定义
|
||||
- 补充 SSE 事件类型定义(message/done/error)
|
||||
- _需求: R1.1, R1.4_
|
||||
|
||||
- [x] 15.3 更新后端 API 参考 `apps/backend/docs/API-REFERENCE.md`
|
||||
- 新增 `xcx_chat` 路由模块文档(5 个端点)
|
||||
- 移除 `xcx_ai_chat` 路由模块文档(已删除)
|
||||
- _需求: R1.2, R1.3_
|
||||
|
||||
- [x] 15.4 更新后端 README `apps/backend/README.md`
|
||||
- 路由模块摘要中:移除 `xcx_ai_chat`,新增 `xcx_chat`(CHAT-1/2/3/4)
|
||||
- 服务层中:新增 `chat_service.py` 说明
|
||||
- _需求: R1.3_
|
||||
|
||||
- [x] 15.5 更新文档地图 `docs/DOCUMENTATION-MAP.md`
|
||||
- 在 3.1 FastAPI 后端部分新增 RNS1.4 模块(xcx_chat.py、chat_service.py、xcx_chat schema)
|
||||
- 在 5.6 Spec 文件表中新增 `rns1-chat-integration` 条目
|
||||
- 更新"最后更新"日期
|
||||
- _规范: doc-map.md 归档规则_
|
||||
|
||||
- [x] 15.6 更新 RNS1 拆分计划 `docs/prd/Neo_Specs/RNS1-split-plan.md`
|
||||
- 标注 RNS1.4 状态为"实施中"或"已完成"(视进度)
|
||||
- _需求: 项目追踪_
|
||||
|
||||
- [x] 16. 最终检查点 — 全量验证
|
||||
- 确保所有测试通过,13 个页面均连接真实后端运行,无 mock 数据残留
|
||||
- 确保 DDL 迁移已合并到主基线,BD 手册已同步更新
|
||||
- 确保 API 契约、后端 README、文档地图均已更新
|
||||
- 确保 AI 对话落库符合 P5 PRD 规范
|
||||
- ask the user if questions arise.
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
|
||||
- 每个任务引用了具体的需求编号(R1-R11)以确保可追溯性
|
||||
- 属性测试验证通用正确性属性(Property 1-9),单元测试验证具体边界条件
|
||||
- 检查点任务确保增量验证,避免问题累积
|
||||
- 后端使用 Python(FastAPI + Pydantic),前端使用 TypeScript(微信小程序)
|
||||
|
||||
### PRD 合规注意事项
|
||||
|
||||
- **P5 PRD 数据写入规则**(`docs/prd/specs/P5-miniapp-ai-integration.md`):
|
||||
- 流式返回完成后,完整 assistant 回复写入 `ai_messages`(role=assistant)
|
||||
- 用户消息在发送时即写入(role=user)
|
||||
- 所有 AI 调用记录写入 `ai_conversations` + `ai_messages`(含 tokens_used 统计)
|
||||
- 首条消息为页面上下文 JSON(`source_page`/`page_context`/`screen_content`/`current_time`)
|
||||
- `app_id` 固定为 `app1_chat`
|
||||
- **对话复用规则**(用户已确认,覆盖 P5 PRD 的"始终新建"规则):
|
||||
- `task` 入口:同一 taskId 始终复用同一对话(无时限)
|
||||
- `customer` / `coach` 入口:最后消息 ≤ 3 天复用,> 3 天新建
|
||||
- `general` 入口(无参数):始终新建
|
||||
- `chat-history` 入口:直接打开已有对话(传 historyId)
|
||||
|
||||
### 文档更新清单
|
||||
|
||||
| 文档 | 更新内容 | 任务 |
|
||||
|------|---------|------|
|
||||
| `docs/database/ddl/zqyy_app__biz.sql` | 合并 5+1 新字段、2 新索引 | 14.2 |
|
||||
| `docs/database/BD_Manual_ai_tables.md` | 新字段(context_type/context_id/title/last_message/last_message_at/reference_card)/索引/兼容性/回滚/验证 SQL | 15.1 |
|
||||
| `docs/miniprogram-dev/API-contract.md` | CHAT 路径迁移 + 新端点定义 | 15.2 |
|
||||
| `apps/backend/docs/API-REFERENCE.md` | xcx_chat 路由模块文档 | 15.3 |
|
||||
| `apps/backend/README.md` | 路由模块 + 服务层更新 | 15.4 |
|
||||
| `docs/DOCUMENTATION-MAP.md` | RNS1.4 模块 + spec 条目 | 15.5 |
|
||||
| `docs/prd/Neo_Specs/RNS1-split-plan.md` | RNS1.4 状态更新 | 15.6 |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user