在准备环境前提交次全部更改。
This commit is contained in:
41
.env
Normal file
41
.env
Normal file
@@ -0,0 +1,41 @@
|
||||
# ==============================================================================
|
||||
# NeoZQYY Monorepo 根 .env — 公共配置层
|
||||
# ==============================================================================
|
||||
# 后端 config.py 从此文件加载公共参数
|
||||
# 优先级:根 .env < 应用 .env.local < 环境变量 < CLI 参数
|
||||
# 敏感值禁止提交;本文件已在 .gitignore 中排除
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 数据库公共连接参数(后端 + ETL 共用同一 PostgreSQL 实例)
|
||||
# ------------------------------------------------------------------------------
|
||||
DB_HOST=100.64.0.4
|
||||
DB_PORT=5432
|
||||
DB_USER=local-Python
|
||||
DB_PASSWORD=Neo-local-1991125
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 数据库名称(开发/测试环境使用测试库,生产环境切换为 etl_feiqiu / zqyy_app)
|
||||
# CHANGE 2026-02-15 | 默认指向测试库,避免开发时误操作生产数据
|
||||
# ------------------------------------------------------------------------------
|
||||
PG_NAME=test_etl_feiqiu
|
||||
APP_DB_NAME=test_zqyy_app
|
||||
ETL_DB_NAME=test_etl_feiqiu
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 组合式 DSN(analyze_dataflow.py / 后端等需要完整连接串的场景使用)
|
||||
# 格式:postgresql://user:password@host:port/dbname
|
||||
# CHANGE 2026-02-16 | 新增,供 dataflow_analyzer 等跨模块脚本直接读取
|
||||
# ------------------------------------------------------------------------------
|
||||
PG_DSN=postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/test_etl_feiqiu
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 数据流结构分析输出目录(analyze_dataflow.py 使用)
|
||||
# 缺省时回退到 docs/reports/
|
||||
# ------------------------------------------------------------------------------
|
||||
SYSTEM_ANALYZE_ROOT=C:/NeoZQYY/export/dataflow_analysis
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 通用
|
||||
# ------------------------------------------------------------------------------
|
||||
TIMEZONE=Asia/Shanghai
|
||||
LOG_LEVEL=INFO
|
||||
371
.env.template
371
.env.template
@@ -1,30 +1,367 @@
|
||||
# ============================================================
|
||||
# ==============================================================================
|
||||
# NeoZQYY Monorepo 公共环境配置模板
|
||||
# 复制为 .env 后填入实际值
|
||||
# ============================================================
|
||||
# ==============================================================================
|
||||
# 使用方式:复制为 .env 后填入实际值
|
||||
# 配置优先级:DEFAULTS < .env < .env.local < 环境变量 < CLI 参数
|
||||
# 语法:KEY=VALUE(正斜杠路径,布尔值用 true/false,列表用逗号分隔)
|
||||
|
||||
# ---------- 数据库(公共) ----------
|
||||
# ------------------------------------------------------------------------------
|
||||
# 门店配置
|
||||
# ------------------------------------------------------------------------------
|
||||
STORE_ID=
|
||||
TIMEZONE=Asia/Shanghai
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 数据库配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 完整 DSN(优先使用,设置后忽略下面的分离式配置)
|
||||
PG_DSN=postgresql://user:password@host:5432/dbname
|
||||
|
||||
# 分离式配置(不使用 DSN 时启用)
|
||||
# PG_HOST=localhost
|
||||
# PG_PORT=5432
|
||||
# PG_NAME=your_database
|
||||
# PG_USER=your_user
|
||||
# PG_PASSWORD=your_password
|
||||
|
||||
# 连接超时(秒,默认 20)
|
||||
PG_CONNECT_TIMEOUT=10
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 数据库会话参数(ETL defaults.py → db.session.*)
|
||||
# ------------------------------------------------------------------------------
|
||||
# 会话时区(默认跟随 TIMEZONE)
|
||||
# DB_SESSION_TIMEZONE=Asia/Shanghai
|
||||
|
||||
# SQL 语句超时(毫秒,默认 30000)
|
||||
# DB_STATEMENT_TIMEOUT_MS=30000
|
||||
|
||||
# 锁等待超时(毫秒,默认 5000)
|
||||
# DB_LOCK_TIMEOUT_MS=5000
|
||||
|
||||
# 事务空闲超时(毫秒,默认 600000 = 10 分钟)
|
||||
# DB_IDLE_IN_TX_TIMEOUT_MS=600000
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 数据库 Schema 配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# CHANGE 2026-02-15 | 对齐新库 etl_feiqiu 六层架构
|
||||
# OLTP 业务数据 schema(默认 ods)
|
||||
SCHEMA_OLTP=ods
|
||||
|
||||
# ETL 管理数据 schema(默认 meta)
|
||||
SCHEMA_ETL=meta
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 后端公共数据库连接参数(后端 config.py 从根 .env 读取)
|
||||
# ------------------------------------------------------------------------------
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
|
||||
# ---------- ETL 数据库(etl_feiqiu) ----------
|
||||
ETL_DB_NAME=etl_feiqiu
|
||||
# ------------------------------------------------------------------------------
|
||||
# 业务数据库配置(后端 API 使用)
|
||||
# ------------------------------------------------------------------------------
|
||||
# 开发/测试环境使用 test_ 前缀库,生产环境切换为 zqyy_app / etl_feiqiu
|
||||
APP_DB_NAME=test_zqyy_app
|
||||
|
||||
# ---------- 业务数据库(zqyy_app) ----------
|
||||
APP_DB_NAME=zqyy_app
|
||||
# ETL 数据库(后端只读访问,用于数据库查看器;省略时复用 DB_HOST/PORT/USER/PASSWORD)
|
||||
# ETL_DB_HOST=
|
||||
# ETL_DB_PORT=
|
||||
# ETL_DB_USER=
|
||||
# ETL_DB_PASSWORD=
|
||||
ETL_DB_NAME=test_etl_feiqiu
|
||||
|
||||
# ---------- 时区 ----------
|
||||
TIMEZONE=Asia/Shanghai
|
||||
# ------------------------------------------------------------------------------
|
||||
# JWT 认证(后端使用,放在 apps/backend/.env.local 中)
|
||||
# ------------------------------------------------------------------------------
|
||||
# JWT_SECRET_KEY=change-me-in-production
|
||||
# JWT_ALGORITHM=HS256
|
||||
# JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
# JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# ---------- 门店标识 ----------
|
||||
STORE_ID=
|
||||
# ------------------------------------------------------------------------------
|
||||
# 微信小程序配置(后端使用,放在 apps/backend/.env.local 中)
|
||||
# ------------------------------------------------------------------------------
|
||||
# 消息推送回调 Token(与微信后台填写的一致)
|
||||
# WX_CALLBACK_TOKEN=
|
||||
# 小程序 AppID(code2Session 登录时需要)
|
||||
# WX_APP_ID=
|
||||
# 小程序 AppSecret(禁止出现在前端代码中)
|
||||
# WX_APP_SECRET=
|
||||
|
||||
# ---------- 上游 API ----------
|
||||
API_BASE_URL=
|
||||
# ------------------------------------------------------------------------------
|
||||
# CORS(后端使用,逗号分隔)
|
||||
# ------------------------------------------------------------------------------
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 日志级别
|
||||
# ------------------------------------------------------------------------------
|
||||
# LOG_LEVEL=INFO
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# API 配置(上游 SaaS API)
|
||||
# ------------------------------------------------------------------------------
|
||||
API_BASE=
|
||||
API_TOKEN=
|
||||
|
||||
# ---------- 日志 ----------
|
||||
LOG_LEVEL=INFO
|
||||
LOG_DIR=logs
|
||||
# API 请求超时(秒,默认 20)
|
||||
API_TIMEOUT=20
|
||||
|
||||
# 分页大小(默认 200)
|
||||
API_PAGE_SIZE=200
|
||||
|
||||
# 最大重试次数(默认 3)
|
||||
API_RETRY_MAX=3
|
||||
|
||||
# 重试退避时间(JSON 数组格式,单位秒,默认 [1, 2, 4])
|
||||
# API_RETRY_BACKOFF=[1, 2, 4]
|
||||
|
||||
# 额外请求参数(JSON 对象格式)
|
||||
# API_PARAMS={}
|
||||
|
||||
# 额外请求头(JSON 对象格式,默认空)
|
||||
# API_HEADERS_EXTRA={}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 路径配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# JSON 导出根目录
|
||||
EXPORT_ROOT=C:/NeoZQYY/export/ETL/JSON
|
||||
|
||||
# 日志输出根目录
|
||||
LOG_ROOT=C:/NeoZQYY/export/ETL/LOG
|
||||
|
||||
# 在线抓取 JSON 输出根目录(FETCH_ONLY 模式使用)
|
||||
FETCH_ROOT=C:/NeoZQYY/export/ETL/JSON
|
||||
|
||||
# 本地清洗入库时的 JSON 输入目录(INGEST_ONLY 模式使用,为空则默认使用本次抓取目录)
|
||||
# INGEST_SOURCE_DIR=
|
||||
|
||||
# manifest 文件名(默认 manifest.json)
|
||||
# MANIFEST_NAME=manifest.json
|
||||
|
||||
# 入库报告文件名(默认 ingest_report.json)
|
||||
# INGEST_REPORT_NAME=ingest_report.json
|
||||
|
||||
# 是否格式化输出 JSON(默认 true)
|
||||
WRITE_PRETTY_JSON=true
|
||||
|
||||
# 单文件最大字节数(默认 50MB = 52428800)
|
||||
# MAX_FILE_BYTES=52428800
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 管线流程配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 运行流程:FULL(抓取+入库)、FETCH_ONLY(仅抓取落盘)、INGEST_ONLY(仅本地入库)
|
||||
PIPELINE_FLOW=FULL
|
||||
|
||||
# 数据源模式(直接设置,不经 PIPELINE_FLOW 映射):hybrid / online / offline
|
||||
# DATA_SOURCE=hybrid
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 时间窗口配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 窗口重叠秒数(默认 600,即 10 分钟)
|
||||
OVERLAP_SECONDS=600
|
||||
|
||||
# 繁忙时段窗口分钟数(默认 30)
|
||||
WINDOW_BUSY_MIN=30
|
||||
|
||||
# 空闲时段窗口分钟数(默认 180)
|
||||
WINDOW_IDLE_MIN=180
|
||||
|
||||
# 空闲时段起止时间(默认 04:00 ~ 16:00)
|
||||
IDLE_START=04:00
|
||||
IDLE_END=16:00
|
||||
|
||||
# 窗口切分单位(默认 day)
|
||||
WINDOW_SPLIT_UNIT=day
|
||||
|
||||
# 窗口切分天数(默认 10)
|
||||
WINDOW_SPLIT_DAYS=10
|
||||
|
||||
# 窗口补偿小时数(默认 2)
|
||||
WINDOW_COMPENSATION_HOURS=2
|
||||
|
||||
# 空结果是否推进游标(默认 true)
|
||||
ALLOW_EMPTY_RESULT_ADVANCE=true
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 快照配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 快照缺失时是否删除(默认 true)
|
||||
SNAPSHOT_MISSING_DELETE=true
|
||||
|
||||
# 快照为空时是否允许删除(默认 false)
|
||||
SNAPSHOT_ALLOW_EMPTY_DELETE=false
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 数据完整性检查配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 检查模式:history(历史区间)/ latest(最近一次)
|
||||
INTEGRITY_MODE=history
|
||||
|
||||
# 历史检查起始日期
|
||||
INTEGRITY_HISTORY_START=2025-07-01
|
||||
|
||||
# 历史检查结束日期(为空则到当天)
|
||||
# INTEGRITY_HISTORY_END=
|
||||
|
||||
# 是否包含维度表(默认 true)
|
||||
INTEGRITY_INCLUDE_DIMENSIONS=true
|
||||
|
||||
# 是否自动触发检查(默认 false)
|
||||
INTEGRITY_AUTO_CHECK=false
|
||||
|
||||
# 是否自动回填缺失数据(默认 false)
|
||||
INTEGRITY_AUTO_BACKFILL=false
|
||||
|
||||
# 是否比较内容(默认 true)
|
||||
INTEGRITY_COMPARE_CONTENT=true
|
||||
|
||||
# 内容比较采样上限(默认 50)
|
||||
INTEGRITY_CONTENT_SAMPLE_LIMIT=50
|
||||
|
||||
# 内容不一致时是否回填(默认 true)
|
||||
INTEGRITY_BACKFILL_MISMATCH=true
|
||||
|
||||
# 回填后是否重新检查(默认 true)
|
||||
INTEGRITY_RECHECK_AFTER_BACKFILL=true
|
||||
|
||||
# 指定 ODS 任务代码(逗号分隔,为空则全部)
|
||||
# INTEGRITY_ODS_TASK_CODES=
|
||||
|
||||
# 是否强制按月切分(默认 true)
|
||||
# INTEGRITY_FORCE_MONTHLY_SPLIT=true
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 清洗配置(ETL defaults.py → clean.*)
|
||||
# ------------------------------------------------------------------------------
|
||||
# 是否记录未知字段(默认 true)
|
||||
# CLEAN_LOG_UNKNOWN_FIELDS=true
|
||||
|
||||
# 未知字段日志上限(默认 50)
|
||||
# CLEAN_UNKNOWN_FIELDS_LIMIT=50
|
||||
|
||||
# 哈希算法(默认 sha1)
|
||||
# CLEAN_HASH_ALGO=sha1
|
||||
|
||||
# 哈希盐值(默认空)
|
||||
# CLEAN_HASH_SALT=
|
||||
|
||||
# 严格数值校验(默认 true)
|
||||
# CLEAN_STRICT_NUMERIC=true
|
||||
|
||||
# 金额舍入精度(默认 2 位小数)
|
||||
# CLEAN_ROUND_MONEY_SCALE=2
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 安全配置(ETL defaults.py → security.*)
|
||||
# ------------------------------------------------------------------------------
|
||||
# 日志中是否脱敏(默认 true)
|
||||
# SECURITY_REDACT_IN_LOGS=true
|
||||
|
||||
# 需脱敏的键名(JSON 数组,默认 ["token","password","Authorization"])
|
||||
# SECURITY_REDACT_KEYS=["token","password","Authorization"]
|
||||
|
||||
# 日志中是否回显 token(默认 false,调试用)
|
||||
# SECURITY_ECHO_TOKEN_IN_LOGS=false
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 校验配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 抓取后校验时是否跳过 ODS 重载(默认 true)
|
||||
VERIFY_SKIP_ODS_ON_FETCH=true
|
||||
|
||||
# 校验时是否使用本地 JSON(默认 true)
|
||||
VERIFY_ODS_LOCAL_JSON=true
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# DWD 层配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 事实表是否使用 UPSERT(默认 true)
|
||||
DWD_FACT_UPSERT=true
|
||||
|
||||
# 事实表 UPSERT 批量大小(默认 1000)
|
||||
# DWD_FACT_UPSERT_BATCH_SIZE=1000
|
||||
|
||||
# 最小批量大小(锁冲突时自动缩小,默认 100)
|
||||
# DWD_FACT_UPSERT_MIN_BATCH_SIZE=100
|
||||
|
||||
# 最大重试次数(默认 2)
|
||||
# DWD_FACT_UPSERT_MAX_RETRIES=2
|
||||
|
||||
# 重试退避时间(JSON 数组,秒,默认 [1,2,4])
|
||||
# DWD_FACT_UPSERT_RETRY_BACKOFF=[1,2,4]
|
||||
|
||||
# 事实表 backfill 锁等待超时(毫秒,为空则沿用 DB_LOCK_TIMEOUT_MS)
|
||||
# DWD_FACT_UPSERT_LOCK_TIMEOUT_MS=
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 任务列表配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# ODS 抓取任务列表(逗号分隔)
|
||||
RUN_TASKS=PRODUCTS,TABLES,MEMBERS,ASSISTANTS,PACKAGES_DEF,ORDERS,PAYMENTS,REFUNDS,COUPON_USAGE,INVENTORY_CHANGE,TOPUPS,TABLE_DISCOUNT,ASSISTANT_ABOLISH,LEDGER
|
||||
|
||||
# DWS 汇总任务列表(逗号分隔,为空则不执行)
|
||||
# RUN_DWS_TASKS=
|
||||
|
||||
# 指数计算任务列表(逗号分隔,为空则不执行)
|
||||
# RUN_INDEX_TASKS=
|
||||
|
||||
# 指数回溯天数(默认 60)
|
||||
INDEX_LOOKBACK_DAYS=60
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# DWS 月度/薪资配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 新人封顶生效日期(默认 2026-03-01)
|
||||
# DWS_MONTHLY_NEW_HIRE_CAP_EFFECTIVE_FROM=2026-03-01
|
||||
|
||||
# 新人封顶天数(默认 25)
|
||||
# DWS_MONTHLY_NEW_HIRE_CAP_DAY=25
|
||||
|
||||
# 新人最高等级(默认 2)
|
||||
# DWS_MONTHLY_NEW_HIRE_MAX_TIER_LEVEL=2
|
||||
|
||||
# 薪资计算运行天数(默认 5)
|
||||
# DWS_SALARY_RUN_DAYS=5
|
||||
|
||||
# 是否允许非周期内运行(默认 false)
|
||||
# DWS_SALARY_ALLOW_OUT_OF_CYCLE=false
|
||||
|
||||
# 包房课单价(默认 138)
|
||||
# DWS_SALARY_ROOM_COURSE_PRICE=138
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# DWS 月度额外参数
|
||||
# ------------------------------------------------------------------------------
|
||||
# 是否允许历史月度重算(默认 false)
|
||||
# DWS_MONTHLY_ALLOW_HISTORY=false
|
||||
|
||||
# 上月宽限天数(默认 5,即次月 1-5 号仍可计算上月)
|
||||
# DWS_MONTHLY_PREV_GRACE_DAYS=5
|
||||
|
||||
# 历史月份数(默认 0,即不回溯)
|
||||
# DWS_MONTHLY_HISTORY_MONTHS=0
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 后端 ETL 项目路径(后端 config.py 使用,缺省按 monorepo 相对路径推算)
|
||||
# ------------------------------------------------------------------------------
|
||||
# ETL_PROJECT_PATH=C:/NeoZQYY/apps/etl/connectors/feiqiu
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 数据流结构分析(analyze_dataflow.py 使用)
|
||||
# ------------------------------------------------------------------------------
|
||||
# 分析结果输出目录(缺省回退到 docs/reports/)
|
||||
# SYSTEM_ANALYZE_ROOT=C:/NeoZQYY/export/dataflow_analysis
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ODS 离线回放配置(仅开发/运维使用)
|
||||
# ------------------------------------------------------------------------------
|
||||
# ODS_JSON_DOC_DIR=export/test-json-doc
|
||||
# ODS_INCLUDE_FILES=
|
||||
# ODS_DROP_SCHEMA_FIRST=true
|
||||
|
||||
66
.gitignore
vendored
66
.gitignore
vendored
@@ -1,23 +1,57 @@
|
||||
# ===== 临时与缓存 =====
|
||||
tmp/
|
||||
# tmp/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
logs/
|
||||
pytest-cache-files-*/
|
||||
# logs/
|
||||
*.log
|
||||
*.jsonl
|
||||
|
||||
# ===== 运行时产出 =====
|
||||
#export/
|
||||
#reports/
|
||||
# scripts/logs/
|
||||
|
||||
# ===== 环境配置(保留模板) =====
|
||||
.env
|
||||
.env.local
|
||||
!.env.template
|
||||
# .env
|
||||
# .env.local
|
||||
# !.env.template
|
||||
|
||||
# ===== Node =====
|
||||
node_modules/
|
||||
|
||||
# ===== Python 虚拟环境 =====
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
# .venv/
|
||||
# venv/
|
||||
# ENV/
|
||||
# env/
|
||||
|
||||
# ===== Python 构建产物 =====
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
dist/
|
||||
|
||||
# ===== 测试覆盖率 =====
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# ===== infra 敏感文件 =====
|
||||
infra/**/*.key
|
||||
@@ -27,8 +61,16 @@ infra/**/*.secret
|
||||
# ===== IDE =====
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.specstory/
|
||||
.cursorindexingignore
|
||||
|
||||
# ===== 分发/构建产物 =====
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
# ===== Windows 杂项 =====
|
||||
*.lnk
|
||||
.Deleted/
|
||||
|
||||
# ===== Kiro 运行时状态 =====
|
||||
.kiro/.audit_state.json
|
||||
.kiro/.last_prompt_id.json
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"at": "2026-02-15T06:00:50.2089232+08:00",
|
||||
"prompt_id": "P20260215-060050"
|
||||
}
|
||||
{
|
||||
"prompt_id": "P20260219-083015",
|
||||
"at": "2026-02-19T08:30:15.914061+08:00"
|
||||
}
|
||||
@@ -4,17 +4,26 @@ description: Run post-change audit + docs sync for NeoZQYY Monorepo; write audit
|
||||
tools: ["read", "write", "shell"]
|
||||
---
|
||||
|
||||
你是专职“审计收口/后处理写入”子代理。你的执行必须尽量不依赖主对话上下文;优先使用本地仓库事实(git、文件内容、prompt_log)完成审计落盘。
|
||||
你是专职"审计收口/后处理写入"子代理。你的执行必须尽量不依赖主对话上下文;优先使用本地仓库事实(git、文件内容、prompt_log)完成审计落盘。
|
||||
|
||||
## 审计产物路径(统一根目录)
|
||||
- 变更审计记录:`docs/audit/changes/<YYYY-MM-DD>__<slug>.md`
|
||||
- 审计一览表:`docs/audit/audit_dashboard.md`(自动生成,勿手动编辑)
|
||||
- Prompt 日志:`docs/audit/prompt_logs/`
|
||||
- 一览表刷新命令:`python scripts/audit/gen_audit_dashboard.py`
|
||||
- 所有审计产物统一写入项目根目录 `docs/audit/`,不要写入子模块(如 `apps/etl/pipelines/feiqiu/docs/audit/`)内部
|
||||
|
||||
## 输入来源(不要询问主代理)
|
||||
- 通过 `git status --porcelain` 和 `git diff` 获取本次未提交变更
|
||||
- 通过 `docs/audit/prompt_log.md` 与 `.kiro/.last_prompt_id.json` 获取最新 Prompt-ID 与 prompt 原文(用于溯源)
|
||||
- 通过项目实际文件内容判断是否“逻辑改动”
|
||||
- 通过 `docs/audit/prompt_logs/` 目录下的独立日志文件与 `.kiro/.last_prompt_id.json` 获取最新 Prompt-ID 与 prompt 原文(用于溯源)
|
||||
- 通过项目实际文件内容判断是否"逻辑改动"
|
||||
|
||||
## 何时需要做“重型后处理”
|
||||
满足任一即执行审计收口(否则只输出“无逻辑改动/无需审计”,并清除待审计标记):
|
||||
- 改动文件命中 ETL 管线高风险路径:`apps/etl/pipelines/feiqiu/` 下的 `api/`、`cli/`、`config/`、`database/`、`loaders/`、`models/`、`orchestration/`、`scd/`、`tasks/`、`utils/`、`quality/`
|
||||
## 何时需要做"重型后处理"
|
||||
满足任一即执行审计收口(否则只输出"无逻辑改动/无需审计",并清除待审计标记):
|
||||
- 改动文件命中 ETL Connector 高风险路径:`apps/etl/pipelines/feiqiu/` 下的 `api/`、`cli/`、`config/`、`database/`、`loaders/`、`models/`、`orchestration/`、`scd/`、`tasks/`、`utils/`、`quality/`
|
||||
- 改动文件命中后端 API:`apps/backend/app/`
|
||||
- 改动文件命中管理后台源码:`apps/admin-web/src/`
|
||||
- 改动文件命中小程序源码:`apps/miniprogram/miniapp/`、`apps/miniprogram/miniprogram/`
|
||||
- 改动文件命中共享包:`packages/shared/`
|
||||
- 改动文件命中数据库定义:`db/` 下的 DDL / migration / seed 文件
|
||||
- 根目录散文件(`pyproject.toml`、`.env*` 等)
|
||||
@@ -29,9 +38,10 @@ tools: ["read", "write", "shell"]
|
||||
- change-annotation-audit(写 docs/audit/changes/... + AI_CHANGELOG + CHANGE 注释)
|
||||
- bd-manual-db-docs(仅当 DB schema 变更)
|
||||
3) 完成后把 `.kiro/.audit_state.json` 的 `audit_required` 置为 false(或清空 reasons/changed_files/last_reminded_at)
|
||||
4) 执行 `python scripts/audit/gen_audit_dashboard.py` 刷新审计一览表
|
||||
|
||||
## 输出(强制极短回执)
|
||||
你最终只允许输出 3 段信息:
|
||||
- done: yes/no
|
||||
- files_written: <按行列出相对路径>
|
||||
- next_step: <若失败给 1~2 条;成功则写 “commit when ready”>
|
||||
- next_step: <若失败给 1~2 条;成功则写 "commit when ready">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"then": {
|
||||
"type": "runCommand",
|
||||
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .kiro/scripts/audit_flagger.ps1"
|
||||
"command": "python .kiro/scripts/audit_flagger.py"
|
||||
},
|
||||
"workspaceFolderName": "NeoZQYY",
|
||||
"shortName": "audit-flagger"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"then": {
|
||||
"type": "runCommand",
|
||||
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .kiro/scripts/audit_reminder.ps1"
|
||||
"command": "python .kiro/scripts/audit_reminder.py"
|
||||
},
|
||||
"workspaceFolderName": "NeoZQYY",
|
||||
"shortName": "audit-reminder"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"name": "change-impact-review(Steering + README)",
|
||||
"description": "每次 agent 执行结束后,评估本轮代码变更是否需要同步更新 product/tech/structure steering 文档及 README,必要时自动更新并输出审计摘要。(已禁用:改为手动 /audit 子代理流程)",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "agentStop"
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "你必须对本轮执行进行「变更影响审查」。\n\n第一步)判断本轮是否引入了「逻辑改动」(业务规则、数据处理/ETL 逻辑、API 行为、鉴权/权限、小程序交互逻辑)。如果没有逻辑改动(仅格式化/注释/拼写修正),输出「无逻辑改动」并结束。\n\n第二步)如果存在逻辑改动,逐一评估以下文档是否需要更新,需要则立即更新:\n- .kiro/steering/product.md(产品定位、业务规则/定义)\n- .kiro/steering/tech.md(技术栈/约束、部署/运行时假设)\n- .kiro/steering/structure.md(目录结构、关键模块边界)\n- README.md(运行方式、环境变量、接口、本地部署、集成说明)\n- gui/README.md\tGUI 的独立文档,需要说明各子目录用途和常用命令\n- docs/ 文档目录索引,帮助找到正确的子目录\n- scripts/ 脚本较多且分子目录,需要说明各子目录用途和常用命令\n- tasks/ 任务开发约定(如何新增任务、注册流程)\n- database/ Schema 约定、迁移规范\n- tests/ 测试运行方式、FakeDB/FakeAPI 用法\n\n第三步)输出审计友好的摘要:\n- 变更范围:涉及的模块/接口/数据库对象\n- 变更原因:为什么改\n- 风险评估:回归范围 + 建议运行的测试/验证\n- 文档同步:已更新的文档列表(或明确说明无需更新的理由)\n\n第4步) 变更标注与审计落盘(强制执行):\n创建或更新审计记录文件:docs/audit/changes/<YYYY-MM-DD>__<slug>.md,内容必须包含:\n- 日期/时间(Asia/Shanghai)\n- 原始用户 Prompt(原文;或引用 Prompt-ID + 不超过 5 行的摘录)\n- 直接原因:AI 分析后“为何必须改” + “修改方案简介”\n- 修改文件清单(Files changed list)\n- 风险点、回滚要点、验证步骤(至少包含可执行的验证方式)\n对每一个被修改的文件,必须在文件内新增或更新 AI_CHANGELOG 记录项,至少包含:\n- 日期\n- Prompt(Prompt-ID + 摘录)\n- 直接原因(必要性 + 方案简介)\n- 变更摘要(改了什么:模块/函数/接口/字段等)\n- 风险与验证(回归范围 + 验证方法/测试点/SQL/联调步骤)\n对每一处“逻辑变更”的代码块,必须在变更附近添加内联 CHANGE 标记注释,至少说明:\n- 变更意图(intent)\n- 关键假设(assumptions)\n- 边界条件/资金口径/精度与舍入规则(若相关)\n- 关联 Prompt(Prompt-ID 或摘录)以及必要的验证提示\n\n硬性规则:如果涉及数据库 schema 或表结构变更,必须同步更新 docs/database/ 下对应的表结构文档。"
|
||||
},
|
||||
"workspaceFolderName": "NeoZQYY",
|
||||
"shortName": "change-impact-review"
|
||||
}
|
||||
15
.kiro/hooks/dataflow-analyze.kiro.hook
Normal file
15
.kiro/hooks/dataflow-analyze.kiro.hook
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "Data Flow Structure Analysis",
|
||||
"description": "手动触发数据流结构分析:先执行 Python 脚本采集 API JSON、DB 表结构、三层字段映射和 BD_manual 业务描述,再由报告生成器输出带锚点链接、业务描述、多示例值和字段差异报告的 Markdown 文档。",
|
||||
"version": "3.0.0",
|
||||
"when": {
|
||||
"type": "userTriggered"
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "执行数据流结构分析,按以下步骤完成:\n\n第一阶段:数据采集\n1. 运行 `python scripts/ops/analyze_dataflow.py` 完成数据采集\n2. 确认采集结果已落盘,包括:\n - json_trees/(含 samples 多示例值)\n - db_schemas/\n - field_mappings/(三层映射 + 锚点)\n - bd_descriptions/(BD_manual 业务描述)\n - collection_manifest.json(含 json_field_count)\n\n第二阶段:报告生成\n3. 运行 `python scripts/ops/gen_dataflow_report.py` 生成 Markdown 报告\n4. 报告包含以下增强内容:\n - 总览表含 API JSON 字段数列\n - 1.1 API↔ODS↔DWD 字段对比差异报告\n - 2.3 覆盖率表含业务描述列\n - API 源字段表含业务描述列 + 多示例值(枚举值解释)\n - ODS 表结构含业务描述列 + 上下游双向映射锚点链接\n - DWD 表结构含业务描述列 + ODS 来源锚点链接\n5. 输出文件路径和关键统计摘要\n\n注意:当前仅分析飞球(feiqiu)连接器。未来新增连接器时,应自动发现并纳入分析范围。"
|
||||
},
|
||||
"workspaceFolderName": "NeoZQYY",
|
||||
"shortName": "dataflow-analyze"
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"name": "DB Schema 文档执行 (bd_manual)",
|
||||
"description": "当数据库 schema/migration 相关文件被保存时,检查是否有表结构变更,并自动更新 docs/database/ 下对应的表结构文档。(已禁用:由 /audit 流程统一处理 DB 文档)",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "fileEdited",
|
||||
"patterns": [
|
||||
"**/migrations/**/*.*",
|
||||
"**/*.sql",
|
||||
"**/*ddl*.*",
|
||||
"**/*schema*.*",
|
||||
"**/*.prisma"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "一个数据库相关文件刚被保存。你必须检查是否发生了 schema/表结构变更。\n\n如果发生了表结构变更,你必须更新以下目录中的文档:\ndocs/database/\n\n最低输出要求(必须写入对应 schema 目录 + 表结构文档):\n1) 变更内容:表/字段/类型/可空性/默认值/约束/索引/外键的具体变化\n2) 变更原因:业务背景与动机\n3) 影响范围:ETL 管线、后端 API 契约、小程序字段等\n4) 回滚策略:如何回退 + 数据回填注意事项\n5) 验证 SQL:至少 3 条查询语句用于验证变更正确性\n6) 溯源留痕日期(Asia/Shanghai,YYYY-MM-DD);Prompt(Prompt-ID + ≤5 行摘录或原文);Direct cause(必要性 + 修改方案简介)\n\n如果没有发生表结构变更(例如仅修改注释),在变更日志文档中写一条简短说明:\"无结构性变更\"(同样要带日期 + Prompt-ID)。"
|
||||
},
|
||||
"workspaceFolderName": "NeoZQYY",
|
||||
"shortName": "db-schema-doc-enforcer"
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"then": {
|
||||
"type": "runCommand",
|
||||
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .kiro/scripts/prompt_audit_log.ps1"
|
||||
"command": "python .kiro/scripts/prompt_audit_log.py"
|
||||
},
|
||||
"workspaceFolderName": "NeoZQYY",
|
||||
"shortName": "prompt-audit-log"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "立刻启动名为 audit-writer 的子代理来执行「后处理写入/审计收口」流程。\n\n约束:\n- 子代理自行使用 git status/diff 与 .kiro/.last_prompt_id.json 中最新 Prompt-ID 作为溯源;不要依赖主对话上下文。\n- 子代理必须按需调用 skill:steering-readme-maintainer、change-annotation-audit、bd-manual-db-docs(仅在满足触发条件时)。\n- 子代理结束后,必须把 .kiro/.audit_state.json 中 audit_required 置为 false(或清空文件),以停止后续提醒。\n- 审计落盘完成后,必须执行 `python scripts/gen_audit_dashboard.py` 刷新审计一览表(docs/audit/audit_dashboard.md)。\n- 你的最终回复必须是「极短回执」,只包含:\n 1) 是否完成(yes/no)\n 2) 写了哪些文件(文件列表)\n 3) 如果失败,下一步怎么做(1~2 条)"
|
||||
"prompt": "立刻启动名为 audit-writer 的子代理来执行「后处理写入/审计收口」流程。\n\n约束:\n- 子代理自行使用 git status/diff 与 .kiro/.last_prompt_id.json 中最新 Prompt-ID 作为溯源;不要依赖主对话上下文。\n- 子代理必须按需调用 skill:steering-readme-maintainer、change-annotation-audit、bd-manual-db-docs(仅在满足触发条件时)。\n- 所有审计产物统一写入项目根目录 docs/audit/(变更记录写 docs/audit/changes/,prompt 日志写 docs/audit/prompt_logs/),不要写入子模块内部。\n- 子代理结束后,必须把 .kiro/.audit_state.json 中 audit_required 置为 false(或清空文件),以停止后续提醒。\n- 审计落盘完成后,必须执行 `python scripts/audit/gen_audit_dashboard.py` 刷新审计一览表(docs/audit/audit_dashboard.md)。\n- 你的最终回复必须是「极短回执」,只包含:\n 1) 是否完成(yes/no)\n 2) 写了哪些文件(文件列表)\n 3) 如果失败,下一步怎么做(1~2 条)"
|
||||
},
|
||||
"workspaceFolderName": "NeoZQYY",
|
||||
"shortName": "audit"
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# audit_flagger.ps1 — 判断 git 工作区是否存在高风险改动
|
||||
# 兼容 Windows PowerShell 5.1(避免 try{} 内嵌套脚本块的解析器 bug)
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
function Get-TaipeiNow {
|
||||
$tz = [TimeZoneInfo]::FindSystemTimeZoneById("Taipei Standard Time")
|
||||
if ($tz) {
|
||||
return [TimeZoneInfo]::ConvertTime([DateTimeOffset]::Now, $tz)
|
||||
}
|
||||
return [DateTimeOffset]::Now
|
||||
}
|
||||
|
||||
function Sha1Hex([string]$s) {
|
||||
$sha1 = [System.Security.Cryptography.SHA1]::Create()
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($s)
|
||||
$hash = $sha1.ComputeHash($bytes)
|
||||
return ([BitConverter]::ToString($hash) -replace "-", "").ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Get-ChangedFiles {
|
||||
$status = git status --porcelain 2>$null
|
||||
if ($LASTEXITCODE -ne 0) { return @() }
|
||||
$result = @()
|
||||
foreach ($line in $status) {
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
$pathPart = $line.Substring([Math]::Min(3, $line.Length - 1)).Trim()
|
||||
if ($pathPart -match " -> ") { $pathPart = ($pathPart -split " -> ")[-1] }
|
||||
if ([string]::IsNullOrWhiteSpace($pathPart)) { continue }
|
||||
$result += $pathPart.Replace("\", "/").Trim()
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
function Test-NoiseFile([string]$f) {
|
||||
if ($f -match "^docs/audit/") { return $true }
|
||||
if ($f -match "^\.kiro/\.audit_state\.json$") { return $true }
|
||||
if ($f -match "^\.kiro/\.last_prompt_id\.json$") { return $true }
|
||||
if ($f -match "^\.kiro/scripts/") { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Write-AuditState([string]$json) {
|
||||
$null = New-Item -ItemType Directory -Force -Path ".kiro" 2>$null
|
||||
Set-Content -Path ".kiro/.audit_state.json" -Value $json -Encoding UTF8
|
||||
}
|
||||
|
||||
# --- 主逻辑 ---
|
||||
|
||||
# 非 git 仓库直接退出
|
||||
$null = git rev-parse --is-inside-work-tree 2>$null
|
||||
if ($LASTEXITCODE -ne 0) { exit 0 }
|
||||
|
||||
$allFiles = Get-ChangedFiles
|
||||
# 过滤噪声
|
||||
$files = @()
|
||||
foreach ($f in $allFiles) {
|
||||
if (-not (Test-NoiseFile $f)) { $files += $f }
|
||||
}
|
||||
$files = $files | Sort-Object -Unique
|
||||
|
||||
$now = Get-TaipeiNow
|
||||
|
||||
if ($files.Count -eq 0) {
|
||||
$json = '{"audit_required":false,"db_docs_required":false,"reasons":[],"changed_files":[],"change_fingerprint":"","marked_at":"' + $now.ToString("o") + '","last_reminded_at":null}'
|
||||
Write-AuditState $json
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 高风险路径("regex|label" 格式,避免 @{} 哈希表在 PS 5.1 的解析问题)
|
||||
$riskRules = @(
|
||||
"^apps/etl/pipelines/feiqiu/(api|cli|config|database|loaders|models|orchestration|scd|tasks|utils|quality)/|etl",
|
||||
"^apps/backend/app/|backend",
|
||||
"^packages/shared/|shared",
|
||||
"^db/|db"
|
||||
)
|
||||
|
||||
$reasons = @()
|
||||
$auditRequired = $false
|
||||
$dbDocsRequired = $false
|
||||
|
||||
foreach ($f in $files) {
|
||||
foreach ($rule in $riskRules) {
|
||||
$idx = $rule.LastIndexOf("|")
|
||||
$pat = $rule.Substring(0, $idx)
|
||||
$lbl = $rule.Substring($idx + 1)
|
||||
if ($f -match $pat) {
|
||||
$auditRequired = $true
|
||||
$tag = "dir:" + $lbl
|
||||
if ($reasons -notcontains $tag) { $reasons += $tag }
|
||||
}
|
||||
}
|
||||
if ($f -notmatch "/") {
|
||||
$auditRequired = $true
|
||||
if ($reasons -notcontains "root-file") { $reasons += "root-file" }
|
||||
}
|
||||
if ($f -match "^db/" -or $f -match "/migrations/" -or $f -match "\.sql$" -or $f -match "\.prisma$") {
|
||||
$dbDocsRequired = $true
|
||||
if ($reasons -notcontains "db-schema-change") { $reasons += "db-schema-change" }
|
||||
}
|
||||
}
|
||||
|
||||
$fp = Sha1Hex(($files -join "`n"))
|
||||
|
||||
# 读取已有状态以保留 last_reminded_at
|
||||
$lastReminded = $null
|
||||
if (Test-Path ".kiro/.audit_state.json") {
|
||||
$raw = Get-Content ".kiro/.audit_state.json" -Raw 2>$null
|
||||
if ($raw) {
|
||||
$existing = $raw | ConvertFrom-Json 2>$null
|
||||
if ($existing -and $existing.change_fingerprint -eq $fp) {
|
||||
$lastReminded = $existing.last_reminded_at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stateObj = [ordered]@{
|
||||
audit_required = [bool]$auditRequired
|
||||
db_docs_required = [bool]$dbDocsRequired
|
||||
reasons = $reasons
|
||||
changed_files = $files | Select-Object -First 50
|
||||
change_fingerprint = $fp
|
||||
marked_at = $now.ToString("o")
|
||||
last_reminded_at = $lastReminded
|
||||
}
|
||||
|
||||
Write-AuditState ($stateObj | ConvertTo-Json -Depth 6)
|
||||
exit 0
|
||||
165
.kiro/scripts/audit_flagger.py
Normal file
165
.kiro/scripts/audit_flagger.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""audit_flagger — 判断 git 工作区是否存在高风险改动,写入 .kiro/.audit_state.json
|
||||
|
||||
替代原 PowerShell 版本,避免 Windows PowerShell 5.1 解析器 bug。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
TZ_TAIPEI = timezone(timedelta(hours=8))
|
||||
|
||||
RISK_RULES = [
|
||||
(re.compile(r"^apps/etl/pipelines/feiqiu/(api|cli|config|database|loaders|models|orchestration|scd|tasks|utils|quality)/"), "etl"),
|
||||
(re.compile(r"^apps/backend/app/"), "backend"),
|
||||
(re.compile(r"^apps/admin-web/src/"), "admin-web"),
|
||||
(re.compile(r"^apps/miniprogram/(miniapp|miniprogram)/"), "miniprogram"),
|
||||
(re.compile(r"^packages/shared/"), "shared"),
|
||||
(re.compile(r"^db/"), "db"),
|
||||
]
|
||||
|
||||
NOISE_PATTERNS = [
|
||||
re.compile(r"^docs/audit/"),
|
||||
re.compile(r"^\.kiro/"), # .kiro 配置变更不触发业务审计
|
||||
re.compile(r"^tmp/"),
|
||||
re.compile(r"^\.hypothesis/"),
|
||||
]
|
||||
|
||||
DB_PATTERNS = [
|
||||
re.compile(r"^db/"),
|
||||
re.compile(r"/migrations/"),
|
||||
re.compile(r"\.sql$"),
|
||||
re.compile(r"\.prisma$"),
|
||||
]
|
||||
|
||||
STATE_PATH = os.path.join(".kiro", ".audit_state.json")
|
||||
|
||||
|
||||
def now_taipei():
|
||||
return datetime.now(TZ_TAIPEI).isoformat()
|
||||
|
||||
|
||||
def sha1hex(s: str) -> str:
|
||||
return hashlib.sha1(s.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def get_changed_files() -> list[str]:
|
||||
"""从 git status --porcelain 提取变更文件路径"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
files = []
|
||||
for line in result.stdout.splitlines():
|
||||
if len(line) < 4:
|
||||
continue
|
||||
path = line[3:].strip()
|
||||
if " -> " in path:
|
||||
path = path.split(" -> ")[-1]
|
||||
path = path.strip().strip('"').replace("\\", "/")
|
||||
if path:
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def is_noise(f: str) -> bool:
|
||||
return any(p.search(f) for p in NOISE_PATTERNS)
|
||||
|
||||
|
||||
def write_state(state: dict):
|
||||
os.makedirs(".kiro", exist_ok=True)
|
||||
with open(STATE_PATH, "w", encoding="utf-8") as fh:
|
||||
json.dump(state, fh, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def main():
|
||||
# 非 git 仓库直接退出
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
all_files = get_changed_files()
|
||||
files = sorted(set(f for f in all_files if not is_noise(f)))
|
||||
now = now_taipei()
|
||||
|
||||
if not files:
|
||||
write_state({
|
||||
"audit_required": False,
|
||||
"db_docs_required": False,
|
||||
"reasons": [],
|
||||
"changed_files": [],
|
||||
"change_fingerprint": "",
|
||||
"marked_at": now,
|
||||
"last_reminded_at": None,
|
||||
})
|
||||
return
|
||||
|
||||
reasons = []
|
||||
audit_required = False
|
||||
db_docs_required = False
|
||||
|
||||
for f in files:
|
||||
for pattern, label in RISK_RULES:
|
||||
if pattern.search(f):
|
||||
audit_required = True
|
||||
tag = f"dir:{label}"
|
||||
if tag not in reasons:
|
||||
reasons.append(tag)
|
||||
# 根目录散文件
|
||||
if "/" not in f:
|
||||
audit_required = True
|
||||
if "root-file" not in reasons:
|
||||
reasons.append("root-file")
|
||||
# DB 文档触发
|
||||
if any(p.search(f) for p in DB_PATTERNS):
|
||||
db_docs_required = True
|
||||
if "db-schema-change" not in reasons:
|
||||
reasons.append("db-schema-change")
|
||||
|
||||
fp = sha1hex("\n".join(files))
|
||||
|
||||
# 保留已有状态的 last_reminded_at
|
||||
last_reminded = None
|
||||
if os.path.isfile(STATE_PATH):
|
||||
try:
|
||||
with open(STATE_PATH, "r", encoding="utf-8") as fh:
|
||||
existing = json.load(fh)
|
||||
if existing.get("change_fingerprint") == fp:
|
||||
last_reminded = existing.get("last_reminded_at")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
write_state({
|
||||
"audit_required": audit_required,
|
||||
"db_docs_required": db_docs_required,
|
||||
"reasons": reasons,
|
||||
"changed_files": files[:50],
|
||||
"change_fingerprint": fp,
|
||||
"marked_at": now,
|
||||
"last_reminded_at": last_reminded,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
# 绝不阻塞 prompt 提交
|
||||
pass
|
||||
@@ -1,72 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-TaipeiNow {
|
||||
try {
|
||||
$tz = [TimeZoneInfo]::FindSystemTimeZoneById("Taipei Standard Time")
|
||||
return [TimeZoneInfo]::ConvertTime([DateTimeOffset]::Now, $tz)
|
||||
} catch {
|
||||
return [DateTimeOffset]::Now
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$statePath = ".kiro/.audit_state.json"
|
||||
if (-not (Test-Path $statePath)) { exit 0 }
|
||||
|
||||
$state = $null
|
||||
try { $state = Get-Content $statePath -Raw | ConvertFrom-Json } catch { exit 0 }
|
||||
if (-not $state) { exit 0 }
|
||||
|
||||
# If no pending audit, do nothing
|
||||
if (-not $state.audit_required) { exit 0 }
|
||||
|
||||
# If working tree is clean (ignoring logs/state), stop reminding
|
||||
$null = git rev-parse --is-inside-work-tree 2>$null
|
||||
if ($LASTEXITCODE -ne 0) { exit 0 }
|
||||
$status = git status --porcelain
|
||||
$files = @()
|
||||
foreach ($line in $status) {
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
$pathPart = $line.Substring([Math]::Min(3, $line.Length-1)).Trim()
|
||||
if ($pathPart -match " -> ") { $pathPart = ($pathPart -split " -> ")[-1] }
|
||||
$p = $pathPart.Replace("\", "/").Trim()
|
||||
if ($p -and $p -notmatch "^docs/audit/" -and $p -notmatch "^\.kiro/") { $files += $p }
|
||||
}
|
||||
if (($files | Sort-Object -Unique).Count -eq 0) {
|
||||
$state.audit_required = $false
|
||||
$state.reasons = @()
|
||||
$state.changed_files = @()
|
||||
$state.last_reminded_at = $null
|
||||
($state | ConvertTo-Json -Depth 6) | Set-Content -Path $statePath -Encoding UTF8
|
||||
exit 0
|
||||
}
|
||||
|
||||
$now = Get-TaipeiNow
|
||||
$minInterval = [TimeSpan]::FromMinutes(15)
|
||||
|
||||
$last = $null
|
||||
if ($state.last_reminded_at) {
|
||||
try { $last = [DateTimeOffset]::Parse($state.last_reminded_at) } catch { $last = $null }
|
||||
}
|
||||
|
||||
$shouldRemind = $true
|
||||
if ($last) {
|
||||
$elapsed = $now - $last
|
||||
if ($elapsed -lt $minInterval) { $shouldRemind = $false }
|
||||
}
|
||||
|
||||
if (-not $shouldRemind) { exit 0 }
|
||||
|
||||
# Update last_reminded_at (persist even if user ignores)
|
||||
$state.last_reminded_at = $now.ToString("o")
|
||||
($state | ConvertTo-Json -Depth 6) | Set-Content -Path $statePath -Encoding UTF8
|
||||
|
||||
$reasons = @()
|
||||
if ($state.reasons) { $reasons = @($state.reasons) }
|
||||
$reasonText = if ($reasons.Count -gt 0) { ($reasons -join ", ") } else { "high-risk paths changed" }
|
||||
|
||||
[Console]::Error.WriteLine("[AUDIT REMINDER] Pending audit detected ($reasonText). Run /audit (Manual: Run /audit hook) to sync docs & write audit artifacts. (rate limit: 15min)")
|
||||
exit 1
|
||||
} catch {
|
||||
exit 0
|
||||
}
|
||||
107
.kiro/scripts/audit_reminder.py
Normal file
107
.kiro/scripts/audit_reminder.py
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""audit_reminder — Agent 结束时检查是否有待审计改动,15 分钟限频提醒。
|
||||
|
||||
替代原 PowerShell 版本,避免 Windows PowerShell 5.1 解析器 bug。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
TZ_TAIPEI = timezone(timedelta(hours=8))
|
||||
STATE_PATH = os.path.join(".kiro", ".audit_state.json")
|
||||
MIN_INTERVAL = timedelta(minutes=15)
|
||||
|
||||
|
||||
def now_taipei():
|
||||
return datetime.now(TZ_TAIPEI)
|
||||
|
||||
|
||||
def load_state():
|
||||
if not os.path.isfile(STATE_PATH):
|
||||
return None
|
||||
try:
|
||||
with open(STATE_PATH, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_state(state):
|
||||
os.makedirs(".kiro", exist_ok=True)
|
||||
with open(STATE_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def get_real_changes():
|
||||
"""获取排除噪声后的变更文件"""
|
||||
try:
|
||||
r = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True, timeout=10)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
files = []
|
||||
for line in r.stdout.splitlines():
|
||||
if len(line) < 4:
|
||||
continue
|
||||
path = line[3:].strip().strip('"').replace("\\", "/")
|
||||
if " -> " in path:
|
||||
path = path.split(" -> ")[-1]
|
||||
# 排除审计产物、.kiro 配置、临时文件
|
||||
if path and not path.startswith("docs/audit/") and not path.startswith(".kiro/") and not path.startswith("tmp/") and not path.startswith(".hypothesis/"):
|
||||
files.append(path)
|
||||
return sorted(set(files))
|
||||
|
||||
|
||||
def main():
|
||||
state = load_state()
|
||||
if not state:
|
||||
sys.exit(0)
|
||||
|
||||
if not state.get("audit_required"):
|
||||
sys.exit(0)
|
||||
|
||||
# 工作树干净时清除审计状态
|
||||
real_files = get_real_changes()
|
||||
if not real_files:
|
||||
state["audit_required"] = False
|
||||
state["reasons"] = []
|
||||
state["changed_files"] = []
|
||||
state["last_reminded_at"] = None
|
||||
save_state(state)
|
||||
sys.exit(0)
|
||||
|
||||
now = now_taipei()
|
||||
|
||||
# 15 分钟限频
|
||||
last_str = state.get("last_reminded_at")
|
||||
if last_str:
|
||||
try:
|
||||
last = datetime.fromisoformat(last_str)
|
||||
if (now - last) < MIN_INTERVAL:
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 更新提醒时间
|
||||
state["last_reminded_at"] = now.isoformat()
|
||||
save_state(state)
|
||||
|
||||
reasons = state.get("reasons", [])
|
||||
reason_text = ", ".join(reasons) if reasons else "high-risk paths changed"
|
||||
sys.stderr.write(
|
||||
f"[AUDIT REMINDER] Pending audit detected ({reason_text}). "
|
||||
f"Run /audit (Manual: Run /audit hook) to sync docs & write audit artifacts. "
|
||||
f"(rate limit: 15min)\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
@@ -1,61 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-TaipeiNow {
|
||||
try {
|
||||
$tz = [TimeZoneInfo]::FindSystemTimeZoneById("Taipei Standard Time")
|
||||
return [TimeZoneInfo]::ConvertTime([DateTimeOffset]::Now, $tz)
|
||||
} catch {
|
||||
return [DateTimeOffset]::Now
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$now = Get-TaipeiNow
|
||||
$promptId = "P{0}" -f $now.ToString("yyyyMMdd-HHmmss")
|
||||
$promptRaw = $env:USER_PROMPT
|
||||
if ($null -eq $promptRaw) { $promptRaw = "" }
|
||||
|
||||
# 截断过长的 prompt(避免意外记录展开的 #context)
|
||||
if ($promptRaw.Length -gt 20000) {
|
||||
$promptRaw = $promptRaw.Substring(0, 5000) + "`n[TRUNCATED: prompt too long; possible expanded #context]"
|
||||
}
|
||||
|
||||
$summary = ($promptRaw -replace "\s+", " ").Trim()
|
||||
if ($summary.Length -gt 120) { $summary = $summary.Substring(0, 120) + "…" }
|
||||
if ([string]::IsNullOrWhiteSpace($summary)) { $summary = "(empty prompt)" }
|
||||
|
||||
# CHANGE [2026-02-15] intent: 简化为每次直接写独立文件到 prompt_logs/,不再维护 prompt_log.md 中间文件
|
||||
$logDir = Join-Path "docs" "audit" "prompt_logs"
|
||||
$null = New-Item -ItemType Directory -Force -Path $logDir 2>$null
|
||||
|
||||
$filename = "prompt_log_{0}.md" -f $now.ToString("yyyyMMdd_HHmmss")
|
||||
$targetLog = Join-Path $logDir $filename
|
||||
|
||||
$timestamp = $now.ToString("yyyy-MM-dd HH:mm:ss zzz")
|
||||
$entry = @"
|
||||
- [$promptId] $timestamp
|
||||
- summary: $summary
|
||||
- prompt:
|
||||
``````text
|
||||
$promptRaw
|
||||
``````
|
||||
|
||||
"@
|
||||
|
||||
Set-Content -Path $targetLog -Value $entry -Encoding UTF8
|
||||
|
||||
# 保存 last prompt id 供下游 /audit 溯源
|
||||
$stateDir = ".kiro"
|
||||
$null = New-Item -ItemType Directory -Force -Path $stateDir 2>$null
|
||||
$lastPrompt = @{
|
||||
prompt_id = $promptId
|
||||
at = $now.ToString("o")
|
||||
} | ConvertTo-Json -Depth 4
|
||||
|
||||
Set-Content -Path (Join-Path $stateDir ".last_prompt_id.json") -Value $lastPrompt -Encoding UTF8
|
||||
|
||||
exit 0
|
||||
} catch {
|
||||
# 不阻塞 prompt 提交
|
||||
exit 0
|
||||
}
|
||||
60
.kiro/scripts/prompt_audit_log.py
Normal file
60
.kiro/scripts/prompt_audit_log.py
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""prompt_audit_log — 每次提交 prompt 时生成独立日志文件。
|
||||
|
||||
替代原 PowerShell 版本,避免 Windows PowerShell 5.1 解析器 bug。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
TZ_TAIPEI = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now(TZ_TAIPEI)
|
||||
prompt_id = f"P{now.strftime('%Y%m%d-%H%M%S')}"
|
||||
prompt_raw = os.environ.get("USER_PROMPT", "")
|
||||
|
||||
# 截断过长的 prompt(避免展开的 #context 占用过多空间)
|
||||
if len(prompt_raw) > 20000:
|
||||
prompt_raw = prompt_raw[:5000] + "\n[TRUNCATED: prompt too long; possible expanded #context]"
|
||||
|
||||
summary = " ".join(prompt_raw.split()).strip()
|
||||
if len(summary) > 120:
|
||||
summary = summary[:120] + "…"
|
||||
if not summary:
|
||||
summary = "(empty prompt)"
|
||||
|
||||
# 写独立日志文件
|
||||
log_dir = os.path.join("docs", "audit", "prompt_logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
filename = f"prompt_log_{now.strftime('%Y%m%d_%H%M%S')}.md"
|
||||
target = os.path.join(log_dir, filename)
|
||||
|
||||
timestamp = now.strftime("%Y-%m-%d %H:%M:%S %z")
|
||||
entry = f"""- [{prompt_id}] {timestamp}
|
||||
- summary: {summary}
|
||||
- prompt:
|
||||
```text
|
||||
{prompt_raw}
|
||||
```
|
||||
"""
|
||||
with open(target, "w", encoding="utf-8") as f:
|
||||
f.write(entry)
|
||||
|
||||
# 保存 last prompt id 供 /audit 溯源
|
||||
os.makedirs(".kiro", exist_ok=True)
|
||||
last_prompt = {"prompt_id": prompt_id, "at": now.isoformat()}
|
||||
with open(os.path.join(".kiro", ".last_prompt_id.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(last_prompt, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
# 不阻塞 prompt 提交
|
||||
pass
|
||||
@@ -1,4 +1,19 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"git": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-server-git@2025.12.18",
|
||||
"--repository",
|
||||
"C:\\NeoZQYY"
|
||||
],
|
||||
"disabled": false,
|
||||
"autoApprove": [
|
||||
"git_status",
|
||||
"git_branch",
|
||||
"all",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
name: steering-readme-maintainer
|
||||
description: 当发生业务/ETL/API/鉴权/小程序交互等逻辑改动时,用于执行变更影响审查并同步更新 product/tech/structure/README 与审计记录。
|
||||
description: 当发生业务/ETL/API/鉴权/小程序交互等逻辑改动时,用于执行变更影响审查并同步更新 product/tech/structure/各级 README 与审计记录。
|
||||
---
|
||||
|
||||
# 目的
|
||||
将“逻辑改动→文档同步→审计留痕”流程标准化,减少漏更与口径漂移风险(资金相关场景优先保证可追溯与可复算)。
|
||||
将"逻辑改动→文档同步→审计留痕"流程标准化,减少漏更与口径漂移风险(资金相关场景优先保证可追溯与可复算)。
|
||||
|
||||
# 触发条件(何时调用本 Skill)
|
||||
- 修改了业务规则/计算口径/资金处理(精度、舍入、阈值等)
|
||||
@@ -13,17 +13,30 @@ description: 当发生业务/ETL/API/鉴权/小程序交互等逻辑改动时,
|
||||
- 修改了小程序关键交互流程(校验、状态机、关键字段)
|
||||
|
||||
# 工作流(必须按顺序执行)
|
||||
## 1) 分类:是否属于“逻辑改动”
|
||||
- 若不是逻辑改动:写明“无逻辑改动”,并说明为何(例如仅格式化/拼写修正/注释调整)。
|
||||
## 1) 分类:是否属于"逻辑改动"
|
||||
- 若不是逻辑改动:写明"无逻辑改动",并说明为何(例如仅格式化/拼写修正/注释调整)。
|
||||
- 若是逻辑改动:进入下一步。
|
||||
|
||||
## 2) Steering 与 README 同步(逐项评估)
|
||||
|
||||
### 2a) Steering 文件
|
||||
- `.kiro/steering/product.md`:业务定义/口径/资金规则是否变化?
|
||||
- `.kiro/steering/tech.md`:技术栈/运行方式/依赖/部署假设是否变化?
|
||||
- `.kiro/steering/structure-lite.md(摘要)/ .kiro/steering/structure.md(仅在目录树/边界变化时)`:目录/模块边界/职责是否变化?
|
||||
- `README.md`:运行方式、配置、环境变量、接口契约、联调步骤是否变化?
|
||||
- `.kiro/steering/structure-lite.md`(摘要)/ `.kiro/steering/structure.md`(仅在目录树/边界变化时):目录/模块边界/职责是否变化?
|
||||
|
||||
> 规则:如果“对读者理解系统行为”有帮助,就应更新;不要为了追求“少改文档”而拒绝同步。
|
||||
### 2b) 各级 README.md(根据变更涉及的模块逐一评估)
|
||||
- `README.md`(根目录):项目总览、快速开始、环境变量、架构概述
|
||||
- `apps/backend/README.md`:后端 API 路由、配置、运行方式、接口契约
|
||||
- `apps/etl/pipelines/feiqiu/README.md`:ETL 任务清单、开发约定、注册流程
|
||||
- `apps/miniprogram/README.md`:小程序页面结构、构建部署
|
||||
- `apps/admin-web/README.md`:管理后台功能说明
|
||||
- `packages/shared/README.md`:共享包模块说明、使用方式
|
||||
- `db/README.md`:Schema 约定、迁移规范、种子数据说明
|
||||
- `scripts/README.md`:各子目录用途、常用脚本说明
|
||||
- `tests/README.md`:测试运行方式、FakeDB/FakeAPI 用法
|
||||
- `docs/README.md`:文档目录索引
|
||||
|
||||
> 规则:只更新与本次变更相关的 README;如果"对读者理解系统行为"有帮助,就应更新;不要为了追求"少改文档"而拒绝同步。若某个 README 尚不存在但变更涉及该模块,应创建。
|
||||
|
||||
## 3) 输出审计友好摘要(对话回复/审计记录都需要)
|
||||
- Changed:改了哪些模块/接口/表/关键文件
|
||||
|
||||
749
.kiro/specs/admin-web-console/design.md
Normal file
749
.kiro/specs/admin-web-console/design.md
Normal file
@@ -0,0 +1,749 @@
|
||||
# 设计文档: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 和 3 种处理模式,前端按 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 |
|
||||
|
||||
3 种处理模式:
|
||||
- `increment_only`:仅增量处理
|
||||
- `verify_only`:校验并修复(可选"校验前从 API 获取")
|
||||
- `increment_verify`:增量 + 校验并修复
|
||||
|
||||
时间窗口模式:
|
||||
- `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 + 3 种处理模式) | 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 状态查询
|
||||
149
.kiro/specs/admin-web-console/requirements.md
Normal file
149
.kiro/specs/admin-web-console/requirements.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# 需求文档: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: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 返回包含具体字段名和错误原因的验证错误
|
||||
292
.kiro/specs/admin-web-console/tasks.md
Normal file
292
.kiro/specs/admin-web-console/tasks.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 实现计划: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 和 3 种处理模式
|
||||
- _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 定义和 3 种处理模式定义
|
||||
- `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,311 +0,0 @@
|
||||
# 设计文档:数据库文档整理与补全
|
||||
|
||||
## 概述
|
||||
|
||||
本特性对 `docs/bd_manual/` 目录进行系统性整理和补全,涵盖四个核心工作流:
|
||||
|
||||
1. **目录结构规范化** — 统一各层目录布局,新增 `ETL_Admin/` 层和根目录 `README.md` 索引
|
||||
2. **DDL 对比同步** — 编写 Python 脚本对比四个 schema 的 DDL 文件与数据库实际状态,以数据库为准修正 DDL
|
||||
3. **ODS 表级文档补全** — 为 `billiards` schema 下所有 ODS 表生成 Markdown 表级文档
|
||||
4. **API→ODS 字段映射文档** — 建立 API JSON 响应到 ODS 表字段的映射关系文档
|
||||
|
||||
本特性以文档和 DDL 维护为主,不涉及业务代码逻辑变更。DDL 修正属于 `database/` 高风险路径,完成后需触发 `/audit`。
|
||||
|
||||
## 架构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph 信息来源
|
||||
DB[(PostgreSQL 数据库)]
|
||||
DDL[database/schema_*.sql]
|
||||
API_REF[docs/api-reference/]
|
||||
PARSERS[models/parsers.py]
|
||||
COMMENTS[DDL COMMENT ON 注释]
|
||||
end
|
||||
|
||||
subgraph 对比脚本
|
||||
COMPARE[scripts/compare_ddl_db.py]
|
||||
end
|
||||
|
||||
subgraph 文档产出
|
||||
README[docs/bd_manual/README.md]
|
||||
ODS_DOCS[docs/bd_manual/ODS/main/*.md]
|
||||
ODS_MAP[docs/bd_manual/ODS/mappings/*.md]
|
||||
ODS_DICT[docs/dictionary/ods_tables_dictionary.md]
|
||||
ETL_DOCS[docs/bd_manual/ETL_Admin/main/*.md]
|
||||
CHANGES[docs/bd_manual/*/changes/*.md]
|
||||
DDL_FIX[database/schema_*.sql 修正]
|
||||
end
|
||||
|
||||
DB -->|information_schema 查询| COMPARE
|
||||
DDL -->|解析 CREATE TABLE| COMPARE
|
||||
COMPARE -->|差异报告| CHANGES
|
||||
COMPARE -->|修正| DDL_FIX
|
||||
|
||||
DB -->|表结构 + COMMENT| ODS_DOCS
|
||||
COMMENTS -->|字段说明| ODS_DOCS
|
||||
API_REF -->|端点信息| ODS_MAP
|
||||
PARSERS -->|转换逻辑| ODS_MAP
|
||||
DB -->|表概览| ODS_DICT
|
||||
DB -->|表结构| ETL_DOCS
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 1. DDL 对比脚本 (`scripts/compare_ddl_db.py`)
|
||||
|
||||
一个独立的 Python 脚本,用于对比 DDL 文件与数据库实际状态。
|
||||
|
||||
**输入**:
|
||||
- DDL 文件路径(`database/schema_*.sql`)
|
||||
- 数据库连接(通过 `PG_DSN` 环境变量或 `--pg-dsn` 参数)
|
||||
|
||||
**输出**:
|
||||
- 控制台差异报告(表级、字段级、类型级)
|
||||
- 可选:`--fix` 模式直接修正 DDL 文件
|
||||
|
||||
**对比逻辑**:
|
||||
- 从 `information_schema.columns` 查询数据库实际表结构
|
||||
- 解析 DDL 文件中的 `CREATE TABLE` 语句提取表名和字段定义
|
||||
- 逐表逐字段对比:表是否存在、字段是否存在、字段类型是否一致、约束是否一致
|
||||
- 差异分类:`MISSING_TABLE`(DDL 缺表)、`EXTRA_TABLE`(DDL 多表)、`MISSING_COLUMN`、`EXTRA_COLUMN`、`TYPE_MISMATCH`、`NULLABLE_MISMATCH`
|
||||
|
||||
**接口**:
|
||||
```python
|
||||
def compare_schema(ddl_path: str, schema_name: str, pg_dsn: str) -> list[SchemaDiff]
|
||||
```
|
||||
|
||||
### 2. ODS 表级文档生成器
|
||||
|
||||
手动编写(非自动生成脚本),参考以下信息来源:
|
||||
- 数据库 `information_schema.columns` 获取字段名、类型、可空性
|
||||
- DDL 文件中的 `COMMENT ON` 注释获取字段说明、示例值、JSON 字段映射
|
||||
- 现有 DWD/DWS 表级文档格式作为模板
|
||||
|
||||
### 3. API→ODS 映射文档
|
||||
|
||||
手动编写,参考以下信息来源:
|
||||
- `docs/api-reference/endpoints/*.md` — API 端点路径、请求参数、响应字段
|
||||
- `docs/api-reference/samples/*.json` — JSON 响应样本
|
||||
- `models/parsers.py` — `TypeParser` 类中的类型转换方法
|
||||
- DDL 文件中的 `COMMENT ON` 注释中的 `【JSON字段】` 标注
|
||||
|
||||
### 4. 目录结构与索引
|
||||
|
||||
**新增目录**:
|
||||
- `docs/bd_manual/ETL_Admin/main/`
|
||||
- `docs/bd_manual/ETL_Admin/changes/`
|
||||
- `docs/bd_manual/ODS/mappings/`
|
||||
|
||||
**新增文件**:
|
||||
- `docs/bd_manual/README.md` — 根索引,列出目录结构和各层文档清单
|
||||
|
||||
## 数据模型
|
||||
|
||||
本特性不引入新的数据模型。涉及的现有 schema 如下:
|
||||
|
||||
| Schema | DDL 文件 | 用途 | 预估表数 |
|
||||
|--------|----------|------|----------|
|
||||
| `billiards_ods` | `database/schema_ODS_doc.sql` | 原始数据存储 | ~22 张 |
|
||||
| `billiards_dwd` | `database/schema_dwd_doc.sql` | 明细数据层 | ~22 张(含 Ex) |
|
||||
| `billiards_dws` | `database/schema_dws.sql` | 数据服务层 | ~30 张 |
|
||||
| `etl_admin` | `database/schema_etl_admin.sql` | ETL 管理元数据 | ~5 张 |
|
||||
|
||||
### 文档模板格式
|
||||
|
||||
**ODS 表级文档模板**(与 DWD/DWS 保持一致):
|
||||
|
||||
```markdown
|
||||
# {表名} {中文说明}
|
||||
|
||||
> 生成时间:YYYY-MM-DD
|
||||
|
||||
## 表信息
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| Schema | billiards |
|
||||
| 表名 | {表名} |
|
||||
| 主键 | {主键字段} |
|
||||
| 数据来源 | {API 端点 / JSON 文件} |
|
||||
| 说明 | {表说明} |
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 序号 | 字段名 | 类型 | 可空 | 说明 |
|
||||
|------|--------|------|------|------|
|
||||
| 1 | ... | ... | ... | ... |
|
||||
|
||||
## 使用说明
|
||||
|
||||
{SQL 示例}
|
||||
|
||||
## 可回溯性
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 可回溯 | ✅ 完全可回溯(保留 payload 原始 JSON) |
|
||||
| 数据来源 | {API 端点路径} |
|
||||
```
|
||||
|
||||
**API→ODS 映射文档模板**:
|
||||
|
||||
```markdown
|
||||
# {API端点名} → {ODS表名} 字段映射
|
||||
|
||||
> 生成时间:YYYY-MM-DD
|
||||
|
||||
## 端点信息
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 接口路径 | {路径} |
|
||||
| 请求方法 | POST |
|
||||
| ODS 对应表 | {表名} |
|
||||
| JSON 数据路径 | {如 data.tenantMemberInfos} |
|
||||
|
||||
## 字段映射
|
||||
|
||||
| JSON 字段 | ODS 列名 | 类型转换 | 说明 |
|
||||
|-----------|----------|----------|------|
|
||||
| id | id | int→BIGINT | 主键 |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
## ETL 补充字段
|
||||
|
||||
| ODS 列名 | 生成逻辑 |
|
||||
|-----------|----------|
|
||||
| content_hash | 对业务字段计算 SHA256 |
|
||||
| source_file | 固定值:{文件名}.json |
|
||||
| source_endpoint | API 端点路径 |
|
||||
| fetched_at | 入库时间戳 |
|
||||
| payload | 完整原始 JSON 记录 |
|
||||
|
||||
## 类型转换规则
|
||||
|
||||
- 时间戳:通过 `TypeParser.parse_timestamp()` 转换,支持字符串和 Unix 毫秒时间戳
|
||||
- 金额:通过 `TypeParser.parse_decimal(value, scale=2)` 转换,ROUND_HALF_UP
|
||||
- 整数:通过 `TypeParser.parse_int()` 转换
|
||||
```
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: 数据层目录结构一致性
|
||||
|
||||
*For any* 数据层目录(ODS、DWD、DWS、ETL_Admin),该目录下都应包含 `main/` 和 `changes/` 两个子目录。
|
||||
|
||||
**Validates: Requirements 1.2**
|
||||
|
||||
### Property 2: DDL 对比脚本差异检测完整性
|
||||
|
||||
*For any* schema 和对应的 DDL 文件,当数据库中存在 DDL 文件未定义的表或字段时,对比脚本应将其报告为 `MISSING_TABLE` 或 `MISSING_COLUMN`;当 DDL 文件中存在数据库没有的表或字段时,应报告为 `EXTRA_TABLE` 或 `EXTRA_COLUMN`;当字段类型不一致时,应报告为 `TYPE_MISMATCH`。
|
||||
|
||||
**Validates: Requirements 2.1, 2.2, 2.3, 2.4**
|
||||
|
||||
### Property 3: DDL 修正后零差异(不动点)
|
||||
|
||||
*For any* schema,在以数据库实际状态修正 DDL 文件后,再次运行对比脚本,差异列表应为空。
|
||||
|
||||
**Validates: Requirements 2.5**
|
||||
|
||||
### Property 4: ODS 表级文档覆盖率
|
||||
|
||||
*For any* `billiards` schema 中的 ODS 表,在 `docs/bd_manual/ODS/main/` 目录下都应存在一份对应的 Markdown 文档。
|
||||
|
||||
**Validates: Requirements 3.1**
|
||||
|
||||
### Property 5: ODS 表级文档格式完整性
|
||||
|
||||
*For any* ODS 表级文档,都应包含以下章节:表信息(含 Schema、表名、主键、数据来源、说明)、字段说明表格、使用说明(含 SQL 示例)、可回溯性信息,以及 ETL 元数据字段(content_hash、source_file、source_endpoint、fetched_at、payload)的说明。
|
||||
|
||||
**Validates: Requirements 3.2, 3.4, 3.5**
|
||||
|
||||
### Property 6: ODS 表级文档命名规范
|
||||
|
||||
*For any* ODS 表级文档文件,其文件名应匹配 `BD_manual_{表名}.md` 格式。
|
||||
|
||||
**Validates: Requirements 3.6**
|
||||
|
||||
### Property 7: 映射文档覆盖率
|
||||
|
||||
*For any* 有对应 ODS 表的 API 端点,在 `docs/bd_manual/ODS/mappings/` 目录下都应存在一份对应的映射文档。
|
||||
|
||||
**Validates: Requirements 4.1**
|
||||
|
||||
### Property 8: 映射文档内容完整性
|
||||
|
||||
*For any* 映射文档,都应包含以下信息:API 端点路径、ODS 表名、JSON 数据路径、字段映射表格,以及 ETL 补充字段(content_hash、source_file、source_endpoint、fetched_at、payload)的生成逻辑。
|
||||
|
||||
**Validates: Requirements 4.2, 4.4**
|
||||
|
||||
### Property 9: 映射文档命名规范
|
||||
|
||||
*For any* 映射文档文件,其文件名应匹配 `mapping_{API端点名}_{ODS表名}.md` 格式。
|
||||
|
||||
**Validates: Requirements 4.6**
|
||||
|
||||
### Property 10: ODS 数据字典覆盖率
|
||||
|
||||
*For any* `billiards` schema 中的 ODS 表,ODS 数据字典中都应有对应的条目,包含表名、中文说明、主键、数据来源信息。
|
||||
|
||||
**Validates: Requirements 5.2**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### DDL 对比脚本
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 数据库连接失败 | 输出错误信息并退出,返回非零退出码 |
|
||||
| DDL 文件不存在 | 输出错误信息并跳过该 schema |
|
||||
| DDL 文件解析失败 | 输出解析错误位置和原因,尽可能继续解析其余部分 |
|
||||
| schema 在数据库中不存在 | 输出警告并跳过 |
|
||||
|
||||
### 文档生成
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 表无 COMMENT 注释 | 字段说明列填写"(待补充)" |
|
||||
| API 端点文档缺失 | 映射文档中标注"端点文档待补充",仅基于 DDL COMMENT 生成 |
|
||||
| 字段类型无法识别 | 保留数据库原始类型字符串 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试
|
||||
|
||||
针对 DDL 对比脚本的核心逻辑编写单元测试(`tests/unit/test_compare_ddl.py`):
|
||||
|
||||
- 测试 DDL 解析器能正确提取表名、字段名、字段类型、约束
|
||||
- 测试差异检测逻辑能识别各类差异(缺失表、多余表、字段差异、类型差异)
|
||||
- 测试边界情况:空 DDL 文件、无表的 schema、COMMENT 中含特殊字符
|
||||
|
||||
### 属性测试
|
||||
|
||||
使用 `hypothesis` 库(Python 属性测试框架)。
|
||||
|
||||
- **Property 2 测试**:生成随机的"DDL 表定义"和"数据库表定义",注入已知差异,验证对比函数能检测到所有差异
|
||||
- **Feature: bd-manual-docs-consolidation, Property 2: DDL 对比脚本差异检测完整性**
|
||||
- 最少 100 次迭代
|
||||
|
||||
- **Property 3 测试**:生成随机的数据库表定义,用其生成 DDL,再运行对比,验证差异为零
|
||||
- **Feature: bd-manual-docs-consolidation, Property 3: DDL 修正后零差异(不动点)**
|
||||
- 最少 100 次迭代
|
||||
|
||||
### 集成验证
|
||||
|
||||
文档覆盖率和格式验证通过 Python 脚本实现(`scripts/validate_bd_manual.py`),可在 CI 中运行:
|
||||
|
||||
- 验证 Property 1(目录结构)、Property 4-10(文档覆盖率、格式、命名)
|
||||
- 输入:文件系统 + 数据库 `information_schema` 查询
|
||||
- 输出:通过/失败报告,列出缺失或不合规的文档
|
||||
|
||||
### 测试配置
|
||||
|
||||
- 属性测试库:`hypothesis`(需添加到开发依赖)
|
||||
- 单元测试:`pytest tests/unit/test_compare_ddl.py`
|
||||
- 集成验证:`python scripts/validate_bd_manual.py --pg-dsn "$PG_DSN"`
|
||||
- 每个属性测试最少 100 次迭代
|
||||
- 每个测试需注释引用对应的设计属性编号
|
||||
@@ -1,80 +0,0 @@
|
||||
# 需求文档
|
||||
|
||||
## 简介
|
||||
|
||||
整理和补全飞球 ETL 系统的数据库文档体系(`docs/bd_manual/`),包括目录结构规范化、DDL 与数据库实际状态的对比同步、ODS 层表级文档补全、以及 API JSON → ODS 字段映射文档的建立。本特性不涉及代码逻辑变更,仅涉及文档和 DDL 文件的维护。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **BD_Manual**: 数据库手册目录(`docs/bd_manual/`),存放各层表级文档、变更记录等
|
||||
- **ODS**: 操作数据存储层(Operational Data Store),`billiards` schema,保留 API 原始数据
|
||||
- **DWD**: 明细数据层(Data Warehouse Detail),`billiards_dwd` schema,清洗后的维度和事实表
|
||||
- **DWS**: 数据服务层(Data Warehouse Service),`billiards_dws` schema,汇总宽表和配置表
|
||||
- **DDL**: 数据定义语言文件(`database/schema_*.sql`),定义表结构
|
||||
- **表级文档**: 以 Markdown 格式编写的单表说明文件,包含表信息、字段说明、使用示例等
|
||||
- **字段映射文档**: 记录 API JSON 响应字段到 ODS 表字段的对应关系和转换逻辑
|
||||
- **SCD2**: 缓慢变化维度类型 2,用于 DWD 维度表的历史版本管理
|
||||
- **ETL_Admin**: ETL 管理 schema(`etl_admin`),存放调度、游标、运行记录等元数据
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:规范化 BD_Manual 目录结构
|
||||
|
||||
**用户故事:** 作为数据开发人员,我希望 BD_Manual 目录结构统一规范,以便快速定位各层各类型的数据库文档。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE BD_Manual SHALL 包含以下顶层子目录:`ODS/`、`DWD/`、`DWS/`、`ETL_Admin/`
|
||||
2. WHEN 某一数据层目录被访问时,THE BD_Manual SHALL 在该层目录下提供 `main/`(表级文档)和 `changes/`(变更记录)两个子目录
|
||||
3. THE DWD 目录 SHALL 额外保留 `Ex/` 子目录用于存放扩展表文档
|
||||
4. THE BD_Manual SHALL 在根目录提供一个 `README.md` 索引文件,列出目录结构说明和各层文档清单
|
||||
5. WHEN ETL_Admin schema 存在表时,THE BD_Manual SHALL 在 `ETL_Admin/main/` 下为每张表提供表级文档
|
||||
|
||||
### 需求 2:DDL 文件与数据库实际状态对比同步
|
||||
|
||||
**用户故事:** 作为数据开发人员,我希望 DDL 文件与数据库实际表结构保持一致,以便 DDL 文件可作为可信的 schema 参考。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 对比 ODS 层 DDL 文件(`database/schema_ODS_doc.sql`)与数据库 `billiards_ods` schema 实际表结构时,THE 对比脚本 SHALL 输出所有差异项(缺失表、多余表、字段差异、类型差异、约束差异)
|
||||
2. WHEN 对比 DWD 层 DDL 文件(`database/schema_dwd_doc.sql`)与数据库 `billiards_dwd` schema 实际表结构时,THE 对比脚本 SHALL 输出所有差异项
|
||||
3. WHEN 对比 DWS 层 DDL 文件(`database/schema_dws.sql`)与数据库 `billiards_dws` schema 实际表结构时,THE 对比脚本 SHALL 输出所有差异项
|
||||
4. WHEN 对比 ETL_Admin 层 DDL 文件(`database/schema_etl_admin.sql`)与数据库 `etl_admin` schema 实际表结构时,THE 对比脚本 SHALL 输出所有差异项
|
||||
5. WHEN 发现差异时,THE DDL 文件 SHALL 以数据库实际状态为准进行修正
|
||||
6. WHEN DDL 文件被修正后,THE 变更记录 SHALL 在对应层的 `changes/` 目录下生成一份差异说明文档
|
||||
|
||||
### 需求 3:补全 ODS 层表级文档
|
||||
|
||||
**用户故事:** 作为数据开发人员,我希望 ODS 层每张表都有完整的表级文档,以便理解原始数据结构和来源。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE ODS 表级文档 SHALL 为 `billiards_ods` schema 中的每张 ODS 表生成一份 Markdown 文档,存放于 `docs/bd_manual/ODS/main/`
|
||||
2. THE ODS 表级文档 SHALL 遵循与 DWD/DWS 表级文档一致的格式,包含:表信息表格、字段说明表格、使用说明(含 SQL 示例)、可回溯性信息
|
||||
3. WHEN ODS 表的字段含有 COMMENT 注释时,THE 表级文档 SHALL 将 COMMENT 中的说明、示例、JSON 字段映射信息提取并填入字段说明
|
||||
4. THE ODS 表级文档的表信息 SHALL 包含 Schema、表名、主键、数据来源(API 端点或文件)、说明
|
||||
5. THE ODS 表级文档 SHALL 包含 ETL 元数据字段(`content_hash`、`source_file`、`source_endpoint`、`fetched_at`、`payload`)的统一说明
|
||||
6. THE ODS 表级文档的文件命名 SHALL 遵循 `BD_manual_{表名}.md` 格式
|
||||
|
||||
### 需求 4:建立 API JSON → ODS 字段映射文档
|
||||
|
||||
**用户故事:** 作为数据开发人员,我希望有一份清晰的 API 响应字段到 ODS 表字段的映射文档,以便理解数据从 API 到 ODS 的转换逻辑。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 映射文档 SHALL 为每个 API 端点与其对应的 ODS 表建立一份映射文件,存放于 `docs/bd_manual/ODS/mappings/`
|
||||
2. THE 映射文档 SHALL 包含以下信息:API 端点路径、对应 ODS 表名、JSON 响应路径(如 `data.tenantMemberInfos`)、每个字段的 JSON 路径到 ODS 列名的映射
|
||||
3. WHEN 字段存在类型转换或值处理逻辑时,THE 映射文档 SHALL 记录转换规则(如时间格式转换、枚举值映射、金额精度处理)
|
||||
4. THE 映射文档 SHALL 标注 ETL 补充字段(`content_hash`、`source_file`、`source_endpoint`、`fetched_at`、`payload`)的生成逻辑
|
||||
5. THE 映射文档 SHALL 参考 `models/parsers.py` 中的解析逻辑和 `docs/api-reference/` 中的端点文档作为信息来源
|
||||
6. THE 映射文档的文件命名 SHALL 遵循 `mapping_{API端点名}_{ODS表名}.md` 格式
|
||||
|
||||
### 需求 5:建立 ODS 数据字典
|
||||
|
||||
**用户故事:** 作为数据开发人员,我希望有一份 ODS 层的数据字典汇总文档,以便快速查阅所有 ODS 表的概览信息。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE ODS 数据字典 SHALL 创建于 `docs/dictionary/ods_tables_dictionary.md`
|
||||
2. THE ODS 数据字典 SHALL 列出所有 ODS 表的概览信息,包含:表名、中文说明、主键、记录数、数据来源
|
||||
3. THE ODS 数据字典 SHALL 遵循与现有 DWD/DWS 数据字典一致的格式
|
||||
@@ -1,111 +0,0 @@
|
||||
# 实施计划:数据库文档整理与补全
|
||||
|
||||
## 概述
|
||||
|
||||
按照"目录结构 → DDL 对比脚本 → DDL 同步 → ODS 文档 → 映射文档 → 数据字典 → 索引"的顺序,逐步完成文档体系的整理和补全。DDL 对比脚本先编写并测试,再用它驱动实际的 DDL 同步工作。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 规范化 BD_Manual 目录结构
|
||||
- 创建 `docs/bd_manual/ETL_Admin/main/` 和 `docs/bd_manual/ETL_Admin/changes/` 目录
|
||||
- 创建 `docs/bd_manual/ODS/mappings/` 目录
|
||||
- 确认 ODS/DWD/DWS 各层均有 `main/` 和 `changes/` 子目录
|
||||
- _Requirements: 1.1, 1.2, 1.3_
|
||||
|
||||
- [ ] 2. 编写 DDL 对比脚本
|
||||
- [x] 2.1 实现 DDL 解析器和对比核心逻辑 (`scripts/compare_ddl_db.py`)
|
||||
- 解析 `CREATE TABLE` 语句提取表名、字段名、字段类型、约束、主键
|
||||
- 查询 `information_schema.columns` 获取数据库实际表结构
|
||||
- 实现逐表逐字段对比,输出差异分类(MISSING_TABLE、EXTRA_TABLE、MISSING_COLUMN、EXTRA_COLUMN、TYPE_MISMATCH、NULLABLE_MISMATCH)
|
||||
- 支持 `--pg-dsn`、`--schema`、`--ddl-path` 参数
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
|
||||
- [x] 2.2 编写 DDL 解析器单元测试 (`tests/unit/test_compare_ddl.py`)
|
||||
- 测试 DDL 解析器正确提取表名、字段、类型、约束
|
||||
- 测试差异检测逻辑识别各类差异
|
||||
- 测试边界情况:空文件、COMMENT 含特殊字符
|
||||
- _Requirements: 2.1_
|
||||
|
||||
- [x] 2.3 编写 DDL 对比属性测试
|
||||
- **Property 2: DDL 对比脚本差异检测完整性**
|
||||
- **Validates: Requirements 2.1, 2.2, 2.3, 2.4**
|
||||
|
||||
- [x] 2.4 编写 DDL 修正不动点属性测试
|
||||
- **Property 3: DDL 修正后零差异(不动点)**
|
||||
- **Validates: Requirements 2.5**
|
||||
|
||||
- [x] 3. 检查点 — 确认对比脚本可用
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 4. 执行 DDL 对比并同步
|
||||
- [x] 4.1 运行对比脚本对比四个 schema(ODS、DWD、DWS、ETL_Admin)
|
||||
- 执行 `scripts/compare_ddl_db.py` 对比每个 schema
|
||||
- 记录所有差异项
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
|
||||
- [x] 4.2 修正 DDL 文件以匹配数据库实际状态
|
||||
- 以数据库为准修正 `database/schema_ODS_doc.sql`、`database/schema_dwd_doc.sql`、`database/schema_dws.sql`、`database/schema_etl_admin.sql`
|
||||
- _Requirements: 2.5_
|
||||
|
||||
- [x] 4.3 生成 DDL 变更记录
|
||||
- 在对应层的 `changes/` 目录下生成差异说明文档(日期前缀命名)
|
||||
- 包含变更说明、兼容性影响、回滚策略、验证 SQL(至少 3 条)
|
||||
- _Requirements: 2.6_
|
||||
|
||||
- [x] 5. 检查点 — 确认 DDL 同步完成
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
- [x] 6. 补全 ODS 层表级文档
|
||||
- [x] 6.1 为每张 ODS 表编写表级文档 (`docs/bd_manual/ODS/main/BD_manual_{表名}.md`)
|
||||
- 从数据库 `information_schema.columns` 获取字段信息
|
||||
- 从 DDL `COMMENT ON` 注释提取字段说明、示例值、JSON 字段映射
|
||||
- 遵循 DWD/DWS 文档格式:表信息、字段说明、使用说明(含 SQL)、可回溯性
|
||||
- 包含 ETL 元数据字段统一说明
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
|
||||
|
||||
- [x] 7. 建立 API→ODS 字段映射文档
|
||||
- [x] 7.1 为每个 API 端点编写映射文档 (`docs/bd_manual/ODS/mappings/mapping_{端点名}_{表名}.md`)
|
||||
- 参考 `docs/api-reference/endpoints/*.md` 获取端点信息和响应字段
|
||||
- 参考 DDL `COMMENT ON` 中的 `【JSON字段】` 标注获取映射关系
|
||||
- 参考 `models/parsers.py` 中 `TypeParser` 的转换方法记录类型转换规则
|
||||
- 包含 ETL 补充字段生成逻辑说明
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
|
||||
|
||||
- [x] 8. 建立 ODS 数据字典和 ETL_Admin 文档
|
||||
- [x] 8.1 创建 ODS 数据字典 (`docs/dictionary/ods_tables_dictionary.md`)
|
||||
- 列出所有 ODS 表概览:表名、中文说明、主键、记录数、数据来源
|
||||
- 遵循现有 DWD/DWS 数据字典格式
|
||||
- _Requirements: 5.1, 5.2, 5.3_
|
||||
|
||||
- [x] 8.2 为 ETL_Admin 表编写表级文档 (`docs/bd_manual/ETL_Admin/main/BD_manual_{表名}.md`)
|
||||
- 从数据库获取 `etl_admin` schema 表结构
|
||||
- 遵循统一文档格式
|
||||
- _Requirements: 1.5_
|
||||
|
||||
- [x] 9. 编写 BD_Manual 根目录 README.md 索引
|
||||
- 创建 `docs/bd_manual/README.md`
|
||||
- 列出目录结构说明、各层文档清单、文档命名规范
|
||||
- _Requirements: 1.4_
|
||||
|
||||
- [x] 10. 编写文档验证脚本
|
||||
- [x] 10.1 实现文档覆盖率和格式验证脚本 (`scripts/validate_bd_manual.py`)
|
||||
- 验证目录结构一致性(Property 1)
|
||||
- 验证 ODS 文档覆盖率和命名规范(Property 4, 6)
|
||||
- 验证 ODS 文档格式完整性(Property 5)
|
||||
- 验证映射文档覆盖率和命名规范(Property 7, 9)
|
||||
- 验证映射文档内容完整性(Property 8)
|
||||
- 验证数据字典覆盖率(Property 10)
|
||||
- 支持 `--pg-dsn` 参数连接数据库获取表清单
|
||||
- _Requirements: 1.2, 3.1, 3.2, 3.6, 4.1, 4.2, 4.6, 5.2_
|
||||
|
||||
- [x] 11. 最终检查点 — 确认所有文档完整
|
||||
- 运行 `scripts/validate_bd_manual.py` 确认所有验证通过
|
||||
- 确保所有测试通过,如有问题请向用户确认。
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选,可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号以便追溯
|
||||
- DDL 修正涉及 `database/` 高风险路径,完成后需触发 `/audit`
|
||||
- 属性测试验证对比脚本的通用正确性,集成验证脚本验证文档体系的完整性
|
||||
- ODS 表级文档和映射文档为手动编写(非自动生成),需逐表参考数据库和 API 文档
|
||||
569
.kiro/specs/dataflow-structure-audit/design.md
Normal file
569
.kiro/specs/dataflow-structure-audit/design.md
Normal file
@@ -0,0 +1,569 @@
|
||||
# 设计文档
|
||||
|
||||
## 概览
|
||||
|
||||
对 `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 |
|
||||
110
.kiro/specs/dataflow-structure-audit/requirements.md
Normal file
110
.kiro/specs/dataflow-structure-audit/requirements.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 需求文档
|
||||
|
||||
## 简介
|
||||
|
||||
对现有 `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 负责语义分析和报告编排
|
||||
|
||||
现有脚本输出样本参见 `docs/reports/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 应自动发现并纳入分析范围。
|
||||
90
.kiro/specs/dataflow-structure-audit/tasks.md
Normal file
90
.kiro/specs/dataflow-structure-audit/tasks.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 实现计划:数据流结构分析重构
|
||||
|
||||
## 概览
|
||||
|
||||
将 `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,229 +0,0 @@
|
||||
# 设计文档:文档体系整理与优化
|
||||
|
||||
## 概述
|
||||
|
||||
本设计针对飞球 ETL 系统的 `docs/` 目录进行结构性优化,包含三个核心工作流:
|
||||
|
||||
1. **文档覆盖度补充**:新增 `architecture/`、`business-rules/`、`operations/` 三个缺失目录及骨架文档,新增项目级 `CHANGELOG.md`,更新文档总索引
|
||||
2. **审计一览表生成**:编写 Python 脚本解析 `docs/audit/changes/` 下的审计源记录,生成结构化的 `audit_dashboard.md` 汇总视图
|
||||
3. **业务规则文档迁移**:将 `index_algorithm_cn.md` 从 `docs/database/DWS/` 迁移至 `docs/business-rules/`,原位置保留重定向说明
|
||||
|
||||
## 架构
|
||||
|
||||
### 文档目录结构(目标状态)
|
||||
|
||||
```
|
||||
docs/
|
||||
├── README.md ← 文档总索引(更新)
|
||||
├── CHANGELOG.md ← 新增:项目级变更日志
|
||||
├── architecture/ ← 新增:架构设计文档
|
||||
│ ├── README.md ← 目录索引
|
||||
│ ├── system_overview.md ← 系统整体架构
|
||||
│ └── data_flow.md ← 数据流向详解
|
||||
├── business-rules/ ← 新增:业务规则文档
|
||||
│ ├── README.md ← 目录索引
|
||||
│ ├── index_algorithm_cn.md ← 迁移自 database/DWS/
|
||||
│ ├── dws_metrics.md ← DWS 口径定义(骨架)
|
||||
│ └── scd2_rules.md ← SCD2 处理规则(骨架)
|
||||
├── operations/ ← 新增:运维文档
|
||||
│ ├── README.md ← 目录索引
|
||||
│ ├── environment_setup.md ← 环境搭建指南
|
||||
│ ├── scheduling.md ← 调度配置说明
|
||||
│ └── troubleshooting.md ← 故障排查手册
|
||||
├── audit/
|
||||
│ ├── audit_dashboard.md ← 新增:审计一览表
|
||||
│ ├── changes/ ← 审计源记录(不变)
|
||||
│ └── ...
|
||||
├── api-reference/ ← 不变
|
||||
├── database/ ← 不变(移除 index_algorithm_cn.md)
|
||||
├── etl_tasks/ ← 不变
|
||||
├── reports/ ← 不变
|
||||
└── requirements/ ← 不变
|
||||
```
|
||||
|
||||
### 审计一览表生成流程
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["docs/audit/changes/*.md"] -->|Python 脚本解析| B["结构化数据列表"]
|
||||
B -->|按时间倒序排列| C["时间线视图"]
|
||||
B -->|按模块分类| D["模块索引视图"]
|
||||
C --> E["audit_dashboard.md"]
|
||||
D --> E
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 组件 1:审计一览表生成脚本
|
||||
|
||||
- 路径:`scripts/gen_audit_dashboard.py`
|
||||
- 职责:扫描 `docs/audit/changes/` 目录,解析每个 Markdown 文件,提取结构化信息,生成 `docs/audit/audit_dashboard.md`
|
||||
- 入口:`python scripts/gen_audit_dashboard.py`
|
||||
|
||||
#### 解析逻辑
|
||||
|
||||
脚本需要从审计源记录中提取以下字段:
|
||||
|
||||
| 字段 | 提取方式 |
|
||||
|------|----------|
|
||||
| 日期 | 从文件名前缀 `YYYY-MM-DD` 提取 |
|
||||
| 标题/需求摘要 | 从 Markdown 一级标题 `# ...` 提取 |
|
||||
| slug | 从文件名 `__` 后的部分提取 |
|
||||
| 修改文件列表 | 从"修改文件清单"或"文件清单"章节的表格/列表中提取 |
|
||||
| 影响模块 | 根据修改文件路径推断所属模块(api/、tasks/、docs/ 等) |
|
||||
| 变更类型 | 从文件内容中提取(bugfix/新增/修改/删除/文档) |
|
||||
| 风险等级 | 从"风险点"章节推断(高/中/低/极低) |
|
||||
|
||||
#### 模块分类规则
|
||||
|
||||
根据修改文件路径前缀映射到功能模块:
|
||||
|
||||
```python
|
||||
MODULE_MAP = {
|
||||
"api/": "API 层",
|
||||
"tasks/ods": "ODS 层",
|
||||
"tasks/dwd": "DWD 层",
|
||||
"tasks/dws": "DWS 层",
|
||||
"tasks/index": "指数算法",
|
||||
"loaders/": "数据装载",
|
||||
"database/": "数据库",
|
||||
"orchestration/": "调度",
|
||||
"config/": "配置",
|
||||
"cli/": "CLI",
|
||||
"models/": "模型",
|
||||
"scd/": "SCD2",
|
||||
"docs/": "文档",
|
||||
"scripts/": "脚本工具",
|
||||
"tests/": "测试",
|
||||
"quality/": "质量校验",
|
||||
"gui/": "GUI",
|
||||
"utils/": "工具库",
|
||||
}
|
||||
```
|
||||
|
||||
### 组件 2:静态文档文件
|
||||
|
||||
纯 Markdown 文件,无代码逻辑。包括:
|
||||
- 架构文档(`docs/architecture/`)
|
||||
- 业务规则文档(`docs/business-rules/`)
|
||||
- 运维文档(`docs/operations/`)
|
||||
- 变更日志(`docs/CHANGELOG.md`)
|
||||
- 更新后的文档总索引(`docs/README.md`)
|
||||
|
||||
### 组件 3:文件迁移与重定向
|
||||
|
||||
- 将 `docs/database/DWS/index_algorithm_cn.md` 内容迁移至 `docs/business-rules/index_algorithm_cn.md`
|
||||
- 在原位置 `docs/database/DWS/index_algorithm_cn.md` 替换为重定向说明
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 审计记录解析结构
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class AuditEntry:
|
||||
"""从单个审计源记录文件解析出的结构化数据"""
|
||||
date: str # YYYY-MM-DD,从文件名提取
|
||||
slug: str # 文件名中 __ 后的标识符
|
||||
title: str # Markdown 一级标题
|
||||
filename: str # 源文件名(不含路径)
|
||||
changed_files: list[str] # 修改的文件路径列表
|
||||
modules: set[str] # 影响的功能模块集合
|
||||
risk_level: str # 风险等级:高/中/低/极低
|
||||
change_type: str # 变更类型:bugfix/功能/文档/重构/清理
|
||||
```
|
||||
|
||||
### 审计一览表输出格式
|
||||
|
||||
`audit_dashboard.md` 包含两个视图:
|
||||
|
||||
1. **时间线视图**(按日期倒序):
|
||||
|
||||
```markdown
|
||||
| 日期 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 |
|
||||
|------|----------|----------|----------|------|------|
|
||||
| 2026-02-15 | docs/database 合并 | 重构 | 文档, 脚本工具 | 极低 | [链接](changes/...) |
|
||||
```
|
||||
|
||||
2. **模块索引视图**(按模块分组):
|
||||
|
||||
```markdown
|
||||
### API 层
|
||||
| 日期 | 需求摘要 | 变更类型 | 风险 | 详情 |
|
||||
...
|
||||
|
||||
### DWS 层
|
||||
| 日期 | 需求摘要 | 变更类型 | 风险 | 详情 |
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*正确性属性是一种在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是连接人类可读规格说明与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
本特性中,大部分需求涉及静态文档文件的创建和目录组织,属于例子级别的验证(文件是否存在、内容是否包含特定条目)。可提取为通用属性的集中在审计一览表生成脚本的解析、分类和排序逻辑。
|
||||
|
||||
### Property 1:审计记录解析-渲染完整性
|
||||
|
||||
*For any* 格式合规的审计源记录 Markdown 文件(包含一级标题、日期行、修改文件清单章节),解析后生成的表格行应包含:日期、需求摘要、变更类型、影响模块和源文件链接。
|
||||
|
||||
**Validates: Requirements 2.1, 2.2**
|
||||
|
||||
### Property 2:文件路径模块分类正确性
|
||||
|
||||
*For any* 文件路径字符串,模块分类函数的返回值应属于预定义的模块名称集合(API 层、ODS 层、DWD 层、DWS 层、指数算法、数据装载、数据库、调度、配置、CLI、模型、SCD2、文档、脚本工具、测试、质量校验、GUI、工具库、其他)。
|
||||
|
||||
**Validates: Requirements 2.3**
|
||||
|
||||
### Property 3:审计条目时间倒序排列
|
||||
|
||||
*For any* 一组审计条目列表,经过排序函数处理后,输出列表中每个条目的日期应大于等于其后续条目的日期(严格非递增序)。
|
||||
|
||||
**Validates: Requirements 2.4**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 审计记录解析容错
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 审计文件缺少一级标题 | 使用文件名 slug 作为标题替代 |
|
||||
| 审计文件缺少"修改文件清单"章节 | `changed_files` 返回空列表,`modules` 标记为 `{"其他"}` |
|
||||
| 审计文件名不符合 `YYYY-MM-DD__slug.md` 格式 | 跳过该文件,输出警告日志 |
|
||||
| 审计文件编码非 UTF-8 | 尝试 UTF-8 解码,失败则跳过并警告 |
|
||||
| `docs/audit/changes/` 目录为空 | 生成空的 dashboard 文件,包含"暂无审计记录"提示 |
|
||||
|
||||
### 文件迁移容错
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| `index_algorithm_cn.md` 源文件不存在 | 脚本报错退出,提示文件路径 |
|
||||
| 目标目录 `docs/business-rules/` 已存在同名文件 | 提示用户确认是否覆盖 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试框架
|
||||
|
||||
- 单元测试:`pytest`
|
||||
- 属性测试:`hypothesis`(Python 属性测试库)
|
||||
- 测试目录:`tests/unit/test_gen_audit_dashboard.py`
|
||||
|
||||
### 属性测试
|
||||
|
||||
每个正确性属性对应一个 `hypothesis` 属性测试,最少运行 100 次迭代:
|
||||
|
||||
- **Property 1**:生成随机的审计 Markdown 内容(包含标题、日期、文件清单),验证解析+渲染后的表格行包含所有必要字段
|
||||
- Tag: `Feature: docs-optimization, Property 1: 审计记录解析-渲染完整性`
|
||||
- **Property 2**:生成随机的文件路径字符串,验证分类结果属于预定义模块集合
|
||||
- Tag: `Feature: docs-optimization, Property 2: 文件路径模块分类正确性`
|
||||
- **Property 3**:生成随机的日期列表构造审计条目,验证排序后严格非递增
|
||||
- Tag: `Feature: docs-optimization, Property 3: 审计条目时间倒序排列`
|
||||
|
||||
### 单元测试
|
||||
|
||||
- 解析真实审计记录文件的具体例子(使用项目中已有的审计文件作为测试输入)
|
||||
- 边界情况:空目录、格式异常文件、缺少章节的文件
|
||||
- 文件存在性检查:验证所有新增目录和文件已创建
|
||||
- 文档总索引完整性:验证 `docs/README.md` 包含所有一级目录条目
|
||||
- 重定向文件验证:验证原 `index_algorithm_cn.md` 位置包含重定向说明
|
||||
@@ -1,56 +0,0 @@
|
||||
# 需求文档:文档体系整理与优化
|
||||
|
||||
## 简介
|
||||
|
||||
对飞球 ETL 系统(etl-billiards)的 `docs/` 目录进行文档体系整理与优化,涵盖三个核心诉求:文档覆盖度评估与缺失类别补充、审计一览表生成、业务规则文档独立目录建设。目标是让项目文档从宏观架构到微观实现形成完整闭环,同时提供可快速检索的审计变更视图。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **文档总索引(Docs_Index)**:`docs/README.md`,项目文档的统一入口与导航页
|
||||
- **审计一览表(Audit_Dashboard)**:基于 `docs/audit/changes/` 源数据生成的汇总视图文件
|
||||
- **审计源记录(Audit_Record)**:`docs/audit/changes/` 目录下的单个审计 Markdown 文件
|
||||
- **业务规则文档目录(Business_Rules_Dir)**:`docs/business-rules/`,存放指数算法、DWS 口径定义、SCD2 规则等业务逻辑文档
|
||||
- **架构设计文档目录(Architecture_Dir)**:`docs/architecture/`,存放系统整体架构、数据流、模块交互等设计文档
|
||||
- **运维文档目录(Operations_Dir)**:`docs/operations/`,存放环境搭建、调度配置、故障排查等运维指南
|
||||
- **变更日志(Changelog)**:`docs/CHANGELOG.md`,项目级版本变更历史记录
|
||||
- **指数算法文档(Index_Algorithm_Doc)**:当前位于 `docs/database/DWS/index_algorithm_cn.md` 的指数算法说明文件
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:文档覆盖度评估与缺失类别补充
|
||||
|
||||
**用户故事:** 作为项目开发者,我希望文档体系涵盖从宏观架构到微观实现的完整说明,以便新成员快速上手、现有成员高效查阅。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Docs_Index SHALL 包含指向所有一级文档目录的链接和简要说明
|
||||
2. WHEN 新增文档目录时,THE Docs_Index SHALL 同步更新对应的目录条目和说明
|
||||
3. THE Architecture_Dir SHALL 包含系统整体架构文档,涵盖数据流向图(ODS→DWD→DWS)、模块交互关系和技术栈说明
|
||||
4. THE Business_Rules_Dir SHALL 包含独立的业务规则与算法文档目录,与数据库表结构文档分离
|
||||
5. THE Operations_Dir SHALL 包含环境搭建指南、调度配置说明和常见故障排查手册
|
||||
6. THE Changelog SHALL 记录项目级版本变更历史,包含日期、变更摘要和影响范围
|
||||
|
||||
### 需求 2:审计一览表生成
|
||||
|
||||
**用户故事:** 作为项目管理者,我希望基于审计源记录生成一个汇总视图,以便一眼了解项目的修改痕迹并按功能模块检索。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Audit_Dashboard SHALL 从 Audit_Record 文件中提取日期、需求摘要、修改内容和影响范围信息
|
||||
2. THE Audit_Dashboard SHALL 以表格形式展示每条审计记录的时间日期、用户需求摘要、修改/新增/删除的内容概要和对项目的影响
|
||||
3. THE Audit_Dashboard SHALL 提供按功能模块分类的索引(如 API 层、ODS 层、DWD 层、DWS 层、文档、基础设施)
|
||||
4. THE Audit_Dashboard SHALL 提供按时间倒序排列的完整变更列表
|
||||
5. WHEN 新的 Audit_Record 被添加到 `docs/audit/changes/` 时,THE Audit_Dashboard SHALL 通过手动重新生成的方式保持同步
|
||||
6. THE Audit_Dashboard SHALL 存放于 `docs/audit/` 目录下,文件名为 `audit_dashboard.md`
|
||||
|
||||
### 需求 3:业务规则文档独立目录
|
||||
|
||||
**用户故事:** 作为项目开发者,我希望业务规则和算法文档有独立的存放目录,以便与数据库表结构文档清晰分离,方便查阅和维护。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Business_Rules_Dir SHALL 作为独立目录存在于 `docs/business-rules/` 路径下
|
||||
2. WHEN Index_Algorithm_Doc 从 `docs/database/DWS/` 迁移至 Business_Rules_Dir 时,THE 原路径 SHALL 保留一个指向新位置的重定向说明
|
||||
3. THE Business_Rules_Dir SHALL 包含目录级 README 文件,列出所有业务规则文档的索引
|
||||
4. THE Business_Rules_Dir SHALL 按业务域组织文档(如指数算法、DWS 口径定义、SCD2 规则、薪酬计算规则)
|
||||
5. THE Docs_Index SHALL 包含 Business_Rules_Dir 的条目和说明
|
||||
@@ -1,100 +0,0 @@
|
||||
# 实施计划:文档体系整理与优化
|
||||
|
||||
## 概述
|
||||
|
||||
基于设计文档,将实施分为四个阶段:新增文档目录与骨架文件、业务规则文档迁移、审计一览表生成脚本开发、文档总索引更新。所有任务聚焦于文件创建/修改和 Python 脚本编写。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 新增文档目录与骨架文件
|
||||
- [x] 1.1 创建 `docs/architecture/` 目录及文档
|
||||
- 创建 `docs/architecture/README.md`(目录索引)
|
||||
- 创建 `docs/architecture/system_overview.md`(系统整体架构:数据流向图、模块交互、技术栈)
|
||||
- 创建 `docs/architecture/data_flow.md`(ODS→DWD→DWS 数据流向详解)
|
||||
- 从根 `README.md` 和 `.kiro/steering/` 中提取架构信息填充内容
|
||||
- _Requirements: 1.3_
|
||||
|
||||
- [x] 1.2 创建 `docs/operations/` 目录及文档
|
||||
- 创建 `docs/operations/README.md`(目录索引)
|
||||
- 创建 `docs/operations/environment_setup.md`(环境搭建指南:Python、PostgreSQL、依赖安装)
|
||||
- 创建 `docs/operations/scheduling.md`(调度配置说明:CLI 参数、定时任务、管道模式)
|
||||
- 创建 `docs/operations/troubleshooting.md`(故障排查手册:常见错误与解决方案)
|
||||
- _Requirements: 1.5_
|
||||
|
||||
- [x] 1.3 创建 `docs/CHANGELOG.md`
|
||||
- 基于 `docs/audit/changes/` 中的审计记录,整理项目级版本变更历史
|
||||
- 包含日期、变更摘要和影响范围
|
||||
- _Requirements: 1.6_
|
||||
|
||||
- [x] 2. 业务规则文档迁移与目录建设
|
||||
- [x] 2.1 创建 `docs/business-rules/` 目录并迁移指数算法文档
|
||||
- 创建 `docs/business-rules/README.md`(目录索引,按业务域列出文档)
|
||||
- 将 `docs/database/DWS/index_algorithm_cn.md` 内容复制到 `docs/business-rules/index_algorithm_cn.md`
|
||||
- 将原 `docs/database/DWS/index_algorithm_cn.md` 替换为重定向说明
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4_
|
||||
|
||||
- [x] 2.2 创建业务规则骨架文档
|
||||
- 创建 `docs/business-rules/dws_metrics.md`(DWS 口径定义骨架)
|
||||
- 创建 `docs/business-rules/scd2_rules.md`(SCD2 处理规则骨架)
|
||||
- _Requirements: 3.4_
|
||||
|
||||
- [x] 3. 检查点 — 确认文档目录结构正确
|
||||
- 确认所有新增目录和文件已创建,如有问题请提出。
|
||||
|
||||
- [x] 4. 审计一览表生成脚本
|
||||
- [x] 4.1 实现审计记录解析模块
|
||||
- 在 `scripts/gen_audit_dashboard.py` 中实现 `AuditEntry` 数据类
|
||||
- 实现 `parse_audit_file(filepath)` 函数:从文件名提取日期/slug,从内容提取标题/修改文件/风险等级
|
||||
- 实现 `classify_module(filepath)` 函数:根据 MODULE_MAP 将文件路径映射到功能模块
|
||||
- 实现 `scan_audit_dir(dirpath)` 函数:扫描目录并返回 AuditEntry 列表
|
||||
- _Requirements: 2.1, 2.3_
|
||||
|
||||
- [x] 4.2 编写属性测试:审计记录解析-渲染完整性
|
||||
- **Property 1: 审计记录解析-渲染完整性**
|
||||
- 使用 hypothesis 生成随机审计 Markdown 内容,验证解析+渲染后表格行包含所有必要字段
|
||||
- **Validates: Requirements 2.1, 2.2**
|
||||
|
||||
- [x] 4.3 编写属性测试:文件路径模块分类正确性
|
||||
- **Property 2: 文件路径模块分类正确性**
|
||||
- 使用 hypothesis 生成随机文件路径,验证分类结果属于预定义模块集合
|
||||
- **Validates: Requirements 2.3**
|
||||
|
||||
- [x] 4.4 实现审计一览表渲染模块
|
||||
- 实现 `render_timeline_table(entries)` 函数:按时间倒序生成 Markdown 表格
|
||||
- 实现 `render_module_index(entries)` 函数:按模块分组生成 Markdown 章节
|
||||
- 实现 `render_dashboard(entries)` 函数:组合时间线和模块索引生成完整 dashboard
|
||||
- _Requirements: 2.2, 2.3, 2.4_
|
||||
|
||||
- [x] 4.5 编写属性测试:审计条目时间倒序排列
|
||||
- **Property 3: 审计条目时间倒序排列**
|
||||
- 使用 hypothesis 生成随机日期列表,验证排序后严格非递增
|
||||
- **Validates: Requirements 2.4**
|
||||
|
||||
- [x] 4.6 编写单元测试
|
||||
- 使用真实审计文件作为测试输入验证解析正确性
|
||||
- 测试边界情况:空目录、格式异常文件、缺少章节的文件
|
||||
- _Requirements: 2.1, 2.3_
|
||||
|
||||
- [x] 4.7 实现主入口并生成 audit_dashboard.md
|
||||
- 实现 `main()` 函数:扫描 → 解析 → 渲染 → 写入 `docs/audit/audit_dashboard.md`
|
||||
- 运行脚本生成实际的 audit_dashboard.md 文件
|
||||
- _Requirements: 2.5, 2.6_
|
||||
|
||||
- [x] 5. 更新文档总索引
|
||||
- [x] 5.1 更新 `docs/README.md`
|
||||
- 添加 `architecture/`、`business-rules/`、`operations/` 三个新目录的条目和说明
|
||||
- 添加 `CHANGELOG.md` 条目
|
||||
- 添加 `audit/audit_dashboard.md` 条目
|
||||
- 移除过时条目(如 `data_exports/`、`templates/`、`test-json-doc/` 如果不存在)
|
||||
- 确保所有一级目录都有对应链接
|
||||
- _Requirements: 1.1, 1.2, 3.5_
|
||||
|
||||
- [x] 6. 最终检查点 — 确认所有文件完整
|
||||
- 确认所有测试通过,所有文档文件已创建,审计一览表已生成,如有问题请提出。
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的任务为可选测试任务,可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号以便追溯
|
||||
- 检查点用于增量验证
|
||||
- 属性测试验证通用正确性属性,单元测试验证具体例子和边界情况
|
||||
245
.kiro/specs/dwd-phase1-refactor/design.md
Normal file
245
.kiro/specs/dwd-phase1-refactor/design.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# 设计文档: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 语句,而非实际连接数据库。
|
||||
81
.kiro/specs/dwd-phase1-refactor/requirements.md
Normal file
81
.kiro/specs/dwd-phase1-refactor/requirements.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 需求文档: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 写入结果
|
||||
96
.kiro/specs/dwd-phase1-refactor/tasks.md
Normal file
96
.kiro/specs/dwd-phase1-refactor/tasks.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 实施计划: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`
|
||||
|
||||
- [~] 7. 最终检查点 - 确保所有测试通过
|
||||
- 运行 `cd apps/etl/pipelines/feiqiu && pytest tests/unit` 和 `cd C:\NeoZQYY && pytest tests/ -v`,确保所有测试通过,如有问题请询问用户。
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选测试任务,可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号,确保可追溯
|
||||
- 检查点确保增量验证
|
||||
- 属性测试验证通用正确性属性,单元测试验证具体示例和边界情况
|
||||
- 本次重构涉及 `tasks/` 高风险路径,完成后需运行 `/audit`
|
||||
497
.kiro/specs/etl-dws-flow-refactor/design.md
Normal file
497
.kiro/specs/etl-dws-flow-refactor/design.md
Normal file
@@ -0,0 +1,497 @@
|
||||
# 设计文档: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"` 默认值。
|
||||
- 子类迁移是渐进式的:先在基类添加默认实现,再逐个子类迁移。
|
||||
|
||||
### 组件 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 级属性测试(根目录) |
|
||||
167
.kiro/specs/etl-dws-flow-refactor/requirements.md
Normal file
167
.kiro/specs/etl-dws-flow-refactor/requirements.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# 需求文档: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 的时间过滤
|
||||
- **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 更新任务列表和继承关系说明
|
||||
190
.kiro/specs/etl-dws-flow-refactor/tasks.md
Normal file
190
.kiro/specs/etl-dws-flow-refactor/tasks.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# 实施计划: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 分支上执行
|
||||
374
.kiro/specs/etl-pipeline-debug/design.md
Normal file
374
.kiro/specs/etl-pipeline-debug/design.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# 设计文档: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
|
||||
188
.kiro/specs/etl-pipeline-debug/requirements.md
Normal file
188
.kiro/specs/etl-pipeline-debug/requirements.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 需求文档: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/` 目录下
|
||||
168
.kiro/specs/etl-pipeline-debug/tasks.md
Normal file
168
.kiro/specs/etl-pipeline-debug/tasks.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 实现计划: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,285 +0,0 @@
|
||||
# 设计文档:ETL 任务说明文档
|
||||
|
||||
## 概述
|
||||
|
||||
本设计描述如何为飞球 ETL 系统生成一套完整的任务说明文档,放置于 `docs/etl_tasks/` 目录下。文档以 Markdown 格式编写,按数据层(ODS / DWD / DWS / INDEX / Utility)分文件组织,并提供一个总览 README 作为入口。
|
||||
|
||||
文档的目标读者是开发者和运维人员,需要覆盖:
|
||||
- 每个任务的代码标识、Python 类、数据来源与目标
|
||||
- Extract / Transform / Load 各阶段的处理逻辑
|
||||
- CLI 参数与管道执行方式
|
||||
- BaseTask 公共机制
|
||||
|
||||
## 架构
|
||||
|
||||
文档为纯静态 Markdown 文件,不涉及运行时代码变更。整体结构如下:
|
||||
|
||||
```
|
||||
docs/etl_tasks/
|
||||
├── README.md # 总览:任务清单 + 跳转链接 + 执行方式
|
||||
├── ods_tasks.md # ODS 层任务详解
|
||||
├── dwd_tasks.md # DWD 层任务详解
|
||||
├── dws_tasks.md # DWS 层任务详解
|
||||
├── index_tasks.md # INDEX 层任务详解
|
||||
├── utility_tasks.md # 工具类任务详解
|
||||
└── base_task_mechanism.md # BaseTask 公共机制与执行参数
|
||||
```
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
README["README.md<br/>总览入口"] --> ODS["ods_tasks.md"]
|
||||
README --> DWD["dwd_tasks.md"]
|
||||
README --> DWS["dws_tasks.md"]
|
||||
README --> IDX["index_tasks.md"]
|
||||
README --> UTL["utility_tasks.md"]
|
||||
README --> BASE["base_task_mechanism.md"]
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
本 spec 不涉及代码组件。产出物为 7 个 Markdown 文件,各文件的内容范围如下:
|
||||
|
||||
### README.md(总览)
|
||||
|
||||
| 章节 | 内容 |
|
||||
|------|------|
|
||||
| 系统简介 | 飞球 ETL 系统概述、数据流向(API → ODS → DWD → DWS) |
|
||||
| 任务清单 | 按层分组的表格:任务代码、Python 类、简要说明、跳转链接 |
|
||||
| 管道类型 | 7 种管道(api_ods / api_ods_dwd / api_full / ods_dwd / dwd_dws / dwd_dws_index / dwd_index)的层组合 |
|
||||
| 处理模式 | increment_only / verify_only / increment_verify 的区别 |
|
||||
| 数据源模式 | online / offline / hybrid 的区别 |
|
||||
| CLI 参数速查 | 所有 CLI 参数的表格(参数名、类型、默认值、说明) |
|
||||
| 常见命令示例 | 典型使用场景的命令行示例 |
|
||||
|
||||
### ods_tasks.md(ODS 层)
|
||||
|
||||
每个 ODS 任务一个小节,包含:
|
||||
- 任务代码与 Python 类
|
||||
- API 端点与请求参数
|
||||
- 字段解析逻辑(transform 阶段)
|
||||
- 目标 ODS 表与写入策略
|
||||
- 特殊说明(如分页、去重、content_hash 等)
|
||||
|
||||
需区分两种 ODS 任务模式:
|
||||
1. 独立任务类(如 OrdersTask、MembersTask):继承 BaseTask,有独立的 E/T/L 实现
|
||||
2. 通用 ODS 任务(由 `ods_tasks.py` 中 OdsTaskSpec + BaseOdsTask 动态生成):通过声明式配置定义端点、列映射等
|
||||
|
||||
已注册的 ODS 任务(14 个独立 + N 个通用):
|
||||
|
||||
| 任务代码 | Python 类 | API 端点 | 目标表 |
|
||||
|----------|-----------|----------|--------|
|
||||
| ORDERS | OrdersTask | /Site/GetAllOrderSettleList | billiards_ods.fact_order |
|
||||
| PAYMENTS | PaymentsTask | 支付相关端点 | billiards_ods.fact_payment |
|
||||
| MEMBERS | MembersTask | /MemberProfile/GetTenantMemberList | billiards_ods.dim_member |
|
||||
| PRODUCTS | ProductsTask | 商品相关端点 | billiards_ods 商品表 |
|
||||
| TABLES | TablesTask | 台桌相关端点 | billiards_ods 台桌表 |
|
||||
| ASSISTANTS | AssistantsTask | 助教相关端点 | billiards_ods 助教表 |
|
||||
| PACKAGES_DEF | PackagesDefTask | 套餐相关端点 | billiards_ods 套餐表 |
|
||||
| REFUNDS | RefundsTask | 退款相关端点 | billiards_ods 退款表 |
|
||||
| COUPON_USAGE | CouponUsageTask | 优惠券相关端点 | billiards_ods 优惠券表 |
|
||||
| INVENTORY_CHANGE | InventoryChangeTask | 库存变动端点 | billiards_ods 库存表 |
|
||||
| TOPUPS | TopupsTask | 充值相关端点 | billiards_ods 充值表 |
|
||||
| TABLE_DISCOUNT | TableDiscountTask | 台费折扣端点 | billiards_ods 折扣表 |
|
||||
| ASSISTANT_ABOLISH | AssistantAbolishTask | 助教取消端点 | billiards_ods 取消表 |
|
||||
| LEDGER | LedgerTask | 台账端点 | billiards_ods 台账表 |
|
||||
|
||||
通用 ODS 任务由 `ODS_TASK_CLASSES` 字典动态注册,每个任务通过 `OdsTaskSpec` 声明:
|
||||
- `endpoint`:API 端点路径
|
||||
- `table`:目标 ODS 表名
|
||||
- `columns`:列定义列表(ColumnSpec)
|
||||
- `page_size`、`data_path`、`list_key`:分页参数
|
||||
- `pk_columns`:主键列
|
||||
- `snapshot_mode`:快照模式(content_hash 去重)
|
||||
|
||||
### dwd_tasks.md(DWD 层)
|
||||
|
||||
DWD 层有 5 个已注册任务:
|
||||
|
||||
| 任务代码 | Python 类 | 说明 |
|
||||
|----------|-----------|------|
|
||||
| DWD_LOAD_FROM_ODS | DwdLoadTask | 核心装载任务:遍历 TABLE_MAP,维度走 SCD2,事实走增量 |
|
||||
| TICKET_DWD | TicketDwdTask | 结账小票明细 → fact_order / fact_order_goods / fact_table_usage / fact_assistant_service |
|
||||
| PAYMENTS_DWD | PaymentsDwdTask | ODS 支付记录 → fact_payment |
|
||||
| MEMBERS_DWD | MembersDwdTask | ODS 会员记录 → dim_member |
|
||||
| DWD_QUALITY_CHECK | DwdQualityTask | ODS 与 DWD 行数/金额核对,输出 JSON 报表 |
|
||||
|
||||
核心任务 DWD_LOAD_FROM_ODS 的处理逻辑:
|
||||
- TABLE_MAP 定义了 40+ 对 DWD→ODS 表映射
|
||||
- 维度表(dim_*):检测 SCD2 列是否存在,有则执行 SCD2 合并(关闭旧版+插入新版),无则执行 Type1 Upsert
|
||||
- 事实表(dwd_*、fact_*):按 fetched_at 水位线增量插入,支持 upsert 或 insert-only
|
||||
- FACT_MAPPINGS 定义了列名映射(ODS 驼峰命名 → DWD 下划线命名)
|
||||
- 每张表独立事务,单表失败不影响后续表
|
||||
|
||||
SCD2 处理流程:
|
||||
1. 从 ODS 取最新快照(DISTINCT ON 按业务主键 + fetched_at DESC)
|
||||
2. 与 DWD 当前版本(scd2_is_current=1)逐列对比
|
||||
3. 有变更:关闭旧版(scd2_end_time=now, scd2_is_current=0)+ 插入新版(version+1)
|
||||
4. 无变更:跳过
|
||||
|
||||
### dws_tasks.md(DWS 层)
|
||||
|
||||
DWS 层有 15 个已注册任务,按业务域分组:
|
||||
|
||||
**助教业绩域:**
|
||||
|
||||
| 任务代码 | Python 类 | 目标表 | 粒度 |
|
||||
|----------|-----------|--------|------|
|
||||
| DWS_ASSISTANT_DAILY | AssistantDailyTask | dws_assistant_daily_detail | 日期+助教 |
|
||||
| DWS_ASSISTANT_MONTHLY | AssistantMonthlyTask | dws_assistant_monthly_summary | 月份+助教 |
|
||||
| DWS_ASSISTANT_CUSTOMER | AssistantCustomerTask | dws_assistant_customer_stats | 日期+助教+会员 |
|
||||
| DWS_ASSISTANT_SALARY | AssistantSalaryTask | dws_assistant_salary_calc | 月份+助教 |
|
||||
| DWS_ASSISTANT_FINANCE | AssistantFinanceTask | dws_assistant_finance_analysis | 日期+助教 |
|
||||
|
||||
**会员分析域:**
|
||||
|
||||
| 任务代码 | Python 类 | 目标表 | 粒度 |
|
||||
|----------|-----------|--------|------|
|
||||
| DWS_MEMBER_CONSUMPTION | MemberConsumptionTask | dws_member_consumption_summary | 日期+会员 |
|
||||
| DWS_MEMBER_VISIT | MemberVisitTask | dws_member_visit_detail | 日期+会员+结账单 |
|
||||
|
||||
**财务统计域:**
|
||||
|
||||
| 任务代码 | Python 类 | 目标表 | 粒度 |
|
||||
|----------|-----------|--------|------|
|
||||
| DWS_FINANCE_DAILY | FinanceDailyTask | dws_finance_daily_summary | 日期 |
|
||||
| DWS_FINANCE_RECHARGE | FinanceRechargeTask | dws_finance_recharge_summary | 日期 |
|
||||
| DWS_FINANCE_INCOME_STRUCTURE | FinanceIncomeStructureTask | dws_finance_income_structure | 日期+收入类型 |
|
||||
| DWS_FINANCE_DISCOUNT_DETAIL | FinanceDiscountDetailTask | dws_finance_discount_detail | 日期+折扣类型 |
|
||||
|
||||
**运维任务:**
|
||||
|
||||
| 任务代码 | Python 类 | 说明 |
|
||||
|----------|-----------|------|
|
||||
| DWS_BUILD_ORDER_SUMMARY | DwsBuildOrderSummaryTask | 构建订单汇总中间表 |
|
||||
| DWS_RETENTION_CLEANUP | DwsRetentionCleanupTask | 按时间分层清理历史数据 |
|
||||
| DWS_MV_REFRESH_FINANCE_DAILY | DwsMvRefreshFinanceDailyTask | 刷新财务日报物化视图 |
|
||||
| DWS_MV_REFRESH_ASSISTANT_DAILY | DwsMvRefreshAssistantDailyTask | 刷新助教日报物化视图 |
|
||||
|
||||
所有 DWS 任务继承 BaseDwsTask,共享以下机制:
|
||||
- 时间分层范围计算(TimeLayer: LAST_2_DAYS / LAST_1_MONTH / LAST_3_MONTHS / LAST_6_MONTHS / ALL)
|
||||
- 配置缓存(ConfigCache):业绩档位、等级价格、奖金规则、区域分类、技能类型
|
||||
- delete-before-insert 更新策略(按日期范围先删后插,保证幂等)
|
||||
- bulk_insert / upsert 写入方法
|
||||
|
||||
### index_tasks.md(INDEX 层)
|
||||
|
||||
INDEX 层有 4 个已注册任务:
|
||||
|
||||
| 任务代码 | Python 类 | 目标表 | 指数类型 |
|
||||
|----------|-----------|--------|----------|
|
||||
| DWS_WINBACK_INDEX | WinbackIndexTask | dws_member_winback_index | WBI(回流指数) |
|
||||
| DWS_NEWCONV_INDEX | NewconvIndexTask | dws_member_newconv_index | NCI(新客转化指数) |
|
||||
| DWS_RELATION_INDEX | RelationIndexTask | dws_relation_index | RS(关系指数) |
|
||||
| DWS_ML_MANUAL_IMPORT | MlManualImportTask | dws_ml_manual_ledger | ML(手动台账导入) |
|
||||
|
||||
所有指数任务继承 BaseIndexTask,共享:
|
||||
- 参数从 `billiards_dws.cfg_index_parameters` 表加载
|
||||
- 百分位历史记录(PercentileHistory)
|
||||
- 标准化的指数计算流程
|
||||
|
||||
### utility_tasks.md(工具类)
|
||||
|
||||
| 任务代码 | Python 类 | 用途 |
|
||||
|----------|-----------|------|
|
||||
| INIT_ODS_SCHEMA | InitOdsSchemaTask | 执行 ODS + etl_admin DDL,创建必要目录 |
|
||||
| INIT_DWD_SCHEMA | InitDwdSchemaTask | 执行 DWD DDL |
|
||||
| INIT_DWS_SCHEMA | InitDwsSchemaTask | 执行 DWS DDL |
|
||||
| MANUAL_INGEST | ManualIngestTask | 从本地 JSON 文件手动入库到 ODS |
|
||||
| ODS_JSON_ARCHIVE | OdsJsonArchiveTask | 归档 ODS JSON 文件 |
|
||||
| CHECK_CUTOFF | CheckCutoffTask | 检查数据截止时间 |
|
||||
| SEED_DWS_CONFIG | SeedDwsConfigTask | 初始化 DWS 配置种子数据 |
|
||||
| DATA_INTEGRITY_CHECK | DataIntegrityTask | 数据完整性校验 |
|
||||
|
||||
### base_task_mechanism.md(公共机制)
|
||||
|
||||
覆盖内容:
|
||||
- BaseTask 模板方法流程(execute → build_context → [分段] → extract → transform → load → commit)
|
||||
- TaskContext 字段说明
|
||||
- 时间窗口计算逻辑(优先级:手动覆盖 > 游标 > 闲忙时段默认值)
|
||||
- 窗口分段(build_window_segments)
|
||||
- TaskRegistry 注册方式与元数据(TaskMeta: task_class, requires_db_config, layer, task_type)
|
||||
- PipelineRunner 管道执行流程
|
||||
- 校验框架(Verifier)概述
|
||||
|
||||
## 数据模型
|
||||
|
||||
本 spec 不涉及数据模型变更。文档中引用的数据模型均为现有系统中的表结构,包括:
|
||||
|
||||
**ODS 层表**(billiards_ods schema):
|
||||
- settlement_records, table_fee_transactions, assistant_service_records, member_profiles, payment_transactions, refund_transactions 等 20+ 表
|
||||
|
||||
**DWD 层表**(billiards_dwd schema):
|
||||
- 维度表:dim_site, dim_table, dim_assistant, dim_member, dim_member_card_account, dim_tenant_goods, dim_store_goods, dim_goods_category, dim_groupbuy_package(各含 _ex 扩展表)
|
||||
- 事实表:dwd_settlement_head, dwd_table_fee_log, dwd_table_fee_adjust, dwd_store_goods_sale, dwd_assistant_service_log, dwd_assistant_trash_event, dwd_member_balance_change, dwd_groupbuy_redemption, dwd_platform_coupon_redemption, dwd_recharge_order, dwd_payment, dwd_refund(各含 _ex 扩展表)
|
||||
|
||||
**DWS 层表**(billiards_dws schema):
|
||||
- 助教域:dws_assistant_daily_detail, dws_assistant_monthly_summary, dws_assistant_customer_stats, dws_assistant_salary_calc, dws_assistant_finance_analysis
|
||||
- 会员域:dws_member_consumption_summary, dws_member_visit_detail
|
||||
- 财务域:dws_finance_daily_summary, dws_finance_recharge_summary, dws_finance_income_structure, dws_finance_discount_detail
|
||||
- 指数域:dws_member_winback_index, dws_member_newconv_index, dws_relation_index, dws_ml_manual_ledger
|
||||
- 配置表:cfg_index_parameters, cfg_skill_type, cfg_performance_tier, cfg_level_price, cfg_bonus_rule, cfg_area_category
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*正确性属性是一种在系统所有有效执行中都应成立的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
由于本 spec 的产出物是文档(Markdown 文件),而非运行时代码,正确性属性主要关注文档的完整性和一致性——即文档是否覆盖了所有已注册任务。
|
||||
|
||||
Property 1: ODS 任务文档覆盖完整性
|
||||
*对于所有*在 `task_registry.py` 中注册且 layer="ODS" 的任务代码,`ods_tasks.md` 中应包含该任务代码的说明章节,并列出其目标表。
|
||||
**Validates: Requirements 2.1, 2.4**
|
||||
|
||||
Property 2: DWD 任务文档覆盖完整性
|
||||
*对于所有*在 `task_registry.py` 中注册且 layer="DWD" 的任务代码,`dwd_tasks.md` 中应包含该任务代码的说明章节,并列出其源表和目标表。
|
||||
**Validates: Requirements 3.1**
|
||||
|
||||
Property 3: DWS 任务文档覆盖完整性
|
||||
*对于所有*在 `task_registry.py` 中注册且 layer="DWS" 的任务代码,`dws_tasks.md` 中应包含该任务代码的说明章节,并标注其更新策略。
|
||||
**Validates: Requirements 4.1, 4.4**
|
||||
|
||||
Property 4: INDEX 任务文档覆盖完整性
|
||||
*对于所有*在 `task_registry.py` 中注册且 layer="INDEX" 的任务代码,`index_tasks.md` 中应包含该任务代码的说明章节。
|
||||
**Validates: Requirements 5.1**
|
||||
|
||||
Property 5: Utility 任务文档覆盖完整性
|
||||
*对于所有*在 `task_registry.py` 中注册且 task_type="utility" 的任务代码,`utility_tasks.md` 中应包含该任务代码的说明章节。
|
||||
**Validates: Requirements 6.1**
|
||||
|
||||
Property 6: CLI 参数文档覆盖完整性
|
||||
*对于所有*在 `cli/main.py` 的 `parse_args()` 中定义的 CLI 参数,`README.md` 或 `base_task_mechanism.md` 中应包含该参数的说明。
|
||||
**Validates: Requirements 7.1**
|
||||
|
||||
Property 7: 管道类型文档覆盖完整性
|
||||
*对于所有*在 `PipelineRunner.PIPELINE_LAYERS` 中定义的管道类型,`README.md` 中应包含该管道类型的层组合说明。
|
||||
**Validates: Requirements 7.2**
|
||||
|
||||
## 错误处理
|
||||
|
||||
本 spec 为文档生成任务,不涉及运行时错误处理。文档编写过程中需注意:
|
||||
|
||||
1. 若源代码中的任务类或注册信息发生变更,文档可能过时——应在 README.md 中注明"最后更新日期"和"基于代码版本"
|
||||
2. 若某个任务的 API 端点或参数无法从代码中直接读取(如动态配置),应在文档中标注"参见配置文件"
|
||||
|
||||
## 测试策略
|
||||
|
||||
**单元测试(示例验证):**
|
||||
- 验证所有 7 个 Markdown 文件存在于 `docs/etl_tasks/` 目录下
|
||||
- 验证 README.md 包含指向其他 6 个文件的链接
|
||||
- 验证每个分层文件中包含对应层的所有已注册任务代码
|
||||
|
||||
**属性测试(覆盖完整性验证):**
|
||||
- 使用 pytest 编写脚本,从 `task_registry.py` 动态读取已注册任务列表
|
||||
- 解析对应的 Markdown 文件,检查每个任务代码是否出现在文档中
|
||||
- 从 `cli/main.py` 解析 CLI 参数列表,检查文档中是否覆盖
|
||||
- 属性测试库:pytest(本项目已使用),配合 parametrize 实现参数化验证
|
||||
- 每个属性测试标注对应的设计属性编号
|
||||
|
||||
**测试标注格式:**
|
||||
```python
|
||||
# Feature: etl-task-documentation, Property 1: ODS 任务文档覆盖完整性
|
||||
def test_ods_task_coverage():
|
||||
...
|
||||
```
|
||||
|
||||
由于本 spec 的产出物是静态文档,属性测试的核心价值在于确保文档与代码的一致性,防止文档遗漏任务。测试应在文档生成后运行一次即可,无需持续集成。
|
||||
@@ -1,119 +0,0 @@
|
||||
# 需求文档:ETL 任务说明文档
|
||||
|
||||
## 简介
|
||||
|
||||
为飞球 ETL 系统(etl-billiards)生成一份完整的任务说明文档,覆盖 ODS、DWD、DWS、INDEX 四层所有已注册任务的逻辑、执行方式、参数含义及处理流程。文档面向开发者和运维人员,放置于 `docs/etl_tasks/` 目录下。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **ETL_System**:飞球 ETL 系统,负责从上游 API 抽取数据并经 ODS → DWD → DWS 三层处理
|
||||
- **Task_Document**:本次生成的 ETL 任务说明文档
|
||||
- **ODS**:操作数据存储层(Operational Data Store),保留 API 原始 payload
|
||||
- **DWD**:明细数据层(Data Warehouse Detail),清洗后的维度表和事实表
|
||||
- **DWS**:数据服务层(Data Warehouse Service),汇总统计表
|
||||
- **INDEX**:指数算法层,基于 DWS 数据计算自定义业务指数
|
||||
- **BaseTask**:所有 ETL 任务的基类,提供 Extract → Transform → Load 模板方法
|
||||
- **TaskRegistry**:任务注册表,维护任务代码与任务类的映射关系
|
||||
- **TaskContext**:运行期上下文,包含 store_id、时间窗口等信息
|
||||
- **Pipeline**:管道,定义多层任务的执行顺序(如 api_ods、api_full、dwd_dws 等)
|
||||
- **Loader**:加载器,负责将转换后的数据写入目标表(upsert/insert)
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:文档结构与组织
|
||||
|
||||
**用户故事:** 作为开发者,我希望文档按数据层分章节组织,以便快速定位特定层的任务说明。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Task_Document SHALL 包含一个总览文件(`README.md`),列出所有层及其任务清单,并提供跳转链接
|
||||
2. THE Task_Document SHALL 按 ODS、DWD、DWS、INDEX、Utility 五个分类分别生成独立的 Markdown 文件
|
||||
3. THE Task_Document SHALL 放置于 `docs/etl_tasks/` 目录下
|
||||
4. WHEN 新增或删除任务时,THE Task_Document SHALL 通过总览文件的任务清单反映当前已注册任务的完整列表
|
||||
|
||||
### 需求 2:ODS 层任务说明
|
||||
|
||||
**用户故事:** 作为开发者,我希望了解每个 ODS 任务的 API 端点、参数、解析逻辑和目标表,以便排查数据抓取问题。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Task_Document SHALL 为每个 ODS 任务列出任务代码、对应的 Python 类、源 API 端点
|
||||
2. THE Task_Document SHALL 说明每个 ODS 任务的 extract 阶段调用的 API 参数及其含义
|
||||
3. THE Task_Document SHALL 说明每个 ODS 任务的 transform 阶段的字段解析和类型转换逻辑
|
||||
4. THE Task_Document SHALL 说明每个 ODS 任务的 load 阶段的目标表名和写入策略(upsert/insert)
|
||||
5. THE Task_Document SHALL 区分"独立 ODS 任务"(如 OrdersTask)和"通用 ODS 任务"(由 ODS_TASK_CLASSES 动态生成)两种模式
|
||||
6. THE Task_Document SHALL 说明通用 ODS 任务的 OdsTaskSpec 配置结构(端点、表名、列映射、分页参数等)
|
||||
|
||||
### 需求 3:DWD 层任务说明
|
||||
|
||||
**用户故事:** 作为开发者,我希望了解 DWD 层任务如何从 ODS 读取数据并清洗装载到维度表和事实表,以便理解数据血缘。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Task_Document SHALL 为每个 DWD 任务列出任务代码、Python 类、源 ODS 表和目标 DWD 表
|
||||
2. THE Task_Document SHALL 说明 DWD_LOAD_FROM_ODS 任务的 TABLE_MAP 映射关系及维度/事实分流逻辑
|
||||
3. THE Task_Document SHALL 说明维度表的 SCD2 处理方式(生效区间、变更检测、历史版本管理)
|
||||
4. THE Task_Document SHALL 说明事实表的增量装载方式(水位线、去重、冲突处理)
|
||||
5. THE Task_Document SHALL 说明 DWD_QUALITY_CHECK 任务的行数/金额核对逻辑和报表输出格式
|
||||
6. THE Task_Document SHALL 说明 TICKET_DWD、PAYMENTS_DWD、MEMBERS_DWD 三个独立 DWD 任务各自的处理特点
|
||||
|
||||
### 需求 4:DWS 层任务说明
|
||||
|
||||
**用户故事:** 作为开发者,我希望了解 DWS 层每个汇总任务的业务含义、数据来源和计算规则,以便验证业务报表的正确性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Task_Document SHALL 为每个 DWS 任务列出任务代码、Python 类、目标表、主键和统计粒度
|
||||
2. THE Task_Document SHALL 说明每个 DWS 任务的数据来源表(DWD 层的哪些表)
|
||||
3. THE Task_Document SHALL 说明每个 DWS 任务的核心业务计算规则(如工资计算公式、业绩档位、排名逻辑等)
|
||||
4. THE Task_Document SHALL 说明每个 DWS 任务的更新策略(delete-before-insert 或 upsert)
|
||||
5. THE Task_Document SHALL 说明物化视图刷新任务(MV_REFRESH)的分层刷新机制和配置方式
|
||||
6. THE Task_Document SHALL 说明数据保留清理任务(RETENTION_CLEANUP)的时间分层策略和配置参数
|
||||
|
||||
### 需求 5:INDEX 层任务说明
|
||||
|
||||
**用户故事:** 作为开发者,我希望了解指数算法任务的计算逻辑和参数含义,以便调优指数模型。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Task_Document SHALL 为每个 INDEX 任务列出任务代码、Python 类、目标表和指数类型
|
||||
2. THE Task_Document SHALL 说明每个指数的计算公式或算法概要(WBI/NCI/RS/ML)
|
||||
3. THE Task_Document SHALL 说明指数参数的配置来源(cfg_index_parameters 表)和参数含义
|
||||
4. THE Task_Document SHALL 说明 ML_MANUAL_IMPORT 任务的 Excel 导入逻辑和模板格式
|
||||
|
||||
### 需求 6:工具类任务说明
|
||||
|
||||
**用户故事:** 作为运维人员,我希望了解 Schema 初始化、手动入库等工具类任务的用途和使用方式。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Task_Document SHALL 为每个工具类任务列出任务代码、Python 类和用途说明
|
||||
2. THE Task_Document SHALL 说明 INIT_ODS_SCHEMA、INIT_DWD_SCHEMA、INIT_DWS_SCHEMA 三个初始化任务执行的 DDL 文件和创建的目录
|
||||
3. THE Task_Document SHALL 说明 MANUAL_INGEST 任务的文件匹配规则、JSON 解析逻辑和入库流程
|
||||
4. THE Task_Document SHALL 说明 ODS_JSON_ARCHIVE 任务的归档策略
|
||||
5. THE Task_Document SHALL 说明 CHECK_CUTOFF 和 DATA_INTEGRITY_CHECK 任务的校验逻辑
|
||||
|
||||
### 需求 7:执行方式与参数说明
|
||||
|
||||
**用户故事:** 作为运维人员,我希望了解如何通过 CLI 和管道模式执行任务,以及各参数的含义。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Task_Document SHALL 说明 CLI 入口(`python -m cli.main`)的所有参数及其含义
|
||||
2. THE Task_Document SHALL 说明管道类型(api_ods、api_ods_dwd、api_full、ods_dwd、dwd_dws、dwd_dws_index、dwd_index)各自包含的层和执行顺序
|
||||
3. THE Task_Document SHALL 说明处理模式(increment_only、verify_only、increment_verify)的区别和适用场景
|
||||
4. THE Task_Document SHALL 说明时间窗口参数(window-start、window-end、window-split、lookback-hours、overlap-seconds)的计算逻辑
|
||||
5. THE Task_Document SHALL 说明数据源模式(online、offline、hybrid)的区别
|
||||
6. THE Task_Document SHALL 提供常见使用场景的命令示例
|
||||
|
||||
### 需求 8:BaseTask 与公共机制说明
|
||||
|
||||
**用户故事:** 作为开发者,我希望了解任务基类的模板方法和公共机制,以便开发新任务时遵循统一模式。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Task_Document SHALL 说明 BaseTask 的 Execute → Extract → Transform → Load 模板方法流程
|
||||
2. THE Task_Document SHALL 说明 TaskContext 的字段含义(store_id、window_start、window_end、window_minutes、cursor)
|
||||
3. THE Task_Document SHALL 说明时间窗口的计算逻辑(游标优先、闲忙时段、手动覆盖)
|
||||
4. THE Task_Document SHALL 说明窗口分段(build_window_segments)的切分策略
|
||||
5. THE Task_Document SHALL 说明任务注册表(TaskRegistry)的注册方式和元数据结构(layer、task_type、requires_db_config)
|
||||
@@ -1,125 +0,0 @@
|
||||
# 实施计划:ETL 任务说明文档
|
||||
|
||||
## 概述
|
||||
|
||||
基于对 `tasks/`、`loaders/`、`orchestration/`、`cli/` 目录下源代码的分析,生成 7 个 Markdown 文档文件,放置于 `docs/etl_tasks/` 目录下。每个任务按照设计文档中定义的结构和内容范围编写。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 创建 `docs/etl_tasks/base_task_mechanism.md`
|
||||
- 说明 BaseTask 的 execute → extract → transform → load 模板方法流程
|
||||
- 说明 TaskContext 字段含义(store_id、window_start、window_end、window_minutes、cursor)
|
||||
- 说明时间窗口计算逻辑(手动覆盖 > 游标 > 闲忙时段默认值)
|
||||
- 说明窗口分段(build_window_segments)切分策略
|
||||
- 说明 TaskRegistry 注册方式与 TaskMeta 元数据结构(layer、task_type、requires_db_config)
|
||||
- 说明 PipelineRunner 管道执行流程与校验框架概述
|
||||
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_
|
||||
|
||||
- [x] 2. 创建 `docs/etl_tasks/ods_tasks.md`
|
||||
- [x] 2.1 编写独立 ODS 任务说明(14 个任务:ORDERS、PAYMENTS、MEMBERS、PRODUCTS、TABLES、ASSISTANTS、PACKAGES_DEF、REFUNDS、COUPON_USAGE、INVENTORY_CHANGE、TOPUPS、TABLE_DISCOUNT、ASSISTANT_ABOLISH、LEDGER)
|
||||
- 每个任务列出:任务代码、Python 类、API 端点、请求参数、字段解析逻辑、目标表、写入策略
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
- [x] 2.2 编写通用 ODS 任务说明(BaseOdsTask + OdsTaskSpec 模式)
|
||||
- 说明 OdsTaskSpec 配置结构(endpoint、table、columns、pk_columns、snapshot_mode 等)
|
||||
- 说明 BaseOdsTask 的通用 execute 流程(API 调用、schema-aware 插入、content_hash 去重、软删除标记)
|
||||
- 列出由 ODS_TASK_CLASSES 动态注册的所有任务
|
||||
- _Requirements: 2.5, 2.6_
|
||||
|
||||
- [x] 3. 创建 `docs/etl_tasks/dwd_tasks.md`
|
||||
- [x] 3.1 编写 DWD_LOAD_FROM_ODS 核心任务说明
|
||||
- 列出完整的 TABLE_MAP 映射表(DWD 表 → ODS 表)
|
||||
- 说明维度/事实分流逻辑(dim_* 走 SCD2 或 Type1 Upsert,其余走增量插入)
|
||||
- 说明 SCD2 处理流程(最新快照选取、变更检测、版本关闭与新建)
|
||||
- 说明事实表增量装载(fetched_at 水位线、upsert/insert-only、FACT_MAPPINGS 列映射)
|
||||
- _Requirements: 3.2, 3.3, 3.4_
|
||||
- [x] 3.2 编写独立 DWD 任务说明(TICKET_DWD、PAYMENTS_DWD、MEMBERS_DWD)
|
||||
- 每个任务列出:源 ODS 表、目标 DWD 表、处理特点
|
||||
- _Requirements: 3.6_
|
||||
- [x] 3.3 编写 DWD_QUALITY_CHECK 任务说明
|
||||
- 说明行数/金额核对逻辑、金额列自动扫描规则、JSON 报表输出格式
|
||||
- _Requirements: 3.5_
|
||||
|
||||
- [x] 4. 创建 `docs/etl_tasks/dws_tasks.md`
|
||||
- [x] 4.1 编写 BaseDwsTask 公共机制说明
|
||||
- 说明时间分层(TimeLayer)、配置缓存(ConfigCache)、delete-before-insert 策略、bulk_insert/upsert 方法
|
||||
- _Requirements: 4.1_
|
||||
- [x] 4.2 编写助教业绩域任务说明(5 个任务)
|
||||
- DWS_ASSISTANT_DAILY:日度服务明细聚合,数据来源、聚合维度、输出字段
|
||||
- DWS_ASSISTANT_MONTHLY:月度汇总,业绩档位计算、排名逻辑、新人封顶规则
|
||||
- DWS_ASSISTANT_CUSTOMER:助教-客户关系统计
|
||||
- DWS_ASSISTANT_SALARY:工资计算公式(基础工资+提成+奖金+扣款)
|
||||
- DWS_ASSISTANT_FINANCE:助教收支分析(收入 vs 日均成本、毛利率)
|
||||
- _Requirements: 4.2, 4.3, 4.4_
|
||||
- [x] 4.3 编写会员分析域任务说明(2 个任务)
|
||||
- DWS_MEMBER_CONSUMPTION:会员消费汇总、客户分层
|
||||
- DWS_MEMBER_VISIT:会员到店明细、服务时长、折扣计算
|
||||
- _Requirements: 4.2, 4.3, 4.4_
|
||||
- [x] 4.4 编写财务统计域任务说明(4 个任务)
|
||||
- DWS_FINANCE_DAILY:财务日报(结算汇总、团购、充值、赠卡消费、费用、平台)
|
||||
- DWS_FINANCE_RECHARGE:充值统计(首充/续充、现金/赠送、卡余额)
|
||||
- DWS_FINANCE_INCOME_STRUCTURE:收入结构分析(按类型、按区域)
|
||||
- DWS_FINANCE_DISCOUNT_DETAIL:折扣明细统计
|
||||
- _Requirements: 4.2, 4.3, 4.4_
|
||||
- [x] 4.5 编写运维任务说明(4 个任务)
|
||||
- DWS_BUILD_ORDER_SUMMARY:订单汇总中间表构建
|
||||
- DWS_RETENTION_CLEANUP:时间分层清理策略、配置参数(enabled、layer、tables、table_layers)
|
||||
- DWS_MV_REFRESH_FINANCE_DAILY / DWS_MV_REFRESH_ASSISTANT_DAILY:物化视图分层刷新机制、L1-L4 层级、配置方式
|
||||
- _Requirements: 4.5, 4.6_
|
||||
|
||||
- [x] 5. 创建 `docs/etl_tasks/index_tasks.md`
|
||||
- 编写 BaseIndexTask 公共机制(参数加载、百分位历史)
|
||||
- 编写 4 个指数任务说明:WBI(回流指数)、NCI(新客转化指数)、RS(关系指数)、ML(手动台账导入)
|
||||
- 说明 cfg_index_parameters 配置表结构和参数含义
|
||||
- 说明 ML_MANUAL_IMPORT 的 Excel 模板格式和导入逻辑
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4_
|
||||
|
||||
- [x] 6. 创建 `docs/etl_tasks/utility_tasks.md`
|
||||
- 编写 8 个工具类任务说明:INIT_ODS_SCHEMA、INIT_DWD_SCHEMA、INIT_DWS_SCHEMA、MANUAL_INGEST、ODS_JSON_ARCHIVE、CHECK_CUTOFF、SEED_DWS_CONFIG、DATA_INTEGRITY_CHECK
|
||||
- 每个任务列出:用途、执行的 DDL/操作、配置参数
|
||||
- 重点说明 MANUAL_INGEST 的文件匹配规则和入库流程
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
|
||||
|
||||
- [x] 7. 创建 `docs/etl_tasks/README.md`(总览)
|
||||
- 编写系统简介和数据流向图(API → ODS → DWD → DWS)
|
||||
- 编写按层分组的任务清单表格(任务代码、Python 类、简要说明、跳转链接)
|
||||
- 编写管道类型说明(7 种管道的层组合)
|
||||
- 编写处理模式说明(increment_only / verify_only / increment_verify)
|
||||
- 编写数据源模式说明(online / offline / hybrid)
|
||||
- 编写 CLI 参数速查表(所有参数的表格)
|
||||
- 编写常见命令示例
|
||||
- _Requirements: 1.1, 1.2, 1.3, 7.1, 7.2, 7.3, 7.5, 7.6_
|
||||
|
||||
- [x] 8. 检查点 - 验证文档完整性
|
||||
- 确认 7 个文件全部存在于 `docs/etl_tasks/` 目录下
|
||||
- 确认 README.md 中的任务清单覆盖所有已注册任务
|
||||
- 确认各分层文件中的任务代码与 task_registry.py 一致
|
||||
- Ensure all files are valid Markdown, ask the user if questions arise.
|
||||
|
||||
- [x] 9. 编写文档覆盖完整性验证脚本
|
||||
- [x] 9.1 编写 ODS 任务覆盖验证
|
||||
- **Property 1: ODS 任务文档覆盖完整性**
|
||||
- **Validates: Requirements 2.1, 2.4**
|
||||
- [x] 9.2 编写 DWD 任务覆盖验证
|
||||
- **Property 2: DWD 任务文档覆盖完整性**
|
||||
- **Validates: Requirements 3.1**
|
||||
- [x] 9.3 编写 DWS 任务覆盖验证
|
||||
- **Property 3: DWS 任务文档覆盖完整性**
|
||||
- **Validates: Requirements 4.1, 4.4**
|
||||
- [x] 9.4 编写 INDEX 和 Utility 任务覆盖验证
|
||||
- **Property 4: INDEX 任务文档覆盖完整性**
|
||||
- **Property 5: Utility 任务文档覆盖完整性**
|
||||
- **Validates: Requirements 5.1, 6.1**
|
||||
- [x] 9.5 编写 CLI 参数和管道类型覆盖验证
|
||||
- **Property 6: CLI 参数文档覆盖完整性**
|
||||
- **Property 7: 管道类型文档覆盖完整性**
|
||||
- **Validates: Requirements 7.1, 7.2**
|
||||
|
||||
- [x] 10. 最终检查点
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## 说明
|
||||
|
||||
- 任务标记 `*` 的为可选项,可跳过以加快 MVP 进度
|
||||
- 每个任务引用了具体的需求编号以便追溯
|
||||
- 检查点确保增量验证
|
||||
- 文档编写顺序:先写公共机制(task 1),再按层写各任务(task 2-6),最后写总览(task 7)
|
||||
@@ -1,553 +0,0 @@
|
||||
# 设计文档:Monorepo 迁移
|
||||
|
||||
## 概述
|
||||
|
||||
本设计将现有单一 ETL 仓库(`FQ-ETL`)迁移为 Monorepo 单体仓库(`NeoZQYY`),采用一次性搬迁策略。核心设计原则:
|
||||
|
||||
1. **最小破坏性**:ETL 整体平移,保持内部结构不变,仅调整外部引用
|
||||
2. **分层隔离**:通过 uv workspace 实现 Python 包依赖隔离,通过 `.env` 分层实现配置隔离
|
||||
3. **数据库重组**:从现有 4 个 schema(billiards_ods/billiards_dwd/billiards_dws/etl_admin)重组为 6 层 schema(meta/ods/dwd/core/dws/app)
|
||||
4. **渐进式扩展**:第一阶段只建必要骨架,未来扩展点记录在 Roadmap 中
|
||||
|
||||
## 架构
|
||||
|
||||
### 整体架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "NeoZQYY Monorepo"
|
||||
subgraph "apps/"
|
||||
ETL["apps/etl/pipelines/feiqiu/"]
|
||||
Backend["apps/backend/ (FastAPI)"]
|
||||
Mini["apps/miniprogram/ (Donut+TDesign)"]
|
||||
Admin["apps/admin-web/ (未来)"]
|
||||
end
|
||||
|
||||
subgraph "packages/"
|
||||
Shared["packages/shared/"]
|
||||
end
|
||||
|
||||
subgraph "gui/"
|
||||
GUI["gui/ (PySide6,过渡期)"]
|
||||
end
|
||||
|
||||
subgraph "db/"
|
||||
ETLDB["db/etl_feiqiu/"]
|
||||
AppDB["db/zqyy_app/"]
|
||||
FDW["db/fdw/"]
|
||||
end
|
||||
end
|
||||
|
||||
ETL --> Shared
|
||||
Backend --> Shared
|
||||
GUI --> Shared
|
||||
Backend --> AppDB
|
||||
ETL --> ETLDB
|
||||
AppDB -.->|postgres_fdw 只读| ETLDB
|
||||
```
|
||||
|
||||
### 数据流架构
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
API["上游 SaaS API"] --> ODS["ods (原始数据)"]
|
||||
ODS --> DWD["dwd (main+EX 明细)"]
|
||||
DWD --> Core["core (统一最小字段集)"]
|
||||
DWD --> DWS["dws (汇总/工资)"]
|
||||
Core --> DWS
|
||||
DWS --> App["app (视图+RLS)"]
|
||||
App -.->|FDW 只读映射| ZqyyApp["zqyy_app DB"]
|
||||
ZqyyApp --> FastAPI["FastAPI 后端"]
|
||||
FastAPI --> MiniApp["微信小程序"]
|
||||
|
||||
subgraph "etl_feiqiu DB"
|
||||
Meta["meta (调度/游标)"]
|
||||
ODS
|
||||
DWD
|
||||
Core
|
||||
DWS
|
||||
App
|
||||
end
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 1. 目录结构生成器(Scaffold)
|
||||
|
||||
负责创建 Monorepo 完整目录结构和基础配置文件。
|
||||
|
||||
**输入**:目标路径 `C:\NeoZQYY\`
|
||||
**输出**:完整目录树 + README.md + 配置文件
|
||||
|
||||
**关键行为**:
|
||||
- 创建所有一级和二级目录
|
||||
- 为每个一级目录生成 README.md(作用 + 结构 + Roadmap)
|
||||
- 生成 `.gitignore`、`.kiroignore`、`.env.template`
|
||||
- 初始化 Git 仓库
|
||||
|
||||
### 2. uv Workspace 配置
|
||||
|
||||
**根 `pyproject.toml`**:
|
||||
```toml
|
||||
[project]
|
||||
name = "neozqyy"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = [
|
||||
"apps/etl/pipelines/feiqiu",
|
||||
"apps/backend",
|
||||
"packages/shared",
|
||||
"gui",
|
||||
]
|
||||
```
|
||||
|
||||
**子项目 `pyproject.toml` 模式**(以 ETL 为例):
|
||||
```toml
|
||||
[project]
|
||||
name = "etl-feiqiu"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"psycopg2-binary>=2.9.0",
|
||||
"requests>=2.28.0",
|
||||
"python-dateutil>=2.8.0",
|
||||
"tzdata>=2023.0",
|
||||
"python-dotenv",
|
||||
"openpyxl>=3.1.0",
|
||||
"neozqyy-shared",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
neozqyy-shared = { workspace = true }
|
||||
```
|
||||
|
||||
### 3. 配置隔离机制
|
||||
|
||||
**分层加载顺序**:
|
||||
```
|
||||
根 .env(公共配置)→ 应用 .env.local(私有覆盖)→ 环境变量 → CLI 参数
|
||||
```
|
||||
|
||||
**实现方式**:
|
||||
- 现有 `AppConfig` 的 `DEFAULTS < ENV < CLI` 模式保持不变
|
||||
- 新增:在 `load_env_overrides()` 中先加载根 `.env`,再加载应用级 `.env.local`
|
||||
- 冲突策略:应用级优先(后加载覆盖先加载)
|
||||
- 缺失检测:在 `_validate()` 中检查必需项,报告缺失项名称
|
||||
|
||||
### 4. ETL 平移策略
|
||||
|
||||
**平移范围**:
|
||||
| 源路径 | 目标路径 | 说明 |
|
||||
|--------|----------|------|
|
||||
| `api/` | `apps/etl/pipelines/feiqiu/api/` | API 客户端 |
|
||||
| `cli/` | `apps/etl/pipelines/feiqiu/cli/` | CLI 入口 |
|
||||
| `config/` | `apps/etl/pipelines/feiqiu/config/` | 配置 |
|
||||
| `loaders/` | `apps/etl/pipelines/feiqiu/loaders/` | 加载器 |
|
||||
| `models/` | `apps/etl/pipelines/feiqiu/models/` | 模型 |
|
||||
| `orchestration/` | `apps/etl/pipelines/feiqiu/orchestration/` | 调度 |
|
||||
| `scd/` | `apps/etl/pipelines/feiqiu/scd/` | SCD2 |
|
||||
| `tasks/` | `apps/etl/pipelines/feiqiu/tasks/` | 任务 |
|
||||
| `utils/` | `apps/etl/pipelines/feiqiu/utils/` | 工具 |
|
||||
| `quality/` | `apps/etl/pipelines/feiqiu/quality/` | 质量检查 |
|
||||
| `tests/` | `apps/etl/pipelines/feiqiu/tests/` | 测试 |
|
||||
| `database/*.sql` | `db/etl_feiqiu/schemas/` | DDL |
|
||||
| `database/migrations/` | `db/etl_feiqiu/migrations/` | 迁移脚本 |
|
||||
| `database/seed_*.sql` | `db/etl_feiqiu/seeds/` | 种子数据 |
|
||||
| `gui/` | `gui/` | GUI(顶层) |
|
||||
|
||||
**import 路径策略**:
|
||||
- ETL 内部使用相对 import(`from .config.settings import AppConfig`)或保持现有绝对 import
|
||||
- `pyproject.toml` 中设置 `pythonpath`,使 `apps/etl/pipelines/feiqiu/` 为 Python 路径根
|
||||
- `pytest.ini` 同步更新 `pythonpath = .`
|
||||
- 目标:ETL 内部代码零修改或最小修改
|
||||
|
||||
### 5. 小程序平移策略
|
||||
|
||||
**平移范围**:
|
||||
| 源路径 | 目标路径 |
|
||||
|--------|----------|
|
||||
| `C:\ZQYY\XCX\`(除 Prototype) | `apps/miniprogram/` |
|
||||
| `C:\ZQYY\XCX\Prototype\` | `docs/h5_ui/` |
|
||||
|
||||
小程序为独立前端项目(Donut + TDesign),不涉及 Python 依赖管理,直接复制即可。
|
||||
|
||||
|
||||
### 6. 数据库 Schema 重组(etl_feiqiu)
|
||||
|
||||
**现有 → 新 schema 映射**:
|
||||
|
||||
| 现有 Schema | 新 Schema | 说明 |
|
||||
|-------------|-----------|------|
|
||||
| `etl_admin` | `meta` | 调度、游标、运行记录 |
|
||||
| `billiards_ods` | `ods` | ODS 原始数据,结构不变 |
|
||||
| `billiards_dwd` | `dwd` | DWD 明细,保留 main+EX 拆分 |
|
||||
| (新增) | `core` | 统一维度/事实最小字段集 |
|
||||
| `billiards_dws` | `dws` | DWS 汇总,结构不变 |
|
||||
| (新增) | `app` | 面向外部的视图/函数 + RLS |
|
||||
|
||||
**core schema 设计原则**:
|
||||
- 仅包含跨系统共享的最小字段集(如会员 ID、姓名、手机号、状态)
|
||||
- 维度表从 DWD 维度表提取核心字段
|
||||
- 事实表从 DWD 事实表提取核心度量
|
||||
- 第一版保持精简,后续按需扩展
|
||||
|
||||
**app schema 设计原则**:
|
||||
- 以视图(VIEW)封装 DWS/Core 层数据
|
||||
- 所有视图启用 RLS,以 `site_id` 过滤
|
||||
- 提供函数接口供 FDW 映射使用
|
||||
- 不存储实际数据,仅做访问层
|
||||
|
||||
**RLS 实现方案**:
|
||||
```sql
|
||||
-- 创建应用角色
|
||||
CREATE ROLE app_reader;
|
||||
|
||||
-- 在 app schema 的视图上启用 RLS
|
||||
ALTER TABLE app.v_member_summary ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 创建策略:根据会话变量 app.current_site_id 过滤
|
||||
CREATE POLICY site_isolation ON app.v_member_summary
|
||||
FOR SELECT TO app_reader
|
||||
USING (site_id = current_setting('app.current_site_id')::bigint);
|
||||
```
|
||||
|
||||
### 7. 业务数据库设计(zqyy_app)
|
||||
|
||||
**核心表**:
|
||||
- `users`:用户账户(微信 OpenID、手机号、角色)
|
||||
- `roles` / `permissions`:RBAC 权限模型
|
||||
- `user_roles`:用户-角色关联
|
||||
- `tasks`:任务管理(审批流)
|
||||
- `approvals`:审批记录
|
||||
|
||||
**FDW 映射**:
|
||||
```sql
|
||||
-- 在 zqyy_app 中创建外部服务器
|
||||
CREATE SERVER etl_feiqiu_server
|
||||
FOREIGN DATA WRAPPER postgres_fdw
|
||||
OPTIONS (host 'localhost', dbname 'etl_feiqiu', port '5432');
|
||||
|
||||
-- 创建用户映射
|
||||
CREATE USER MAPPING FOR app_user
|
||||
SERVER etl_feiqiu_server
|
||||
OPTIONS (user 'app_reader', password '***');
|
||||
|
||||
-- 导入 app schema 的外部表
|
||||
IMPORT FOREIGN SCHEMA app
|
||||
FROM SERVER etl_feiqiu_server
|
||||
INTO fdw_etl;
|
||||
```
|
||||
|
||||
**约束**:FDW 映射为只读,`zqyy_app` 不存储 ETL 数据副本。
|
||||
|
||||
### 8. FastAPI 后端骨架
|
||||
|
||||
**项目结构**:
|
||||
```
|
||||
apps/backend/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI 入口
|
||||
│ ├── config.py # 配置加载
|
||||
│ ├── database.py # 数据库连接
|
||||
│ ├── routers/ # 路由模块
|
||||
│ │ └── __init__.py
|
||||
│ ├── middleware/ # 中间件
|
||||
│ │ └── __init__.py
|
||||
│ └── schemas/ # Pydantic 模型
|
||||
│ └── __init__.py
|
||||
├── tests/
|
||||
│ └── __init__.py
|
||||
├── pyproject.toml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
**关键配置**:
|
||||
- 连接 `zqyy_app` 数据库(通过 FDW 访问 ETL 数据)
|
||||
- OpenAPI 文档自动生成(FastAPI 内置)
|
||||
- 依赖 `packages/shared` 获取通用工具
|
||||
|
||||
### 9. 共享包(packages/shared)
|
||||
|
||||
**模块划分**:
|
||||
```
|
||||
packages/shared/
|
||||
├── src/
|
||||
│ └── neozqyy_shared/
|
||||
│ ├── __init__.py
|
||||
│ ├── enums.py # 字段枚举定义
|
||||
│ ├── money.py # 金额精度工具(CNY, numeric(2))
|
||||
│ └── datetime_utils.py # 时间处理工具
|
||||
├── tests/
|
||||
│ └── __init__.py
|
||||
├── pyproject.toml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
**提取来源**:
|
||||
- `enums.py`:从 ETL 的 `models/` 中提取通用枚举
|
||||
- `money.py`:金额四舍五入、格式化(`Decimal` + `ROUND_HALF_UP`,scale=2)
|
||||
- `datetime_utils.py`:时区转换、日期范围计算(从 `utils/` 提取)
|
||||
|
||||
### 10. .kiro 迁移
|
||||
|
||||
**迁移内容**:
|
||||
- 复制 `.kiro/steering/` 到 Monorepo
|
||||
- 更新 `product.md`:从单一 ETL 视角扩展为 Monorepo 全局视角
|
||||
- 更新 `tech.md`:新增 FastAPI、uv workspace、Donut+TDesign 等技术栈
|
||||
- 更新 `structure-lite.md`:反映 Monorepo 目录结构和模块边界
|
||||
- 更新路径引用:所有 steering 文件中的路径适配新结构
|
||||
|
||||
## 数据模型
|
||||
|
||||
### etl_feiqiu 数据库(六层 Schema)
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
META {
|
||||
bigint run_id PK
|
||||
text task_code
|
||||
timestamptz started_at
|
||||
timestamptz ended_at
|
||||
text status
|
||||
jsonb result_summary
|
||||
}
|
||||
|
||||
META ||--o{ ODS : "调度触发"
|
||||
|
||||
ODS {
|
||||
bigint id PK
|
||||
text content_hash PK
|
||||
jsonb payload
|
||||
text source_endpoint
|
||||
timestamptz fetched_at
|
||||
}
|
||||
|
||||
ODS ||--o{ DWD : "清洗装载"
|
||||
|
||||
DWD {
|
||||
bigint id PK
|
||||
timestamptz scd2_start_time
|
||||
timestamptz scd2_end_time
|
||||
int scd2_is_current
|
||||
int scd2_version
|
||||
}
|
||||
|
||||
DWD ||--o{ CORE : "提取最小字段集"
|
||||
|
||||
CORE {
|
||||
bigint id PK
|
||||
text name
|
||||
bigint site_id
|
||||
}
|
||||
|
||||
DWD ||--o{ DWS : "汇总聚合"
|
||||
CORE ||--o{ DWS : "汇总聚合"
|
||||
|
||||
DWS {
|
||||
bigint id PK
|
||||
date stat_date
|
||||
numeric amount
|
||||
bigint site_id
|
||||
}
|
||||
|
||||
DWS ||--o{ APP : "视图封装"
|
||||
|
||||
APP {
|
||||
text view_name
|
||||
text rls_policy
|
||||
}
|
||||
```
|
||||
|
||||
### zqyy_app 数据库
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USERS {
|
||||
bigint id PK
|
||||
text wx_openid UK
|
||||
text mobile
|
||||
text nickname
|
||||
int status
|
||||
timestamptz created_at
|
||||
}
|
||||
|
||||
ROLES {
|
||||
int id PK
|
||||
text name UK
|
||||
text description
|
||||
}
|
||||
|
||||
USER_ROLES {
|
||||
bigint user_id FK
|
||||
int role_id FK
|
||||
}
|
||||
|
||||
PERMISSIONS {
|
||||
int id PK
|
||||
text resource
|
||||
text action
|
||||
}
|
||||
|
||||
ROLE_PERMISSIONS {
|
||||
int role_id FK
|
||||
int permission_id FK
|
||||
}
|
||||
|
||||
USERS ||--o{ USER_ROLES : "拥有"
|
||||
ROLES ||--o{ USER_ROLES : "分配给"
|
||||
ROLES ||--o{ ROLE_PERMISSIONS : "包含"
|
||||
PERMISSIONS ||--o{ ROLE_PERMISSIONS : "授予"
|
||||
|
||||
FDW_ETL_VIEWS {
|
||||
text foreign_table_name
|
||||
text source_schema
|
||||
text mapping_type
|
||||
}
|
||||
```
|
||||
|
||||
### 配置分层模型
|
||||
|
||||
```
|
||||
优先级(低 → 高):
|
||||
┌─────────────────────────────┐
|
||||
│ 根 .env(公共配置模板) │ DB_HOST, DB_PORT, TIMEZONE
|
||||
├─────────────────────────────┤
|
||||
│ 应用 .env.local(私有覆盖) │ DB_NAME, DB_PASSWORD, API_TOKEN
|
||||
├─────────────────────────────┤
|
||||
│ 环境变量 │ 运行时覆盖
|
||||
├─────────────────────────────┤
|
||||
│ CLI 参数 │ 最高优先级
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: README.md 结构完整性
|
||||
|
||||
*对于任意* Monorepo 一级目录,其 README.md 文件应存在且包含"作用说明"、"结构描述"和"Roadmap"三个段落。
|
||||
|
||||
**Validates: Requirements 1.5**
|
||||
|
||||
### Property 2: Python 子项目配置完整性
|
||||
|
||||
*对于任意* uv workspace 声明的 Python 子项目成员,该子项目目录下应存在独立的 `pyproject.toml` 文件,且文件中包含 `[project]` 段落。
|
||||
|
||||
**Validates: Requirements 3.2**
|
||||
|
||||
### Property 3: 配置优先级 - .env.local 覆盖
|
||||
|
||||
*对于任意*配置项名称和两个不同的值,当根 `.env` 和应用 `.env.local` 都定义了该配置项时,配置加载器返回的值应等于 `.env.local` 中的值。
|
||||
|
||||
**Validates: Requirements 4.3**
|
||||
|
||||
### Property 4: 必需配置缺失检测
|
||||
|
||||
*对于任意*必需配置项,当所有配置层级(.env、.env.local、环境变量、CLI)均未提供该项时,配置加载器应抛出错误,且错误信息中包含该缺失配置项的名称。
|
||||
|
||||
**Validates: Requirements 4.4**
|
||||
|
||||
### Property 5: 文件迁移完整性
|
||||
|
||||
*对于任意*源-目标目录映射关系(ETL 业务代码、database 文件、tests 目录),源目录中的每个文件在目标目录的对应位置都应存在且内容一致。
|
||||
|
||||
**Validates: Requirements 5.1, 5.2, 5.3**
|
||||
|
||||
### Property 6: Schema 表定义迁移完整性
|
||||
|
||||
*对于任意*现有数据库 schema(billiards_ods、billiards_dws)中的表,新 schema(ods、dws)的 DDL 文件中应包含该表的 CREATE TABLE 定义。
|
||||
|
||||
**Validates: Requirements 7.3, 7.6**
|
||||
|
||||
### Property 7: Core schema 最小字段集
|
||||
|
||||
*对于任意* core schema 中的表,其字段数量应严格少于对应 dwd schema 中同名(或对应)表的字段数量。
|
||||
|
||||
**Validates: Requirements 7.5**
|
||||
|
||||
### Property 8: 测试数据库结构一致性
|
||||
|
||||
*对于任意*生产数据库(etl_feiqiu、zqyy_app)中的 schema 和表定义,对应的测试数据库(test_etl_feiqiu、test_zqyy_app)中应存在相同的 schema 和表结构。
|
||||
|
||||
**Validates: Requirements 9.1, 9.2**
|
||||
|
||||
### Property 9: Steering 文件路径更新
|
||||
|
||||
*对于任意* `.kiro/steering/` 目录下的文件,文件内容中不应包含旧仓库路径引用(如 `FQ-ETL`、`C:\ZQYY\FQ-ETL`)。
|
||||
|
||||
**Validates: Requirements 10.2**
|
||||
|
||||
### Property 10: 业务表 site_id 字段存在性
|
||||
|
||||
*对于任意* app schema 中的业务视图和 dws/core schema 中的业务表,其定义中应包含 `site_id` 字段。
|
||||
|
||||
**Validates: Requirements 13.1**
|
||||
|
||||
### Property 11: RLS 按 site_id 隔离
|
||||
|
||||
*对于任意* app schema 中启用了 RLS 的视图,当会话变量 `app.current_site_id` 设置为某个门店 ID 时,查询结果应仅包含该 `site_id` 的数据行。
|
||||
|
||||
**Validates: Requirements 13.2**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 配置错误
|
||||
- **缺失必需配置**:启动时立即报错,列出所有缺失项名称,不启动服务
|
||||
- **配置值格式错误**:报告具体的配置项路径和期望格式
|
||||
- **.env 文件不存在**:使用默认值继续,不报错(.env.template 仅为模板)
|
||||
|
||||
### 迁移错误
|
||||
- **源文件不存在**:记录警告日志,继续迁移其他文件,最终汇总报告缺失文件列表
|
||||
- **目标目录已存在**:提示用户确认是否覆盖,默认不覆盖
|
||||
- **import 路径修复失败**:记录错误日志,标记需要手动修复的文件
|
||||
|
||||
### 数据库错误
|
||||
- **Schema 创建失败**:回滚当前 schema 的所有 DDL,报告失败原因
|
||||
- **FDW 连接失败**:记录错误日志,不影响本地表的正常使用
|
||||
- **RLS 策略创建失败**:回滚策略创建,报告受影响的表
|
||||
|
||||
### 测试数据库错误
|
||||
- **结构不一致**:提供 diff 工具比较生产与测试库结构差异
|
||||
- **数据迁移失败**:回滚到迁移前状态,报告失败的表和原因
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试框架
|
||||
- **单元测试**:`pytest`(Python 子项目)
|
||||
- **属性测试**:`hypothesis`(Python 属性测试库)
|
||||
- 每个属性测试配置最少 100 次迭代
|
||||
|
||||
### 单元测试覆盖
|
||||
|
||||
1. **Scaffold 测试**:验证目录创建、文件生成的具体示例
|
||||
2. **配置加载器测试**:验证分层加载、冲突处理、缺失检测的边界情况
|
||||
3. **迁移脚本测试**:验证文件复制、路径映射的具体场景
|
||||
4. **DDL 语法测试**:验证生成的 SQL 语法正确性
|
||||
|
||||
### 属性测试覆盖
|
||||
|
||||
每个属性测试必须引用设计文档中的属性编号:
|
||||
|
||||
- **Feature: monorepo-migration, Property 1: README.md 结构完整性** — 验证所有一级目录 README 包含必需段落
|
||||
- **Feature: monorepo-migration, Property 2: Python 子项目配置完整性** — 验证所有 workspace 成员有 pyproject.toml
|
||||
- **Feature: monorepo-migration, Property 3: 配置优先级** — 生成随机配置项,验证 .env.local 覆盖行为
|
||||
- **Feature: monorepo-migration, Property 4: 必需配置缺失检测** — 生成随机必需项组合,验证缺失报错
|
||||
- **Feature: monorepo-migration, Property 5: 文件迁移完整性** — 验证源-目标文件映射的完整性
|
||||
- **Feature: monorepo-migration, Property 6: Schema 表定义迁移完整性** — 验证现有表在新 DDL 中存在
|
||||
- **Feature: monorepo-migration, Property 7: Core schema 最小字段集** — 验证 core 表字段数少于 dwd
|
||||
- **Feature: monorepo-migration, Property 8: 测试数据库结构一致性** — 验证测试库与生产库结构相同
|
||||
- **Feature: monorepo-migration, Property 9: Steering 文件路径更新** — 验证无旧路径残留
|
||||
- **Feature: monorepo-migration, Property 10: 业务表 site_id 存在性** — 验证业务表包含 site_id
|
||||
- **Feature: monorepo-migration, Property 11: RLS 隔离** — 验证 RLS 按 site_id 过滤(集成测试)
|
||||
|
||||
### 集成测试
|
||||
|
||||
- **ETL 运行验证**:在新目录结构下运行 `pytest tests/unit`,确保所有现有测试通过
|
||||
- **数据库 Schema 验证**:在测试数据库上执行 DDL,验证 schema 创建成功
|
||||
- **FDW 连接验证**:验证 zqyy_app 通过 FDW 可读取 etl_feiqiu 的 app schema 数据
|
||||
- **uv workspace 验证**:运行 `uv sync`,验证所有子项目依赖正确解析
|
||||
@@ -1,186 +0,0 @@
|
||||
# 需求文档:Monorepo 迁移
|
||||
|
||||
## 简介
|
||||
|
||||
将现有台球厅运营助手项目从单一 ETL 仓库(`FQ-ETL`)扩展为 Monorepo 单体仓库(`NeoZQYY`),整合 ETL 管线、微信小程序后端、小程序前端、管理后台等多个子项目。迁移采用一次性搬迁策略,不保留 Git 历史,所有架构决策已在前期讨论中确认。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Monorepo**:单体仓库,多个子项目共存于同一 Git 仓库中
|
||||
- **ETL_Pipeline**:数据抽取-转换-加载管线,负责从上游 SaaS API 抓取数据并逐层处理
|
||||
- **ODS**:操作数据存储层(Operational Data Store),保留源 payload
|
||||
- **DWD**:明细数据层(Data Warehouse Detail),清洗后的明细数据
|
||||
- **DWS**:数据服务层(Data Warehouse Service),汇总与聚合数据
|
||||
- **Core**:统一维度/事实最小字段集层,位于 DWD 与 DWS 之间
|
||||
- **App_Schema**:应用层 schema,提供视图/函数 + RLS 供外部访问
|
||||
- **Meta_Schema**:元数据层 schema,存储 ETL 调度、游标、运行记录
|
||||
- **FDW**:PostgreSQL 外部数据包装器(Foreign Data Wrapper),用于跨库只读映射
|
||||
- **uv_Workspace**:Python 包管理工具 uv 的 workspace 模式,管理多包依赖
|
||||
- **RLS**:行级安全策略(Row Level Security),用于多门店数据隔离
|
||||
- **SCD2**:缓慢变化维度类型 2(Slowly Changing Dimension Type 2),维度历史追踪
|
||||
- **etl_feiqiu**:飞球平台 ETL 数据库实例名
|
||||
- **zqyy_app**:业务应用数据库实例名(用户/权限/任务/审批)
|
||||
- **site_id**:门店标识字段,用于多门店数据隔离
|
||||
- **Scaffold**:项目骨架,包含目录结构、配置文件、README 等基础设施
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:Monorepo 骨架搭建
|
||||
|
||||
**用户故事:** 作为开发者,我希望在 `C:\NeoZQYY\` 创建完整的 Monorepo 目录结构和基础配置,以便所有子项目有统一的组织方式和开发规范。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Scaffold 初始化执行时,THE Scaffold SHALL 在 `C:\NeoZQYY\` 下创建以下一级目录:`apps/`、`gui/`、`packages/`、`db/`、`docs/`、`infra/`、`scripts/`、`samples/`、`tests/`、`tmp/`、`.kiro/`
|
||||
2. WHEN Scaffold 初始化执行时,THE Scaffold SHALL 在 `apps/` 下创建子目录:`etl/`(含 `pipelines/feiqiu/`)、`backend/`、`miniprogram/`、`admin-web/`
|
||||
3. WHEN Scaffold 初始化执行时,THE Scaffold SHALL 在 `db/` 下创建子目录:`etl_feiqiu/`(含 `schemas/`、`migrations/`、`seeds/`)、`zqyy_app/`(含 `schemas/`、`migrations/`、`seeds/`)、`fdw/`
|
||||
4. WHEN Scaffold 初始化执行时,THE Scaffold SHALL 在 `docs/` 下创建子目录:`prd/`、`contracts/`(含 `openapi/`、`schemas/`、`data_dictionary/`)、`permission_matrix/`、`architecture/`、`database/`、`h5_ui/`、`ops/`、`audit/`、`roadmap/`
|
||||
5. THE Scaffold SHALL 为每个一级目录生成 `README.md` 文件,包含该目录的作用说明、内部结构描述和 Roadmap 段落
|
||||
6. WHEN 某个功能"暂不实施但未来必须做"时,THE Scaffold SHALL 将该内容记录在对应目录 `README.md` 的 Roadmap 段落中
|
||||
|
||||
### 需求 2:Git 仓库与版本控制配置
|
||||
|
||||
**用户故事:** 作为开发者,我希望新 Monorepo 有正确的 Git 配置,以便代码版本管理规范且安全。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Git 仓库初始化时,THE Scaffold SHALL 创建新的 Git 仓库,不迁移旧仓库历史
|
||||
2. THE Scaffold SHALL 生成 `.gitignore` 文件,排除 `tmp/`、`__pycache__/`、`.env`(非模板)、`*.pyc`、`.hypothesis/`、`.pytest_cache/`、`logs/`、`node_modules/`、虚拟环境目录等
|
||||
3. THE Scaffold SHALL 生成 `.kiroignore` 文件,排除不需要 Kiro 索引的目录
|
||||
|
||||
### 需求 3:Python 包管理与 uv Workspace 配置
|
||||
|
||||
**用户故事:** 作为开发者,我希望使用 `pyproject.toml` + `uv` workspace 管理多包依赖,以便各子项目的依赖隔离且可统一管理。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Scaffold SHALL 在 Monorepo 根目录生成 `pyproject.toml`,配置 uv workspace 并声明所有 Python 子项目成员
|
||||
2. THE Scaffold SHALL 为每个 Python 子项目(`apps/etl/pipelines/feiqiu/`、`apps/backend/`、`packages/shared/`、`gui/`)生成独立的 `pyproject.toml`
|
||||
3. WHEN 子项目声明对 `packages/shared` 的依赖时,THE uv_Workspace SHALL 通过 workspace 路径引用解析该依赖
|
||||
|
||||
### 需求 4:环境配置隔离
|
||||
|
||||
**用户故事:** 作为开发者,我希望公共配置和各应用私有配置分层管理,以便敏感信息不泄露且配置不冲突。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Scaffold SHALL 在 Monorepo 根目录生成 `.env.template` 文件,包含公共配置项(数据库主机、端口等非敏感信息)的模板
|
||||
2. WHEN 各应用需要私有配置时,THE Scaffold SHALL 支持在应用目录下放置 `.env.local` 文件覆盖公共配置
|
||||
3. IF 公共 `.env` 与应用 `.env.local` 存在同名配置项且值冲突,THEN THE 配置加载器 SHALL 以应用级 `.env.local` 的值为准
|
||||
4. IF 必需的配置项在所有层级均缺失,THEN THE 配置加载器 SHALL 在启动时报告明确的错误信息,指出缺失的配置项名称
|
||||
|
||||
### 需求 5:ETL 项目平移
|
||||
|
||||
**用户故事:** 作为开发者,我希望将现有 ETL 项目整体平移到 Monorepo 中,以便 ETL 功能在新仓库中正常运行。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL 平移执行时,THE 迁移脚本 SHALL 将 `C:\ZQYY\FQ-ETL` 的业务代码(`api/`、`cli/`、`config/`、`loaders/`、`models/`、`orchestration/`、`scd/`、`tasks/`、`utils/`、`quality/`)复制到 `apps/etl/pipelines/feiqiu/`
|
||||
2. WHEN ETL 平移执行时,THE 迁移脚本 SHALL 将 `database/` 目录的 DDL、seed、migration 文件迁移到 `db/etl_feiqiu/` 对应子目录
|
||||
3. WHEN ETL 平移执行时,THE 迁移脚本 SHALL 将 `tests/` 目录复制到 `apps/etl/pipelines/feiqiu/tests/`
|
||||
4. WHEN ETL 平移完成后,THE ETL_Pipeline SHALL 通过 `pytest tests/unit` 验证所有单元测试通过
|
||||
5. IF ETL 内部存在需要调整的 import 路径,THEN THE 迁移脚本 SHALL 更新这些路径以适配新目录结构
|
||||
6. WHEN ETL 平移执行时,THE 迁移脚本 SHALL 将现有 `gui/` 目录迁移到 Monorepo 顶层 `gui/`
|
||||
|
||||
### 需求 6:小程序前端平移
|
||||
|
||||
**用户故事:** 作为开发者,我希望将微信小程序项目迁移到 Monorepo 中,以便前端代码与后端统一管理。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 小程序平移执行时,THE 迁移脚本 SHALL 将 `C:\ZQYY\XCX` 的项目文件复制到 `apps/miniprogram/`
|
||||
2. WHEN 小程序平移执行时,THE 迁移脚本 SHALL 将 `C:\ZQYY\XCX\Prototype` 目录复制到 `docs/h5_ui/`
|
||||
3. WHEN 小程序平移完成后,THE 小程序项目 SHALL 保持原有的 Donut + TDesign 技术栈配置不变
|
||||
|
||||
### 需求 7:数据库 Schema 重组(etl_feiqiu)
|
||||
|
||||
**用户故事:** 作为数据工程师,我希望将 ETL 数据库重组为六层 schema 架构,以便数据分层清晰、职责明确。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 数据库迁移 SHALL 为 `etl_feiqiu` 数据库创建六个 schema:`meta`、`ods`、`dwd`、`core`、`dws`、`app`
|
||||
2. WHEN `meta` schema 创建时,THE DDL SHALL 包含 ETL 调度、游标、运行记录相关表(从现有 `etl_admin` schema 迁移)
|
||||
3. WHEN `ods` schema 创建时,THE DDL SHALL 包含现有 `billiards_ods` 的所有表定义
|
||||
4. WHEN `dwd` schema 创建时,THE DDL SHALL 保留现有 main + EX 拆分模式(因字段量大)
|
||||
5. WHEN `core` schema 创建时,THE DDL SHALL 仅包含统一维度表和事实表的最小字段集
|
||||
6. WHEN `dws` schema 创建时,THE DDL SHALL 包含现有 `billiards_dws` 的汇总表定义(助教业绩、财务日报、工资计算等)
|
||||
7. WHEN `app` schema 创建时,THE DDL SHALL 创建面向外部访问的视图和函数,并配置 RLS 策略以 `site_id` 隔离多门店数据
|
||||
8. THE 数据库迁移 SHALL 将所有 DDL 文件存放在 `db/etl_feiqiu/schemas/` 目录下,每个 schema 一个独立文件
|
||||
|
||||
### 需求 8:业务数据库设计(zqyy_app)
|
||||
|
||||
**用户故事:** 作为后端开发者,我希望有独立的业务数据库存储用户、权限、任务、审批等应用数据,以便业务逻辑与 ETL 数据解耦。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 数据库迁移 SHALL 为 `zqyy_app` 数据库创建用户管理、权限控制、任务管理、审批流程相关的表结构
|
||||
2. THE 数据库迁移 SHALL 将 `zqyy_app` 的 DDL 文件存放在 `db/zqyy_app/schemas/` 目录下
|
||||
3. WHEN `zqyy_app` 需要访问 ETL 数据时,THE FDW 配置 SHALL 通过 `postgres_fdw` 将 `etl_feiqiu` 的 `app` schema 映射为 `zqyy_app` 中的外部表
|
||||
4. THE FDW 配置 SHALL 以只读方式映射,`zqyy_app` 不存储 ETL 数据的副本
|
||||
5. THE FDW 配置文件 SHALL 存放在 `db/fdw/` 目录下
|
||||
|
||||
### 需求 9:测试数据库镜像
|
||||
|
||||
**用户故事:** 作为开发者,我希望有与生产结构完全一致的测试数据库,以便在不影响生产数据的情况下进行开发和测试。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 数据库迁移 SHALL 创建 `test_etl_feiqiu` 数据库,其 schema 结构与 `etl_feiqiu` 完全一致
|
||||
2. THE 数据库迁移 SHALL 创建 `test_zqyy_app` 数据库,其 schema 结构与 `zqyy_app` 完全一致
|
||||
3. WHEN 测试数据库创建完成后,THE 迁移脚本 SHALL 提供从现有 `LLZQ-test` 数据库迁移测试数据到新结构的脚本
|
||||
4. WHEN 生产数据库 schema 发生变更时,THE 测试数据库 SHALL 同步应用相同的迁移脚本以保持结构一致
|
||||
|
||||
### 需求 10:.kiro 配置迁移与 Steering 更新
|
||||
|
||||
**用户故事:** 作为开发者,我希望 Kiro IDE 的配置和 steering 文件适配 Monorepo 结构,以便 AI 辅助开发在新仓库中正常工作。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN .kiro 迁移执行时,THE 迁移脚本 SHALL 将现有 `.kiro/steering/` 文件复制到 Monorepo 的 `.kiro/steering/`
|
||||
2. WHEN .kiro 迁移完成后,THE Steering 文件 SHALL 更新所有路径引用以反映 Monorepo 目录结构
|
||||
3. WHEN .kiro 迁移完成后,THE Steering 文件 SHALL 更新 `product.md`、`tech.md`、`structure.md` 为 Monorepo 视角的内容
|
||||
|
||||
### 需求 11:FastAPI 后端骨架
|
||||
|
||||
**用户故事:** 作为后端开发者,我希望有 FastAPI 项目骨架,以便快速开始小程序后端 API 的开发。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 后端骨架创建时,THE Scaffold SHALL 在 `apps/backend/` 下生成 FastAPI 项目结构,包含入口文件、路由目录、中间件目录、配置文件
|
||||
2. THE 后端骨架 SHALL 配置 OpenAPI 文档自动生成
|
||||
3. THE 后端骨架 SHALL 配置数据库连接模块,支持连接 `zqyy_app` 数据库
|
||||
4. THE 后端骨架 SHALL 包含独立的 `pyproject.toml`,声明 FastAPI 及相关依赖
|
||||
|
||||
### 需求 12:共享包基础结构
|
||||
|
||||
**用户故事:** 作为开发者,我希望有统一的共享包存放跨项目复用的工具代码,以便避免代码重复。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 共享包创建时,THE Scaffold SHALL 在 `packages/shared/` 下生成 Python 包结构,包含 `__init__.py` 和 `pyproject.toml`
|
||||
2. THE 共享包 SHALL 提取并包含以下通用工具模块:字段枚举定义、金额精度处理工具(CNY,numeric(2))、时间处理工具
|
||||
3. WHEN ETL 或后端项目引用共享包时,THE uv_Workspace SHALL 通过 workspace 路径依赖解析 `packages/shared`
|
||||
|
||||
### 需求 13:多门店数据隔离
|
||||
|
||||
**用户故事:** 作为系统架构师,我希望在同一数据库内通过 RLS 实现多门店数据隔离,以便未来扩展到多门店场景。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 数据库设计 SHALL 在所有业务表中包含 `site_id` 字段标识门店归属
|
||||
2. WHEN RLS 策略启用时,THE 数据库 SHALL 根据当前会话的 `site_id` 参数自动过滤查询结果,仅返回该门店的数据
|
||||
3. WHEN 每个门店运行 ETL 时,THE ETL_Pipeline SHALL 作为独立进程执行,使用该门店的 `site_id` 标识数据
|
||||
|
||||
### 需求 14:基础设施配置管理
|
||||
|
||||
**用户故事:** 作为运维人员,我希望基础设施配置纳入版本控制,以便环境配置可追溯、可复现。
|
||||
|
||||
### 需求15:避免影响kiro性能,完成Monorepo后,根据文件和目录结构。编辑.kiroignore
|
||||
|
||||
验收标准:完善
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Scaffold SHALL 在 `infra/` 下创建 `jump_proxy/`、`tailscale/`、`firewall/` 子目录
|
||||
2. THE infra 目录 SHALL 纳入 Git 版本控制
|
||||
3. IF infra 目录中包含敏感配置文件,THEN THE `.gitignore` SHALL 排除这些敏感文件,同时保留非敏感的配置模板
|
||||
@@ -1,215 +0,0 @@
|
||||
# 实施计划:Monorepo 迁移
|
||||
|
||||
## 概述
|
||||
|
||||
将现有 ETL 仓库迁移为 Monorepo 单体仓库,分 7 个阶段执行。每个阶段包含具体的代码/文件操作任务,按依赖顺序排列。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. Monorepo 骨架搭建
|
||||
- [x] 1.1 在 `C:\NeoZQYY\` 创建完整目录结构
|
||||
- 创建所有一级目录:`apps/`、`gui/`、`packages/`、`db/`、`docs/`、`infra/`、`scripts/`、`samples/`、`tests/`、`tmp/`、`.kiro/`
|
||||
- 创建 `apps/` 子目录:`etl/pipelines/feiqiu/`、`backend/`、`miniprogram/`、`admin-web/`
|
||||
- 创建 `db/` 子目录:`etl_feiqiu/schemas/`、`etl_feiqiu/migrations/`、`etl_feiqiu/seeds/`、`zqyy_app/schemas/`、`zqyy_app/migrations/`、`zqyy_app/seeds/`、`fdw/`
|
||||
- 创建 `docs/` 子目录:`prd/`、`contracts/openapi/`、`contracts/schemas/`、`contracts/data_dictionary/`、`permission_matrix/`、`architecture/`、`database/`、`h5_ui/`、`ops/`、`audit/`、`roadmap/`
|
||||
- 创建 `infra/` 子目录:`jump_proxy/`、`tailscale/`、`firewall/`
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 14.1_
|
||||
|
||||
- [x] 1.2 生成所有一级目录的 README.md
|
||||
- 每个 README 包含:作用说明、内部结构描述、Roadmap 段落
|
||||
- 一级目录列表:`apps/`、`gui/`、`packages/`、`db/`、`docs/`、`infra/`、`scripts/`、`samples/`、`tests/`
|
||||
- `apps/etl/README.md` 的 Roadmap 记录未来 sdk/connectors 拆分计划
|
||||
- `packages/README.md` 的 Roadmap 记录 etl_sdk、authz、data_contracts 候选
|
||||
- `db/README.md` 的 Roadmap 记录 FDW 演进计划
|
||||
- _Requirements: 1.5, 1.6_
|
||||
|
||||
- [x] 1.3 编写 README 结构完整性属性测试
|
||||
- **Property 1: README.md 结构完整性**
|
||||
- **Validates: Requirements 1.5**
|
||||
|
||||
- [x] 1.4 初始化 Git 仓库并生成版本控制配置
|
||||
- 在 `C:\NeoZQYY\` 执行 `git init`
|
||||
- 生成 `.gitignore`:排除 `tmp/`、`__pycache__/`、`.env`、`*.pyc`、`.hypothesis/`、`.pytest_cache/`、`logs/`、`node_modules/`、虚拟环境目录、`infra/` 下的敏感文件
|
||||
- 生成 `.kiroignore`
|
||||
- _Requirements: 2.1, 2.2, 2.3, 14.2, 14.3_
|
||||
|
||||
- [x] 1.5 配置 pyproject.toml 和 uv workspace
|
||||
- 生成根 `pyproject.toml`,声明 workspace 成员:`apps/etl/pipelines/feiqiu`、`apps/backend`、`packages/shared`、`gui`
|
||||
- 为每个 Python 子项目生成独立 `pyproject.toml`
|
||||
- _Requirements: 3.1, 3.2_
|
||||
|
||||
- [x] 1.6 编写 Python 子项目配置完整性属性测试
|
||||
- **Property 2: Python 子项目配置完整性**
|
||||
- **Validates: Requirements 3.2**
|
||||
|
||||
- [x] 1.7 生成环境配置模板
|
||||
- 生成根 `.env.template`,包含公共配置项模板(DB_HOST、DB_PORT、TIMEZONE 等)
|
||||
- _Requirements: 4.1_
|
||||
|
||||
- [x] 2. 检查点 - 骨架验证
|
||||
- 确保所有目录和文件已创建,ask the user if questions arise.
|
||||
|
||||
- [x] 3. ETL 项目平移
|
||||
- [x] 3.1 复制 ETL 业务代码到 Monorepo
|
||||
- 将 `C:\ZQYY\FQ-ETL` 的 `api/`、`cli/`、`config/`、`loaders/`、`models/`、`orchestration/`、`scd/`、`tasks/`、`utils/`、`quality/` 复制到 `apps/etl/pipelines/feiqiu/`
|
||||
- 将 `tests/` 复制到 `apps/etl/pipelines/feiqiu/tests/`
|
||||
- 将 `requirements.txt`、`pytest.ini`、`run_etl.bat`、`run_etl.sh` 复制到 `apps/etl/pipelines/feiqiu/`
|
||||
- _Requirements: 5.1, 5.3_
|
||||
|
||||
- [x] 3.2 迁移数据库文件到 db/etl_feiqiu/
|
||||
- 将 `database/schema_*.sql` 复制到 `db/etl_feiqiu/schemas/`
|
||||
- 将 `database/migrations/` 复制到 `db/etl_feiqiu/migrations/`
|
||||
- 将 `database/seed_*.sql` 复制到 `db/etl_feiqiu/seeds/`
|
||||
- 将 `database/connection.py`、`database/operations.py`、`database/base.py` 保留在 ETL 内部(`apps/etl/pipelines/feiqiu/database/`)
|
||||
- _Requirements: 5.2_
|
||||
|
||||
- [x] 3.3 迁移 GUI 到顶层
|
||||
- 将 `C:\ZQYY\FQ-ETL\gui/` 复制到 `C:\NeoZQYY\gui/`
|
||||
- 生成 `gui/pyproject.toml`,声明 PySide6 依赖
|
||||
- _Requirements: 5.6_
|
||||
|
||||
- [x] 3.4 调整 ETL 的 pyproject.toml 和 pytest.ini
|
||||
- 更新 `apps/etl/pipelines/feiqiu/pyproject.toml`,从 `requirements.txt` 提取依赖
|
||||
- 更新 `apps/etl/pipelines/feiqiu/pytest.ini`,设置 `pythonpath = .`
|
||||
- _Requirements: 5.4, 5.5_
|
||||
|
||||
- [x] 3.5 编写文件迁移完整性属性测试
|
||||
- **Property 5: 文件迁移完整性**
|
||||
- **Validates: Requirements 5.1, 5.2, 5.3**
|
||||
|
||||
- [x] 4. 检查点 - ETL 平移验证
|
||||
- 在 `apps/etl/pipelines/feiqiu/` 下运行 `pytest tests/unit`,确保所有单元测试通过
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 5. 小程序前端平移
|
||||
- [x] 5.1 复制小程序项目到 Monorepo
|
||||
- 将 `C:\ZQYY\XCX\`(除 Prototype 目录)复制到 `apps/miniprogram/`
|
||||
- 将 `C:\ZQYY\XCX\Prototype\` 复制到 `docs/h5_ui/`
|
||||
- 生成 `apps/miniprogram/README.md`
|
||||
- _Requirements: 6.1, 6.2, 6.3_
|
||||
|
||||
- [x] 6. 数据库 Schema 重组
|
||||
- [x] 6.1 编写 etl_feiqiu 六层 Schema DDL
|
||||
- 创建 `db/etl_feiqiu/schemas/meta.sql`:从现有 `etl_admin` schema 迁移调度、游标、运行记录表
|
||||
- 创建 `db/etl_feiqiu/schemas/ods.sql`:从现有 `billiards_ods` 迁移所有表定义,schema 名改为 `ods`
|
||||
- 创建 `db/etl_feiqiu/schemas/dwd.sql`:从现有 `billiards_dwd` 迁移,保留 main+EX 拆分
|
||||
- 创建 `db/etl_feiqiu/schemas/core.sql`:设计统一维度/事实最小字段集表
|
||||
- 创建 `db/etl_feiqiu/schemas/dws.sql`:从现有 `billiards_dws` 迁移汇总表
|
||||
- 创建 `db/etl_feiqiu/schemas/app.sql`:创建面向外部的视图 + RLS 策略(以 `site_id` 隔离)
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8_
|
||||
|
||||
- [x] 6.2 编写 Schema 表定义迁移完整性属性测试
|
||||
- **Property 6: Schema 表定义迁移完整性**
|
||||
- **Validates: Requirements 7.3, 7.6**
|
||||
|
||||
- [x] 6.3 编写 Core schema 最小字段集属性测试
|
||||
- **Property 7: Core schema 最小字段集**
|
||||
- **Validates: Requirements 7.5**
|
||||
|
||||
- [x] 6.4 编写 zqyy_app 数据库 Schema DDL
|
||||
- 创建 `db/zqyy_app/schemas/init.sql`:用户表、角色表、权限表、用户角色关联表、任务表、审批表
|
||||
- 所有业务表包含 `site_id` 字段
|
||||
- _Requirements: 8.1, 8.2, 13.1_
|
||||
|
||||
- [x] 6.5 编写 FDW 映射配置
|
||||
- 创建 `db/fdw/setup_fdw.sql`:CREATE SERVER、CREATE USER MAPPING(只读角色)、IMPORT FOREIGN SCHEMA
|
||||
- _Requirements: 8.3, 8.4, 8.5_
|
||||
|
||||
- [x] 6.6 编写业务表 site_id 存在性属性测试
|
||||
- **Property 10: 业务表 site_id 字段存在性**
|
||||
- **Validates: Requirements 13.1**
|
||||
|
||||
- [x] 6.7 编写测试数据库创建脚本
|
||||
- 创建 `db/etl_feiqiu/scripts/create_test_db.sql`:创建 `test_etl_feiqiu`,复用生产 DDL
|
||||
- 创建 `db/zqyy_app/scripts/create_test_db.sql`:创建 `test_zqyy_app`,复用生产 DDL
|
||||
- 创建 `db/scripts/migrate_test_data.sql`:从 `LLZQ-test` 迁移测试数据的脚本
|
||||
- _Requirements: 9.1, 9.2, 9.3_
|
||||
|
||||
- [x] 6.8 编写测试数据库结构一致性属性测试
|
||||
- **Property 8: 测试数据库结构一致性**
|
||||
- **Validates: Requirements 9.1, 9.2**
|
||||
|
||||
- [x] 7. 检查点 - 数据库 Schema 验证
|
||||
- 确保所有 DDL 文件语法正确,ask the user if questions arise.
|
||||
|
||||
|
||||
- [ ] 8. .kiro 迁移与 Steering 更新
|
||||
- [-] 8.1 复制 .kiro/steering/ 到 Monorepo
|
||||
- 将 `C:\ZQYY\FQ-ETL\.kiro\steering\` 所有文件复制到 `C:\NeoZQYY\.kiro\steering\`
|
||||
- 将 `C:\ZQYY\FQ-ETL\.kiro\specs\` 复制到 `C:\NeoZQYY\.kiro\specs\`(包含本 spec)
|
||||
- _Requirements: 10.1_
|
||||
|
||||
- [~] 8.2 更新 Steering 文件为 Monorepo 视角
|
||||
- 更新 `product.md`:从单一 ETL 扩展为 Monorepo 全局视角(ETL + 后端 + 小程序 + GUI)
|
||||
- 更新 `tech.md`:新增 FastAPI、uv workspace、Donut+TDesign 技术栈
|
||||
- 更新 `structure-lite.md`:反映 Monorepo 目录结构和模块边界
|
||||
- 更新所有 steering 文件中的路径引用,移除旧仓库路径(`FQ-ETL`、`C:\ZQYY\FQ-ETL`)
|
||||
- _Requirements: 10.2, 10.3_
|
||||
|
||||
- [~] 8.3 编写 Steering 文件路径更新属性测试
|
||||
- **Property 9: Steering 文件路径更新**
|
||||
- **Validates: Requirements 10.2**
|
||||
|
||||
- [ ] 9. FastAPI 后端骨架
|
||||
- [~] 9.1 创建 FastAPI 项目结构
|
||||
- 创建 `apps/backend/app/__init__.py`、`main.py`、`config.py`、`database.py`
|
||||
- 创建 `apps/backend/app/routers/__init__.py`
|
||||
- 创建 `apps/backend/app/middleware/__init__.py`
|
||||
- 创建 `apps/backend/app/schemas/__init__.py`
|
||||
- 创建 `apps/backend/tests/__init__.py`
|
||||
- `main.py` 中配置 FastAPI 实例,启用 OpenAPI 文档自动生成
|
||||
- `database.py` 中配置 `zqyy_app` 数据库连接
|
||||
- _Requirements: 11.1, 11.2, 11.3_
|
||||
|
||||
- [~] 9.2 生成 apps/backend/pyproject.toml
|
||||
- 声明 FastAPI、uvicorn、psycopg2-binary、neozqyy-shared 等依赖
|
||||
- 配置 uv workspace 源引用 `neozqyy-shared`
|
||||
- _Requirements: 11.4_
|
||||
|
||||
- [~] 9.3 生成 apps/backend/README.md
|
||||
- 包含作用说明、项目结构、启动方式、Roadmap
|
||||
- _Requirements: 1.5_
|
||||
|
||||
- [ ] 10. 共享包搭建
|
||||
- [~] 10.1 创建 packages/shared 包结构
|
||||
- 创建 `packages/shared/src/neozqyy_shared/__init__.py`
|
||||
- 创建 `packages/shared/src/neozqyy_shared/enums.py`:字段枚举定义(从 ETL models/ 提取通用枚举)
|
||||
- 创建 `packages/shared/src/neozqyy_shared/money.py`:金额精度工具(Decimal + ROUND_HALF_UP,scale=2)
|
||||
- 创建 `packages/shared/src/neozqyy_shared/datetime_utils.py`:时区转换、日期范围计算
|
||||
- 创建 `packages/shared/tests/__init__.py`
|
||||
- _Requirements: 12.1, 12.2_
|
||||
|
||||
- [~] 10.2 生成 packages/shared/pyproject.toml
|
||||
- 声明包名 `neozqyy-shared`,最小依赖(python-dateutil、tzdata)
|
||||
- _Requirements: 12.3_
|
||||
|
||||
- [~] 10.3 编写配置优先级属性测试
|
||||
- **Property 3: 配置优先级 - .env.local 覆盖**
|
||||
- **Validates: Requirements 4.3**
|
||||
|
||||
- [~] 10.4 编写必需配置缺失检测属性测试
|
||||
- **Property 4: 必需配置缺失检测**
|
||||
- **Validates: Requirements 4.4**
|
||||
|
||||
- [ ] 11. 检查点 - 全局验证
|
||||
- 验证 uv workspace 依赖解析:在根目录运行 `uv sync`
|
||||
- 验证 ETL 单元测试:在 `apps/etl/pipelines/feiqiu/` 下运行 `pytest tests/unit`
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 12. RLS 与多门店隔离验证
|
||||
- [~] 12.1 编写 RLS 按 site_id 隔离属性测试
|
||||
- **Property 11: RLS 按 site_id 隔离**
|
||||
- **Validates: Requirements 13.2**
|
||||
- 需要集成测试环境(test_etl_feiqiu 数据库)
|
||||
|
||||
- [ ] 13. 最终检查点
|
||||
- 确保所有文件已创建、所有 README 已编写、所有 DDL 语法正确
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的任务为可选测试任务,可跳过以加速 MVP
|
||||
- 每个任务引用具体需求编号,确保可追溯
|
||||
- 检查点确保增量验证,避免问题累积
|
||||
- 属性测试验证通用正确性,单元测试验证具体边界情况
|
||||
- 文件复制操作需要用户在终端手动执行(涉及跨目录操作),Kiro 负责生成目标文件内容
|
||||
449
.kiro/specs/ods-dedup-standardize/design.md
Normal file
449
.kiro/specs/ods-dedup-standardize/design.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# 设计文档: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
|
||||
```
|
||||
143
.kiro/specs/ods-dedup-standardize/requirements.md
Normal file
143
.kiro/specs/ods-dedup-standardize/requirements.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# 需求文档:ODS 去重与软删除机制标准化
|
||||
|
||||
## 简介
|
||||
|
||||
NeoZQYY ETL 系统的 23 个 ODS 任务在去重和软删除机制上存在配置误导、无意义版本膨胀、软删除语义不清等问题。本需求旨在精简 `OdsTaskSpec` 配置、标准化 content_hash 去重策略、优化软删除语义,使 ODS 层真正实现"忠实记录上游数据版本变更"的职责。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **OdsTaskSpec**:`ods_tasks.py` 中定义 ODS 任务配置的 dataclass,包含端点、主键、去重开关等字段
|
||||
- **BaseOdsTask**:ODS 任务执行基类,包含写入、去重、软删除等核心逻辑
|
||||
- **content_hash**:基于记录内容计算的 SHA-256 哈希值,与业务 ID 组成复合主键
|
||||
- **skip_unchanged**:重命名后的去重开关(原 `enable_content_hash_dedup`),为 True 时跳过内容未变的记录
|
||||
- **SnapshotMode**:新增枚举,统一表达软删除快照策略(NONE / FULL_TABLE / WINDOW)
|
||||
- **业务主键**:ODS 表复合主键中除 content_hash 外的列(通常为 `id`)
|
||||
- **软删除**:将上游已不存在的记录标记为 `is_delete=1`
|
||||
- **payload**:ODS 表中存储的原始 API 响应 JSON
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:删除运行时无效的 conflict_columns_override 字段
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望移除不生效的配置字段,以避免对 ODS 写入行为产生误解。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN `OdsTaskSpec` dataclass 被定义时,THE OdsTaskSpec SHALL 不包含 `conflict_columns_override` 字段
|
||||
2. WHEN `OdsTaskSpec.__post_init__` 执行校验时,THE OdsTaskSpec SHALL 不包含任何引用 `conflict_columns_override` 的校验逻辑
|
||||
3. WHEN 23 个 `ODS_TASK_SPECS` 声明被更新后,THE ODS_TASK_SPECS SHALL 不包含任何 `conflict_columns_override` 参数
|
||||
|
||||
### 需求 2:用 SnapshotMode 枚举替代软删除组合字段
|
||||
|
||||
**用户故事:** 作为 ETL 开发者,我希望用单一枚举字段表达快照策略,以消除 `snapshot_full_table` 和 `snapshot_window_columns` 的组合歧义。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE SnapshotMode 枚举 SHALL 定义三个值:NONE、FULL_TABLE、WINDOW
|
||||
2. WHEN `OdsTaskSpec` dataclass 被定义时,THE OdsTaskSpec SHALL 使用 `snapshot_mode: SnapshotMode` 替代 `snapshot_full_table: bool` 和 `snapshot_window_columns`
|
||||
3. WHEN `snapshot_mode` 为 WINDOW 时,THE OdsTaskSpec SHALL 要求 `snapshot_time_column` 为非空字符串
|
||||
4. WHEN `snapshot_mode` 为 FULL_TABLE 或 NONE 时,THE OdsTaskSpec SHALL 要求 `snapshot_time_column` 为 None
|
||||
5. IF `snapshot_mode` 为 WINDOW 且 `snapshot_time_column` 为 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 逐个文件检查并确保内容与代码实现一致
|
||||
141
.kiro/specs/ods-dedup-standardize/tasks.md
Normal file
141
.kiro/specs/ods-dedup-standardize/tasks.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 实现计划:ODS 去重与软删除机制标准化
|
||||
|
||||
## 概述
|
||||
|
||||
按方案 1→2→3→4 的顺序递进实现,每个方案完成后有检查点。核心改动集中在 `ods_tasks.py`,辅以 DDL 迁移和文档同步。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 方案 1:清理 OdsTaskSpec 无效/冗余配置
|
||||
- [x] 1.1 添加 SnapshotMode 枚举,重构 OdsTaskSpec dataclass
|
||||
- 在 `ods_tasks.py` 顶部定义 `SnapshotMode` 枚举(NONE / FULL_TABLE / WINDOW)
|
||||
- 从 OdsTaskSpec 中删除 `conflict_columns_override`、`include_site_column`、`include_page_no`、`include_page_size`、`snapshot_full_table`、`snapshot_window_columns`、`enable_content_hash_dedup`
|
||||
- 添加 `skip_unchanged: bool = True`、`snapshot_mode: SnapshotMode = SnapshotMode.NONE`、`snapshot_time_column: str | None = None`
|
||||
- 重写 `__post_init__` 校验逻辑:WINDOW 必须有 snapshot_time_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,424 +0,0 @@
|
||||
# 设计文档:仓库治理只读审计
|
||||
|
||||
## 概述
|
||||
|
||||
本设计描述三个 Python 审计脚本的实现方案,用于对 etl-billiards 仓库进行只读分析并生成三份 Markdown 报告。脚本仅读取文件系统和源代码,不连接数据库、不修改任何现有文件,仅在 `docs/audit/repo/` 目录下输出报告。
|
||||
|
||||
审计脚本采用模块化设计:一个共享的仓库扫描器负责遍历文件系统,三个独立的分析器分别生成文件清单、流程树和文档对齐报告。
|
||||
|
||||
## 架构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[scripts/audit/run_audit.py<br/>审计主入口] --> B[scripts/audit/scanner.py<br/>仓库扫描器]
|
||||
A --> C[scripts/audit/inventory_analyzer.py<br/>文件清单分析器]
|
||||
A --> D[scripts/audit/flow_analyzer.py<br/>流程树分析器]
|
||||
A --> E[scripts/audit/doc_alignment_analyzer.py<br/>文档对齐分析器]
|
||||
|
||||
B --> F[文件系统<br/>只读遍历]
|
||||
C --> G[docs/audit/repo/file_inventory.md]
|
||||
D --> H[docs/audit/repo/flow_tree.md]
|
||||
E --> I[docs/audit/repo/doc_alignment.md]
|
||||
|
||||
C --> B
|
||||
D --> B
|
||||
E --> B
|
||||
```
|
||||
|
||||
### 执行流程
|
||||
|
||||
1. `run_audit.py` 作为主入口,初始化扫描器并依次调用三个分析器
|
||||
2. `scanner.py` 递归遍历仓库,构建文件元信息列表(路径、大小、类型)
|
||||
3. 各分析器接收扫描结果,执行各自的分析逻辑,输出 Markdown 报告
|
||||
4. 所有报告写入 `docs/audit/repo/` 目录
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 1. 仓库扫描器 (`scripts/audit/scanner.py`)
|
||||
|
||||
负责递归遍历仓库文件系统,返回结构化的文件元信息。
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class FileEntry:
|
||||
"""单个文件/目录的元信息"""
|
||||
rel_path: str # 相对于仓库根目录的路径
|
||||
is_dir: bool # 是否为目录
|
||||
size_bytes: int # 文件大小(目录为 0)
|
||||
extension: str # 文件扩展名(小写,含点号)
|
||||
is_empty_dir: bool # 是否为空目录
|
||||
|
||||
EXCLUDED_PATTERNS: list[str] = [
|
||||
".git", "__pycache__", ".pytest_cache",
|
||||
"*.pyc", ".kiro",
|
||||
]
|
||||
|
||||
def scan_repo(root: Path, exclude: list[str] = EXCLUDED_PATTERNS) -> list[FileEntry]:
|
||||
"""递归扫描仓库,返回所有文件和目录的元信息列表"""
|
||||
...
|
||||
```
|
||||
|
||||
### 2. 文件清单分析器 (`scripts/audit/inventory_analyzer.py`)
|
||||
|
||||
对扫描结果进行用途分类和处置标签分配。
|
||||
|
||||
```python
|
||||
# 用途分类枚举
|
||||
class Category(str, Enum):
|
||||
CORE_CODE = "核心代码"
|
||||
CONFIG = "配置"
|
||||
DATABASE_DEF = "数据库定义"
|
||||
TEST = "测试"
|
||||
DOCS = "文档"
|
||||
SCRIPTS = "脚本工具"
|
||||
GUI = "GUI"
|
||||
BUILD_DEPLOY = "构建与部署"
|
||||
LOG_OUTPUT = "日志与输出"
|
||||
TEMP_DEBUG = "临时与调试"
|
||||
OTHER = "其他"
|
||||
|
||||
# 处置标签枚举
|
||||
class Disposition(str, Enum):
|
||||
KEEP = "保留"
|
||||
CANDIDATE_DELETE = "候选删除"
|
||||
CANDIDATE_ARCHIVE = "候选归档"
|
||||
NEEDS_REVIEW = "待确认"
|
||||
|
||||
@dataclass
|
||||
class InventoryItem:
|
||||
"""清单条目"""
|
||||
rel_path: str
|
||||
category: Category
|
||||
disposition: Disposition
|
||||
description: str
|
||||
|
||||
def classify(entry: FileEntry) -> InventoryItem:
|
||||
"""根据路径、扩展名等规则对单个文件/目录进行分类和标签分配"""
|
||||
...
|
||||
|
||||
def build_inventory(entries: list[FileEntry]) -> list[InventoryItem]:
|
||||
"""批量分类所有文件条目"""
|
||||
...
|
||||
|
||||
def render_inventory_report(items: list[InventoryItem], repo_root: str) -> str:
|
||||
"""生成 Markdown 格式的文件清单报告"""
|
||||
...
|
||||
```
|
||||
|
||||
**分类规则(按优先级从高到低)**:
|
||||
|
||||
| 路径模式 | 用途分类 | 默认处置 |
|
||||
|---------|---------|---------|
|
||||
| `tmp/` 下所有文件 | 临时与调试 | 候选删除/候选归档 |
|
||||
| `logs/`、`export/` 下的运行时产出 | 日志与输出 | 候选归档 |
|
||||
| `*.lnk`、`*.rar` 文件 | 其他 | 候选删除 |
|
||||
| 空目录(如 `Deleded & backup/`) | 其他 | 候选删除 |
|
||||
| `tasks/`、`loaders/`、`scd/`、`orchestration/`、`quality/`、`models/`、`utils/`、`api/` | 核心代码 | 保留 |
|
||||
| `config/` | 配置 | 保留 |
|
||||
| `database/*.sql`、`database/migrations/` | 数据库定义 | 保留 |
|
||||
| `database/*.py` | 核心代码 | 保留 |
|
||||
| `tests/` | 测试 | 保留 |
|
||||
| `docs/` | 文档 | 保留 |
|
||||
| `scripts/` 下的 `.py` 文件 | 脚本工具 | 保留/待确认 |
|
||||
| `gui/` | GUI | 保留 |
|
||||
| `setup.py`、`build_exe.py`、`*.bat`、`*.sh`、`*.ps1` | 构建与部署 | 保留 |
|
||||
| 根目录散落文件(`Prompt用.md`、`Untitled`、`fix_symbols.py` 等) | 其他 | 待确认 |
|
||||
|
||||
### 3. 流程树分析器 (`scripts/audit/flow_analyzer.py`)
|
||||
|
||||
通过静态分析 Python 源码的 `import` 语句和类继承关系,构建从入口到末端模块的调用树。
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class FlowNode:
|
||||
"""流程树节点"""
|
||||
name: str # 节点名称(模块名/类名/函数名)
|
||||
source_file: str # 所在源文件路径
|
||||
node_type: str # 类型:entry/module/class/function
|
||||
children: list["FlowNode"]
|
||||
|
||||
def parse_imports(filepath: Path) -> list[str]:
|
||||
"""使用 ast 模块解析 Python 文件的 import 语句,返回被导入的本地模块列表"""
|
||||
...
|
||||
|
||||
def build_flow_tree(repo_root: Path, entry_file: str) -> FlowNode:
|
||||
"""从指定入口文件出发,递归追踪 import 链,构建流程树"""
|
||||
...
|
||||
|
||||
def find_orphan_modules(repo_root: Path, all_entries: list[FileEntry], reachable: set[str]) -> list[str]:
|
||||
"""找出未被任何入口直接或间接引用的 Python 模块"""
|
||||
...
|
||||
|
||||
def render_flow_report(trees: list[FlowNode], orphans: list[str], repo_root: str) -> str:
|
||||
"""生成 Markdown 格式的流程树报告(含 Mermaid 图和缩进文本)"""
|
||||
...
|
||||
```
|
||||
|
||||
**入口点识别**:
|
||||
- CLI 入口:`cli/main.py` → `main()` 函数
|
||||
- GUI 入口:`gui/main.py` → `main()` 函数
|
||||
- 批处理入口:`run_etl.bat`、`run_gui.bat`、`run_ods.bat` → 解析其中的 `python` 命令
|
||||
- 运维脚本:`scripts/*.py` → 各自的 `if __name__ == "__main__"` 块
|
||||
|
||||
**静态分析策略**:
|
||||
- 使用 Python `ast` 模块解析源文件,提取 `import` 和 `from ... import` 语句
|
||||
- 仅追踪项目内部模块(排除标准库和第三方包)
|
||||
- 通过 `orchestration/task_registry.py` 的注册语句识别所有任务类及其源文件
|
||||
- 通过类继承关系(`BaseTask`、`BaseLoader`、`BaseDwsTask` 等)识别任务和加载器层级
|
||||
|
||||
### 4. 文档对齐分析器 (`scripts/audit/doc_alignment_analyzer.py`)
|
||||
|
||||
检查文档与代码之间的映射关系、过期点、冲突点和缺失点。
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class DocMapping:
|
||||
"""文档与代码的映射关系"""
|
||||
doc_path: str # 文档文件路径
|
||||
doc_topic: str # 文档主题
|
||||
related_code: list[str] # 关联的代码文件/模块
|
||||
status: str # 状态:aligned/stale/conflict/orphan
|
||||
|
||||
@dataclass
|
||||
class AlignmentIssue:
|
||||
"""对齐问题"""
|
||||
doc_path: str
|
||||
issue_type: str # stale/conflict/missing
|
||||
description: str
|
||||
related_code: str
|
||||
|
||||
def scan_docs(repo_root: Path) -> list[str]:
|
||||
"""扫描所有文档文件路径"""
|
||||
...
|
||||
|
||||
def extract_code_references(doc_path: Path) -> list[str]:
|
||||
"""从文档中提取代码引用(文件路径、类名、函数名、表名等)"""
|
||||
...
|
||||
|
||||
def check_reference_validity(ref: str, repo_root: Path) -> bool:
|
||||
"""检查文档中的代码引用是否仍然有效"""
|
||||
...
|
||||
|
||||
def find_undocumented_modules(repo_root: Path, documented: set[str]) -> list[str]:
|
||||
"""找出缺少文档的核心代码模块"""
|
||||
...
|
||||
|
||||
def check_ddl_vs_dictionary(repo_root: Path) -> list[AlignmentIssue]:
|
||||
"""比对 DDL 文件与数据字典文档的覆盖度"""
|
||||
...
|
||||
|
||||
def check_api_samples_vs_parsers(repo_root: Path) -> list[AlignmentIssue]:
|
||||
"""比对 API 响应样本与 ODS 表结构/解析器的一致性"""
|
||||
...
|
||||
|
||||
def render_alignment_report(mappings: list[DocMapping], issues: list[AlignmentIssue], repo_root: str) -> str:
|
||||
"""生成 Markdown 格式的文档对齐报告"""
|
||||
...
|
||||
```
|
||||
|
||||
**文档来源识别**:
|
||||
- `docs/` 目录下的 `.md`、`.txt`、`.csv` 文件
|
||||
- 根目录的 `README.md`
|
||||
- `开发笔记/` 目录
|
||||
- 各模块内的 `README.md`(`gui/README.md`、`fetch-test/README.md`)
|
||||
- `.kiro/steering/` 下的引导文件
|
||||
- `docs/test-json-doc/` 下的 API 响应样本及分析文档
|
||||
|
||||
**对齐检查策略**:
|
||||
- 过期点检测:文档中引用的文件路径、类名、函数名在代码中已不存在
|
||||
- 冲突点检测:DDL 中的表/字段定义与数据字典文档不一致;API 样本字段与解析器不匹配
|
||||
- 缺失点检测:核心代码模块(`tasks/`、`loaders/`、`orchestration/` 等)缺少对应文档
|
||||
|
||||
### 5. 审计主入口 (`scripts/audit/run_audit.py`)
|
||||
|
||||
```python
|
||||
def run_audit(repo_root: Path | None = None) -> None:
|
||||
"""执行完整审计流程,生成三份报告到 docs/audit/"""
|
||||
...
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_audit()
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
### FileEntry(文件元信息)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `rel_path` | `str` | 相对路径 |
|
||||
| `is_dir` | `bool` | 是否为目录 |
|
||||
| `size_bytes` | `int` | 文件大小 |
|
||||
| `extension` | `str` | 扩展名 |
|
||||
| `is_empty_dir` | `bool` | 是否为空目录 |
|
||||
|
||||
### InventoryItem(清单条目)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `rel_path` | `str` | 相对路径 |
|
||||
| `category` | `Category` | 用途分类 |
|
||||
| `disposition` | `Disposition` | 处置标签 |
|
||||
| `description` | `str` | 简要说明 |
|
||||
|
||||
### FlowNode(流程树节点)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | `str` | 节点名称 |
|
||||
| `source_file` | `str` | 源文件路径 |
|
||||
| `node_type` | `str` | 节点类型 |
|
||||
| `children` | `list[FlowNode]` | 子节点列表 |
|
||||
|
||||
### DocMapping / AlignmentIssue(文档对齐)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `doc_path` | `str` | 文档路径 |
|
||||
| `doc_topic` / `issue_type` | `str` | 主题/问题类型 |
|
||||
| `related_code` | `list[str]` / `str` | 关联代码 |
|
||||
| `status` / `description` | `str` | 状态/描述 |
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是指在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是连接人类可读规格说明与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: classify 完整性
|
||||
|
||||
*对于任意* `FileEntry`,`classify` 函数返回的 `InventoryItem` 的 `category` 字段应属于 `Category` 枚举,`disposition` 字段应属于 `Disposition` 枚举,且 `description` 字段为非空字符串。
|
||||
|
||||
**Validates: Requirements 1.2, 1.3**
|
||||
|
||||
### Property 2: 清单渲染完整性
|
||||
|
||||
*对于任意* `InventoryItem` 列表,`render_inventory_report` 生成的 Markdown 文本中,每个条目对应的行应包含该条目的 `rel_path`、`category.value`、`disposition.value` 和 `description` 四个字段。
|
||||
|
||||
**Validates: Requirements 1.4**
|
||||
|
||||
### Property 3: 空目录标记为候选删除
|
||||
|
||||
*对于任意* `is_empty_dir=True` 的 `FileEntry`,`classify` 返回的 `disposition` 应为 `Disposition.CANDIDATE_DELETE`。
|
||||
|
||||
**Validates: Requirements 1.5**
|
||||
|
||||
### Property 4: .lnk/.rar 文件标记为候选删除
|
||||
|
||||
*对于任意* 扩展名为 `.lnk` 或 `.rar` 的 `FileEntry`,`classify` 返回的 `disposition` 应为 `Disposition.CANDIDATE_DELETE`。
|
||||
|
||||
**Validates: Requirements 1.6**
|
||||
|
||||
### Property 5: tmp/ 下文件处置范围
|
||||
|
||||
*对于任意* `rel_path` 以 `tmp/` 开头的 `FileEntry`,`classify` 返回的 `disposition` 应为 `Disposition.CANDIDATE_DELETE` 或 `Disposition.CANDIDATE_ARCHIVE` 之一。
|
||||
|
||||
**Validates: Requirements 1.7**
|
||||
|
||||
### Property 6: 运行时产出目录标记为候选归档
|
||||
|
||||
*对于任意* `rel_path` 以 `logs/` 或 `export/` 开头且非 `__init__.py` 的 `FileEntry`,`classify` 返回的 `disposition` 应为 `Disposition.CANDIDATE_ARCHIVE`。
|
||||
|
||||
**Validates: Requirements 1.8**
|
||||
|
||||
### Property 7: 扫描器排除规则
|
||||
|
||||
*对于任意* 文件树,`scan_repo` 返回的 `FileEntry` 列表中不应包含 `rel_path` 匹配排除模式(`.git`、`__pycache__`、`.pytest_cache`)的条目。
|
||||
|
||||
**Validates: Requirements 1.1**
|
||||
|
||||
### Property 8: 清单按分类分组
|
||||
|
||||
*对于任意* `InventoryItem` 列表,`render_inventory_report` 生成的 Markdown 中,同一 `Category` 的条目应连续出现(即按分类分组排列)。
|
||||
|
||||
**Validates: Requirements 1.10**
|
||||
|
||||
### Property 9: 流程树节点 source_file 有效性
|
||||
|
||||
*对于任意* `FlowNode` 树中的节点,`source_file` 字段应为非空字符串,且对应的文件在仓库中实际存在。
|
||||
|
||||
**Validates: Requirements 2.7**
|
||||
|
||||
### Property 10: 孤立模块检测正确性
|
||||
|
||||
*对于任意* 文件集合和可达模块集合,`find_orphan_modules` 返回的孤立模块列表中的每个模块都不应出现在可达集合中,且可达集合中的每个模块都不应出现在孤立列表中。
|
||||
|
||||
**Validates: Requirements 2.8**
|
||||
|
||||
### Property 11: 过期引用检测
|
||||
|
||||
*对于任意* 文档中提取的代码引用,若该引用指向的文件路径在仓库中不存在,则 `check_reference_validity` 应返回 `False`。
|
||||
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
### Property 12: 缺失文档检测
|
||||
|
||||
*对于任意* 核心代码模块集合和已文档化模块集合,`find_undocumented_modules` 返回的缺失列表应恰好等于核心模块集合与已文档化集合的差集。
|
||||
|
||||
**Validates: Requirements 3.5**
|
||||
|
||||
### Property 13: 统计摘要一致性
|
||||
|
||||
*对于任意* 报告的统计摘要,各分类/标签的计数之和应等于对应条目列表的总长度。
|
||||
|
||||
**Validates: Requirements 4.5, 4.6, 4.7**
|
||||
|
||||
### Property 14: 报告头部元信息
|
||||
|
||||
*对于任意* 报告输出,头部应包含一个符合 ISO 格式的时间戳字符串和仓库根目录路径字符串。
|
||||
|
||||
**Validates: Requirements 4.2**
|
||||
|
||||
### Property 15: 写操作仅限 docs/audit/
|
||||
|
||||
*对于任意* 审计执行过程,所有文件写操作的目标路径应以 `docs/audit/repo/` 为前缀。
|
||||
|
||||
**Validates: Requirements 5.2**
|
||||
|
||||
### Property 16: 文档对齐报告分区完整性
|
||||
|
||||
*对于任意* `render_alignment_report` 的输出,Markdown 文本应包含"映射关系"、"过期点"、"冲突点"、"缺失点"四个分区标题。
|
||||
|
||||
**Validates: Requirements 3.8**
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| 文件读取权限不足 | 记录警告到报告的"错误"分区,跳过该文件,继续处理 |
|
||||
| Python 源文件语法错误(`ast.parse` 失败) | 记录警告,将该文件标记为"待确认",不中断流程树构建 |
|
||||
| 文档中的代码引用格式无法解析 | 跳过该引用,不产生误报 |
|
||||
| DDL 文件 SQL 语法不规范 | 使用正则提取 `CREATE TABLE` 和列定义,容忍非标准语法 |
|
||||
| `docs/audit/repo/` 目录创建失败 | 抛出异常并终止,因为无法输出报告 |
|
||||
| 编码问题(非 UTF-8 文件) | 尝试 `utf-8` → `gbk` → `latin-1` 回退读取,记录编码警告 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试框架
|
||||
|
||||
- 单元测试与属性测试均使用 `pytest`
|
||||
- 属性测试库:`hypothesis`(Python 生态最成熟的属性测试框架)
|
||||
- 测试文件位于 `tests/unit/test_audit_*.py`
|
||||
|
||||
### 单元测试
|
||||
|
||||
针对具体示例和边界情况:
|
||||
- 扫描器对实际仓库子集的遍历结果
|
||||
- classify 对已知文件路径的分类正确性(如 `tmp/hebing.py` → 临时与调试/候选删除)
|
||||
- 入口点识别对实际仓库的结果
|
||||
- DDL 与数据字典的比对结果
|
||||
- 文件读取失败时的容错行为
|
||||
- `docs/audit/repo/` 目录不存在时的自动创建
|
||||
|
||||
### 属性测试
|
||||
|
||||
每个正确性属性对应一个属性测试,使用 `hypothesis` 生成随机输入:
|
||||
|
||||
- 每个属性测试至少运行 100 次迭代
|
||||
- 每个测试用注释标注对应的设计属性编号
|
||||
- 标注格式:**Feature: repo-audit, Property {N}: {属性标题}**
|
||||
|
||||
**生成器策略**:
|
||||
- `FileEntry` 生成器:随机路径(含各种扩展名、目录层级)、随机大小、随机 is_dir/is_empty_dir
|
||||
- `InventoryItem` 生成器:随机 Category/Disposition 组合、随机描述文本
|
||||
- `FlowNode` 生成器:随机树结构(限制深度和宽度)
|
||||
- 文件树生成器:构造临时目录结构用于扫描器测试
|
||||
@@ -1,90 +0,0 @@
|
||||
# 需求文档:仓库治理只读审计
|
||||
|
||||
## 简介
|
||||
|
||||
对飞球 ETL 系统 (etl-billiards) 仓库进行全面的只读审计分析,产出三份结构化报告:文件/目录清单(含处置建议)、项目流程树(从入口到末端逻辑)、文档对齐报告(文档与代码的映射关系)。本阶段不修改任何文件,所有处置决策留待用户逐一确认后再执行。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **审计脚本 (Audit_Script)**:执行只读分析并生成报告的 Python 脚本集合
|
||||
- **文件清单 (File_Inventory)**:按用途归类的仓库文件与目录列表,每项附带处置标签
|
||||
- **处置标签 (Disposition_Tag)**:对文件/目录的处置建议,取值为:保留、候选删除、候选归档、待确认
|
||||
- **流程树 (Flow_Tree)**:从程序入口出发,沿调用链展开到各子模块/子逻辑的树状结构
|
||||
- **文档对齐报告 (Doc_Alignment_Report)**:文档与代码之间映射关系的分析报告,包含过期点、冲突点、缺失点
|
||||
- **入口 (Entry_Point)**:程序的顶层启动点,如 `cli/main.py`、`gui/main.py`、`scripts/*.py`
|
||||
- **ODS/DWD/DWS**:数据仓库三层架构——操作数据存储层/明细数据层/数据服务层
|
||||
- **SCD2**:缓慢变化维度类型 2,维度表的历史版本管理策略
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:文件与目录清单生成
|
||||
|
||||
**用户故事:** 作为项目维护者,我希望获得一份按用途归类的仓库文件与目录清单,以便了解每个文件的角色并决定其去留。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 审计脚本扫描仓库根目录时,THE Audit_Script SHALL 递归遍历所有文件和目录(排除 `.git/`、`__pycache__/`、`.pytest_cache/` 等运行时缓存目录)
|
||||
2. WHEN 审计脚本处理每个文件或目录时,THE Audit_Script SHALL 将其归入以下用途分类之一:核心代码、配置、数据库定义、测试、文档、脚本工具、GUI、构建与部署、日志与输出、临时与调试、其他
|
||||
3. WHEN 审计脚本完成归类后,THE Audit_Script SHALL 为每个条目分配一个处置标签(保留/候选删除/候选归档/待确认)
|
||||
4. WHEN 审计脚本生成清单时,THE File_Inventory SHALL 包含以下字段:相对路径、用途分类、处置标签、简要说明
|
||||
5. WHEN 审计脚本遇到空目录(如 `database/Deleded & backup/`、`scripts/Deleded & backup/`)时,THE Audit_Script SHALL 将其标记为"候选删除"
|
||||
6. WHEN 审计脚本遇到 `.lnk` 快捷方式文件或 `.rar` 压缩包时,THE Audit_Script SHALL 将其标记为"候选删除"
|
||||
7. WHEN 审计脚本遇到 `tmp/` 目录下的文件时,THE Audit_Script SHALL 逐一评估并标记为"候选删除"或"候选归档"
|
||||
8. WHEN 审计脚本遇到 `logs/`、`export/` 目录下的运行时产出文件时,THE Audit_Script SHALL 将其标记为"候选归档"
|
||||
9. IF 审计脚本无法确定某文件的用途分类,THEN THE Audit_Script SHALL 将其标记为"待确认"并在说明中注明原因
|
||||
10. WHEN 审计脚本完成清单生成后,THE File_Inventory SHALL 以 Markdown 表格格式输出,按用途分类分组排列
|
||||
|
||||
### 需求 2:项目流程树生成
|
||||
|
||||
**用户故事:** 作为项目维护者,我希望获得一份从入口到各子模块的调用流程树,以便理解系统的执行路径和模块依赖关系。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 审计脚本分析项目入口时,THE Audit_Script SHALL 识别以下入口点:`cli/main.py`(CLI 主入口)、`gui/main.py`(GUI 主入口)、`scripts/*.py`(运维脚本)、批处理文件(`run_etl.bat`、`run_gui.bat`、`run_ods.bat` 等)
|
||||
2. WHEN 审计脚本从 CLI 入口展开时,THE Flow_Tree SHALL 追踪以下调用链:CLI 参数解析 → 配置加载 → 调度器初始化 → 任务注册表查询 → 任务执行(Extract → Transform → Load)→ 加载器调用 → 数据库操作
|
||||
3. WHEN 审计脚本从 GUI 入口展开时,THE Flow_Tree SHALL 追踪以下调用链:GUI 主窗口初始化 → 各面板/组件加载 → 后台工作线程 → CLI 命令构建 → 任务执行
|
||||
4. WHEN 审计脚本分析任务模块时,THE Flow_Tree SHALL 区分以下任务类型:ODS 抓取任务、DWD 加载任务、DWS 汇总任务、校验任务、Schema 初始化任务
|
||||
5. WHEN 审计脚本分析加载器模块时,THE Flow_Tree SHALL 区分以下加载器类型:ODS 通用加载器、维度加载器(SCD2)、事实表加载器
|
||||
6. WHEN 审计脚本生成流程树时,THE Flow_Tree SHALL 以缩进文本或 Mermaid 图的形式输出,层级深度至少达到函数/方法级别
|
||||
7. WHEN 审计脚本分析模块依赖时,THE Flow_Tree SHALL 标注每个节点所在的源文件路径
|
||||
8. IF 审计脚本发现存在孤立模块(未被任何入口直接或间接引用的代码文件),THEN THE Flow_Tree SHALL 在报告末尾单独列出这些孤立模块
|
||||
|
||||
### 需求 3:文档对齐报告生成
|
||||
|
||||
**用户故事:** 作为项目维护者,我希望了解现有文档与代码之间的对齐状况,以便识别过期、冲突和缺失的文档。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 审计脚本扫描文档目录时,THE Audit_Script SHALL 识别以下文档来源:`docs/` 目录、`README.md`、`开发笔记/`、各模块内的 `README.md`(如 `gui/README.md`、`fetch-test/README.md`)、`.kiro/steering/` 下的引导文件
|
||||
2. WHEN 审计脚本分析每份文档时,THE Doc_Alignment_Report SHALL 建立文档与代码模块之间的映射关系
|
||||
3. WHEN 审计脚本检测到文档引用了已不存在的代码实体(函数、类、文件路径)时,THE Doc_Alignment_Report SHALL 将该引用标记为"过期点"
|
||||
4. WHEN 审计脚本检测到文档描述与代码实际行为不一致时,THE Doc_Alignment_Report SHALL 将该处标记为"冲突点"
|
||||
5. WHEN 审计脚本检测到核心代码模块缺少对应文档时,THE Doc_Alignment_Report SHALL 将该模块标记为"缺失点"
|
||||
6. WHEN 审计脚本分析 DDL 文件(`database/schema_*.sql`)时,THE Doc_Alignment_Report SHALL 检查数据字典文档(`docs/dwd_main_tables_dictionary.md`、`docs/dws_tables_dictionary.md`)是否覆盖了所有表和字段
|
||||
7. WHEN 审计脚本分析 `docs/test-json-doc/` 下的 API 响应样本时,THE Doc_Alignment_Report SHALL 检查样本字段是否与 ODS 表结构和解析器(`models/parsers.py`)一致
|
||||
8. WHEN 审计脚本完成分析后,THE Doc_Alignment_Report SHALL 以 Markdown 格式输出,包含以下分区:映射关系表、过期点列表、冲突点列表、缺失点列表
|
||||
|
||||
### 需求 4:报告输出与格式规范
|
||||
|
||||
**用户故事:** 作为项目维护者,我希望审计报告以统一、可读的格式输出,以便后续逐项决策和执行。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Audit_Script SHALL 将三份报告输出到 `docs/audit/repo/` 目录下,文件名分别为 `file_inventory.md`、`flow_tree.md`、`doc_alignment.md`
|
||||
2. THE Audit_Script SHALL 在每份报告的头部包含生成时间戳和仓库根目录路径
|
||||
3. WHEN 报告引用代码标识符(类名、函数名、变量名、文件路径)时,THE Audit_Script SHALL 保留英文原文,使用行内代码格式(反引号)
|
||||
4. WHEN 报告包含说明性文字时,THE Audit_Script SHALL 使用简体中文
|
||||
5. THE Audit_Script SHALL 在文件清单报告末尾附加统计摘要:各用途分类的文件数量、各处置标签的文件数量
|
||||
6. THE Audit_Script SHALL 在流程树报告末尾附加统计摘要:入口点数量、任务数量、加载器数量、孤立模块数量
|
||||
7. THE Audit_Script SHALL 在文档对齐报告末尾附加统计摘要:过期点数量、冲突点数量、缺失点数量
|
||||
|
||||
### 需求 5:只读安全保障
|
||||
|
||||
**用户故事:** 作为项目维护者,我希望审计过程不会修改仓库中的任何文件,以确保分析阶段的安全性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Audit_Script SHALL 仅执行文件系统的读取操作(读取文件内容、列出目录、获取文件元信息)
|
||||
2. THE Audit_Script SHALL 仅在 `docs/audit/repo/` 目录下创建新文件,该目录为报告专用输出目录
|
||||
3. IF 审计脚本在执行过程中遇到权限错误或文件读取失败,THEN THE Audit_Script SHALL 在报告中记录该错误并继续处理其余文件
|
||||
4. THE Audit_Script SHALL 在运行前检查 `docs/audit/repo/` 目录是否存在,若不存在则创建该目录
|
||||
@@ -1,118 +0,0 @@
|
||||
# 实施计划:仓库治理只读审计
|
||||
|
||||
## 概述
|
||||
|
||||
将设计文档中的审计脚本拆分为增量式编码任务。每个任务构建在前一个任务之上,最终产出可运行的审计工具集。所有脚本位于 `scripts/audit/` 目录,报告输出到 `docs/audit/repo/`。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 搭建审计脚本骨架和数据模型
|
||||
- [x] 1.1 创建 `scripts/audit/__init__.py` 和数据模型定义
|
||||
- 定义 `FileEntry` dataclass(`rel_path`, `is_dir`, `size_bytes`, `extension`, `is_empty_dir`)
|
||||
- 定义 `Category` 和 `Disposition` 枚举
|
||||
- 定义 `InventoryItem` dataclass
|
||||
- 定义 `FlowNode` dataclass
|
||||
- 定义 `DocMapping` 和 `AlignmentIssue` dataclass
|
||||
- _Requirements: 1.2, 1.3, 1.4, 2.7, 3.2, 3.3_
|
||||
|
||||
- [x] 1.2 编写 classify 完整性属性测试
|
||||
- **Property 1: classify 完整性**
|
||||
- **Validates: Requirements 1.2, 1.3**
|
||||
|
||||
- [x] 2. 实现仓库扫描器
|
||||
- [x] 2.1 创建 `scripts/audit/scanner.py`
|
||||
- 实现 `EXCLUDED_PATTERNS` 常量和排除匹配逻辑
|
||||
- 实现 `scan_repo(root, exclude)` 函数:递归遍历文件系统,返回 `list[FileEntry]`
|
||||
- 处理空目录检测(`is_empty_dir`)
|
||||
- 处理文件读取权限错误(跳过并记录)
|
||||
- _Requirements: 1.1, 5.1, 5.3_
|
||||
|
||||
- [x] 2.2 编写扫描器排除规则属性测试
|
||||
- **Property 7: 扫描器排除规则**
|
||||
- **Validates: Requirements 1.1**
|
||||
|
||||
- [x] 3. 实现文件清单分析器
|
||||
- [x] 3.1 创建 `scripts/audit/inventory_analyzer.py`
|
||||
- 实现 `classify(entry: FileEntry) -> InventoryItem` 函数,包含完整分类规则表
|
||||
- 实现 `build_inventory(entries) -> list[InventoryItem]` 批量分类函数
|
||||
- 实现 `render_inventory_report(items, repo_root) -> str` Markdown 渲染函数
|
||||
- 包含统计摘要生成(各分类/标签计数)
|
||||
- 注意:需求 1.8 仅覆盖 `logs/` 和 `export/` 目录(不含 `reports/`)
|
||||
- _Requirements: 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 4.2, 4.5_
|
||||
|
||||
- [x] 3.2 编写 classify 分类规则属性测试
|
||||
- **Property 3: 空目录标记为候选删除**
|
||||
- **Property 4: .lnk/.rar 文件标记为候选删除**
|
||||
- **Property 5: tmp/ 下文件处置范围**
|
||||
- **Property 6: 运行时产出目录标记为候选归档**(仅 `logs/`、`export/`)
|
||||
- **Validates: Requirements 1.5, 1.6, 1.7, 1.8**
|
||||
|
||||
- [x] 3.3 编写清单渲染属性测试
|
||||
- **Property 2: 清单渲染完整性**
|
||||
- **Property 8: 清单按分类分组**
|
||||
- **Validates: Requirements 1.4, 1.10**
|
||||
|
||||
- [x] 4. 检查点 - 确保文件清单模块测试通过
|
||||
- 确保所有测试通过,如有疑问请向用户确认。
|
||||
|
||||
- [x] 5. 实现流程树分析器
|
||||
- [x] 5.1 创建 `scripts/audit/flow_analyzer.py`
|
||||
- 实现 `parse_imports(filepath)` 函数:使用 `ast` 模块解析 Python 文件的 import 语句
|
||||
- 实现 `build_flow_tree(repo_root, entry_file)` 函数:从入口递归追踪 import 链
|
||||
- 实现 `find_orphan_modules(repo_root, all_entries, reachable)` 函数
|
||||
- 实现 `render_flow_report(trees, orphans, repo_root)` 函数:生成 Mermaid 图和缩进文本
|
||||
- 包含入口点识别逻辑(CLI、GUI、批处理、运维脚本)
|
||||
- 包含任务类型和加载器类型区分逻辑
|
||||
- 包含统计摘要生成
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 4.6_
|
||||
|
||||
- [x] 5.2 编写流程树属性测试
|
||||
- **Property 9: 流程树节点 source_file 有效性**
|
||||
- **Property 10: 孤立模块检测正确性**
|
||||
- **Validates: Requirements 2.7, 2.8**
|
||||
|
||||
- [x] 6. 实现文档对齐分析器
|
||||
- [x] 6.1 创建 `scripts/audit/doc_alignment_analyzer.py`
|
||||
- 实现 `scan_docs(repo_root)` 函数:扫描所有文档来源
|
||||
- 实现 `extract_code_references(doc_path)` 函数:从文档提取代码引用
|
||||
- 实现 `check_reference_validity(ref, repo_root)` 函数
|
||||
- 实现 `find_undocumented_modules(repo_root, documented)` 函数
|
||||
- 实现 `check_ddl_vs_dictionary(repo_root)` 函数:DDL 与数据字典比对
|
||||
- 实现 `check_api_samples_vs_parsers(repo_root)` 函数:API 样本与解析器比对
|
||||
- 实现 `render_alignment_report(mappings, issues, repo_root)` 函数
|
||||
- 包含统计摘要生成
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 4.7_
|
||||
|
||||
- [x] 6.2 编写文档对齐属性测试
|
||||
- **Property 11: 过期引用检测**
|
||||
- **Property 12: 缺失文档检测**
|
||||
- **Property 16: 文档对齐报告分区完整性**
|
||||
- **Validates: Requirements 3.3, 3.5, 3.8**
|
||||
|
||||
- [x] 7. 检查点 - 确保流程树和文档对齐模块测试通过
|
||||
- 确保所有测试通过,如有疑问请向用户确认。
|
||||
|
||||
- [x] 8. 实现审计主入口和报告输出
|
||||
- [x] 8.1 创建 `scripts/audit/run_audit.py`
|
||||
- 实现 `run_audit(repo_root)` 主函数:依次调用扫描器和三个分析器
|
||||
- 实现 `docs/audit/repo/` 目录检查与创建逻辑
|
||||
- 实现报告头部元信息(时间戳、仓库路径)注入
|
||||
- 实现三份报告的文件写入
|
||||
- 添加 `if __name__ == "__main__"` 入口
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 5.2, 5.4_
|
||||
|
||||
- [x] 8.2 编写报告输出属性测试
|
||||
- **Property 13: 统计摘要一致性**
|
||||
- **Property 14: 报告头部元信息**
|
||||
- **Property 15: 写操作仅限 docs/audit/**
|
||||
- **Validates: Requirements 4.2, 4.5, 4.6, 4.7, 5.2**
|
||||
|
||||
- [x] 9. 最终检查点 - 确保所有测试通过
|
||||
- 确保所有测试通过,如有疑问请向用户确认。
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
|
||||
- 每个任务引用了具体的需求编号,便于追溯
|
||||
- 属性测试使用 `hypothesis` 库,每个测试至少 100 次迭代
|
||||
- 单元测试验证具体示例和边界情况,属性测试验证通用正确性
|
||||
@@ -1,462 +0,0 @@
|
||||
# 设计文档:ETL 调度器重构
|
||||
|
||||
## 概述
|
||||
|
||||
本次重构将 `ETLScheduler`(约 900 行,职责混乱的"上帝类")拆分为三层清晰的架构:
|
||||
|
||||
1. **CLI 层**(`cli/main.py`):参数解析、配置加载、资源创建与释放
|
||||
2. **PipelineRunner**(`orchestration/pipeline_runner.py`):管道定义、层→任务映射、校验编排
|
||||
3. **TaskExecutor**(`orchestration/task_executor.py`):单任务执行、游标管理、运行记录
|
||||
|
||||
核心设计原则:**单个任务是最小执行单元,管道模式只是"调度拼接"**。每层通过依赖注入接收协作对象,不自行创建资源,便于独立测试。
|
||||
|
||||
## 架构
|
||||
|
||||
### 分层架构图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
CLI["CLI 层<br/>cli/main.py<br/>参数解析 · 配置加载 · 资源管理"]
|
||||
PR["PipelineRunner<br/>orchestration/pipeline_runner.py<br/>管道定义 · 层→任务映射 · 校验编排"]
|
||||
TE["TaskExecutor<br/>orchestration/task_executor.py<br/>单任务执行 · 游标管理 · 运行记录"]
|
||||
TR["TaskRegistry<br/>orchestration/task_registry.py<br/>任务注册 · 元数据查询"]
|
||||
CM["CursorManager"]
|
||||
RT["RunTracker"]
|
||||
DB["DatabaseConnection"]
|
||||
API["APIClient"]
|
||||
|
||||
CLI -->|"创建并注入"| PR
|
||||
CLI -->|"创建并注入"| TE
|
||||
CLI -->|"管理生命周期"| DB
|
||||
CLI -->|"管理生命周期"| API
|
||||
PR -->|"委托执行"| TE
|
||||
PR -->|"查询任务"| TR
|
||||
TE -->|"查询元数据"| TR
|
||||
TE -->|"管理游标"| CM
|
||||
TE -->|"记录运行"| RT
|
||||
TE -->|"使用"| DB
|
||||
TE -->|"使用"| API
|
||||
```
|
||||
|
||||
### 调用流程
|
||||
|
||||
**传统模式**(`--tasks`):
|
||||
```
|
||||
CLI → TaskExecutor.run_tasks([task_codes]) → TaskExecutor._run_single_task() × N
|
||||
```
|
||||
|
||||
**管道模式**(`--pipeline`):
|
||||
```
|
||||
CLI → PipelineRunner.run(pipeline, processing_mode, ...)
|
||||
→ PipelineRunner._resolve_tasks(layers)
|
||||
→ TaskExecutor.run_tasks([resolved_tasks])
|
||||
→ [可选] PipelineRunner._run_verification(layers, ...)
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### TaskExecutor
|
||||
|
||||
负责单任务执行的完整生命周期。从原 `ETLScheduler` 中提取 `_run_single_task`、`_execute_fetch`、`_execute_ingest`、`_execute_ods_record_and_load`、`_run_utility_task` 等方法。
|
||||
|
||||
```python
|
||||
class TaskExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
db_ops: DatabaseOperations,
|
||||
api_client: APIClient,
|
||||
cursor_mgr: CursorManager,
|
||||
run_tracker: RunTracker,
|
||||
task_registry: TaskRegistry,
|
||||
logger: logging.Logger,
|
||||
):
|
||||
...
|
||||
|
||||
def run_tasks(
|
||||
self,
|
||||
task_codes: list[str],
|
||||
data_source: str = "hybrid", # online / offline / hybrid
|
||||
) -> list[dict[str, Any]]:
|
||||
"""批量执行任务列表,返回每个任务的结果。"""
|
||||
...
|
||||
|
||||
def run_single_task(
|
||||
self,
|
||||
task_code: str,
|
||||
run_uuid: str,
|
||||
store_id: int,
|
||||
data_source: str = "hybrid",
|
||||
) -> dict[str, Any]:
|
||||
"""执行单个任务的完整生命周期。"""
|
||||
...
|
||||
```
|
||||
|
||||
关键变化:
|
||||
- `data_source` 作为显式参数传入,不再读取 `self.pipeline_flow` 全局状态
|
||||
- 工具类任务判断通过 `TaskRegistry.get_metadata(task_code)` 查询,不再硬编码
|
||||
- 不自行创建 `DatabaseConnection` 或 `APIClient`
|
||||
|
||||
### PipelineRunner
|
||||
|
||||
负责管道编排。从原 `ETLScheduler` 中提取 `run_pipeline_with_verification`、`_run_layer_verification`、`_get_tasks_for_layers` 等方法。
|
||||
|
||||
```python
|
||||
class PipelineRunner:
|
||||
# 管道定义(从 scheduler.py 模块级常量迁移至此)
|
||||
PIPELINE_LAYERS: dict[str, list[str]] = {
|
||||
"api_ods": ["ODS"],
|
||||
"api_ods_dwd": ["ODS", "DWD"],
|
||||
"api_full": ["ODS", "DWD", "DWS", "INDEX"],
|
||||
"ods_dwd": ["DWD"],
|
||||
"dwd_dws": ["DWS"],
|
||||
"dwd_dws_index": ["DWS", "INDEX"],
|
||||
"dwd_index": ["INDEX"],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
task_executor: TaskExecutor,
|
||||
task_registry: TaskRegistry,
|
||||
db_conn: DatabaseConnection,
|
||||
api_client: APIClient,
|
||||
logger: logging.Logger,
|
||||
):
|
||||
...
|
||||
|
||||
def run(
|
||||
self,
|
||||
pipeline: str,
|
||||
processing_mode: str = "increment_only",
|
||||
data_source: str = "hybrid",
|
||||
window_start: datetime | None = None,
|
||||
window_end: datetime | None = None,
|
||||
window_split: str | None = None,
|
||||
task_codes: list[str] | None = None,
|
||||
fetch_before_verify: bool = False,
|
||||
verify_tables: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""执行管道,返回汇总结果。"""
|
||||
...
|
||||
|
||||
def _resolve_tasks(self, layers: list[str]) -> list[str]:
|
||||
"""根据层列表解析任务代码,优先查询 TaskRegistry 元数据。"""
|
||||
...
|
||||
|
||||
def _run_verification(self, layers, window_start, window_end, ...):
|
||||
"""执行后置校验(从原 _run_layer_verification 迁移)。"""
|
||||
...
|
||||
```
|
||||
|
||||
### TaskRegistry(增强)
|
||||
|
||||
在现有注册功能基础上增加元数据支持。
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TaskMeta:
|
||||
"""任务元数据"""
|
||||
task_class: type
|
||||
requires_db_config: bool = True
|
||||
layer: str | None = None # "ODS" / "DWD" / "DWS" / "INDEX" / None
|
||||
task_type: str = "etl" # "etl" / "utility" / "verification"
|
||||
|
||||
class TaskRegistry:
|
||||
def __init__(self):
|
||||
self._tasks: dict[str, TaskMeta] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
task_code: str,
|
||||
task_class: type,
|
||||
requires_db_config: bool = True,
|
||||
layer: str | None = None,
|
||||
task_type: str = "etl",
|
||||
):
|
||||
"""注册任务类及其元数据。"""
|
||||
self._tasks[task_code.upper()] = TaskMeta(
|
||||
task_class=task_class,
|
||||
requires_db_config=requires_db_config,
|
||||
layer=layer,
|
||||
task_type=task_type,
|
||||
)
|
||||
|
||||
def create_task(self, task_code, config, db_connection, api_client, logger):
|
||||
"""创建任务实例(保持原有接口不变)。"""
|
||||
...
|
||||
|
||||
def get_metadata(self, task_code: str) -> TaskMeta | None:
|
||||
"""查询任务元数据。"""
|
||||
...
|
||||
|
||||
def get_tasks_by_layer(self, layer: str) -> list[str]:
|
||||
"""获取指定层的所有任务代码。"""
|
||||
...
|
||||
|
||||
def is_utility_task(self, task_code: str) -> bool:
|
||||
"""判断是否为工具类任务(不需要游标/运行记录)。"""
|
||||
meta = self.get_metadata(task_code)
|
||||
return meta is not None and not meta.requires_db_config
|
||||
|
||||
def get_all_task_codes(self) -> list[str]:
|
||||
"""获取所有已注册的任务代码(保持原有接口)。"""
|
||||
...
|
||||
```
|
||||
|
||||
### CLI 层重构
|
||||
|
||||
```python
|
||||
# cli/main.py 核心流程伪代码
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
config = AppConfig.load(build_cli_overrides(args))
|
||||
|
||||
# 资源创建
|
||||
db_conn = DatabaseConnection(...)
|
||||
api_client = APIClient(...)
|
||||
|
||||
try:
|
||||
# 组装依赖
|
||||
db_ops = DatabaseOperations(db_conn)
|
||||
cursor_mgr = CursorManager(db_conn)
|
||||
run_tracker = RunTracker(db_conn)
|
||||
registry = default_registry
|
||||
|
||||
executor = TaskExecutor(config, db_ops, api_client, cursor_mgr, run_tracker, registry, logger)
|
||||
|
||||
if args.pipeline:
|
||||
runner = PipelineRunner(config, executor, registry, db_conn, api_client, logger)
|
||||
runner.run(
|
||||
pipeline=args.pipeline,
|
||||
processing_mode=args.processing_mode,
|
||||
data_source=resolve_data_source(args),
|
||||
...
|
||||
)
|
||||
else:
|
||||
task_codes = config.get("run.tasks")
|
||||
data_source = resolve_data_source(args)
|
||||
executor.run_tasks(task_codes, data_source=data_source)
|
||||
finally:
|
||||
db_conn.close()
|
||||
```
|
||||
|
||||
### 参数映射
|
||||
|
||||
| 旧参数 | 旧值 | 新参数 | 新值 | 说明 |
|
||||
|--------|------|--------|------|------|
|
||||
| `--pipeline-flow` | `FULL` | `--data-source` | `hybrid` | 在线抓取 + 本地入库 |
|
||||
| `--pipeline-flow` | `FETCH_ONLY` | `--data-source` | `online` | 仅在线抓取落盘 |
|
||||
| `--pipeline-flow` | `INGEST_ONLY` | `--data-source` | `offline` | 仅本地清洗入库 |
|
||||
|
||||
### 静态方法归位
|
||||
|
||||
| 方法 | 原位置 | 新位置 | 理由 |
|
||||
|------|--------|--------|------|
|
||||
| `_map_run_status` | `ETLScheduler` | `RunTracker` | 状态映射是运行记录的职责 |
|
||||
| `_filter_verify_tables` | `ETLScheduler` | `tasks/verification/` 模块 | 校验表过滤是校验模块的职责 |
|
||||
|
||||
## 数据模型
|
||||
|
||||
### TaskMeta(新增)
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TaskMeta:
|
||||
task_class: type # 任务类引用
|
||||
requires_db_config: bool = True # 是否需要数据库任务配置(游标/运行记录)
|
||||
layer: str | None = None # 所属层:"ODS"/"DWD"/"DWS"/"INDEX"/None
|
||||
task_type: str = "etl" # 任务类型:"etl"/"utility"/"verification"
|
||||
```
|
||||
|
||||
### DataSource 枚举
|
||||
|
||||
```python
|
||||
class DataSource(str, Enum):
|
||||
ONLINE = "online" # 仅在线抓取(原 FETCH_ONLY)
|
||||
OFFLINE = "offline" # 仅本地入库(原 INGEST_ONLY)
|
||||
HYBRID = "hybrid" # 抓取 + 入库(原 FULL)
|
||||
```
|
||||
|
||||
### 配置键映射
|
||||
|
||||
| 旧键 | 新键 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `app.timezone` | `app.timezone` | `Asia/Shanghai`(原 `Asia/Shanghai`) |
|
||||
| `pipeline.flow` | `run.data_source` | `hybrid` |
|
||||
| `pipeline.fetch_root` | `io.fetch_root` | `export/JSON` |
|
||||
| `pipeline.ingest_source_dir` | `io.ingest_source_dir` | `""` |
|
||||
|
||||
### 任务执行结果(不变)
|
||||
|
||||
```python
|
||||
# 单任务结果
|
||||
{
|
||||
"task_code": str,
|
||||
"status": str, # "SUCCESS" / "FAIL" / "SKIP"
|
||||
"counts": {
|
||||
"fetched": int,
|
||||
"inserted": int,
|
||||
"updated": int,
|
||||
"skipped": int,
|
||||
"errors": int,
|
||||
},
|
||||
"window": {"start": datetime, "end": datetime, "minutes": int} | None,
|
||||
"dump_dir": str | None,
|
||||
}
|
||||
|
||||
# 管道结果
|
||||
{
|
||||
"status": str,
|
||||
"pipeline": str,
|
||||
"layers": list[str],
|
||||
"results": list[dict], # 各任务结果
|
||||
"verification_summary": dict | None, # 校验汇总
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*正确性属性是一种在系统所有有效执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1:data_source 参数决定执行路径
|
||||
|
||||
*对于任意* 任务代码和任意 `data_source` 值(online/offline/hybrid),TaskExecutor 执行该任务时,抓取阶段执行当且仅当 `data_source` 为 `online` 或 `hybrid`,入库阶段执行当且仅当 `data_source` 为 `offline` 或 `hybrid`。
|
||||
|
||||
**验证:需求 1.2**
|
||||
|
||||
### Property 2:成功任务推进游标
|
||||
|
||||
*对于任意* 非工具类任务,当任务执行成功且返回包含有效 `window`(含 `start` 和 `end`)的结果时,CursorManager.advance 应被调用且参数与返回的窗口一致。
|
||||
|
||||
**验证:需求 1.3**
|
||||
|
||||
### Property 3:失败任务标记 FAIL 并重新抛出
|
||||
|
||||
*对于任意* 非工具类任务,当任务执行过程中抛出异常时,RunTracker 应被更新为 FAIL 状态,且该异常应被重新抛出给调用方。
|
||||
|
||||
**验证:需求 1.4**
|
||||
|
||||
### Property 4:工具类任务由元数据决定
|
||||
|
||||
*对于任意* 任务代码,TaskExecutor 是否跳过游标管理和运行记录,取决于 TaskRegistry 中该任务的 `requires_db_config` 元数据。当 `requires_db_config=False` 时跳过,否则执行完整生命周期。
|
||||
|
||||
**验证:需求 1.6, 4.2**
|
||||
|
||||
### Property 5:管道名称→层列表映射
|
||||
|
||||
*对于任意* 有效的管道名称,PipelineRunner 解析出的层列表应与 `PIPELINE_LAYERS` 字典中的定义完全一致。
|
||||
|
||||
**验证:需求 2.1**
|
||||
|
||||
### Property 6:processing_mode 控制执行流程
|
||||
|
||||
*对于任意* processing_mode 值,增量 ETL 执行当且仅当模式包含 `increment`(即 `increment_only` 或 `increment_verify`),校验流程执行当且仅当模式包含 `verify`(即 `verify_only` 或 `increment_verify`)。
|
||||
|
||||
**验证:需求 2.3, 2.4**
|
||||
|
||||
### Property 7:管道结果汇总完整性
|
||||
|
||||
*对于任意* 一组任务执行结果,PipelineRunner 返回的汇总字典应包含 `status`、`pipeline`、`layers`、`results` 字段,且 `results` 列表长度等于实际执行的任务数。
|
||||
|
||||
**验证:需求 2.6**
|
||||
|
||||
### Property 8:TaskRegistry 元数据 round-trip
|
||||
|
||||
*对于任意* 任务代码、任务类和元数据组合(requires_db_config、layer、task_type),注册后通过 `get_metadata` 查询应返回相同的元数据值。
|
||||
|
||||
**验证:需求 4.1**
|
||||
|
||||
### Property 9:TaskRegistry 向后兼容默认值
|
||||
|
||||
*对于任意* 使用旧接口(仅 task_code 和 task_class)注册的任务,查询元数据应返回 `requires_db_config=True`、`layer=None`、`task_type="etl"`。
|
||||
|
||||
**验证:需求 4.4**
|
||||
|
||||
### Property 10:按层查询任务
|
||||
|
||||
*对于任意* 注册了 `layer` 元数据的任务集合,`get_tasks_by_layer(layer)` 返回的任务代码集合应等于所有 `layer` 匹配的已注册任务代码集合。
|
||||
|
||||
**验证:需求 4.3**
|
||||
|
||||
### Property 11:pipeline_flow → data_source 映射一致性
|
||||
|
||||
*对于任意* 旧 `pipeline_flow` 值(FULL/FETCH_ONLY/INGEST_ONLY),映射到 `data_source` 的结果应与预定义映射表一致:FULL→hybrid、FETCH_ONLY→online、INGEST_ONLY→offline。同样,配置键 `pipeline.flow` 应自动映射到 `run.data_source`。
|
||||
|
||||
**验证:需求 8.1, 8.2, 8.3, 5.2, 8.4**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### TaskExecutor 错误处理
|
||||
|
||||
- 任务执行异常:更新 RunTracker 状态为 FAIL(含 error_message),然后重新抛出异常
|
||||
- 游标推进失败:记录错误日志,不影响任务结果(任务本身已成功)
|
||||
- 任务配置不存在:返回 `{"status": "SKIP"}` 结果,不抛异常
|
||||
|
||||
### PipelineRunner 错误处理
|
||||
|
||||
- 单个任务失败:记录错误,继续执行后续任务(与当前行为一致)
|
||||
- 校验框架未安装:返回 `{"status": "SKIPPED"}` 并记录警告
|
||||
- 无效管道名称:抛出 `ValueError`
|
||||
|
||||
### CLI 错误处理
|
||||
|
||||
- 配置加载失败:`SystemExit` 并输出错误信息
|
||||
- 资源创建失败:`SystemExit` 并输出错误信息
|
||||
- 执行过程异常:记录错误日志,`finally` 块确保资源释放,返回非零退出码
|
||||
|
||||
### 弃用警告
|
||||
|
||||
- 使用 Python `warnings.warn(DeprecationWarning)` 发出弃用警告
|
||||
- 同时在日志中记录映射详情,便于运维排查
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试
|
||||
|
||||
使用 `pytest` + 现有的 `FakeDB`/`FakeAPI` 测试工具(`tests/unit/task_test_utils.py`)。
|
||||
|
||||
**TaskExecutor 测试**:
|
||||
- 注入 mock 依赖(FakeDB、FakeAPI、mock CursorManager、mock RunTracker)
|
||||
- 验证成功/失败/跳过三种路径
|
||||
- 验证工具类任务不触发游标/运行记录
|
||||
- 验证 data_source 参数正确控制抓取/入库阶段
|
||||
|
||||
**PipelineRunner 测试**:
|
||||
- 注入 mock TaskExecutor
|
||||
- 验证不同 processing_mode 下的执行流程
|
||||
- 验证管道→层→任务的解析链
|
||||
|
||||
**TaskRegistry 测试**:
|
||||
- 验证元数据注册和查询
|
||||
- 验证向后兼容(无元数据注册)
|
||||
- 验证按层查询
|
||||
|
||||
**配置兼容性测试**:
|
||||
- 验证旧键→新键映射
|
||||
- 验证优先级规则
|
||||
- 验证默认值变更
|
||||
|
||||
### 属性测试
|
||||
|
||||
使用 `hypothesis` 库进行属性测试,每个属性至少运行 100 次迭代。
|
||||
|
||||
每个属性测试必须用注释标注对应的设计属性编号:
|
||||
```python
|
||||
# Feature: scheduler-refactor, Property 8: TaskRegistry 元数据 round-trip
|
||||
```
|
||||
|
||||
**属性测试覆盖**:
|
||||
- Property 1: data_source 参数决定执行路径
|
||||
- Property 2: 成功任务推进游标
|
||||
- Property 3: 失败任务标记 FAIL 并重新抛出
|
||||
- Property 4: 工具类任务由元数据决定
|
||||
- Property 5: 管道名称→层列表映射
|
||||
- Property 6: processing_mode 控制执行流程
|
||||
- Property 7: 管道结果汇总完整性
|
||||
- Property 8: TaskRegistry 元数据 round-trip
|
||||
- Property 9: TaskRegistry 向后兼容默认值
|
||||
- Property 10: 按层查询任务
|
||||
- Property 11: pipeline_flow → data_source 映射一致性
|
||||
@@ -1,123 +0,0 @@
|
||||
# 需求文档:ETL 调度器重构
|
||||
|
||||
## 简介
|
||||
|
||||
当前 `orchestration/scheduler.py`(约 900 行)中的 `ETLScheduler` 类承担了过多职责:单任务执行、管道编排、资源管理。CLI 参数命名混乱(`--pipeline` vs `--pipeline-flow` vs `--processing-mode`),全局状态耦合严重,配置键语义重叠。本次重构将调度器拆分为三层架构(CLI → PipelineRunner → TaskExecutor),重新设计参数命名,消除全局状态依赖,使每层可独立测试。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **TaskExecutor**:任务执行器,负责单个 ETL 任务的执行、游标管理和运行记录
|
||||
- **PipelineRunner**:管道运行器,负责管道定义、层→任务映射、校验编排
|
||||
- **TaskRegistry**:任务注册表,管理所有已注册的任务类及其元数据
|
||||
- **DataSource**:数据源模式,取代原 `pipeline.flow`,表示数据来自在线 API(`online`)、本地 JSON(`offline`)或混合模式(`hybrid`)
|
||||
- **ProcessingMode**:处理模式,控制 ETL 执行策略(仅增量 / 仅校验 / 增量+校验)
|
||||
- **Pipeline**:管道,定义一组按层顺序执行的 ETL 任务集合(如 `api_full` = ODS → DWD → DWS → INDEX)
|
||||
- **CursorManager**:游标管理器,管理任务的时间水位(上次处理到哪里)
|
||||
- **RunTracker**:运行记录器,在 `etl_admin` Schema 中记录每次任务执行的状态和统计
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:架构分层 — TaskExecutor(执行层)
|
||||
|
||||
**用户故事:** 作为开发者,我希望单任务执行逻辑独立封装在 TaskExecutor 中,以便可以脱离管道上下文独立测试和复用。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE TaskExecutor SHALL 封装单个任务的完整执行生命周期:创建运行记录、执行任务、更新游标、记录结果
|
||||
2. WHEN TaskExecutor 执行一个任务时,THE TaskExecutor SHALL 接收显式的 `data_source` 参数,而非读取全局状态
|
||||
3. WHEN 任务执行成功且返回有效时间窗口时,THE TaskExecutor SHALL 推进该任务的游标水位
|
||||
4. WHEN 任务执行过程中发生异常时,THE TaskExecutor SHALL 将运行记录状态更新为 FAIL 并重新抛出异常
|
||||
5. THE TaskExecutor SHALL 通过构造函数接收 `db_ops`、`api_client`、`cursor_manager`、`run_tracker`、`task_registry` 等依赖,而非自行创建
|
||||
6. WHEN 执行工具类任务(如 INIT_ODS_SCHEMA)时,THE TaskExecutor SHALL 跳过游标管理和运行记录,直接执行任务
|
||||
|
||||
### 需求 2:架构分层 — PipelineRunner(编排层)
|
||||
|
||||
**用户故事:** 作为开发者,我希望管道编排逻辑独立封装在 PipelineRunner 中,以便管道定义和校验流程可以独立演进。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE PipelineRunner SHALL 根据管道名称解析出需要执行的层列表(如 `api_full` → `["ODS", "DWD", "DWS", "INDEX"]`)
|
||||
2. WHEN PipelineRunner 执行管道时,THE PipelineRunner SHALL 委托 TaskExecutor 逐个执行任务,而非直接操作数据库或 API
|
||||
3. WHEN 处理模式为 `verify_only` 时,THE PipelineRunner SHALL 跳过增量 ETL,仅执行校验流程
|
||||
4. WHEN 处理模式为 `increment_verify` 时,THE PipelineRunner SHALL 先执行增量 ETL,再执行校验流程
|
||||
5. THE PipelineRunner SHALL 根据层列表自动选择对应的任务代码,支持配置覆盖
|
||||
6. WHEN 管道执行完成时,THE PipelineRunner SHALL 汇总所有任务的执行结果并返回统一的结果字典
|
||||
|
||||
### 需求 3:架构分层 — CLI 层重构
|
||||
|
||||
**用户故事:** 作为运维人员,我希望 CLI 参数命名清晰、语义无歧义,以便快速理解和正确使用各种执行模式。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CLI SHALL 将 `--pipeline-flow`(FULL/FETCH_ONLY/INGEST_ONLY)重命名为 `--data-source`(online/offline/hybrid),并保留旧名称作为别名
|
||||
2. THE CLI SHALL 保留 `--pipeline` 参数用于管道模式,保留 `--tasks` 参数用于传统模式
|
||||
3. WHEN 用户同时指定 `--pipeline` 和 `--tasks` 时,THE CLI SHALL 将 `--tasks` 作为管道内的任务过滤器
|
||||
4. THE CLI SHALL 保留 `--processing-mode`(increment_only/verify_only/increment_verify)参数不变
|
||||
5. WHEN 用户使用旧参数名 `--pipeline-flow` 时,THE CLI SHALL 发出弃用警告并将值映射到新的 `--data-source` 参数
|
||||
6. THE CLI SHALL 仅负责参数解析和配置加载,将执行逻辑委托给 PipelineRunner 或 TaskExecutor
|
||||
|
||||
### 需求 4:任务分类元数据化
|
||||
|
||||
**用户故事:** 作为开发者,我希望任务的分类信息(是否需要数据库配置、所属层等)由任务注册表管理,而非硬编码在调度器中。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE TaskRegistry SHALL 支持在注册任务时附带元数据(`requires_db_config`、`layer`、`task_type`)
|
||||
2. WHEN TaskExecutor 需要判断任务是否为工具类任务时,THE TaskExecutor SHALL 查询 TaskRegistry 的元数据,而非检查硬编码集合
|
||||
3. WHEN PipelineRunner 需要根据层获取任务列表时,THE PipelineRunner SHALL 查询 TaskRegistry 的 `layer` 元数据
|
||||
4. THE TaskRegistry SHALL 保持向后兼容,无元数据的任务默认为 `requires_db_config=True`、`layer=None`
|
||||
|
||||
### 需求 5:配置键重构
|
||||
|
||||
**用户故事:** 作为运维人员,我希望配置键命名合理、语义清晰,以便正确配置 ETL 系统的运行参数。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE AppConfig SHALL 将 `app.timezone` 默认值从 `Asia/Shanghai` 改为 `Asia/Shanghai`
|
||||
2. THE AppConfig SHALL 将 `pipeline.flow` 配置键重命名为 `run.data_source`,并保留旧键作为兼容别名
|
||||
3. WHEN 配置中同时存在旧键 `pipeline.flow` 和新键 `run.data_source` 时,THE AppConfig SHALL 优先使用新键的值
|
||||
4. THE AppConfig SHALL 将 `pipeline.fetch_root` 和 `pipeline.ingest_source_dir` 移至 `io` 命名空间下(`io.fetch_root`、`io.ingest_source_dir`)
|
||||
|
||||
### 需求 6:资源管理与生命周期
|
||||
|
||||
**用户故事:** 作为开发者,我希望数据库连接和 API 客户端的创建与关闭由 CLI 层统一管理,以便确保资源正确释放。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CLI SHALL 在 `finally` 块中关闭数据库连接和 API 客户端,确保异常情况下资源也能释放
|
||||
2. THE TaskExecutor SHALL 通过依赖注入接收已创建的数据库连接和 API 客户端,而非自行创建
|
||||
3. THE PipelineRunner SHALL 通过依赖注入接收已创建的数据库连接和 API 客户端,而非自行创建
|
||||
4. WHEN CLI 创建资源时,THE CLI SHALL 使用 Python 上下文管理器(`with` 语句)或 `try/finally` 模式管理生命周期
|
||||
|
||||
### 需求 7:静态方法归位
|
||||
|
||||
**用户故事:** 作为开发者,我希望与调度器无关的静态工具方法移至合适的模块,以便保持类的职责单一。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE `_map_run_status` 方法 SHALL 从 ETLScheduler 移至 RunTracker 或独立的工具模块
|
||||
2. THE `_filter_verify_tables` 方法 SHALL 从 ETLScheduler 移至校验相关模块
|
||||
3. WHEN 静态方法被移动后,THE 原调用方 SHALL 更新导入路径以引用新位置
|
||||
|
||||
### 需求 8:向后兼容与过渡
|
||||
|
||||
**用户故事:** 作为运维人员,我希望重构后的系统在过渡期内兼容旧的 CLI 参数和配置键,以便平滑迁移。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户使用旧参数 `--pipeline-flow FULL` 时,THE CLI SHALL 将其等价映射为 `--data-source hybrid` 并发出弃用警告
|
||||
2. WHEN 用户使用旧参数 `--pipeline-flow FETCH_ONLY` 时,THE CLI SHALL 将其等价映射为 `--data-source online` 并发出弃用警告
|
||||
3. WHEN 用户使用旧参数 `--pipeline-flow INGEST_ONLY` 时,THE CLI SHALL 将其等价映射为 `--data-source offline` 并发出弃用警告
|
||||
4. WHEN 配置文件中使用旧键 `pipeline.flow` 时,THE AppConfig SHALL 自动映射到新键 `run.data_source`
|
||||
5. THE 系统 SHALL 在日志中记录所有弃用映射,便于运维人员逐步迁移
|
||||
|
||||
### 需求 9:可测试性
|
||||
|
||||
**用户故事:** 作为开发者,我希望重构后的每一层都可以独立进行单元测试,以便快速验证逻辑正确性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE TaskExecutor SHALL 支持通过注入 mock 依赖(FakeDB、FakeAPI)进行单元测试,无需真实数据库
|
||||
2. THE PipelineRunner SHALL 支持通过注入 mock TaskExecutor 进行单元测试,无需执行真实任务
|
||||
3. THE TaskRegistry SHALL 支持在测试中创建独立实例,不依赖全局 `default_registry`
|
||||
4. WHEN 运行单元测试时,THE 测试 SHALL 验证各层之间的交互契约(调用参数、返回值格式)
|
||||
@@ -1,147 +0,0 @@
|
||||
# 实现计划:ETL 调度器重构
|
||||
|
||||
## 概述
|
||||
|
||||
将 `ETLScheduler`(~900 行)拆分为 TaskExecutor(执行层)、PipelineRunner(编排层)、增强版 TaskRegistry(元数据),重构 CLI 参数和配置键,保持向后兼容。采用自底向上的实现顺序:先基础组件,再上层编排,最后 CLI 集成。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 增强 TaskRegistry,支持元数据注册与查询
|
||||
- [x] 1.1 扩展 TaskRegistry 类,添加 TaskMeta 数据类和元数据相关方法
|
||||
- 在 `orchestration/task_registry.py` 中添加 `TaskMeta` dataclass(`task_class`、`requires_db_config`、`layer`、`task_type`)
|
||||
- 修改 `register()` 方法签名,增加可选的 `requires_db_config`、`layer`、`task_type` 参数
|
||||
- 添加 `get_metadata()`、`get_tasks_by_layer()`、`is_utility_task()` 方法
|
||||
- 保持 `create_task()` 和 `get_all_task_codes()` 接口不变
|
||||
- _需求: 4.1, 4.4_
|
||||
|
||||
- [x] 1.2 更新所有任务注册调用,添加元数据
|
||||
- 将原 `NO_DB_CONFIG_TASKS` 硬编码集合中的任务标记为 `requires_db_config=False`
|
||||
- 为 ODS 任务添加 `layer="ODS"`,DWD 任务添加 `layer="DWD"`,DWS 任务添加 `layer="DWS"`,INDEX 任务添加 `layer="INDEX"`
|
||||
- 工具类任务标记 `task_type="utility"`,校验类任务标记 `task_type="verification"`
|
||||
- _需求: 4.1, 4.2, 4.3_
|
||||
|
||||
- [x] 1.3 编写 TaskRegistry 属性测试
|
||||
- **Property 8: TaskRegistry 元数据 round-trip**
|
||||
- **验证: 需求 4.1**
|
||||
|
||||
- [x] 1.4 编写 TaskRegistry 向后兼容和按层查询属性测试
|
||||
- **Property 9: TaskRegistry 向后兼容默认值**
|
||||
- **Property 10: 按层查询任务**
|
||||
- **验证: 需求 4.4, 4.3**
|
||||
|
||||
- [x] 2. 配置键重构与向后兼容
|
||||
- [x] 2.1 修改 `config/defaults.py` 默认值
|
||||
- 将 `app.timezone` 默认值从 `Asia/Shanghai` 改为 `Asia/Shanghai`
|
||||
- 将 `db.session.timezone` 默认值从 `Asia/Shanghai` 改为 `Asia/Shanghai`
|
||||
- 添加 `run.data_source` 键(默认 `hybrid`)
|
||||
- 将 `pipeline.fetch_root` 和 `pipeline.ingest_source_dir` 复制到 `io.fetch_root` 和 `io.ingest_source_dir`(保留旧键兼容)
|
||||
- _需求: 5.1, 5.2, 5.4_
|
||||
|
||||
- [x] 2.2 在 `config/settings.py` 的 `_normalize()` 中添加兼容映射逻辑
|
||||
- 旧键 `pipeline.flow` → 新键 `run.data_source`(值映射:FULL→hybrid, FETCH_ONLY→online, INGEST_ONLY→offline)
|
||||
- 旧键 `pipeline.fetch_root` → `io.fetch_root`,`pipeline.ingest_source_dir` → `io.ingest_source_dir`
|
||||
- 新键优先:当新旧键同时存在时,使用新键的值
|
||||
- 记录弃用警告日志
|
||||
- _需求: 5.2, 5.3, 5.4, 8.4, 8.5_
|
||||
|
||||
- [x] 2.3 编写配置映射属性测试
|
||||
- **Property 11: pipeline_flow → data_source 映射一致性**
|
||||
- **验证: 需求 8.1, 8.2, 8.3, 5.2, 8.4**
|
||||
|
||||
- [x] 3. 静态方法归位
|
||||
- [x] 3.1 将 `_map_run_status` 移至 RunTracker
|
||||
- 在 `orchestration/run_tracker.py` 中添加 `map_run_status()` 静态方法(从 `ETLScheduler._map_run_status` 复制)
|
||||
- _需求: 7.1_
|
||||
|
||||
- [x] 3.2 将 `_filter_verify_tables` 移至校验模块
|
||||
- 在 `tasks/verification/` 下合适的模块中添加 `filter_verify_tables()` 函数
|
||||
- _需求: 7.2_
|
||||
|
||||
- [x] 4. 检查点 — 确保所有测试通过
|
||||
- 运行 `pytest tests/unit`,确保所有测试通过,如有问题请询问用户。
|
||||
|
||||
- [x] 5. 实现 TaskExecutor(执行层)
|
||||
- [x] 5.1 创建 `orchestration/task_executor.py`
|
||||
- 实现 `TaskExecutor` 类,构造函数接收 `config`、`db_ops`、`api_client`、`cursor_mgr`、`run_tracker`、`task_registry`、`logger`
|
||||
- 从 `ETLScheduler` 迁移以下方法:`run_tasks`、`_run_single_task`、`_execute_fetch`、`_execute_ingest`、`_execute_ods_record_and_load`、`_run_utility_task`、`_build_fetch_dir`、`_resolve_ingest_source`、`_counts_from_fetch`、`_load_task_config`、`_maybe_run_integrity_check`、`_attach_run_file_logger`
|
||||
- 将 `data_source` 改为方法参数(替代原 `self.pipeline_flow` 全局状态)
|
||||
- 使用 `self.task_registry.is_utility_task()` 替代硬编码的 `NO_DB_CONFIG_TASKS`
|
||||
- 使用 `RunTracker.map_run_status()` 替代 `self._map_run_status()`
|
||||
- 添加 `DataSource` 枚举类(`online`/`offline`/`hybrid`)
|
||||
- _需求: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
|
||||
|
||||
- [x] 5.2 编写 TaskExecutor 属性测试
|
||||
- **Property 1: data_source 参数决定执行路径**
|
||||
- **Property 2: 成功任务推进游标**
|
||||
- **Property 3: 失败任务标记 FAIL 并重新抛出**
|
||||
- **Property 4: 工具类任务由元数据决定**
|
||||
- **验证: 需求 1.2, 1.3, 1.4, 1.6, 4.2**
|
||||
|
||||
- [x] 6. 实现 PipelineRunner(编排层)
|
||||
- [x] 6.1 创建 `orchestration/pipeline_runner.py`
|
||||
- 实现 `PipelineRunner` 类,构造函数接收 `config`、`task_executor`、`task_registry`、`db_conn`、`api_client`、`logger`
|
||||
- 将 `PIPELINE_LAYERS` 常量从 `scheduler.py` 迁移至此
|
||||
- 从 `ETLScheduler` 迁移以下方法:`run_pipeline_with_verification`(重命名为 `run`)、`_run_layer_verification`(重命名为 `_run_verification`)、`_get_tasks_for_layers`(重命名为 `_resolve_tasks`)
|
||||
- 使用 `filter_verify_tables()`(已移至校验模块)替代原内联静态方法
|
||||
- 使用 `task_registry.get_tasks_by_layer()` 作为默认任务解析,配置覆盖优先
|
||||
- _需求: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_
|
||||
|
||||
- [x] 6.2 编写 PipelineRunner 属性测试
|
||||
- **Property 5: 管道名称→层列表映射**
|
||||
- **Property 6: processing_mode 控制执行流程**
|
||||
- **Property 7: 管道结果汇总完整性**
|
||||
- **验证: 需求 2.1, 2.3, 2.4, 2.6**
|
||||
|
||||
- [x] 7. 检查点 — 确保所有测试通过
|
||||
- 运行 `pytest tests/unit`,确保所有测试通过,如有问题请询问用户。
|
||||
|
||||
- [x] 8. 重构 CLI 层
|
||||
- [x] 8.1 重构 `cli/main.py` 参数解析
|
||||
- 添加 `--data-source` 参数(choices: online/offline/hybrid,默认 hybrid)
|
||||
- 保留 `--pipeline-flow` 作为弃用别名,使用时发出 `DeprecationWarning` 并映射到 `--data-source`
|
||||
- 更新 `build_cli_overrides()` 将 `--data-source` 写入 `run.data_source` 配置键
|
||||
- _需求: 3.1, 3.5, 8.1, 8.2, 8.3_
|
||||
|
||||
- [x] 8.2 重构 `cli/main.py` 的 `main()` 函数
|
||||
- 在 `try/finally` 块中管理 `DatabaseConnection` 和 `APIClient` 的生命周期
|
||||
- 在 `try` 块内组装 `TaskExecutor` 和 `PipelineRunner`(依赖注入)
|
||||
- 管道模式委托 `PipelineRunner.run()`,传统模式委托 `TaskExecutor.run_tasks()`
|
||||
- 添加 `resolve_data_source(args)` 辅助函数处理新旧参数映射
|
||||
- _需求: 3.2, 3.3, 3.4, 3.6, 6.1, 6.4_
|
||||
|
||||
- [x] 8.3 编写 CLI 参数解析单元测试
|
||||
- 测试 `--data-source` 新参数正确解析
|
||||
- 测试 `--pipeline-flow` 旧参数弃用映射
|
||||
- 测试 `--pipeline` + `--tasks` 同时使用时的行为
|
||||
- _需求: 3.1, 3.3, 3.5_
|
||||
|
||||
- [x] 9. 清理旧代码与集成
|
||||
- [x] 9.1 重构 `orchestration/scheduler.py` 为薄包装层
|
||||
- 将 `ETLScheduler` 改为薄包装,内部委托 `TaskExecutor` 和 `PipelineRunner`
|
||||
- 保留 `ETLScheduler` 类名和 `run_tasks()`、`run_pipeline_with_verification()`、`close()` 公共接口,标记为弃用
|
||||
- 确保 GUI 层(`gui/workers/`)等现有调用方无需立即修改
|
||||
- _需求: 8.1, 8.4_
|
||||
|
||||
- [x] 9.2 更新 GUI 工作线程中的调度器引用
|
||||
- 检查 `gui/workers/` 中对 `ETLScheduler` 的使用
|
||||
- 如有直接引用内部方法,更新为使用新的公共接口
|
||||
- _需求: 7.3_
|
||||
|
||||
- [x] 9.3 编写集成测试验证端到端流程
|
||||
- 使用 FakeDB/FakeAPI 验证 CLI → PipelineRunner → TaskExecutor 完整调用链
|
||||
- 验证传统模式和管道模式均正常工作
|
||||
- _需求: 9.4_
|
||||
|
||||
- [x] 10. 最终检查点 — 确保所有测试通过
|
||||
- 运行 `pytest tests/unit`,确保所有测试通过,如有问题请询问用户。
|
||||
|
||||
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选测试任务,可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号,确保可追溯性
|
||||
- 检查点确保增量验证,避免问题累积
|
||||
- 属性测试使用 `hypothesis` 库,验证通用正确性属性
|
||||
- 单元测试验证具体示例和边界条件
|
||||
- `ETLScheduler` 保留为薄包装层,确保 GUI 等现有调用方平滑过渡
|
||||
@@ -1,22 +1,32 @@
|
||||
---
|
||||
inclusion: always
|
||||
---
|
||||
|
||||
# 产品概述
|
||||
|
||||
飞球 ETL 系统 (etl-billiards) — 面向台球门店业务的数据仓库 ETL 管线。
|
||||
NeoZQYY Monorepo — 面向台球门店业务的全栈数据平台,包含 ETL Connector、后端 API、管理后台、微信小程序。
|
||||
|
||||
## 功能
|
||||
## 子系统
|
||||
- ETL Connector:从上游 SaaS API 抽取运营数据,经 ODS → DWD → DWS 三层处理
|
||||
- FastAPI 后端:业务 API 服务
|
||||
- 微信小程序:C 端用户界面
|
||||
- 管理后台(`apps/admin-web/`):任务管理、调度配置、数据查看、ETL 状态监控(已替代原 PySide6 桌面 GUI)
|
||||
- 共享包:枚举、金额精度、时间工具
|
||||
|
||||
> 各子系统路径见 `structure-lite.md`
|
||||
|
||||
## ETL 功能
|
||||
- 从上游 SaaS API 抽取运营数据(订单、支付、会员、助教、库存等)
|
||||
- 原始数据落地 **ODS**(操作数据存储层),保留源 payload 便于回溯
|
||||
- 清洗装载至 **DWD**(明细数据层),维度走 SCD2,事实按时间增量
|
||||
- 汇总至 **DWS**(数据服务层):助教业绩、财务日报、会员分析、工资计算、自定义指数算法(WBI/NCI/RS/OS/MS/ML)
|
||||
- 提供 **PySide6 桌面 GUI**,支持任务管理、调度配置
|
||||
- 原始数据落地 ODS,保留源 payload 便于回溯
|
||||
- 清洗装载至 DWD,维度走 SCD2,事实按时间增量
|
||||
- 汇总至 DWS:助教业绩、财务日报、会员分析、工资计算、自定义指数算法(WBI/NCI/RS/OS/MS/ML)
|
||||
- 支持在线(API 抓取)和离线(JSON 回放)两种模式
|
||||
|
||||
## 业务上下文
|
||||
- 单租户:一家台球门店(由 `STORE_ID` 标识)
|
||||
- 核心实体:会员(客户)、助教(教练)、台桌、订单、支付、退款、团购套餐、库存
|
||||
- 多门店隔离:通过 `site_id` + RLS 实现
|
||||
- 核心实体:会员、助教、台桌、订单、支付、退款、团购套餐、库存
|
||||
- 领域语言以中文为主;代码注释、文档、UI 文案均为中文
|
||||
- 货币:人民币(CNY),金额以 numeric(2) 存储
|
||||
|
||||
## 主要入口
|
||||
- CLI:`python -m cli.main`(主入口)
|
||||
- GUI:`python -m gui.main`
|
||||
- 批处理脚本:`run_etl.bat`、`run_gui.bat`(根目录)、`scripts/run_ods.bat`
|
||||
详见 `tech.md` 常用命令节。
|
||||
|
||||
@@ -4,30 +4,30 @@ inclusion: always
|
||||
|
||||
# 项目结构(Lite)
|
||||
|
||||
目标:在不注入大段目录树的前提下,让 Agent 快速理解“模块边界 + 高风险区”。
|
||||
> 详细目录树、架构模式、文件归属规则展开说明见 `structure.md`(读到 pyproject.toml 或 agent 定义时自动加载,也可 `#structure-full` 手动加载)。
|
||||
|
||||
## 关键模块边界(高风险路径 = 变更默认需要审计)
|
||||
- `cli/`:命令行入口与参数/运行模式(影响一键增量、调度参数等)
|
||||
- `config/`:默认值、环境变量解析、AppConfig、调度任务配置(影响运行时假设)
|
||||
- `api/`:外部接口客户端与端点路由(影响抓取/契约/回放)
|
||||
- `database/`:连接、DDL/schema、seed、migrations(影响数据结构与回滚)
|
||||
- `tasks/`:ETL 任务(ODS/DWD/DWS/指数/校验),业务规则主要落在这里
|
||||
- `loaders/`:upsert 与维度/事实装载(影响落库与冲突处理)
|
||||
- `scd/`:SCD2 处理(影响维度历史与生效区间)
|
||||
- `orchestration/`:调度/注册/游标/运行记录(影响增量水位与可重复性)
|
||||
- `models/`:解析与验证器(影响字段校验与转换)
|
||||
- `utils/`:日志、JSON 存储、窗口切分等通用工具(影响全局行为)
|
||||
- 根目录散文件:`.env*`、`pyproject.toml`、`requirements*`、`Makefile`、`README.md` 等(影响运行/依赖/发布)
|
||||
## 顶层目录
|
||||
- `apps/etl/connectors/feiqiu/` — 飞球 Connector
|
||||
- `apps/backend/` — FastAPI 后端
|
||||
- `apps/miniprogram/` — 微信小程序
|
||||
- `apps/admin-web/` — 管理后台(React + Vite + Ant Design)
|
||||
- `packages/shared/` — 跨项目共享包
|
||||
- `db/` — DDL / 迁移 / 种子(`etl_feiqiu/`、`zqyy_app/`、`fdw/`)
|
||||
- `docs/` — 项目级文档 + `audit/`(统一审计落地点)
|
||||
- `tests/` — Monorepo 级属性测试
|
||||
- `scripts/` — 项目级运维脚本
|
||||
|
||||
## 架构要点(摘要)
|
||||
- 任务模式:每个 ETL 任务继承 `BaseTask`(Extract → Transform → Load),并在 `orchestration/task_registry.py` 注册
|
||||
- 加载器模式:每张目标表一个 Loader,维度/事实分目录;核心是 `upsert()` 与冲突处理策略
|
||||
- 配置分层:DEFAULTS → `.env` → CLI 覆盖;通过 `AppConfig.get("dotted.path")` 访问
|
||||
- 管线流程:`FULL` / `FETCH_ONLY` / `INGEST_ONLY` 由 CLI 或环境变量控制
|
||||
- 调度器:负责游标(水位)与运行记录(增量正确性关键)
|
||||
## 高风险路径(变更需审计)
|
||||
- `apps/etl/connectors/feiqiu/` 下:`api/`、`cli/`、`config/`、`database/`、`loaders/`、`models/`、`orchestration/`、`scd/`、`tasks/`、`utils/`、`quality/`
|
||||
- `apps/backend/app/`、`apps/admin-web/src/`、`apps/miniprogram/miniapp/`、`apps/miniprogram/miniprogram/`
|
||||
- `packages/shared/`、`db/`、根目录散文件(`.env*`、`pyproject.toml`)
|
||||
|
||||
## 编码/命名约定(摘要)
|
||||
- 文件编码:UTF-8
|
||||
- SQL:纯 SQL(非 ORM);迁移脚本放 `database/migrations/`,推荐“日期前缀”命名
|
||||
- 任务:大写蛇形命名(例如 `DWD_LOAD_FROM_ODS`)
|
||||
- 日志:统一经由 `utils/logging_utils.py`
|
||||
## 文件归属规则(强制)
|
||||
- 模块专属的 docs/tests/scripts → 放模块内部
|
||||
- 项目级/跨模块的 docs/tests/scripts → 放根目录
|
||||
- 审计产物统一写 `docs/audit/`,禁止写入子模块内部
|
||||
- 一览表刷新:`python scripts/audit/gen_audit_dashboard.py`
|
||||
|
||||
## 编码/命名约定
|
||||
- UTF-8、纯 SQL(非 ORM)、迁移脚本 `db/etl_feiqiu/migrations/`(日期前缀)
|
||||
- 任务大写蛇形(`DWD_LOAD_FROM_ODS`)、日志经 `utils/logging_utils.py`
|
||||
|
||||
@@ -1,124 +1,112 @@
|
||||
---
|
||||
inclusion: auto
|
||||
inclusion: fileMatch
|
||||
fileMatchPattern: "pyproject.toml,**/pyproject.toml,.kiro/steering/structure-lite.md,.kiro/agents/**"
|
||||
name: structure-full
|
||||
description: Full directory tree + architecture patterns. Load only for large refactors, module moves, or changes spanning multiple subsystems.
|
||||
description: 完整目录树 + 架构模式 + 文件归属规则展开。读到项目配置或 steering/agent 定义时自动加载。
|
||||
---
|
||||
|
||||
# 项目结构
|
||||
# NeoZQYY Monorepo 完整结构
|
||||
|
||||
```
|
||||
NeoZQYY/ # Monorepo 工作区根目录(C:\NeoZQYY)
|
||||
├── cli/ # CLI 入口(main.py)
|
||||
├── config/ # 配置:默认值、环境变量解析、AppConfig、调度任务配置
|
||||
│ └── scheduled_tasks.json
|
||||
├── api/ # API 客户端(HTTP、本地 JSON 回放、录制)
|
||||
│ └── endpoint_routing.py # 端点路由映射
|
||||
├── database/ # 数据库连接、操作、DDL Schema、种子脚本、迁移
|
||||
│ ├── migrations/ # 迁移脚本(纯 SQL,日期前缀命名)
|
||||
│ ├── schema_*.sql # DDL 定义
|
||||
│ └── seed_*.sql # 种子数据
|
||||
├── tasks/ # ETL 任务实现(按数据层分目录)
|
||||
│ ├── base_task.py # BaseTask 基类,提供 Extract/Transform/Load 模板
|
||||
│ ├── ods/ # ODS 层抓取任务(16 个业务实体 + ods_tasks 工厂)
|
||||
│ ├── dwd/ # DWD 层装载任务(base_dwd_task、维度/事实装载、质量检查)
|
||||
│ ├── dws/ # DWS 汇总与指数任务
|
||||
│ │ └── index/ # 指数计算任务(亲密度、新客转化、召回、关系、赢回)
|
||||
│ ├── utility/ # 工具类任务(Schema 初始化、手动入库、完整性检查、DWS 构建等)
|
||||
│ └── verification/ # ETL 后置校验任务(ODS/DWD/DWS/指数校验器)
|
||||
├── loaders/ # 数据加载器(ODS、维度、事实)
|
||||
│ ├── base_loader.py # BaseLoader 基类,定义 upsert 接口
|
||||
│ ├── ods/ # 通用 ODS 加载器
|
||||
│ ├── dimensions/ # SCD2 维度加载器(会员、助教、商品、台桌、套餐)
|
||||
│ └── facts/ # 事实表加载器(订单、支付、退款、小票、充值等)
|
||||
├── scd/ # SCD2(缓慢变化维度)处理器
|
||||
├── orchestration/ # 调度器、任务注册表、游标管理、运行记录
|
||||
│ ├── pipeline_runner.py # 管线运行器
|
||||
│ ├── task_executor.py # 任务执行器
|
||||
│ ├── task_registry.py # 任务注册表
|
||||
│ ├── scheduler.py # ETL 调度器
|
||||
│ ├── cursor_manager.py # 游标(水位)管理
|
||||
│ └── run_tracker.py # 运行记录追踪
|
||||
├── quality/ # 数据质量检查器(余额一致性、完整性)
|
||||
│ └── integrity_service.py # 完整性检查服务
|
||||
├── models/ # 解析器与验证器
|
||||
├── utils/ # 工具函数:日志、JSON 存储、报告、窗口切分
|
||||
├── gui/ # PySide6 桌面 GUI
|
||||
│ ├── main_window.py
|
||||
│ ├── widgets/ # UI 面板与组件
|
||||
│ ├── workers/ # 后台工作线程
|
||||
│ ├── models/ # GUI 数据模型(任务、调度)
|
||||
│ ├── utils/ # GUI 专用工具(设置、CLI 构建器)
|
||||
│ └── resources/ # 样式表
|
||||
├── scripts/ # 运维/工具脚本
|
||||
│ ├── run_update.py # 一键增量更新入口(ODS → DWD → DWS)
|
||||
│ ├── run_ods.bat # ODS 批处理入口
|
||||
│ ├── audit/ # 仓库审计脚本(扫描器、分析器、报告生成)
|
||||
│ ├── check/ # 数据检查脚本(完整性、ODS 缺口、DWD 服务、内容哈希等)
|
||||
│ ├── db_admin/ # 数据库管理脚本(Excel 导入)
|
||||
│ ├── export/ # 数据导出脚本(指数、团购、亲密度、会员明细等)
|
||||
│ ├── rebuild/ # 数据重建脚本(全量 ODS→DWD 重建)
|
||||
│ └── repair/ # 数据修复脚本(回填、去重、hash 修复、维度修复、索引调优)
|
||||
├── tests/ # 测试套件
|
||||
│ ├── unit/ # 单元测试(FakeDB/FakeAPI,无需真实数据库)
|
||||
│ └── integration/ # 集成测试(需要 TEST_DB_DSN 或真实数据库)
|
||||
├── docs/ # 文档
|
||||
│ ├── CHANGELOG.md # 项目级版本变更历史
|
||||
│ ├── audit/ # 审计产物
|
||||
│ │ ├── changes/ # AI 逐次变更审计记录
|
||||
│ │ ├── repo/ # 仓库审计报告(自动生成)
|
||||
│ │ ├── prompt_logs/ # Prompt 日志(每次 prompt 一个独立文件,按时间戳命名)
|
||||
│ │ └── audit_dashboard.md # 审计一览表(/audit 自动刷新)
|
||||
│ ├── architecture/ # 架构设计文档(系统概览、数据流向)
|
||||
│ ├── business-rules/ # 业务规则文档(指数算法、DWS 口径、SCD2 规则)
|
||||
│ ├── operations/ # 运维文档(环境搭建、调度配置、故障排查)
|
||||
│ ├── database/ # 数据库文档统一目录(ODS/DWD/DWS/ETL_Admin 表手册 + 概览索引)
|
||||
│ │ ├── overview/ # 层级概览 / 速查索引
|
||||
│ │ ├── ODS/ # ODS 层表手册(main/mappings/changes)
|
||||
│ │ ├── DWD/ # DWD 层表手册(main + Ex 扩展)
|
||||
│ │ ├── DWS/ # DWS 层表手册
|
||||
│ │ └── ETL_Admin/ # ETL 管理层表手册
|
||||
│ ├── etl_tasks/ # ETL 任务文档
|
||||
│ ├── requirements/ # 需求文档(功能需求、口径补充、指数 PRD)
|
||||
│ ├── reports/ # 分析报告
|
||||
│ ├── api-reference/ # API 参考文档(标准化)
|
||||
│ │ ├── api_registry.json # API 注册表(25 个端点定义)
|
||||
│ │ ├── summary/ # 每个 API 一个精简版 .md(25 个)
|
||||
│ │ ├── endpoints/ # 每个 API 一个详细版 .md 文档(24 个)
|
||||
│ │ └── samples/ # 最新响应样本(JSON)
|
||||
├── reports/ # 质检输出(JSON,已 gitignore)
|
||||
├── export/ # JSON 落盘与日志(已 gitignore)
|
||||
├── logs/ # 运行日志(已 gitignore)
|
||||
└── .Deleted/ # 已归档/废弃文件(隐藏目录,已 gitignore)
|
||||
NeoZQYY/
|
||||
├── apps/
|
||||
│ ├── etl/connectors/feiqiu/ # 飞球 Connector(数据源连接器)
|
||||
│ │ ├── api/ # API 客户端(HTTP、本地 JSON 回放、录制)
|
||||
│ │ ├── cli/ # CLI 入口
|
||||
│ │ ├── config/ # 配置(默认值、环境变量、AppConfig、调度任务)
|
||||
│ │ ├── database/ # 数据库连接与操作(Python 模块)
|
||||
│ │ ├── tasks/ # ETL 任务(ods/dwd/dws/index/utility/verification)
|
||||
│ │ ├── loaders/ # 数据加载器(ods/dimensions/facts)
|
||||
│ │ ├── scd/ # SCD2 处理器
|
||||
│ │ ├── orchestration/ # 调度器、任务注册、游标、运行记录
|
||||
│ │ ├── quality/ # 数据质量检查
|
||||
│ │ ├── models/ # 解析器与验证器
|
||||
│ │ ├── utils/ # 工具函数(日志、JSON 存储、窗口切分)
|
||||
│ │ ├── docs/ # ETL 专属文档(api-reference、business-rules、etl_tasks 等)
|
||||
│ │ ├── tests/ # ETL 测试(unit/integration)
|
||||
│ │ ├── scripts/ # ETL 专属脚本(check/repair/rebuild/export/audit)
|
||||
│ │ └── pyproject.toml
|
||||
│ ├── backend/ # FastAPI 后端
|
||||
│ │ ├── app/ # main.py, config.py, database.py, routers/, middleware/, schemas/
|
||||
│ │ ├── tests/ # 后端测试
|
||||
│ │ └── pyproject.toml
|
||||
│ ├── miniprogram/ # 微信小程序
|
||||
│ │ ├── miniapp/ # 小程序源码(主包)
|
||||
│ │ ├── miniprogram/ # 小程序源码(分包)
|
||||
│ │ └── doc/ # 小程序文档
|
||||
│ └── admin-web/ # 管理后台
|
||||
│ ├── src/ # 前端源码(api/components/pages/store/types)
|
||||
│ └── src/__tests__/ # 前端测试
|
||||
├── packages/shared/ # 跨项目共享包(enums, money, datetime_utils)
|
||||
├── db/
|
||||
│ ├── etl_feiqiu/
|
||||
│ │ ├── schemas/ # 六层 Schema DDL(meta/ods/dwd/core/dws/app)
|
||||
│ │ ├── migrations/ # 迁移脚本(日期前缀)
|
||||
│ │ ├── seeds/ # 种子数据
|
||||
│ │ └── scripts/ # 测试数据库脚本
|
||||
│ ├── zqyy_app/schemas/ # 业务数据库 DDL
|
||||
│ └── fdw/ # FDW 跨库映射
|
||||
├── docs/ # 项目级文档
|
||||
│ ├── audit/ # 统一审计落地点
|
||||
│ │ ├── changes/ # 变更审计记录
|
||||
│ │ ├── prompt_logs/ # Prompt 日志
|
||||
│ │ └── audit_dashboard.md # 审计一览表(自动生成)
|
||||
│ ├── database/ # 全局数据库文档
|
||||
│ ├── architecture/ # 架构设计
|
||||
│ ├── prd/ # 产品需求
|
||||
│ ├── contracts/ # 数据契约
|
||||
│ └── ...
|
||||
├── tests/ # Monorepo 级属性测试(hypothesis)
|
||||
├── scripts/ # 项目级运维脚本
|
||||
│ ├── audit/ # 审计工具(gen_audit_dashboard.py)
|
||||
│ ├── ops/ # 日常运维(init_databases、clone_to_test_db 等)
|
||||
│ └── migrate/ # 一次性迁移脚本
|
||||
├── pyproject.toml # uv workspace 根配置
|
||||
├── .env.template
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 架构模式
|
||||
- 任务模式:继承 `BaseTask`(Extract → Transform → Load),在 `orchestration/task_registry.py` 注册
|
||||
- 加载器模式:每张目标表一个 Loader,`upsert()` + 冲突处理
|
||||
- 配置分层:DEFAULTS → `.env` → CLI 覆盖
|
||||
- Flow:通过 `--pipeline` 参数指定(如 `api_full`),旧 `--pipeline-flow` 已弃用
|
||||
- 多门店隔离:`site_id` + RLS(`app` schema 视图层)
|
||||
- 跨库访问:`zqyy_app` 通过 FDW 只读映射 `etl_feiqiu.app`
|
||||
|
||||
- **任务模式**:每个 ETL 任务继承 `BaseTask`(Extract → Transform → Load 模板方法),在 `orchestration/task_registry.py` 中注册。
|
||||
- **加载器模式**:每张目标表对应一个加载器,继承 `BaseLoader` 并实现 `upsert()` 方法。维度加载器在 `loaders/dimensions/`,事实加载器在 `loaders/facts/`。
|
||||
- **配置分层**:`DEFAULTS` 字典 → `.env` 覆盖 → CLI 参数覆盖。通过 `AppConfig.get("dotted.path")` 访问。
|
||||
- **管线流程**:`FULL`(抓取 + 入库)、`FETCH_ONLY`(仅抓取)、`INGEST_ONLY`(仅入库)。由 `--pipeline-flow` CLI 参数或 `PIPELINE_FLOW` 环境变量控制。
|
||||
- **调度器**:`ETLScheduler` 编排任务执行,管理游标(水位),在 `etl_admin` Schema 中记录运行状态。
|
||||
- **API 抽象**:`APIClient`(HTTP)、`LocalJsonClient`(离线回放)、`RecordingAPIClient`(抓取 + 落盘)共享相同接口,任务代码无需关心数据来源。
|
||||
## 文件归属规则(展开说明)
|
||||
|
||||
## 编码约定
|
||||
- 文件编码:UTF-8,文件头加 `# -*- coding: utf-8 -*-`
|
||||
- 日志格式:通过 `utils/logging_utils.py` 统一
|
||||
- 任务代码:大写蛇形命名(如 `DWD_LOAD_FROM_ODS`、`DWS_ASSISTANT_DAILY`)
|
||||
- SQL 文件:纯 SQL,不使用 ORM;通过 `psycopg2` 执行
|
||||
- 数据库操作:批量 upsert + 冲突处理,显式 commit/rollback
|
||||
- 中文注释和文档字符串是正常且预期的
|
||||
### 模块内部(各 APP / Connector 自治)
|
||||
每个子模块的以下目录属于模块专属,只放该模块自身的内容:
|
||||
- `docs/` — 模块专属文档(API 参考、业务规则、任务说明、运维指南等)
|
||||
- `tests/` — 模块专属测试(单元测试、集成测试)
|
||||
- `scripts/` — 模块专属脚本(数据检查、修复、导出等)
|
||||
|
||||
<!--
|
||||
AI_CHANGELOG:
|
||||
- 日期: 2026-02-13
|
||||
- Prompt: P20260213-171500 — "继续"(Task 3 API 文档全面重构续接)
|
||||
- 直接原因: 新增 docs/api-reference/ 目录替代旧 test-json-doc,需在项目结构文档中反映
|
||||
- 变更摘要: docs/ 树中新增 api-reference/(含 api_registry.json、endpoints/、samples/);test-json-doc 标记为 [已废弃]
|
||||
- 风险与验证: 纯文档结构描述变更,无运行时影响;验证方式:对比实际目录 `ls docs/api-reference/` 确认一致
|
||||
禁止将项目级内容放入模块内部目录,也禁止将模块专属内容放到根目录。
|
||||
|
||||
- 日期: 2026-02-14
|
||||
- Prompt: P20260214-130000 — 25 个 API 文档归档至 summary/ + 字段分组修正
|
||||
- 直接原因: 新增 summary/ 子目录存放精简版 API 文档,需在项目结构中反映
|
||||
- 变更摘要: api-reference/ 树中新增 summary/(25 个精简版 .md);endpoints/ 说明从"25 个"更正为"24 个"
|
||||
- 风险与验证: 纯文档结构描述变更,无运行时影响
|
||||
-->
|
||||
### 项目级(根目录统管)
|
||||
- `docs/` — 跨模块文档:架构设计、PRD、权限矩阵、数据契约、运维手册、路线图
|
||||
- `docs/audit/` — 统一审计落地点(所有模块的变更记录、Prompt 日志、审计一览表)
|
||||
- `docs/database/` — 全局数据库文档(跨模块共享的 DB 视角)
|
||||
- `tests/` — Monorepo 级属性测试(守护项目结构/约定/跨模块一致性)
|
||||
- `scripts/` — 项目级运维脚本(数据库初始化、迁移、审计工具等)
|
||||
|
||||
### 审计产物路径(硬约束)
|
||||
- 变更审计记录:`docs/audit/changes/<YYYY-MM-DD>__<slug>.md`
|
||||
- 审计一览表:`docs/audit/audit_dashboard.md`(自动生成,勿手动编辑)
|
||||
- Prompt 日志:`docs/audit/prompt_logs/`
|
||||
- 一览表生成脚本:`scripts/audit/gen_audit_dashboard.py`
|
||||
- 禁止将审计产物写入子模块内部(如 `apps/etl/connectors/feiqiu/docs/audit/`)
|
||||
|
||||
### 速查表
|
||||
|
||||
| 判断标准 | 放置位置 |
|
||||
|----------|----------|
|
||||
| 只有本模块开发者需要看的文档 | 模块内 `docs/` |
|
||||
| 跨模块对照或全局视角的文档 | 根 `docs/` |
|
||||
| 只验证本模块逻辑的测试 | 模块内 `tests/` |
|
||||
| 守护 monorepo 结构/约定的测试 | 根 `tests/` |
|
||||
| 只操作本模块数据的脚本 | 模块内 `scripts/` |
|
||||
| 运维/全局工具脚本 | 根 `scripts/` |
|
||||
| 审计记录(任何模块的变更) | 根 `docs/audit/` |
|
||||
| 数据库文档(全局 schema 视角) | 根 `docs/database/` |
|
||||
|
||||
@@ -1,60 +1,62 @@
|
||||
---
|
||||
inclusion: always
|
||||
---
|
||||
|
||||
# 技术栈与构建
|
||||
|
||||
## 语言与运行时
|
||||
- Python 3.10+(测试缓存中观察到 3.13)
|
||||
- 未提交虚拟环境;用户自行管理
|
||||
- Python 3.10+
|
||||
- uv workspace 统一依赖管理(根 `pyproject.toml` 声明 3 个 workspace 成员)
|
||||
|
||||
## 核心依赖(requirements.txt)
|
||||
- `psycopg2-binary>=2.9.0` — PostgreSQL 驱动
|
||||
- `requests>=2.28.0` — 上游 API 的 HTTP 客户端
|
||||
- `python-dateutil>=2.8.0` / `tzdata>=2023.0` — 日期解析与时区处理
|
||||
- `python-dotenv` — `.env` 文件加载
|
||||
- `openpyxl>=3.1.0` — Excel 导入导出(DWS 数据)
|
||||
- `PySide6>=6.5.0` — Qt 桌面 GUI 框架
|
||||
- `flask>=2.3` — 可选 Web API
|
||||
- `pyinstaller>=6.0.0` — 可选,仅打包 EXE 时需要
|
||||
## 核心依赖
|
||||
- ETL:`psycopg2-binary`、`requests`、`python-dateutil`、`tzdata`、`python-dotenv`、`openpyxl`
|
||||
- 后端:`fastapi`、`uvicorn[standard]`、`psycopg2-binary`、`python-dotenv`
|
||||
- 管理后台:`React`、`Vite`、`Ant Design`(`apps/admin-web/`,独立 pnpm 管理)
|
||||
- 共享包:`neozqyy-shared`(workspace 内部引用)
|
||||
- 测试:`pytest`、`hypothesis`
|
||||
|
||||
## 数据库
|
||||
- PostgreSQL(连接远程实例)
|
||||
- Schema:`billiards_ods`(ODS 原始数据)、`billiards_dwd`(明细数据)、`billiards_dws`(汇总数据)、`etl_admin`(调度/运行记录)
|
||||
- DDL 文件位于 `database/schema_*.sql`,种子脚本位于 `database/seed_*.sql`
|
||||
- 迁移脚本位于 `database/migrations/`(纯 SQL,日期前缀命名)
|
||||
- PostgreSQL(远程实例)
|
||||
- 六层 Schema 架构:`meta`(调度元数据)、`ods`(原始数据)、`dwd`(明细数据)、`core`(跨门店标准化)、`dws`(汇总数据)、`app`(RLS 视图层)
|
||||
- 业务数据库:`zqyy_app`(用户/RBAC/任务/审批),通过 FDW 只读映射 ETL 数据
|
||||
- DDL 文件位于 `db/etl_feiqiu/schemas/`,迁移脚本位于 `db/etl_feiqiu/migrations/`
|
||||
- 种子数据位于 `db/etl_feiqiu/seeds/`
|
||||
|
||||
## 测试
|
||||
- 框架:`pytest`(未固定在 requirements 中,需单独安装)
|
||||
- 配置:`pytest.ini` 设置 `pythonpath = .`
|
||||
- 结构:`tests/unit/`(基于 mock,无需数据库)、`tests/integration/`(需要 `TEST_DB_DSN`)
|
||||
- 测试工具:`tests/unit/task_test_utils.py` 提供 FakeDB/FakeAPI 辅助类
|
||||
- ETL 单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit`
|
||||
- ETL 集成测试:`TEST_DB_DSN="..." pytest tests/integration`
|
||||
- Monorepo 属性测试:`pytest tests/ -v`(根目录,hypothesis)
|
||||
- 测试工具:`apps/etl/connectors/feiqiu/tests/unit/task_test_utils.py` 提供 FakeDB/FakeAPI
|
||||
|
||||
## 常用命令
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
uv sync
|
||||
|
||||
# 在线全流程 ETL(抓取 + 入库)
|
||||
python -m cli.main --pg-dsn "$PG_DSN" --store-id "$STORE_ID" --api-token "$API_TOKEN"
|
||||
|
||||
# 运行指定任务
|
||||
python -m cli.main --tasks INIT_ODS_SCHEMA,MANUAL_INGEST --data-source offline
|
||||
|
||||
# 试运行(不写库)
|
||||
# ETL 开发
|
||||
cd apps/etl/connectors/feiqiu
|
||||
python -m cli.main --dry-run --tasks DWD_LOAD_FROM_ODS
|
||||
|
||||
# 单元测试
|
||||
pytest tests/unit
|
||||
# 后端开发
|
||||
cd apps/backend
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# 集成测试(需要数据库)
|
||||
TEST_DB_DSN="postgresql://..." pytest tests/integration
|
||||
# ETL 单元测试
|
||||
cd apps/etl/connectors/feiqiu && pytest tests/unit
|
||||
|
||||
# 启动 GUI
|
||||
python -m gui.main
|
||||
# 属性测试
|
||||
cd C:\NeoZQYY && pytest tests/ -v
|
||||
```
|
||||
|
||||
## 配置体系
|
||||
- 分层叠加:`config/defaults.py` < `.env` / 环境变量 < CLI 参数
|
||||
- 配置类:`config.settings.AppConfig`,支持点号路径访问(`config.get("db.dsn")`)
|
||||
- 敏感值(DSN、API Token)放在 `.env` 中,禁止提交
|
||||
- 分层叠加:根 `.env` < 应用 `.env.local` < 环境变量 < CLI 参数
|
||||
- ETL 配置类:`apps/etl/connectors/feiqiu/config/settings.py` → `AppConfig`
|
||||
- 敏感值放在 `.env` / `.env.local` 中,禁止提交;`.env.template` 提供模板
|
||||
|
||||
## 打包
|
||||
- 已移除 EXE 打包支持(`build_exe.py`、`setup.py` 已归档至 `.Deleted/`)
|
||||
- 直接通过 `python -m cli.main` 或 `python -m gui.main` 运行
|
||||
## 脚本执行规范
|
||||
- 需要执行多步操作、文件处理、数据库操作等脚本级任务时,优先编写 Python 脚本(`.py`)再通过 `python script.py` 执行
|
||||
- 避免直接使用 PowerShell 编写复杂逻辑,防止转义符、编码、管道等语法陷阱
|
||||
- 以下情况可以直接用 shell 命令:
|
||||
- 用户明确指定使用 PowerShell / CMD
|
||||
- 操作本身是单条简单命令(如 `pytest`、`uv sync`、`git status`)
|
||||
- Python 脚本放置遵循"两层分治"原则:一次性运维脚本放 `scripts/ops/`,模块专属脚本放模块内 `scripts/`的合理目录下
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
"settings": {
|
||||
}
|
||||
}
|
||||
13
README.md
13
README.md
@@ -1,16 +1,15 @@
|
||||
# NeoZQYY Monorepo
|
||||
|
||||
台球门店运营助手一体化平台,整合 ETL 数据管线、微信小程序后端、小程序前端、管理后台与桌面 GUI。
|
||||
台球门店运营助手一体化平台,整合 ETL 数据 Connector、微信小程序后端、小程序前端与管理后台。
|
||||
|
||||
## 项目结构
|
||||
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| apps/etl/pipelines/feiqiu/ | ETL 数据管线(飞球平台) |
|
||||
| apps/etl/connectors/feiqiu/ | 飞球 Connector(数据源连接器) |
|
||||
| apps/backend/ | FastAPI 后端(小程序 API) |
|
||||
| apps/miniprogram/ | 微信小程序(Donut + TDesign) |
|
||||
| apps/admin-web/ | 管理后台(规划中) |
|
||||
| gui/ | PySide6 桌面 GUI(过渡期) |
|
||||
| apps/admin-web/ | 管理后台(React + Vite + Ant Design) |
|
||||
| packages/shared/ | 跨项目共享包(枚举、金额精度、时间工具) |
|
||||
| db/ | 数据库 DDL、迁移、种子脚本 |
|
||||
| docs/ | 文档(PRD、契约、权限矩阵、架构等) |
|
||||
@@ -26,7 +25,7 @@
|
||||
uv sync
|
||||
|
||||
# 运行 ETL
|
||||
cd apps/etl/pipelines/feiqiu
|
||||
cd apps/etl/connectors/feiqiu
|
||||
python -m cli.main --pg-dsn "$PG_DSN" --store-id "$STORE_ID" --api-token "$API_TOKEN"
|
||||
|
||||
# 启动后端 API
|
||||
@@ -34,7 +33,7 @@ cd apps/backend
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# 运行 ETL 单元测试
|
||||
cd apps/etl/pipelines/feiqiu
|
||||
cd apps/etl/connectors/feiqiu
|
||||
pytest tests/unit
|
||||
```
|
||||
|
||||
@@ -49,5 +48,5 @@ pytest tests/unit
|
||||
- Python 3.10+, uv workspace
|
||||
- PostgreSQL(六层 Schema:meta/ods/dwd/core/dws/app)
|
||||
- FastAPI + uvicorn
|
||||
- PySide6(桌面 GUI)
|
||||
- React + Vite + Ant Design(管理后台)
|
||||
- Donut + TDesign(微信小程序)
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
## 作用说明
|
||||
|
||||
应用项目顶层目录,存放所有可独立部署/运行的子项目。当前包含 ETL 数据管线、FastAPI 后端、微信小程序前端,以及预留的管理后台。
|
||||
应用项目顶层目录,存放所有可独立部署/运行的子项目。当前包含 ETL Connector、FastAPI 后端、微信小程序前端,以及预留的管理后台。
|
||||
|
||||
## 内部结构
|
||||
|
||||
- `etl/pipelines/feiqiu/` — 飞球平台 ETL 管线(抽取→清洗→汇总全流程)
|
||||
- `etl/pipelines/feiqiu/` — 飞球 Connector(数据源连接器,抽取→清洗→汇总全流程)
|
||||
- `backend/` — FastAPI 后端(小程序 API、权限、审批)
|
||||
- `miniprogram/` — 微信小程序前端(Donut + TDesign)
|
||||
- `admin-web/` — 管理后台(预留,暂未实施)
|
||||
|
||||
## Roadmap
|
||||
|
||||
- 新增更多数据源管线时,在 `etl/pipelines/` 下按平台名创建子目录
|
||||
- 新增更多 Connector 时,在 `etl/pipelines/` 下按平台名创建子目录
|
||||
- `admin-web/` 待产品需求确认后启动
|
||||
|
||||
13
apps/admin-web/index.html
Normal file
13
apps/admin-web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NeoZQYY 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
apps/admin-web/package.json
Normal file
35
apps/admin-web/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "admin-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"antd": "^5.24.7",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.1",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.1.4"
|
||||
}
|
||||
}
|
||||
2851
apps/admin-web/pnpm-lock.yaml
generated
Normal file
2851
apps/admin-web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
196
apps/admin-web/src/App.tsx
Normal file
196
apps/admin-web/src/App.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 主布局与路由配置。
|
||||
*
|
||||
* - Ant Design Layout:Sider + Content + Footer(状态栏)
|
||||
* - react-router-dom:6 个功能页面路由 + 登录页路由
|
||||
* - 路由守卫:未登录重定向到登录页
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Routes, Route, Navigate, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Layout, Menu, Spin, Space, Typography, Tag, Button, Tooltip } from "antd";
|
||||
import {
|
||||
SettingOutlined,
|
||||
UnorderedListOutlined,
|
||||
ToolOutlined,
|
||||
DatabaseOutlined,
|
||||
DashboardOutlined,
|
||||
FileTextOutlined,
|
||||
LogoutOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { useAuthStore } from "./store/authStore";
|
||||
import { fetchQueue } from "./api/execution";
|
||||
import type { QueuedTask } from "./types";
|
||||
import Login from "./pages/Login";
|
||||
import TaskConfig from "./pages/TaskConfig";
|
||||
import TaskManager from "./pages/TaskManager";
|
||||
import EnvConfig from "./pages/EnvConfig";
|
||||
import DBViewer from "./pages/DBViewer";
|
||||
import ETLStatus from "./pages/ETLStatus";
|
||||
import LogViewer from "./pages/LogViewer";
|
||||
|
||||
const { Sider, Content, Footer } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 侧边栏导航配置 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const NAV_ITEMS: MenuProps["items"] = [
|
||||
{ key: "/", icon: <SettingOutlined />, label: "任务配置" },
|
||||
{ key: "/task-manager", icon: <UnorderedListOutlined />, label: "任务管理" },
|
||||
{ key: "/etl-status", icon: <DashboardOutlined />, label: "ETL 状态" },
|
||||
{ key: "/db-viewer", icon: <DatabaseOutlined />, label: "数据库" },
|
||||
{ key: "/log-viewer", icon: <FileTextOutlined />, label: "日志" },
|
||||
{ key: "/env-config", icon: <ToolOutlined />, label: "环境配置" },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 路由守卫 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 主布局 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const AppLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
const [runningTask, setRunningTask] = useState<QueuedTask | null>(null);
|
||||
|
||||
const pollQueue = useCallback(async () => {
|
||||
try {
|
||||
const queue = await fetchQueue();
|
||||
const running = queue.find((t) => t.status === "running") ?? null;
|
||||
setRunningTask(running);
|
||||
} catch {
|
||||
// 网络异常时不更新状态
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
pollQueue();
|
||||
const timer = setInterval(pollQueue, 5_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [pollQueue]);
|
||||
|
||||
const onMenuClick: MenuProps["onClick"] = ({ key }) => { navigate(key); };
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider
|
||||
collapsible
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 48,
|
||||
margin: "12px 16px",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
textAlign: "center",
|
||||
lineHeight: "48px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
NeoZQYY
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={NAV_ITEMS}
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ padding: "12px 16px" }}>
|
||||
<Tooltip title="退出登录">
|
||||
<Button
|
||||
type="text" icon={<LogoutOutlined />}
|
||||
onClick={handleLogout}
|
||||
style={{ color: "rgba(255,255,255,0.65)", width: "100%" }}
|
||||
>
|
||||
退出
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Content style={{ margin: 16, minHeight: 280 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<TaskConfig />} />
|
||||
<Route path="/task-manager" element={<TaskManager />} />
|
||||
<Route path="/env-config" element={<EnvConfig />} />
|
||||
<Route path="/db-viewer" element={<DBViewer />} />
|
||||
<Route path="/etl-status" element={<ETLStatus />} />
|
||||
<Route path="/log-viewer" element={<LogViewer />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
<Footer
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "6px 16px",
|
||||
background: "#fafafa",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
}}
|
||||
>
|
||||
{runningTask ? (
|
||||
<Space size={8}>
|
||||
<Spin size="small" />
|
||||
<Text>执行中</Text>
|
||||
<Tag color="processing">{runningTask.config.pipeline}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{runningTask.config.tasks.slice(0, 3).join(", ")}
|
||||
{runningTask.config.tasks.length > 3 && ` +${runningTask.config.tasks.length - 3}`}
|
||||
</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>无任务执行中</Text>
|
||||
)}
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 根组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const App: React.FC = () => {
|
||||
const hydrate = useAuthStore((s) => s.hydrate);
|
||||
|
||||
useEffect(() => { hydrate(); }, [hydrate]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<AppLayout />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
125
apps/admin-web/src/__tests__/flowLayers.test.ts
Normal file
125
apps/admin-web/src/__tests__/flowLayers.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Flow 层级与任务兼容性测试
|
||||
*
|
||||
* **Validates: Requirements 2.2**
|
||||
*
|
||||
* Property 21: 对任意 Flow 类型和任务定义,当 Flow 包含的层不包含该任务所属层时,
|
||||
* 该任务不应出现在可选列表中;当 Flow 包含该任务所属层时,该任务应出现在可选列表中。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getFlowLayers } from "../pages/TaskConfig";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 预期的 Flow 定义(来自设计文档) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const EXPECTED_FLOWS: Record<string, string[]> = {
|
||||
api_ods: ["ODS"],
|
||||
api_ods_dwd: ["ODS", "DWD"],
|
||||
api_full: ["ODS", "DWD", "DWS", "INDEX"],
|
||||
ods_dwd: ["DWD"],
|
||||
dwd_dws: ["DWS"],
|
||||
dwd_dws_index: ["DWS", "INDEX"],
|
||||
dwd_index: ["INDEX"],
|
||||
};
|
||||
|
||||
describe("getFlowLayers — Flow 层级与任务兼容性", () => {
|
||||
/* ---- 1. 每个已知 Flow 返回正确的层列表 ---- */
|
||||
it.each(Object.entries(EXPECTED_FLOWS))(
|
||||
"Flow '%s' 应返回 %j",
|
||||
(flowId, expectedLayers) => {
|
||||
expect(getFlowLayers(flowId)).toEqual(expectedLayers);
|
||||
},
|
||||
);
|
||||
|
||||
/* ---- 2. 未知 Flow ID 返回空数组 ---- */
|
||||
it("未知 Flow ID 应返回空数组", () => {
|
||||
expect(getFlowLayers("unknown_flow")).toEqual([]);
|
||||
expect(getFlowLayers("")).toEqual([]);
|
||||
expect(getFlowLayers("API_FULL")).toEqual([]); // 大小写敏感
|
||||
});
|
||||
|
||||
/* ---- 3. 所有 7 种 Flow 都有定义 ---- */
|
||||
it("应定义全部 7 种 Flow", () => {
|
||||
const allFlowIds = Object.keys(EXPECTED_FLOWS);
|
||||
expect(allFlowIds).toHaveLength(7);
|
||||
for (const flowId of allFlowIds) {
|
||||
expect(getFlowLayers(flowId).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- 4. 层级互斥性验证 ---- */
|
||||
describe("层级互斥性", () => {
|
||||
it("api_ods 不包含 DWD / DWS / INDEX", () => {
|
||||
const layers = getFlowLayers("api_ods");
|
||||
expect(layers).not.toContain("DWD");
|
||||
expect(layers).not.toContain("DWS");
|
||||
expect(layers).not.toContain("INDEX");
|
||||
});
|
||||
|
||||
it("ods_dwd 只包含 DWD,不包含 ODS / DWS / INDEX", () => {
|
||||
const layers = getFlowLayers("ods_dwd");
|
||||
expect(layers).not.toContain("ODS");
|
||||
expect(layers).not.toContain("DWS");
|
||||
expect(layers).not.toContain("INDEX");
|
||||
});
|
||||
|
||||
it("dwd_dws 只包含 DWS,不包含 ODS / DWD / INDEX", () => {
|
||||
const layers = getFlowLayers("dwd_dws");
|
||||
expect(layers).not.toContain("ODS");
|
||||
expect(layers).not.toContain("DWD");
|
||||
expect(layers).not.toContain("INDEX");
|
||||
});
|
||||
|
||||
it("dwd_index 只包含 INDEX,不包含 ODS / DWD / DWS", () => {
|
||||
const layers = getFlowLayers("dwd_index");
|
||||
expect(layers).not.toContain("ODS");
|
||||
expect(layers).not.toContain("DWD");
|
||||
expect(layers).not.toContain("DWS");
|
||||
});
|
||||
});
|
||||
|
||||
/* ---- 5. 任务兼容性:模拟任务按层过滤 ---- */
|
||||
describe("任务兼容性过滤", () => {
|
||||
// 模拟任务定义
|
||||
const mockTasks = [
|
||||
{ code: "FETCH_ORDERS", layer: "ODS" },
|
||||
{ code: "LOAD_DWD_ORDERS", layer: "DWD" },
|
||||
{ code: "AGG_DAILY_REVENUE", layer: "DWS" },
|
||||
{ code: "CALC_WBI_INDEX", layer: "INDEX" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据 Flow 包含的层过滤任务(与 TaskSelector 组件逻辑一致)
|
||||
*/
|
||||
function filterTasksByFlow(flowId: string) {
|
||||
const layers = getFlowLayers(flowId);
|
||||
return mockTasks.filter((t) => layers.includes(t.layer));
|
||||
}
|
||||
|
||||
it("api_ods 只显示 ODS 任务", () => {
|
||||
const visible = filterTasksByFlow("api_ods");
|
||||
expect(visible.map((t) => t.code)).toEqual(["FETCH_ORDERS"]);
|
||||
});
|
||||
|
||||
it("api_full 显示所有层的任务", () => {
|
||||
const visible = filterTasksByFlow("api_full");
|
||||
expect(visible).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("dwd_dws_index 显示 DWS 和 INDEX 任务", () => {
|
||||
const visible = filterTasksByFlow("dwd_dws_index");
|
||||
const codes = visible.map((t) => t.code);
|
||||
expect(codes).toContain("AGG_DAILY_REVENUE");
|
||||
expect(codes).toContain("CALC_WBI_INDEX");
|
||||
expect(codes).not.toContain("FETCH_ORDERS");
|
||||
expect(codes).not.toContain("LOAD_DWD_ORDERS");
|
||||
});
|
||||
|
||||
it("未知 Flow 不显示任何任务", () => {
|
||||
const visible = filterTasksByFlow("nonexistent");
|
||||
expect(visible).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
169
apps/admin-web/src/__tests__/logFilter.test.ts
Normal file
169
apps/admin-web/src/__tests__/logFilter.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 日志过滤正确性测试
|
||||
*
|
||||
* **Validates: Requirements 9.2**
|
||||
*
|
||||
* Property 19: 对任意日志行集合和过滤关键词,过滤后的结果应只包含
|
||||
* 含有该关键词的日志行,且不遗漏任何匹配行。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterLogLines } from "../pages/LogViewer";
|
||||
|
||||
describe("filterLogLines — 日志过滤正确性", () => {
|
||||
/* ---- 1. 空关键词返回所有行 ---- */
|
||||
it("空关键词返回所有行", () => {
|
||||
const lines = ["INFO 启动", "ERROR 失败", "DEBUG 调试"];
|
||||
expect(filterLogLines(lines, "")).toEqual(lines);
|
||||
});
|
||||
|
||||
/* ---- 2. 空格关键词返回所有行 ---- */
|
||||
it("空格关键词返回所有行", () => {
|
||||
const lines = ["行1", "行2", "行3"];
|
||||
expect(filterLogLines(lines, " ")).toEqual(lines);
|
||||
expect(filterLogLines(lines, "\t")).toEqual(lines);
|
||||
});
|
||||
|
||||
/* ---- 3. 匹配的行被保留 ---- */
|
||||
it("匹配的行被保留", () => {
|
||||
const lines = ["INFO 启动成功", "ERROR 连接失败", "INFO 处理完成"];
|
||||
expect(filterLogLines(lines, "INFO")).toEqual([
|
||||
"INFO 启动成功",
|
||||
"INFO 处理完成",
|
||||
]);
|
||||
});
|
||||
|
||||
/* ---- 4. 不匹配的行被过滤掉 ---- */
|
||||
it("不匹配的行被过滤掉", () => {
|
||||
const lines = ["INFO ok", "ERROR fail", "WARN slow"];
|
||||
const result = filterLogLines(lines, "ERROR");
|
||||
expect(result).not.toContain("INFO ok");
|
||||
expect(result).not.toContain("WARN slow");
|
||||
});
|
||||
|
||||
/* ---- 5. 大小写不敏感匹配 ---- */
|
||||
it("大小写不敏感匹配", () => {
|
||||
const lines = ["Error occurred", "error found", "ERROR critical"];
|
||||
const result = filterLogLines(lines, "error");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toEqual(lines);
|
||||
});
|
||||
|
||||
/* ---- 6. 空行数组返回空数组 ---- */
|
||||
it("空行数组返回空数组", () => {
|
||||
expect(filterLogLines([], "anything")).toEqual([]);
|
||||
});
|
||||
|
||||
/* ---- 7. 所有行都匹配时返回全部 ---- */
|
||||
it("所有行都匹配时返回全部", () => {
|
||||
const lines = ["log: a", "log: b", "log: c"];
|
||||
expect(filterLogLines(lines, "log")).toEqual(lines);
|
||||
});
|
||||
|
||||
/* ---- 8. 没有行匹配时返回空数组 ---- */
|
||||
it("没有行匹配时返回空数组", () => {
|
||||
const lines = ["hello", "world", "foo"];
|
||||
expect(filterLogLines(lines, "zzz")).toEqual([]);
|
||||
});
|
||||
|
||||
/* ---- 9. 关键词在行首/行中/行尾都能匹配 ---- */
|
||||
describe("关键词位置匹配", () => {
|
||||
const keyword = "target";
|
||||
|
||||
it("行首匹配", () => {
|
||||
expect(filterLogLines(["target is here"], keyword)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("行中匹配", () => {
|
||||
expect(filterLogLines(["the target found"], keyword)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("行尾匹配", () => {
|
||||
expect(filterLogLines(["found the target"], keyword)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/* ---- 10. 特殊字符关键词正常工作 ---- */
|
||||
it("特殊字符关键词正常工作", () => {
|
||||
const lines = [
|
||||
"path: /api/v1/users",
|
||||
"regex: [a-z]+",
|
||||
"price: $100.00",
|
||||
"normal line",
|
||||
];
|
||||
// 包含 '/' 的关键词
|
||||
expect(filterLogLines(lines, "/api")).toEqual(["path: /api/v1/users"]);
|
||||
// 包含 '[' 的关键词
|
||||
expect(filterLogLines(lines, "[a-z]")).toEqual(["regex: [a-z]+"]);
|
||||
// 包含 '$' 的关键词
|
||||
expect(filterLogLines(lines, "$100")).toEqual(["price: $100.00"]);
|
||||
});
|
||||
|
||||
/* ---- 11. Property: 过滤结果是原始数组的子集 ---- */
|
||||
it("过滤结果是原始数组的子集", () => {
|
||||
const lines = ["alpha", "beta", "gamma", "delta", "epsilon"];
|
||||
const keywords = ["a", "eta", "xyz", ""];
|
||||
|
||||
for (const kw of keywords) {
|
||||
const result = filterLogLines(lines, kw);
|
||||
// 结果中的每一行都必须存在于原始数组中
|
||||
for (const line of result) {
|
||||
expect(lines).toContain(line);
|
||||
}
|
||||
// 结果长度不超过原始数组
|
||||
expect(result.length).toBeLessThanOrEqual(lines.length);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- 12. Property: 过滤结果中每一行都包含关键词 ---- */
|
||||
it("过滤结果中每一行都包含关键词", () => {
|
||||
const lines = [
|
||||
"2024-01-01 INFO 启动",
|
||||
"2024-01-01 ERROR 数据库连接失败",
|
||||
"2024-01-01 WARN 内存不足",
|
||||
"2024-01-01 INFO 处理完成",
|
||||
"2024-01-01 DEBUG SQL: SELECT *",
|
||||
];
|
||||
const keywords = ["INFO", "error", "SQL", "2024", "不存在的关键词"];
|
||||
|
||||
for (const kw of keywords) {
|
||||
const result = filterLogLines(lines, kw);
|
||||
const lower = kw.toLowerCase();
|
||||
for (const line of result) {
|
||||
expect(line.toLowerCase()).toContain(lower);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- 13. Property: 原始数组中包含关键词的行都在结果中(不遗漏) ---- */
|
||||
it("原始数组中包含关键词的行都在结果中(不遗漏)", () => {
|
||||
const lines = [
|
||||
"INFO 启动",
|
||||
"ERROR 失败",
|
||||
"INFO 完成",
|
||||
"WARN 超时",
|
||||
"INFO 关闭",
|
||||
];
|
||||
const keyword = "INFO";
|
||||
const result = filterLogLines(lines, keyword);
|
||||
const lower = keyword.toLowerCase();
|
||||
|
||||
// 手动找出所有应匹配的行
|
||||
const expected = lines.filter((l) => l.toLowerCase().includes(lower));
|
||||
expect(result).toEqual(expected);
|
||||
|
||||
// 确认没有遗漏:原始数组中每一行如果包含关键词,就必须在结果中
|
||||
for (const line of lines) {
|
||||
if (line.toLowerCase().includes(lower)) {
|
||||
expect(result).toContain(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- 补充:保持原始顺序 ---- */
|
||||
it("过滤结果保持原始顺序", () => {
|
||||
const lines = ["c-match", "a-match", "b-no", "d-match"];
|
||||
const result = filterLogLines(lines, "match");
|
||||
expect(result).toEqual(["c-match", "a-match", "d-match"]);
|
||||
});
|
||||
});
|
||||
159
apps/admin-web/src/api/client.ts
Normal file
159
apps/admin-web/src/api/client.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* axios 实例 & JWT 拦截器。
|
||||
*
|
||||
* - 请求拦截器:自动从 localStorage 读取 access_token 并附加 Authorization header
|
||||
* - 响应拦截器:遇到 401 时尝试用 refresh_token 刷新,刷新失败则清除令牌并跳转 /login
|
||||
* - 并发刷新保护:多个请求同时 401 时只触发一次 refresh,其余排队等待
|
||||
*/
|
||||
|
||||
import axios, {
|
||||
type AxiosError,
|
||||
type AxiosRequestConfig,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ACCESS_TOKEN_KEY = "access_token";
|
||||
const REFRESH_TOKEN_KEY = "refresh_token";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* axios 实例 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: "/api",
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 请求拦截器 — 附加 JWT */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 响应拦截器 — 401 自动刷新 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 是否正在刷新中 */
|
||||
let isRefreshing = false;
|
||||
|
||||
/** 等待刷新完成的请求队列 */
|
||||
let pendingQueue: {
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}[] = [];
|
||||
|
||||
/** 刷新完成后,依次重放排队的请求 */
|
||||
function processPendingQueue(token: string | null, error: unknown) {
|
||||
pendingQueue.forEach(({ resolve, reject }) => {
|
||||
if (token) {
|
||||
resolve(token);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
pendingQueue = [];
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as AxiosRequestConfig & {
|
||||
_retried?: boolean;
|
||||
};
|
||||
|
||||
// 非 401、无原始请求、或已重试过 → 直接抛出
|
||||
if (
|
||||
error.response?.status !== 401 ||
|
||||
!originalRequest ||
|
||||
originalRequest._retried
|
||||
) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 刷新端点本身 401 → 不再递归刷新
|
||||
if (originalRequest.url === "/auth/refresh") {
|
||||
clearTokensAndRedirect();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 已有刷新请求在飞 → 排队等待
|
||||
if (isRefreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
pendingQueue.push({ resolve, reject });
|
||||
}).then((newToken) => {
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
};
|
||||
originalRequest._retried = true;
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
// 发起刷新
|
||||
isRefreshing = true;
|
||||
originalRequest._retried = true;
|
||||
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!refreshToken) {
|
||||
isRefreshing = false;
|
||||
processPendingQueue(null, error);
|
||||
clearTokensAndRedirect();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
// 用独立 axios 调用避免被自身拦截器干扰
|
||||
const { data } = await axios.post<{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}>("/api/auth/refresh", { refresh_token: refreshToken });
|
||||
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, data.access_token);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token);
|
||||
|
||||
processPendingQueue(data.access_token, null);
|
||||
|
||||
// 重放原始请求
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${data.access_token}`,
|
||||
};
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processPendingQueue(null, refreshError);
|
||||
clearTokensAndRedirect();
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 辅助 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function clearTokensAndRedirect() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
|
||||
// 派发自定义事件,让 authStore 监听并重置状态
|
||||
// 避免直接 import authStore 导致循环依赖
|
||||
window.dispatchEvent(new Event("auth:force-logout"));
|
||||
|
||||
// 避免在登录页反复跳转
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
59
apps/admin-web/src/api/dbViewer.ts
Normal file
59
apps/admin-web/src/api/dbViewer.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 数据库查看器相关 API 调用。
|
||||
*
|
||||
* - fetchSchemas:获取 Schema 列表
|
||||
* - fetchTables:获取指定 Schema 下的表列表(含行数)
|
||||
* - fetchColumns:获取指定表的列定义
|
||||
* - executeQuery:执行只读 SQL 查询
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** 表信息 */
|
||||
export interface TableInfo {
|
||||
name: string;
|
||||
row_count: number;
|
||||
}
|
||||
|
||||
/** 列定义 */
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
data_type: string;
|
||||
is_nullable: boolean;
|
||||
default_value: string | null;
|
||||
}
|
||||
|
||||
/** 查询结果 */
|
||||
export interface QueryResult {
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
row_count: number;
|
||||
}
|
||||
|
||||
/** 获取所有 Schema */
|
||||
export async function fetchSchemas(): Promise<string[]> {
|
||||
const { data } = await apiClient.get<{ schemas: string[] }>('/db/schemas');
|
||||
return data.schemas;
|
||||
}
|
||||
|
||||
/** 获取指定 Schema 下的表列表 */
|
||||
export async function fetchTables(schema: string): Promise<TableInfo[]> {
|
||||
const { data } = await apiClient.get<{ tables: TableInfo[] }>(
|
||||
`/db/schemas/${encodeURIComponent(schema)}/tables`,
|
||||
);
|
||||
return data.tables;
|
||||
}
|
||||
|
||||
/** 获取指定表的列定义 */
|
||||
export async function fetchColumns(schema: string, table: string): Promise<ColumnInfo[]> {
|
||||
const { data } = await apiClient.get<{ columns: ColumnInfo[] }>(
|
||||
`/db/tables/${encodeURIComponent(schema)}/${encodeURIComponent(table)}/columns`,
|
||||
);
|
||||
return data.columns;
|
||||
}
|
||||
|
||||
/** 执行只读 SQL 查询 */
|
||||
export async function executeQuery(sql: string): Promise<QueryResult> {
|
||||
const { data } = await apiClient.post<QueryResult>('/db/query', { sql });
|
||||
return data;
|
||||
}
|
||||
44
apps/admin-web/src/api/envConfig.ts
Normal file
44
apps/admin-web/src/api/envConfig.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 环境配置相关 API 调用。
|
||||
*
|
||||
* - fetchEnvConfig:获取键值对列表(敏感值已掩码)
|
||||
* - updateEnvConfig:批量更新键值对
|
||||
* - exportEnvConfig:导出去敏感值的配置文件(浏览器下载)
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { EnvConfigItem } from '../types';
|
||||
|
||||
/** 获取环境配置列表 */
|
||||
export async function fetchEnvConfig(): Promise<EnvConfigItem[]> {
|
||||
const { data } = await apiClient.get<{ items: EnvConfigItem[] }>('/env-config');
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/** 批量更新环境配置 */
|
||||
export async function updateEnvConfig(items: EnvConfigItem[]): Promise<void> {
|
||||
await apiClient.put('/env-config', { items });
|
||||
}
|
||||
|
||||
/** 导出配置文件(去敏感值),触发浏览器下载 */
|
||||
export async function exportEnvConfig(): Promise<void> {
|
||||
const response = await apiClient.get('/env-config/export', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
// 从响应头提取文件名,回退默认值
|
||||
const disposition = response.headers['content-disposition'] as string | undefined;
|
||||
let filename = 'env-config.txt';
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename="?([^";\s]+)"?/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
// 创建临时链接触发下载
|
||||
const url = URL.createObjectURL(response.data as Blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
38
apps/admin-web/src/api/etlStatus.ts
Normal file
38
apps/admin-web/src/api/etlStatus.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* ETL 状态监控 API 调用。
|
||||
*
|
||||
* - fetchCursors:获取各任务的数据游标(最后抓取时间、记录数)
|
||||
* - fetchRecentRuns:获取最近执行记录
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** ETL 游标信息 */
|
||||
export interface CursorInfo {
|
||||
task_code: string;
|
||||
last_fetch_time: string | null;
|
||||
record_count: number | null;
|
||||
}
|
||||
|
||||
/** 最近执行记录 */
|
||||
export interface RecentRun {
|
||||
id: string;
|
||||
task_codes: string[];
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
exit_code: number | null;
|
||||
}
|
||||
|
||||
/** 获取各任务的数据游标 */
|
||||
export async function fetchCursors(): Promise<CursorInfo[]> {
|
||||
const { data } = await apiClient.get<CursorInfo[]>('/etl-status/cursors');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 获取最近执行记录 */
|
||||
export async function fetchRecentRuns(): Promise<RecentRun[]> {
|
||||
const { data } = await apiClient.get<RecentRun[]>('/etl-status/recent-runs');
|
||||
return data;
|
||||
}
|
||||
47
apps/admin-web/src/api/execution.ts
Normal file
47
apps/admin-web/src/api/execution.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 任务执行相关 API 调用。
|
||||
*
|
||||
* - submitToQueue:提交任务配置到执行队列
|
||||
* - executeDirectly:直接执行任务
|
||||
* - fetchQueue:获取当前队列
|
||||
* - fetchHistory:获取执行历史
|
||||
* - deleteFromQueue:从队列删除任务
|
||||
* - cancelExecution:取消执行中的任务
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { TaskConfig, QueuedTask, ExecutionLog } from '../types';
|
||||
|
||||
/** 提交任务配置到执行队列 */
|
||||
export async function submitToQueue(config: TaskConfig): Promise<{ id: string }> {
|
||||
const { data } = await apiClient.post<{ id: string }>('/execution/queue', config);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 直接执行任务(不经过队列) */
|
||||
export async function executeDirectly(config: TaskConfig): Promise<{ execution_id: string }> {
|
||||
const { data } = await apiClient.post<{ execution_id: string }>('/execution/run', config);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 获取当前任务队列 */
|
||||
export async function fetchQueue(): Promise<QueuedTask[]> {
|
||||
const { data } = await apiClient.get<QueuedTask[]>('/execution/queue');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 获取执行历史记录 */
|
||||
export async function fetchHistory(limit = 50): Promise<ExecutionLog[]> {
|
||||
const { data } = await apiClient.get<ExecutionLog[]>('/execution/history', { params: { limit } });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 从队列中删除待执行任务 */
|
||||
export async function deleteFromQueue(id: string): Promise<void> {
|
||||
await apiClient.delete(`/execution/queue/${id}`);
|
||||
}
|
||||
|
||||
/** 取消执行中的任务 */
|
||||
export async function cancelExecution(id: string): Promise<void> {
|
||||
await apiClient.post(`/execution/${id}/cancel`);
|
||||
}
|
||||
48
apps/admin-web/src/api/schedules.ts
Normal file
48
apps/admin-web/src/api/schedules.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 调度任务相关 API 调用。
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { ScheduledTask, ScheduleConfig, TaskConfig } from '../types';
|
||||
|
||||
/** 获取调度任务列表 */
|
||||
export async function fetchSchedules(): Promise<ScheduledTask[]> {
|
||||
const { data } = await apiClient.get<ScheduledTask[]>('/schedules');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 创建调度任务 */
|
||||
export async function createSchedule(payload: {
|
||||
name: string;
|
||||
task_codes: string[];
|
||||
task_config: TaskConfig;
|
||||
schedule_config: ScheduleConfig;
|
||||
}): Promise<ScheduledTask> {
|
||||
const { data } = await apiClient.post<ScheduledTask>('/schedules', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 更新调度任务 */
|
||||
export async function updateSchedule(
|
||||
id: string,
|
||||
payload: Partial<{
|
||||
name: string;
|
||||
task_codes: string[];
|
||||
task_config: TaskConfig;
|
||||
schedule_config: ScheduleConfig;
|
||||
}>,
|
||||
): Promise<ScheduledTask> {
|
||||
const { data } = await apiClient.put<ScheduledTask>(`/schedules/${id}`, payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 删除调度任务 */
|
||||
export async function deleteSchedule(id: string): Promise<void> {
|
||||
await apiClient.delete(`/schedules/${id}`);
|
||||
}
|
||||
|
||||
/** 启用/禁用调度任务 */
|
||||
export async function toggleSchedule(id: string): Promise<ScheduledTask> {
|
||||
const { data } = await apiClient.patch<ScheduledTask>(`/schedules/${id}/toggle`);
|
||||
return data;
|
||||
}
|
||||
32
apps/admin-web/src/api/tasks.ts
Normal file
32
apps/admin-web/src/api/tasks.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 任务相关 API 调用。
|
||||
*
|
||||
* - fetchTaskRegistry:获取按业务域分组的任务注册表
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { TaskConfig, TaskDefinition } from '../types';
|
||||
|
||||
/** 获取按业务域分组的任务注册表 */
|
||||
export async function fetchTaskRegistry(): Promise<Record<string, TaskDefinition[]>> {
|
||||
// 后端返回 { groups: { 域名: [TaskItem] } },需要解包
|
||||
const { data } = await apiClient.get<{ groups: Record<string, TaskDefinition[]> }>('/tasks/registry');
|
||||
return data.groups;
|
||||
}
|
||||
|
||||
/** 获取按业务域分组的 DWD 表定义 */
|
||||
export async function fetchDwdTables(): Promise<Record<string, string[]>> {
|
||||
// 后端返回 { groups: { 域名: [DwdTableItem] } },需要解包并提取 table_name
|
||||
const { data } = await apiClient.get<{ groups: Record<string, { table_name: string }[]> }>('/tasks/dwd-tables');
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [domain, items] of Object.entries(data.groups)) {
|
||||
result[domain] = items.map((item) => item.table_name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 验证任务配置并返回生成的 CLI 命令预览 */
|
||||
export async function validateTaskConfig(config: TaskConfig): Promise<{ command: string }> {
|
||||
const { data } = await apiClient.post<{ command: string }>('/tasks/validate', { config });
|
||||
return data;
|
||||
}
|
||||
187
apps/admin-web/src/components/DwdTableSelector.tsx
Normal file
187
apps/admin-web/src/components/DwdTableSelector.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 按业务域分组的 DWD 表选择器。
|
||||
*
|
||||
* 从 /api/tasks/dwd-tables 获取 DWD 表定义,按业务域折叠展示,
|
||||
* 支持全选/反选。仅在 Flow 包含 DWD 层时由父组件渲染。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Collapse,
|
||||
Checkbox,
|
||||
Spin,
|
||||
Alert,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { CheckboxChangeEvent } from "antd/es/checkbox";
|
||||
import { fetchDwdTables } from "../api/tasks";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface DwdTableSelectorProps {
|
||||
/** 已选中的 DWD 表名列表 */
|
||||
selectedTables: string[];
|
||||
/** 选中表变化回调 */
|
||||
onTablesChange: (tables: string[]) => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const DwdTableSelector: React.FC<DwdTableSelectorProps> = ({
|
||||
selectedTables,
|
||||
onTablesChange,
|
||||
}) => {
|
||||
/** 按业务域分组的 DWD 表 */
|
||||
const [tableGroups, setTableGroups] = useState<Record<string, string[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/* ---------- 加载 DWD 表定义 ---------- */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchDwdTables()
|
||||
.then((data) => {
|
||||
if (!cancelled) setTableGroups(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err?.message ?? "获取 DWD 表列表失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** 所有表名的扁平列表 */
|
||||
const allTableNames = useMemo(
|
||||
() => Object.values(tableGroups).flat(),
|
||||
[tableGroups],
|
||||
);
|
||||
|
||||
/* ---------- 事件处理 ---------- */
|
||||
|
||||
/** 单个业务域的勾选变化 */
|
||||
const handleDomainChange = useCallback(
|
||||
(domain: string, checkedTables: string[]) => {
|
||||
const domainTables = new Set(tableGroups[domain] ?? []);
|
||||
const otherSelected = selectedTables.filter((t) => !domainTables.has(t));
|
||||
onTablesChange([...otherSelected, ...checkedTables]);
|
||||
},
|
||||
[selectedTables, tableGroups, onTablesChange],
|
||||
);
|
||||
|
||||
/** 全选 */
|
||||
const handleSelectAll = useCallback(() => {
|
||||
onTablesChange(allTableNames);
|
||||
}, [allTableNames, onTablesChange]);
|
||||
|
||||
/** 反选 */
|
||||
const handleInvertSelection = useCallback(() => {
|
||||
const currentSet = new Set(selectedTables);
|
||||
const inverted = allTableNames.filter((t) => !currentSet.has(t));
|
||||
onTablesChange(inverted);
|
||||
}, [allTableNames, selectedTables, onTablesChange]);
|
||||
|
||||
/* ---------- 渲染 ---------- */
|
||||
|
||||
if (loading) {
|
||||
return <Spin tip="加载 DWD 表列表…" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert type="error" message="加载失败" description={error} />;
|
||||
}
|
||||
|
||||
const domainEntries = Object.entries(tableGroups);
|
||||
|
||||
if (domainEntries.length === 0) {
|
||||
return <Text type="secondary">无可选 DWD 表</Text>;
|
||||
}
|
||||
|
||||
const selectedCount = selectedTables.filter((t) =>
|
||||
allTableNames.includes(t),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 全选 / 反选 */}
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Button size="small" onClick={handleSelectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="small" onClick={handleInvertSelection}>
|
||||
反选
|
||||
</Button>
|
||||
<Text type="secondary">
|
||||
已选 {selectedCount} / {allTableNames.length}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Collapse
|
||||
defaultActiveKey={domainEntries.map(([d]) => d)}
|
||||
items={domainEntries.map(([domain, tables]) => {
|
||||
const domainSelected = selectedTables.filter((t) =>
|
||||
tables.includes(t),
|
||||
);
|
||||
|
||||
const allChecked = domainSelected.length === tables.length;
|
||||
const indeterminate = domainSelected.length > 0 && !allChecked;
|
||||
|
||||
const handleDomainCheckAll = (e: CheckboxChangeEvent) => {
|
||||
handleDomainChange(domain, e.target.checked ? tables : []);
|
||||
};
|
||||
|
||||
return {
|
||||
key: domain,
|
||||
label: (
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
indeterminate={indeterminate}
|
||||
checked={allChecked}
|
||||
onChange={handleDomainCheckAll}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
{domain}
|
||||
<Text type="secondary" style={{ marginLeft: 4 }}>
|
||||
({domainSelected.length}/{tables.length})
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Checkbox.Group
|
||||
value={domainSelected}
|
||||
onChange={(checked) =>
|
||||
handleDomainChange(domain, checked as string[])
|
||||
}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
{tables.map((table) => (
|
||||
<Checkbox key={table} value={table}>
|
||||
{table}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DwdTableSelector;
|
||||
68
apps/admin-web/src/components/ErrorBoundary.tsx
Normal file
68
apps/admin-web/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 全局错误边界 — 捕获 React 渲染异常,显示错误信息而非白屏。
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Result, Button, Typography } from "antd";
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("[ErrorBoundary]", error, info.componentStack);
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: 48 }}>
|
||||
<Result
|
||||
status="error"
|
||||
title="页面渲染出错"
|
||||
subTitle="请尝试刷新页面,如果问题持续请联系管理员。"
|
||||
extra={
|
||||
<Button type="primary" onClick={this.handleReload}>
|
||||
刷新页面
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{this.state.error && (
|
||||
<Paragraph>
|
||||
<Text type="danger" code style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
|
||||
{this.state.error.message}
|
||||
</Text>
|
||||
</Paragraph>
|
||||
)}
|
||||
</Result>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
79
apps/admin-web/src/components/LogStream.tsx
Normal file
79
apps/admin-web/src/components/LogStream.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 日志流展示组件。
|
||||
*
|
||||
* - 等宽字体展示日志行
|
||||
* - 自动滚动到底部(useRef + scrollIntoView)
|
||||
* - 提供"暂停自动滚动"按钮(toggle)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "antd";
|
||||
import { PauseCircleOutlined, PlayCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
export interface LogStreamProps {
|
||||
/** 可选的执行 ID,用于标题展示 */
|
||||
executionId?: string;
|
||||
/** 日志行数组 */
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
const LogStream: React.FC<LogStreamProps> = ({ lines }) => {
|
||||
const [autoscroll, setAutoscroll] = useState(true);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoscroll && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [lines, autoscroll]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = !autoscroll;
|
||||
setAutoscroll(next);
|
||||
// 恢复时立即滚动到底部
|
||||
if (next && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ marginBottom: 8, textAlign: "right" }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={autoscroll ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{autoscroll ? "暂停滚动" : "恢复滚动"}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
background: "#1e1e1e",
|
||||
color: "#d4d4d4",
|
||||
fontFamily: "'Cascadia Code', 'Fira Code', 'Consolas', monospace",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
minHeight: 300,
|
||||
}}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<div style={{ color: "#888" }}>暂无日志</div>
|
||||
) : (
|
||||
lines.map((line, i) => (
|
||||
<div key={i} style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogStream;
|
||||
407
apps/admin-web/src/components/ScheduleTab.tsx
Normal file
407
apps/admin-web/src/components/ScheduleTab.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 调度管理 Tab 组件。
|
||||
*
|
||||
* 功能:
|
||||
* - 调度任务列表(名称、调度类型、启用 Switch、下次执行、执行次数、最近状态、操作)
|
||||
* - 创建/编辑调度任务 Modal(名称 + 调度配置)
|
||||
* - 删除确认
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table, Tag, Button, Switch, Popconfirm, Space, Modal, Form,
|
||||
Input, Select, InputNumber, TimePicker, Checkbox, message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import type { ScheduledTask, ScheduleConfig } from '../types';
|
||||
import {
|
||||
fetchSchedules,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
deleteSchedule,
|
||||
toggleSchedule,
|
||||
} from '../api/schedules';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 & 工具 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
success: 'success',
|
||||
failed: 'error',
|
||||
running: 'processing',
|
||||
cancelled: 'warning',
|
||||
};
|
||||
|
||||
const SCHEDULE_TYPE_LABEL: Record<string, string> = {
|
||||
once: '一次性',
|
||||
interval: '固定间隔',
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
cron: 'Cron',
|
||||
};
|
||||
|
||||
const INTERVAL_UNIT_LABEL: Record<string, string> = {
|
||||
minutes: '分钟',
|
||||
hours: '小时',
|
||||
days: '天',
|
||||
};
|
||||
|
||||
const WEEKDAY_OPTIONS = [
|
||||
{ label: '周一', value: 1 },
|
||||
{ label: '周二', value: 2 },
|
||||
{ label: '周三', value: 3 },
|
||||
{ label: '周四', value: 4 },
|
||||
{ label: '周五', value: 5 },
|
||||
{ label: '周六', value: 6 },
|
||||
{ label: '周日', value: 0 },
|
||||
];
|
||||
|
||||
/** 格式化时间 */
|
||||
function fmtTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
/** 根据调度配置生成可读描述 */
|
||||
function describeSchedule(cfg: ScheduleConfig): string {
|
||||
switch (cfg.schedule_type) {
|
||||
case 'once':
|
||||
return '一次性';
|
||||
case 'interval':
|
||||
return `每 ${cfg.interval_value} ${INTERVAL_UNIT_LABEL[cfg.interval_unit] ?? cfg.interval_unit}`;
|
||||
case 'daily':
|
||||
return `每日 ${cfg.daily_time}`;
|
||||
case 'weekly': {
|
||||
const days = (cfg.weekly_days ?? [])
|
||||
.map((d) => WEEKDAY_OPTIONS.find((o) => o.value === d)?.label ?? `${d}`)
|
||||
.join('、');
|
||||
return `每周 ${days} ${cfg.weekly_time}`;
|
||||
}
|
||||
case 'cron':
|
||||
return `Cron: ${cfg.cron_expression}`;
|
||||
default:
|
||||
return cfg.schedule_type;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 调度配置表单子组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 根据调度类型动态渲染配置项 */
|
||||
const ScheduleConfigFields: React.FC<{ scheduleType: string }> = ({ scheduleType }) => {
|
||||
switch (scheduleType) {
|
||||
case 'interval':
|
||||
return (
|
||||
<Space>
|
||||
<Form.Item name={['schedule_config', 'interval_value']} noStyle rules={[{ required: true }]}>
|
||||
<InputNumber min={1} placeholder="间隔值" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['schedule_config', 'interval_unit']} noStyle rules={[{ required: true }]}>
|
||||
<Select style={{ width: 100 }} options={[
|
||||
{ label: '分钟', value: 'minutes' },
|
||||
{ label: '小时', value: 'hours' },
|
||||
{ label: '天', value: 'days' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
);
|
||||
case 'daily':
|
||||
return (
|
||||
<Form.Item name={['schedule_config', 'daily_time']} label="执行时间" rules={[{ required: true }]}>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
);
|
||||
case 'weekly':
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['schedule_config', 'weekly_days']} label="星期" rules={[{ required: true }]}>
|
||||
<Checkbox.Group options={WEEKDAY_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['schedule_config', 'weekly_time']} label="执行时间" rules={[{ required: true }]}>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'cron':
|
||||
return (
|
||||
<Form.Item name={['schedule_config', 'cron_expression']} label="Cron 表达式" rules={[{ required: true }]}>
|
||||
<Input placeholder="0 4 * * *" />
|
||||
</Form.Item>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 主组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ScheduleTab: React.FC = () => {
|
||||
const [data, setData] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [scheduleType, setScheduleType] = useState<string>('daily');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
/* 加载列表 */
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await fetchSchedules());
|
||||
} catch {
|
||||
message.error('加载调度任务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/* 打开创建 Modal */
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
schedule_config: {
|
||||
schedule_type: 'daily',
|
||||
interval_value: 1,
|
||||
interval_unit: 'hours',
|
||||
daily_time: dayjs('04:00', 'HH:mm'),
|
||||
weekly_days: [1],
|
||||
weekly_time: dayjs('04:00', 'HH:mm'),
|
||||
cron_expression: '0 4 * * *',
|
||||
},
|
||||
});
|
||||
setScheduleType('daily');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/* 打开编辑 Modal */
|
||||
const openEdit = (record: ScheduledTask) => {
|
||||
setEditing(record);
|
||||
const cfg = record.schedule_config;
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
schedule_config: {
|
||||
...cfg,
|
||||
daily_time: cfg.daily_time ? dayjs(cfg.daily_time, 'HH:mm') : undefined,
|
||||
weekly_time: cfg.weekly_time ? dayjs(cfg.weekly_time, 'HH:mm') : undefined,
|
||||
},
|
||||
});
|
||||
setScheduleType(cfg.schedule_type);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/* 提交创建/编辑 */
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
|
||||
// 将 dayjs 对象转为字符串
|
||||
const cfg = { ...values.schedule_config };
|
||||
if (cfg.daily_time && typeof cfg.daily_time !== 'string') {
|
||||
cfg.daily_time = cfg.daily_time.format('HH:mm');
|
||||
}
|
||||
if (cfg.weekly_time && typeof cfg.weekly_time !== 'string') {
|
||||
cfg.weekly_time = cfg.weekly_time.format('HH:mm');
|
||||
}
|
||||
|
||||
const scheduleConfig: ScheduleConfig = {
|
||||
schedule_type: cfg.schedule_type ?? 'daily',
|
||||
interval_value: cfg.interval_value ?? 1,
|
||||
interval_unit: cfg.interval_unit ?? 'hours',
|
||||
daily_time: cfg.daily_time ?? '04:00',
|
||||
weekly_days: cfg.weekly_days ?? [1],
|
||||
weekly_time: cfg.weekly_time ?? '04:00',
|
||||
cron_expression: cfg.cron_expression ?? '0 4 * * *',
|
||||
enabled: true,
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
await updateSchedule(editing.id, {
|
||||
name: values.name,
|
||||
schedule_config: scheduleConfig,
|
||||
});
|
||||
message.success('调度任务已更新');
|
||||
} else {
|
||||
// 创建时使用默认 task_config(简化实现)
|
||||
await createSchedule({
|
||||
name: values.name,
|
||||
task_codes: [],
|
||||
task_config: {
|
||||
tasks: [],
|
||||
pipeline: 'api_full',
|
||||
processing_mode: 'increment_only',
|
||||
pipeline_flow: 'FULL',
|
||||
dry_run: false,
|
||||
window_mode: 'lookback',
|
||||
window_start: null,
|
||||
window_end: null,
|
||||
window_split: null,
|
||||
window_split_days: null,
|
||||
lookback_hours: 24,
|
||||
overlap_seconds: 600,
|
||||
fetch_before_verify: false,
|
||||
skip_ods_when_fetch_before_verify: false,
|
||||
ods_use_local_json: false,
|
||||
store_id: null,
|
||||
dwd_only_tables: null,
|
||||
force_full: false,
|
||||
extra_args: {},
|
||||
},
|
||||
schedule_config: scheduleConfig,
|
||||
});
|
||||
message.success('调度任务已创建');
|
||||
}
|
||||
|
||||
setModalOpen(false);
|
||||
load();
|
||||
} catch {
|
||||
// 表单验证失败,不做额外处理
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteSchedule(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
/* 启用/禁用 */
|
||||
const handleToggle = async (id: string) => {
|
||||
try {
|
||||
await toggleSchedule(id);
|
||||
load();
|
||||
} catch {
|
||||
message.error('切换状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
/* 表格列定义 */
|
||||
const columns: ColumnsType<ScheduledTask> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '调度类型',
|
||||
key: 'schedule_type',
|
||||
render: (_: unknown, record: ScheduledTask) => describeSchedule(record.schedule_config),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 80,
|
||||
render: (enabled: boolean, record: ScheduledTask) => (
|
||||
<Switch checked={enabled} onChange={() => handleToggle(record.id)} size="small" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '下次执行',
|
||||
dataIndex: 'next_run_at',
|
||||
key: 'next_run_at',
|
||||
render: fmtTime,
|
||||
},
|
||||
{
|
||||
title: '执行次数',
|
||||
dataIndex: 'run_count',
|
||||
key: 'run_count',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '最近状态',
|
||||
dataIndex: 'last_status',
|
||||
key: 'last_status',
|
||||
width: 100,
|
||||
render: (s: string | null) =>
|
||||
s ? <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag> : '—',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
render: (_: unknown, record: ScheduledTask) => (
|
||||
<Space size="small">
|
||||
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确认删除该调度任务?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建调度
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table<ScheduledTask>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
|
||||
{/* 创建/编辑 Modal */}
|
||||
<Modal
|
||||
title={editing ? '编辑调度任务' : '新建调度任务'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" preserve={false}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入调度任务名称' }]}>
|
||||
<Input placeholder="例如:每日全量同步" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={['schedule_config', 'schedule_type']} label="调度类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={Object.entries(SCHEDULE_TYPE_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
onChange={(v: string) => setScheduleType(v)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<ScheduleConfigFields scheduleType={scheduleType} />
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleTab;
|
||||
309
apps/admin-web/src/components/TaskSelector.tsx
Normal file
309
apps/admin-web/src/components/TaskSelector.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 按业务域分组的任务选择器。
|
||||
*
|
||||
* 从 /api/tasks/registry 获取任务注册表,按业务域折叠展示,
|
||||
* 支持全选/反选和按 Flow 层级过滤。
|
||||
* 当 Flow 包含 DWD 层时,在 DWD 任务下方内嵌表过滤子选项。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Collapse,
|
||||
Checkbox,
|
||||
Spin,
|
||||
Alert,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Tag,
|
||||
Divider,
|
||||
} from "antd";
|
||||
import type { CheckboxChangeEvent } from "antd/es/checkbox";
|
||||
import { fetchTaskRegistry, fetchDwdTables } from "../api/tasks";
|
||||
import type { TaskDefinition } from "../types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface TaskSelectorProps {
|
||||
/** 当前 Flow 包含的层(如 ["ODS", "DWD"]) */
|
||||
layers: string[];
|
||||
/** 已选中的任务编码列表 */
|
||||
selectedTasks: string[];
|
||||
/** 选中任务变化回调 */
|
||||
onTasksChange: (tasks: string[]) => void;
|
||||
/** DWD 表过滤:已选中的表名列表 */
|
||||
selectedDwdTables?: string[];
|
||||
/** DWD 表过滤变化回调 */
|
||||
onDwdTablesChange?: (tables: string[]) => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 过滤逻辑 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function filterTasksByLayers(
|
||||
tasks: TaskDefinition[],
|
||||
layers: string[],
|
||||
): TaskDefinition[] {
|
||||
if (layers.length === 0) return [];
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TaskSelector: React.FC<TaskSelectorProps> = ({
|
||||
layers,
|
||||
selectedTasks,
|
||||
onTasksChange,
|
||||
selectedDwdTables = [],
|
||||
onDwdTablesChange,
|
||||
}) => {
|
||||
const [registry, setRegistry] = useState<Record<string, TaskDefinition[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// DWD 表定义(按域分组)
|
||||
const [dwdTableGroups, setDwdTableGroups] = useState<Record<string, string[]>>({});
|
||||
const showDwdFilter = layers.includes("DWD") && !!onDwdTablesChange;
|
||||
|
||||
/* ---------- 加载任务注册表 ---------- */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const promises: Promise<void>[] = [
|
||||
fetchTaskRegistry()
|
||||
.then((data) => { if (!cancelled) setRegistry(data); })
|
||||
.catch((err) => { if (!cancelled) setError(err?.message ?? "获取任务列表失败"); }),
|
||||
];
|
||||
// 如果包含 DWD 层,同时加载 DWD 表定义
|
||||
if (layers.includes("DWD")) {
|
||||
promises.push(
|
||||
fetchDwdTables()
|
||||
.then((data) => { if (!cancelled) setDwdTableGroups(data); })
|
||||
.catch(() => { /* DWD 表加载失败不阻塞任务列表 */ }),
|
||||
);
|
||||
}
|
||||
|
||||
Promise.all(promises).finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [layers]);
|
||||
|
||||
/* ---------- 按 layers 过滤后的分组 ---------- */
|
||||
const filteredGroups = useMemo(() => {
|
||||
const result: Record<string, TaskDefinition[]> = {};
|
||||
for (const [domain, tasks] of Object.entries(registry)) {
|
||||
const visible = filterTasksByLayers(tasks, layers);
|
||||
if (visible.length > 0) {
|
||||
result[domain] = [...visible].sort((a, b) => {
|
||||
if (a.is_common === b.is_common) return 0;
|
||||
return a.is_common ? -1 : 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [registry, layers]);
|
||||
|
||||
const allVisibleCodes = useMemo(
|
||||
() => Object.values(filteredGroups).flatMap((t) => t.map((d) => d.code)),
|
||||
[filteredGroups],
|
||||
);
|
||||
|
||||
// DWD 表扁平列表
|
||||
const allDwdTableNames = useMemo(
|
||||
() => Object.values(dwdTableGroups).flat(),
|
||||
[dwdTableGroups],
|
||||
);
|
||||
|
||||
/* ---------- 事件处理 ---------- */
|
||||
|
||||
const handleDomainChange = useCallback(
|
||||
(domain: string, checkedCodes: string[]) => {
|
||||
const otherDomainCodes = selectedTasks.filter(
|
||||
(code) => !filteredGroups[domain]?.some((t) => t.code === code),
|
||||
);
|
||||
onTasksChange([...otherDomainCodes, ...checkedCodes]);
|
||||
},
|
||||
[selectedTasks, filteredGroups, onTasksChange],
|
||||
);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
onTasksChange(allVisibleCodes);
|
||||
}, [allVisibleCodes, onTasksChange]);
|
||||
|
||||
const handleInvertSelection = useCallback(() => {
|
||||
const currentSet = new Set(selectedTasks);
|
||||
const inverted = allVisibleCodes.filter((code) => !currentSet.has(code));
|
||||
onTasksChange(inverted);
|
||||
}, [allVisibleCodes, selectedTasks, onTasksChange]);
|
||||
|
||||
/* ---------- DWD 表过滤事件 ---------- */
|
||||
|
||||
const handleDwdDomainTableChange = useCallback(
|
||||
(domain: string, checked: string[]) => {
|
||||
if (!onDwdTablesChange) return;
|
||||
const domainTables = new Set(dwdTableGroups[domain] ?? []);
|
||||
const otherSelected = selectedDwdTables.filter((t) => !domainTables.has(t));
|
||||
onDwdTablesChange([...otherSelected, ...checked]);
|
||||
},
|
||||
[selectedDwdTables, dwdTableGroups, onDwdTablesChange],
|
||||
);
|
||||
|
||||
const handleDwdSelectAll = useCallback(() => {
|
||||
onDwdTablesChange?.(allDwdTableNames);
|
||||
}, [allDwdTableNames, onDwdTablesChange]);
|
||||
|
||||
const handleDwdClearAll = useCallback(() => {
|
||||
onDwdTablesChange?.([]);
|
||||
}, [onDwdTablesChange]);
|
||||
|
||||
/* ---------- 渲染 ---------- */
|
||||
|
||||
if (loading) return <Spin tip="加载任务列表…" />;
|
||||
if (error) return <Alert type="error" message="加载失败" description={error} />;
|
||||
|
||||
const domainEntries = Object.entries(filteredGroups);
|
||||
if (domainEntries.length === 0) return <Text type="secondary">当前 Flow 无可选任务</Text>;
|
||||
|
||||
const selectedCount = selectedTasks.filter((c) => allVisibleCodes.includes(c)).length;
|
||||
// DWD 装载任务是否被选中
|
||||
const dwdLoadSelected = selectedTasks.includes("DWD_LOAD_FROM_ODS");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Button size="small" onClick={handleSelectAll}>全选</Button>
|
||||
<Button size="small" onClick={handleInvertSelection}>反选</Button>
|
||||
<Text type="secondary">已选 {selectedCount} / {allVisibleCodes.length}</Text>
|
||||
</Space>
|
||||
|
||||
<Collapse
|
||||
defaultActiveKey={domainEntries.map(([d]) => d)}
|
||||
items={domainEntries.map(([domain, tasks]) => {
|
||||
const domainCodes = tasks.map((t) => t.code);
|
||||
const domainSelected = selectedTasks.filter((c) => domainCodes.includes(c));
|
||||
const allChecked = domainSelected.length === domainCodes.length;
|
||||
const indeterminate = domainSelected.length > 0 && !allChecked;
|
||||
|
||||
const handleDomainCheckAll = (e: CheckboxChangeEvent) => {
|
||||
handleDomainChange(domain, e.target.checked ? domainCodes : []);
|
||||
};
|
||||
|
||||
return {
|
||||
key: domain,
|
||||
label: (
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
indeterminate={indeterminate}
|
||||
checked={allChecked}
|
||||
onChange={handleDomainCheckAll}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
{domain}
|
||||
<Text type="secondary" style={{ marginLeft: 4 }}>
|
||||
({domainSelected.length}/{domainCodes.length})
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Checkbox.Group
|
||||
value={domainSelected}
|
||||
onChange={(checked) => handleDomainChange(domain, checked as string[])}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{tasks.map((t) => (
|
||||
<Checkbox key={t.code} value={t.code}>
|
||||
<Text strong style={t.is_common === false ? { color: "#999" } : undefined}>{t.code}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>{t.name}</Text>
|
||||
{t.is_common === false && (
|
||||
<Tag color="default" style={{ marginLeft: 6, fontSize: 11 }}>不常用</Tag>
|
||||
)}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* DWD 表过滤:仅在 DWD 层且 DWD_LOAD_FROM_ODS 被选中时显示 */}
|
||||
{showDwdFilter && dwdLoadSelected && allDwdTableNames.length > 0 && (
|
||||
<>
|
||||
<Divider style={{ margin: "12px 0 8px" }} />
|
||||
<div style={{ padding: "0 4px" }}>
|
||||
<Space style={{ marginBottom: 6 }}>
|
||||
<Text strong style={{ fontSize: 13 }}>DWD 表过滤</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{selectedDwdTables.length === 0
|
||||
? "(未选择 = 全部装载)"
|
||||
: `已选 ${selectedDwdTables.length} / ${allDwdTableNames.length}`}
|
||||
</Text>
|
||||
</Space>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<Space size={4}>
|
||||
<Button size="small" type="link" style={{ padding: 0, fontSize: 12 }} onClick={handleDwdSelectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="small" type="link" style={{ padding: 0, fontSize: 12 }} onClick={handleDwdClearAll}>
|
||||
清空(全部装载)
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={Object.entries(dwdTableGroups).map(([domain, tables]) => {
|
||||
const domainSelected = selectedDwdTables.filter((t) => tables.includes(t));
|
||||
const allDomainChecked = domainSelected.length === tables.length;
|
||||
const domainIndeterminate = domainSelected.length > 0 && !allDomainChecked;
|
||||
|
||||
return {
|
||||
key: domain,
|
||||
label: (
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
indeterminate={domainIndeterminate}
|
||||
checked={allDomainChecked}
|
||||
onChange={(e: CheckboxChangeEvent) =>
|
||||
handleDwdDomainTableChange(domain, e.target.checked ? tables : [])
|
||||
}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
{domain}
|
||||
<Text type="secondary" style={{ marginLeft: 4, fontSize: 12 }}>
|
||||
({domainSelected.length}/{tables.length})
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Checkbox.Group
|
||||
value={domainSelected}
|
||||
onChange={(checked) => handleDwdDomainTableChange(domain, checked as string[])}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
{tables.map((table) => (
|
||||
<Checkbox key={table} value={table}>
|
||||
<Text style={{ fontSize: 12 }}>{table}</Text>
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskSelector;
|
||||
22
apps/admin-web/src/main.tsx
Normal file
22
apps/admin-web/src/main.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import App from "./App";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
|
||||
/**
|
||||
* 入口:ErrorBoundary + BrowserRouter + antd 中文 locale + App 根组件。
|
||||
*/
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
235
apps/admin-web/src/pages/DBViewer.tsx
Normal file
235
apps/admin-web/src/pages/DBViewer.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 数据库查看器页面。
|
||||
*
|
||||
* - 左侧:Schema → Table 层级树,异步加载
|
||||
* - 右侧上方:SQL 编辑器 + 执行按钮
|
||||
* - 右侧下方:列定义 / 查询结果 Table
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Tree, Input, Button, Table, Space, message, Spin, Tag, Card, Typography, Tooltip } from 'antd';
|
||||
import {
|
||||
PlayCircleOutlined, ReloadOutlined, TableOutlined,
|
||||
DatabaseOutlined, CopyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode, EventDataNode } from 'antd/es/tree';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
fetchSchemas, fetchTables, fetchColumns, executeQuery,
|
||||
type ColumnInfo, type QueryResult,
|
||||
} from '../api/dbViewer';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const schemaKey = (schema: string) => `s::${schema}`;
|
||||
const tableKey = (schema: string, table: string) => `t::${schema}::${table}`;
|
||||
|
||||
function parseTableKey(key: string): { schema: string; table: string } | null {
|
||||
if (!key.startsWith('t::')) return null;
|
||||
const parts = key.slice(3).split('::');
|
||||
if (parts.length !== 2) return null;
|
||||
return { schema: parts[0], table: parts[1] };
|
||||
}
|
||||
|
||||
const DBViewer: React.FC = () => {
|
||||
const [treeData, setTreeData] = useState<DataNode[]>([]);
|
||||
const [loadingTree, setLoadingTree] = useState(false);
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
const [selectedTable, setSelectedTable] = useState<{ schema: string; table: string } | null>(null);
|
||||
const [columnData, setColumnData] = useState<ColumnInfo[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [sql, setSql] = useState('');
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [loadingQuery, setLoadingQuery] = useState(false);
|
||||
|
||||
const loadSchemas = useCallback(async () => {
|
||||
setLoadingTree(true);
|
||||
try {
|
||||
const schemas = await fetchSchemas();
|
||||
setTreeData(
|
||||
schemas.map((s) => ({
|
||||
title: s, key: schemaKey(s), icon: <DatabaseOutlined />, isLeaf: false,
|
||||
})),
|
||||
);
|
||||
} catch { message.error('加载 Schema 列表失败'); }
|
||||
finally { setLoadingTree(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadSchemas(); }, [loadSchemas]);
|
||||
|
||||
const onLoadData = async (node: EventDataNode<DataNode>) => {
|
||||
const key = node.key as string;
|
||||
if (!key.startsWith('s::')) return;
|
||||
if (node.children && node.children.length > 0) return;
|
||||
const schema = key.slice(3);
|
||||
try {
|
||||
const tables = await fetchTables(schema);
|
||||
const children: DataNode[] = tables.map((t) => ({
|
||||
title: (
|
||||
<Space size={4}>
|
||||
<span>{t.name}</span>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>({t.row_count.toLocaleString()})</Text>
|
||||
</Space>
|
||||
),
|
||||
key: tableKey(schema, t.name), icon: <TableOutlined />, isLeaf: true,
|
||||
}));
|
||||
setTreeData((prev) => prev.map((n) => n.key === key ? { ...n, children } : n));
|
||||
} catch { message.error(`加载 ${schema} 的表列表失败`); }
|
||||
};
|
||||
|
||||
const onSelectNode = async (_: React.Key[], info: { node: DataNode }) => {
|
||||
const key = info.node.key as string;
|
||||
const parsed = parseTableKey(key);
|
||||
if (!parsed) return;
|
||||
setSelectedTable(parsed);
|
||||
setLoadingColumns(true);
|
||||
setQueryResult(null);
|
||||
try {
|
||||
const cols = await fetchColumns(parsed.schema, parsed.table);
|
||||
setColumnData(cols);
|
||||
setSql(`SELECT * FROM ${parsed.schema}.${parsed.table} LIMIT 100;`);
|
||||
} catch { message.error('加载列定义失败'); setColumnData([]); }
|
||||
finally { setLoadingColumns(false); }
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
const trimmed = sql.trim();
|
||||
if (!trimmed) { message.warning('请输入 SQL 语句'); return; }
|
||||
setLoadingQuery(true);
|
||||
try {
|
||||
const result = await executeQuery(trimmed);
|
||||
setQueryResult(result);
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } };
|
||||
const msg = axiosErr.response?.data?.detail ?? (err instanceof Error ? err.message : '查询执行失败');
|
||||
message.error(msg);
|
||||
setQueryResult(null);
|
||||
} finally { setLoadingQuery(false); }
|
||||
};
|
||||
|
||||
const handleCopySql = () => {
|
||||
navigator.clipboard.writeText(sql).then(() => message.success('已复制'));
|
||||
};
|
||||
|
||||
const columnDefColumns: ColumnsType<ColumnInfo> = [
|
||||
{ title: '列名', dataIndex: 'name', key: 'name', render: (v: string) => <code>{v}</code> },
|
||||
{ title: '数据类型', dataIndex: 'data_type', key: 'data_type' },
|
||||
{
|
||||
title: '可空', dataIndex: 'is_nullable', key: 'is_nullable', width: 70, align: 'center',
|
||||
render: (v: boolean) => v ? <Tag color="orange">YES</Tag> : <Tag color="blue">NO</Tag>,
|
||||
},
|
||||
{
|
||||
title: '默认值', dataIndex: 'default_value', key: 'default_value',
|
||||
render: (v: string | null) => v != null ? <code style={{ fontSize: 12 }}>{v}</code> : <Text type="secondary">—</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const resultColumns: ColumnsType<Record<string, unknown>> = queryResult
|
||||
? queryResult.columns.map((col, idx) => ({
|
||||
title: col, dataIndex: String(idx), key: col, ellipsis: true,
|
||||
render: (v: unknown) => {
|
||||
if (v === null || v === undefined) return <Text type="secondary">NULL</Text>;
|
||||
return String(v);
|
||||
},
|
||||
}))
|
||||
: [];
|
||||
|
||||
const resultDataSource: Record<string, unknown>[] = queryResult
|
||||
? queryResult.rows.map((row, rowIdx) => {
|
||||
const obj: Record<string, unknown> = { _key: rowIdx };
|
||||
row.forEach((cell, colIdx) => { obj[String(colIdx)] = cell; });
|
||||
return obj;
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<DatabaseOutlined style={{ marginRight: 8 }} />
|
||||
数据库查看器
|
||||
</Title>
|
||||
{selectedTable && (
|
||||
<Tag color="blue">{selectedTable.schema}.{selectedTable.table}</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flex: 1, gap: 12, minHeight: 0 }}>
|
||||
{/* 左侧树 */}
|
||||
<Card
|
||||
size="small"
|
||||
title="Schema / 表"
|
||||
extra={<Button size="small" icon={<ReloadOutlined />} onClick={loadSchemas} loading={loadingTree} />}
|
||||
style={{ width: 260, minWidth: 260, display: 'flex', flexDirection: 'column' }}
|
||||
styles={{ body: { flex: 1, overflow: 'auto', padding: '8px 12px' } }}
|
||||
>
|
||||
<Spin spinning={loadingTree}>
|
||||
<Tree
|
||||
showIcon treeData={treeData} loadData={onLoadData}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={(keys) => setExpandedKeys(keys)}
|
||||
onSelect={onSelectNode}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* 右侧 */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
{/* SQL 编辑器 */}
|
||||
<Card size="small" style={{ marginBottom: 12 }} styles={{ body: { padding: '8px 12px' } }}>
|
||||
<TextArea
|
||||
rows={5} value={sql} onChange={(e) => setSql(e.target.value)}
|
||||
placeholder="输入 SQL 查询语句…"
|
||||
style={{ fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace", fontSize: 13, marginBottom: 8 }}
|
||||
onKeyDown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); handleExecute(); } }}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleExecute} loading={loadingQuery}>
|
||||
执行 <Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>(Ctrl+Enter)</Text>
|
||||
</Button>
|
||||
<Tooltip title="复制 SQL">
|
||||
<Button icon={<CopyOutlined />} onClick={handleCopySql} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 结果区域 */}
|
||||
<Card size="small" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
styles={{ body: { flex: 1, overflow: 'auto', padding: '8px 12px' } }}
|
||||
>
|
||||
{queryResult ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text type="secondary">查询返回 {queryResult.row_count} 行</Text>
|
||||
</div>
|
||||
<Table<Record<string, unknown>>
|
||||
rowKey="_key" columns={resultColumns} dataSource={resultDataSource}
|
||||
pagination={{ pageSize: 50, showSizeChanger: false, showTotal: (t) => `共 ${t} 行` }}
|
||||
size="small" scroll={{ x: 'max-content' }} bordered
|
||||
/>
|
||||
</>
|
||||
) : selectedTable ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong>{selectedTable.schema}.{selectedTable.table}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>列定义</Text>
|
||||
</div>
|
||||
<Table<ColumnInfo>
|
||||
rowKey="name" columns={columnDefColumns} dataSource={columnData}
|
||||
loading={loadingColumns} pagination={false} size="small" bordered
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: '#bbb', textAlign: 'center', marginTop: 60 }}>
|
||||
在左侧选择一张表查看列定义,或输入 SQL 执行查询
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DBViewer;
|
||||
137
apps/admin-web/src/pages/ETLStatus.tsx
Normal file
137
apps/admin-web/src/pages/ETLStatus.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* ETL 状态监控页面。
|
||||
*
|
||||
* - 游标状态 Table
|
||||
* - 最近执行记录 Table
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Tag, Button, message, Typography, Card, Row, Col, Statistic } from 'antd';
|
||||
import { ReloadOutlined, DashboardOutlined, DatabaseOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
fetchCursors, fetchRecentRuns,
|
||||
type CursorInfo, type RecentRun,
|
||||
} from '../api/etlStatus';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
success: 'green', failed: 'red', running: 'blue', cancelled: 'orange',
|
||||
};
|
||||
|
||||
function formatTime(raw: string | null): string {
|
||||
if (!raw) return '—';
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms == null) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainSec = seconds % 60;
|
||||
return `${minutes}m ${remainSec}s`;
|
||||
}
|
||||
|
||||
const cursorColumns: ColumnsType<CursorInfo> = [
|
||||
{ title: '任务编码', dataIndex: 'task_code', key: 'task_code', render: (v: string) => <code>{v}</code> },
|
||||
{ title: '最后抓取时间', dataIndex: 'last_fetch_time', key: 'last_fetch_time', render: (v: string | null) => formatTime(v) },
|
||||
{
|
||||
title: '记录数', dataIndex: 'record_count', key: 'record_count', align: 'right',
|
||||
render: (v: number | null) => (v != null ? <Text strong>{v.toLocaleString()}</Text> : '—'),
|
||||
},
|
||||
];
|
||||
|
||||
const runColumns: ColumnsType<RecentRun> = [
|
||||
{ title: '任务名称', dataIndex: 'task_codes', key: 'task_codes', render: (codes: string[]) => codes.join(', ') || '—' },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||
render: (status: string) => <Tag color={STATUS_COLOR[status] ?? 'default'}>{status}</Tag>,
|
||||
},
|
||||
{ title: '开始时间', dataIndex: 'started_at', key: 'started_at', width: 170, render: (v: string) => formatTime(v) },
|
||||
{ title: '执行时长', dataIndex: 'duration_ms', key: 'duration_ms', width: 100, render: (v: number | null) => formatDuration(v) },
|
||||
];
|
||||
|
||||
const ETLStatus: React.FC = () => {
|
||||
const [cursors, setCursors] = useState<CursorInfo[]>([]);
|
||||
const [runs, setRuns] = useState<RecentRun[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [c, r] = await Promise.all([fetchCursors(), fetchRecentRuns()]);
|
||||
setCursors(c);
|
||||
setRuns(r);
|
||||
} catch { message.error('加载 ETL 状态失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// 统计
|
||||
const successCount = runs.filter((r) => r.status === 'success').length;
|
||||
const failedCount = runs.filter((r) => r.status === 'failed').length;
|
||||
const runningCount = runs.filter((r) => r.status === 'running').length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<DashboardOutlined style={{ marginRight: 8 }} />
|
||||
ETL 状态监控
|
||||
</Title>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="游标数" value={cursors.length} prefix={<DatabaseOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="最近执行" value={runs.length} prefix={<PlayCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="成功" value={successCount} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="失败 / 运行中"
|
||||
value={failedCount}
|
||||
suffix={runningCount > 0 ? ` / ${runningCount}` : ''}
|
||||
valueStyle={{ color: failedCount > 0 ? '#ff4d4f' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card size="small" title="游标状态" style={{ marginBottom: 12 }}>
|
||||
<Table<CursorInfo>
|
||||
rowKey="task_code" columns={cursorColumns} dataSource={cursors}
|
||||
loading={loading} pagination={false} size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="最近执行记录">
|
||||
<Table<RecentRun>
|
||||
rowKey="id" columns={runColumns} dataSource={runs}
|
||||
loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ETLStatus;
|
||||
164
apps/admin-web/src/pages/EnvConfig.tsx
Normal file
164
apps/admin-web/src/pages/EnvConfig.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 环境配置页面。
|
||||
*
|
||||
* - Ant Design Table 展示键值对,支持 inline 编辑
|
||||
* - 敏感值显示为 ****,编辑时可输入新值
|
||||
* - 顶部按钮栏:刷新、保存、导出
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Table, Button, Input, Tag, Space, message, Card, Typography, Badge } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import {
|
||||
ReloadOutlined, SaveOutlined, DownloadOutlined, ToolOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { EnvConfigItem } from '../types';
|
||||
import { fetchEnvConfig, updateEnvConfig, exportEnvConfig } from '../api/envConfig';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const MASK = '****';
|
||||
|
||||
const EnvConfig: React.FC = () => {
|
||||
const [items, setItems] = useState<EnvConfigItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [dirtyMap, setDirtyMap] = useState<Record<string, string>>({});
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchEnvConfig();
|
||||
setItems(data);
|
||||
setDirtyMap({});
|
||||
setEditingKey(null);
|
||||
} catch { message.error('加载环境配置失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const startEdit = (key: string, currentValue: string, isSensitive: boolean) => {
|
||||
setEditingKey(key);
|
||||
setEditValue(isSensitive ? '' : (dirtyMap[key] ?? currentValue));
|
||||
setTimeout(() => { inputRef.current?.focus(); }, 0);
|
||||
};
|
||||
|
||||
const confirmEdit = (key: string, originalValue: string, isSensitive: boolean) => {
|
||||
const trimmed = editValue.trim();
|
||||
if (isSensitive && trimmed === '') {
|
||||
setDirtyMap((prev) => { const next = { ...prev }; delete next[key]; return next; });
|
||||
} else if (!isSensitive && trimmed === originalValue) {
|
||||
setDirtyMap((prev) => { const next = { ...prev }; delete next[key]; return next; });
|
||||
} else {
|
||||
setDirtyMap((prev) => ({ ...prev, [key]: trimmed }));
|
||||
}
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => { setEditingKey(null); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (Object.keys(dirtyMap).length === 0) { message.info('没有需要保存的修改'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = items.map((item) => ({
|
||||
key: item.key,
|
||||
value: dirtyMap[item.key] ?? item.value,
|
||||
is_sensitive: item.is_sensitive,
|
||||
}));
|
||||
await updateEnvConfig(payload);
|
||||
message.success('保存成功');
|
||||
await load();
|
||||
} catch { message.error('保存失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try { await exportEnvConfig(); message.success('导出成功'); }
|
||||
catch { message.error('导出失败'); }
|
||||
finally { setExporting(false); }
|
||||
};
|
||||
|
||||
const columns: ColumnsType<EnvConfigItem> = [
|
||||
{
|
||||
title: '键名', dataIndex: 'key', key: 'key', width: '35%',
|
||||
render: (text: string) => <code style={{ fontSize: 12 }}>{text}</code>,
|
||||
},
|
||||
{
|
||||
title: '值', dataIndex: 'value', key: 'value', width: '50%',
|
||||
render: (_: string, record: EnvConfigItem) => {
|
||||
if (editingKey === record.key) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef} value={editValue} size="small"
|
||||
placeholder={record.is_sensitive ? '输入新值(留空则不修改)' : undefined}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onPressEnter={() => confirmEdit(record.key, record.value, record.is_sensitive)}
|
||||
onBlur={() => confirmEdit(record.key, record.value, record.is_sensitive)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') cancelEdit(); }}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isDirty = record.key in dirtyMap;
|
||||
const displayValue = record.is_sensitive
|
||||
? (isDirty ? MASK + ' (已修改)' : MASK)
|
||||
: (isDirty ? dirtyMap[record.key] : record.value);
|
||||
return (
|
||||
<span
|
||||
style={{ cursor: 'pointer', color: isDirty ? '#1677ff' : undefined, fontFamily: 'monospace', fontSize: 12 }}
|
||||
onClick={() => startEdit(record.key, record.value, record.is_sensitive)}
|
||||
title="点击编辑"
|
||||
>
|
||||
{displayValue || <Text type="secondary">(空)</Text>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'is_sensitive', key: 'is_sensitive', width: '15%', align: 'center',
|
||||
render: (v: boolean) => v ? <Tag color="red">敏感</Tag> : <Tag color="green">普通</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
const hasDirty = Object.keys(dirtyMap).length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<ToolOutlined style={{ marginRight: 8 }} />
|
||||
环境配置
|
||||
</Title>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
<Badge count={hasDirty ? Object.keys(dirtyMap).length : 0} size="small">
|
||||
<Button
|
||||
type="primary" icon={<SaveOutlined />}
|
||||
onClick={handleSave} loading={saving} disabled={!hasDirty}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Badge>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small">
|
||||
<Table<EnvConfigItem>
|
||||
rowKey="key" columns={columns} dataSource={items}
|
||||
loading={loading} pagination={false} size="small"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvConfig;
|
||||
138
apps/admin-web/src/pages/LogViewer.tsx
Normal file
138
apps/admin-web/src/pages/LogViewer.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 日志查看器页面。
|
||||
*
|
||||
* - 输入执行 ID,通过 WebSocket 实时接收日志
|
||||
* - 支持加载历史日志
|
||||
* - 关键词过滤(大小写不敏感)
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Input, Button, Space, message, Card, Typography, Tag, Badge } from "antd";
|
||||
import {
|
||||
LinkOutlined, DisconnectOutlined, HistoryOutlined,
|
||||
FileTextOutlined, SearchOutlined, ClearOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { apiClient } from "../api/client";
|
||||
import LogStream from "../components/LogStream";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 纯函数:日志过滤 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function filterLogLines(lines: string[], keyword: string): string[] {
|
||||
if (!keyword.trim()) return lines;
|
||||
const lower = keyword.toLowerCase();
|
||||
return lines.filter((line) => line.toLowerCase().includes(lower));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 页面组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LogViewer: React.FC = () => {
|
||||
const [executionId, setExecutionId] = useState("");
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [filterKeyword, setFilterKeyword] = useState("");
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { wsRef.current?.close(); };
|
||||
}, []);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
const id = executionId.trim();
|
||||
if (!id) { message.warning("请输入执行 ID"); return; }
|
||||
wsRef.current?.close();
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = window.location.host;
|
||||
const ws = new WebSocket(`${protocol}//${host}/ws/logs/${id}`);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => { setConnected(true); message.success("WebSocket 已连接"); };
|
||||
ws.onmessage = (event) => { setLines((prev) => [...prev, event.data]); };
|
||||
ws.onclose = () => { setConnected(false); };
|
||||
ws.onerror = () => { message.error("WebSocket 连接失败"); setConnected(false); };
|
||||
}, [executionId]);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
setConnected(false);
|
||||
}, []);
|
||||
|
||||
const handleLoadHistory = useCallback(async () => {
|
||||
const id = executionId.trim();
|
||||
if (!id) { message.warning("请输入执行 ID"); return; }
|
||||
try {
|
||||
const { data } = await apiClient.get<{ execution_id: string; output_log: string | null; error_log: string | null }>(
|
||||
`/execution/${id}/logs`
|
||||
);
|
||||
const parts: string[] = [];
|
||||
if (data.output_log) parts.push(data.output_log);
|
||||
if (data.error_log) parts.push(data.error_log);
|
||||
const historyLines = parts.join("\n").split("\n");
|
||||
setLines(historyLines);
|
||||
message.success("历史日志加载完成");
|
||||
} catch { message.error("加载历史日志失败"); }
|
||||
}, [executionId]);
|
||||
|
||||
const handleClear = useCallback(() => { setLines([]); }, []);
|
||||
|
||||
const filteredLines = filterLogLines(lines, filterKeyword);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ marginBottom: 12, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<FileTextOutlined style={{ marginRight: 8 }} />
|
||||
日志查看器
|
||||
</Title>
|
||||
<Space>
|
||||
{connected && <Badge status="processing" text={<Text type="success">已连接</Text>} />}
|
||||
<Tag>{lines.length} 行</Tag>
|
||||
{filterKeyword && <Tag color="blue">{filteredLines.length} 条匹配</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="执行 ID"
|
||||
value={executionId}
|
||||
onChange={(e) => setExecutionId(e.target.value)}
|
||||
style={{ width: 280, fontFamily: "monospace" }}
|
||||
onPressEnter={handleConnect}
|
||||
allowClear
|
||||
/>
|
||||
{connected ? (
|
||||
<Button icon={<DisconnectOutlined />} danger onClick={handleDisconnect}>断开</Button>
|
||||
) : (
|
||||
<Button type="primary" icon={<LinkOutlined />} onClick={handleConnect}>连接</Button>
|
||||
)}
|
||||
<Button icon={<HistoryOutlined />} onClick={handleLoadHistory}>加载历史</Button>
|
||||
<Button icon={<ClearOutlined />} onClick={handleClear} disabled={lines.length === 0}>清空</Button>
|
||||
</Space>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="过滤关键词..."
|
||||
value={filterKeyword}
|
||||
onChange={(e) => setFilterKeyword(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 日志流 */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<LogStream executionId={executionId} lines={filteredLines} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogViewer;
|
||||
92
apps/admin-web/src/pages/Login.tsx
Normal file
92
apps/admin-web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 登录页面 — Ant Design Form + Zustand authStore。
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button, Card, Form, Input, message, Typography, Space } from "antd";
|
||||
import { LockOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface LoginFormValues {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(values.username, values.password);
|
||||
message.success("登录成功");
|
||||
navigate("/", { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const detail =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail ?? "登录失败,请检查用户名和密码";
|
||||
message.error(detail);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
width: 400,
|
||||
borderRadius: 12,
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%", textAlign: "center", marginBottom: 24 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>NeoZQYY</Title>
|
||||
<Text type="secondary">管理后台</Text>
|
||||
</Space>
|
||||
|
||||
<Form<LoginFormValues>
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: "请输入用户名" }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: "请输入密码" }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
573
apps/admin-web/src/pages/TaskConfig.tsx
Normal file
573
apps/admin-web/src/pages/TaskConfig.tsx
Normal file
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* ETL 任务配置页面。
|
||||
*
|
||||
* 提供 Flow 选择、处理模式、时间窗口、高级选项等配置区域,
|
||||
* 以及连接器/Store 选择、任务选择、DWD 表选择、CLI 命令预览和任务提交功能。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Card,
|
||||
Radio,
|
||||
Checkbox,
|
||||
InputNumber,
|
||||
DatePicker,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Input,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Badge,
|
||||
Alert,
|
||||
TreeSelect,
|
||||
Tooltip,
|
||||
Segmented,
|
||||
} from "antd";
|
||||
import {
|
||||
SendOutlined,
|
||||
ThunderboltOutlined,
|
||||
CodeOutlined,
|
||||
SettingOutlined,
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
ShopOutlined,
|
||||
ApiOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import TaskSelector from "../components/TaskSelector";
|
||||
import { validateTaskConfig } from "../api/tasks";
|
||||
import { submitToQueue, executeDirectly } from "../api/execution";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import type { RadioChangeEvent } from "antd";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import type { TaskConfig as TaskConfigType } from "../types";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Flow 定义 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const FLOW_DEFINITIONS: Record<string, { name: string; layers: string[]; desc: string }> = {
|
||||
api_ods: { name: "API → ODS", layers: ["ODS"], desc: "仅抓取原始数据" },
|
||||
api_ods_dwd: { name: "API → ODS → DWD", layers: ["ODS", "DWD"], desc: "抓取并清洗装载" },
|
||||
api_full: { name: "API → ODS → DWD → DWS → INDEX", layers: ["ODS", "DWD", "DWS", "INDEX"], desc: "全链路执行" },
|
||||
ods_dwd: { name: "ODS → DWD", layers: ["DWD"], desc: "仅清洗装载" },
|
||||
dwd_dws: { name: "DWD → DWS汇总", layers: ["DWS"], desc: "仅汇总计算" },
|
||||
dwd_dws_index: { name: "DWD → DWS → INDEX", layers: ["DWS", "INDEX"], desc: "汇总+指数" },
|
||||
dwd_index: { name: "DWD → INDEX", layers: ["INDEX"], desc: "仅指数计算" },
|
||||
};
|
||||
|
||||
export function getFlowLayers(flowId: string): string[] {
|
||||
return FLOW_DEFINITIONS[flowId]?.layers ?? [];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 处理模式 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const PROCESSING_MODES = [
|
||||
{ value: "increment_only", label: "仅增量", desc: "按游标增量抓取和装载" },
|
||||
{ value: "verify_only", label: "校验并修复", desc: "对比源和目标,修复差异" },
|
||||
{ value: "increment_verify", label: "增量+校验", desc: "先增量再校验" },
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 时间窗口 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type WindowMode = "lookback" | "custom";
|
||||
|
||||
const WINDOW_SPLIT_OPTIONS = [
|
||||
{ value: 0, label: "不切分" },
|
||||
{ value: 1, label: "1天" },
|
||||
{ value: 10, label: "10天" },
|
||||
{ value: 30, label: "30天" },
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 连接器 → 门店 树形数据结构 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 连接器定义:每个连接器下挂载门店列表 */
|
||||
interface ConnectorDef {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const CONNECTOR_DEFS: ConnectorDef[] = [
|
||||
{ id: "feiqiu", label: "飞球", icon: <ApiOutlined /> },
|
||||
];
|
||||
|
||||
/** 构建 TreeSelect 的 treeData,连接器为父节点,门店为子节点 */
|
||||
function buildConnectorStoreTree(
|
||||
connectors: ConnectorDef[],
|
||||
siteId: number | null,
|
||||
): { treeData: { title: React.ReactNode; value: string; key: string; children?: { title: React.ReactNode; value: string; key: string }[] }[]; allValues: string[] } {
|
||||
const allValues: string[] = [];
|
||||
const treeData = connectors.map((c) => {
|
||||
// 每个连接器下挂载当前用户的门店(未来可扩展为多门店)
|
||||
const stores = siteId
|
||||
? [{ title: (<Space size={4}><ShopOutlined /><span>门店 {siteId}</span></Space>), value: `${c.id}::${siteId}`, key: `${c.id}::${siteId}` }]
|
||||
: [];
|
||||
stores.forEach((s) => allValues.push(s.value));
|
||||
return {
|
||||
title: (<Space size={4}>{c.icon}<span>{c.label}</span></Space>),
|
||||
value: c.id,
|
||||
key: c.id,
|
||||
children: stores,
|
||||
};
|
||||
});
|
||||
return { treeData, allValues };
|
||||
}
|
||||
|
||||
/** 从选中值中解析出 store_id 列表 */
|
||||
function parseSelectedStoreIds(selected: string[]): number[] {
|
||||
const ids: number[] = [];
|
||||
for (const v of selected) {
|
||||
// 格式: "connector::storeId"
|
||||
const parts = v.split("::");
|
||||
if (parts.length === 2) {
|
||||
const num = Number(parts[1]);
|
||||
if (!isNaN(num)) ids.push(num);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 页面组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TaskConfig: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
/* ---------- 连接器 & Store 树形选择 ---------- */
|
||||
const { treeData: connectorTreeData, allValues: allConnectorStoreValues } = useMemo(
|
||||
() => buildConnectorStoreTree(CONNECTOR_DEFS, user?.site_id ?? null),
|
||||
[user?.site_id],
|
||||
);
|
||||
// 默认全选
|
||||
const [selectedConnectorStores, setSelectedConnectorStores] = useState<string[]>([]);
|
||||
|
||||
// 初始化时默认全选
|
||||
useEffect(() => {
|
||||
if (selectedConnectorStores.length === 0 && allConnectorStoreValues.length > 0) {
|
||||
setSelectedConnectorStores(allConnectorStoreValues);
|
||||
}
|
||||
}, [allConnectorStoreValues]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 从选中值解析 store_id(取第一个,当前单门店场景)
|
||||
const selectedStoreIds = useMemo(() => parseSelectedStoreIds(selectedConnectorStores), [selectedConnectorStores]);
|
||||
const effectiveStoreId = selectedStoreIds.length === 1 ? selectedStoreIds[0] : null;
|
||||
|
||||
/* ---------- Flow ---------- */
|
||||
const [flow, setFlow] = useState<string>("api_ods_dwd");
|
||||
|
||||
/* ---------- 处理模式 ---------- */
|
||||
const [processingMode, setProcessingMode] = useState<string>("increment_only");
|
||||
const [fetchBeforeVerify, setFetchBeforeVerify] = useState(false);
|
||||
|
||||
/* ---------- 时间窗口 ---------- */
|
||||
const [windowMode, setWindowMode] = useState<WindowMode>("lookback");
|
||||
const [lookbackHours, setLookbackHours] = useState<number>(24);
|
||||
const [overlapSeconds, setOverlapSeconds] = useState<number>(600);
|
||||
const [windowStart, setWindowStart] = useState<Dayjs | null>(null);
|
||||
const [windowEnd, setWindowEnd] = useState<Dayjs | null>(null);
|
||||
const [windowSplitDays, setWindowSplitDays] = useState<number>(0);
|
||||
|
||||
/* ---------- 任务选择 ---------- */
|
||||
const [selectedTasks, setSelectedTasks] = useState<string[]>([]);
|
||||
const [selectedDwdTables, setSelectedDwdTables] = useState<string[]>([]);
|
||||
|
||||
/* ---------- 高级选项 ---------- */
|
||||
const [dryRun, setDryRun] = useState(false);
|
||||
const [forceFull, setForceFull] = useState(false);
|
||||
const [useLocalJson, setUseLocalJson] = useState(false);
|
||||
|
||||
/* ---------- CLI 预览 ---------- */
|
||||
const [cliCommand, setCliCommand] = useState<string>("");
|
||||
const [cliEdited, setCliEdited] = useState(false);
|
||||
const [cliLoading, setCliLoading] = useState(false);
|
||||
|
||||
/* ---------- 提交状态 ---------- */
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/* ---------- 派生状态 ---------- */
|
||||
const layers = getFlowLayers(flow);
|
||||
const showVerifyOption = processingMode === "verify_only";
|
||||
|
||||
/* ---------- 构建 TaskConfig 对象 ---------- */
|
||||
const buildTaskConfig = (): TaskConfigType => ({
|
||||
tasks: selectedTasks,
|
||||
pipeline: flow,
|
||||
processing_mode: processingMode,
|
||||
pipeline_flow: "FULL",
|
||||
dry_run: dryRun,
|
||||
window_mode: windowMode,
|
||||
window_start: windowMode === "custom" && windowStart ? windowStart.format("YYYY-MM-DD") : null,
|
||||
window_end: windowMode === "custom" && windowEnd ? windowEnd.format("YYYY-MM-DD") : null,
|
||||
window_split: windowSplitDays > 0 ? "day" : null,
|
||||
window_split_days: windowSplitDays > 0 ? windowSplitDays : null,
|
||||
lookback_hours: lookbackHours,
|
||||
overlap_seconds: overlapSeconds,
|
||||
fetch_before_verify: fetchBeforeVerify,
|
||||
skip_ods_when_fetch_before_verify: false,
|
||||
ods_use_local_json: useLocalJson,
|
||||
store_id: effectiveStoreId,
|
||||
dwd_only_tables: selectedDwdTables.length > 0 ? selectedDwdTables : null,
|
||||
force_full: forceFull,
|
||||
extra_args: {},
|
||||
});
|
||||
|
||||
/* ---------- 自动刷新 CLI 预览 ---------- */
|
||||
const refreshCli = async () => {
|
||||
setCliLoading(true);
|
||||
try {
|
||||
const { command } = await validateTaskConfig(buildTaskConfig());
|
||||
setCliCommand(command);
|
||||
setCliEdited(false);
|
||||
} catch {
|
||||
// 静默失败,保留上次命令
|
||||
} finally {
|
||||
setCliLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 配置变化时自动刷新 CLI(防抖)
|
||||
useEffect(() => {
|
||||
if (cliEdited) return; // 用户手动编辑过则不自动刷新
|
||||
const timer = setTimeout(refreshCli, 500);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flow, processingMode, fetchBeforeVerify, windowMode, lookbackHours, overlapSeconds,
|
||||
windowStart, windowEnd, windowSplitDays, selectedTasks, selectedDwdTables,
|
||||
dryRun, forceFull, useLocalJson, selectedConnectorStores]);
|
||||
|
||||
/* ---------- 事件处理 ---------- */
|
||||
const handleFlowChange = (e: RadioChangeEvent) => setFlow(e.target.value);
|
||||
|
||||
const handleSubmitToQueue = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitToQueue(buildTaskConfig());
|
||||
message.success("已提交到执行队列");
|
||||
navigate("/task-manager");
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "提交失败";
|
||||
message.error(`提交到队列失败:${msg}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteDirectly = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await executeDirectly(buildTaskConfig());
|
||||
message.success("任务已开始执行");
|
||||
navigate("/task-manager");
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "执行失败";
|
||||
message.error(`直接执行失败:${msg}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 样式常量 ---------- */
|
||||
const cardStyle = { marginBottom: 12 };
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
fontSize: 13, fontWeight: 500, color: "#666", marginBottom: 8, display: "block",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 960, margin: "0 auto" }}>
|
||||
{/* ---- 页面标题 ---- */}
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} />
|
||||
任务配置
|
||||
</Title>
|
||||
<Space>
|
||||
<Badge count={selectedTasks.length} size="small" offset={[-4, 0]}>
|
||||
<Text type="secondary">已选任务</Text>
|
||||
</Badge>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* ---- 第一行:连接器/门店 + Flow ---- */}
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Card size="small" title={<Space size={4}><ApiOutlined />连接器 / 门店</Space>} style={cardStyle}>
|
||||
<TreeSelect
|
||||
treeData={connectorTreeData}
|
||||
value={selectedConnectorStores}
|
||||
onChange={setSelectedConnectorStores}
|
||||
treeCheckable
|
||||
treeDefaultExpandAll
|
||||
showCheckedStrategy={TreeSelect.SHOW_CHILD}
|
||||
placeholder="选择连接器和门店"
|
||||
style={{ width: "100%" }}
|
||||
maxTagCount={3}
|
||||
maxTagPlaceholder={(omitted) => `+${omitted.length} 项`}
|
||||
treeCheckStrictly={false}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 11, marginTop: 6, display: "block" }}>
|
||||
{selectedStoreIds.length === 0
|
||||
? "未选择门店,将使用 JWT 默认值"
|
||||
: `已选 ${selectedStoreIds.length} 个门店`}
|
||||
</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Card size="small" title="执行流程 (Flow)" style={cardStyle}>
|
||||
<Radio.Group value={flow} onChange={handleFlowChange} style={{ width: "100%" }}>
|
||||
<Row gutter={[0, 4]}>
|
||||
{Object.entries(FLOW_DEFINITIONS).map(([id, def]) => (
|
||||
<Col span={12} key={id}>
|
||||
<Tooltip title={def.desc}>
|
||||
<Radio value={id}>
|
||||
<Text strong style={{ fontSize: 12 }}>{id}</Text>
|
||||
</Radio>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Radio.Group>
|
||||
<div style={{ marginTop: 6, padding: "4px 8px", background: "#f6f8fa", borderRadius: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{layers.join(" → ") || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* ---- 第二行:处理模式 + 时间窗口 ---- */}
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Card size="small" title="处理模式" style={cardStyle}>
|
||||
<Radio.Group
|
||||
value={processingMode}
|
||||
onChange={(e) => {
|
||||
setProcessingMode(e.target.value);
|
||||
if (e.target.value === "increment_only") setFetchBeforeVerify(false);
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{PROCESSING_MODES.map((m) => (
|
||||
<Radio key={m.value} value={m.value}>
|
||||
<Text strong>{m.label}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{m.desc}</Text>
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
{showVerifyOption && (
|
||||
<Checkbox
|
||||
checked={fetchBeforeVerify}
|
||||
onChange={(e) => setFetchBeforeVerify(e.target.checked)}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
校验前从 API 获取
|
||||
</Checkbox>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<><ClockCircleOutlined style={{ marginRight: 6 }} />时间窗口</>}
|
||||
style={cardStyle}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Segmented
|
||||
value={windowMode}
|
||||
onChange={(v) => setWindowMode(v as WindowMode)}
|
||||
options={[
|
||||
{ value: "lookback", label: "回溯模式" },
|
||||
{ value: "custom", label: "自定义范围" },
|
||||
]}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{windowMode === "lookback" ? (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Text style={sectionTitleStyle}>回溯小时数</Text>
|
||||
<InputNumber
|
||||
min={1} max={720} value={lookbackHours}
|
||||
onChange={(v) => setLookbackHours(v ?? 24)}
|
||||
style={{ width: "100%" }}
|
||||
addonAfter="小时"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text style={sectionTitleStyle}>冗余秒数</Text>
|
||||
<InputNumber
|
||||
min={0} max={7200} value={overlapSeconds}
|
||||
onChange={(v) => setOverlapSeconds(v ?? 600)}
|
||||
style={{ width: "100%" }}
|
||||
addonAfter="秒"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Text style={sectionTitleStyle}>开始日期</Text>
|
||||
<DatePicker
|
||||
value={windowStart} onChange={setWindowStart}
|
||||
placeholder="选择开始日期" style={{ width: "100%" }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text style={sectionTitleStyle}>结束日期</Text>
|
||||
<DatePicker
|
||||
value={windowEnd} onChange={setWindowEnd}
|
||||
placeholder="选择结束日期" style={{ width: "100%" }}
|
||||
status={windowStart && windowEnd && windowEnd.isBefore(windowStart) ? "error" : undefined}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text style={sectionTitleStyle}>窗口切分</Text>
|
||||
<Radio.Group
|
||||
value={windowSplitDays}
|
||||
onChange={(e) => setWindowSplitDays(e.target.value)}
|
||||
>
|
||||
{WINDOW_SPLIT_OPTIONS.map((opt) => (
|
||||
<Radio.Button key={opt.value} value={opt.value}>{opt.label}</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* ---- 高级选项(带描述) ---- */}
|
||||
<Card size="small" title="高级选项" style={cardStyle}>
|
||||
<Row gutter={[24, 8]}>
|
||||
<Col span={12}>
|
||||
<Checkbox checked={dryRun} onChange={(e) => setDryRun(e.target.checked)}>
|
||||
<Text strong>dry-run</Text>
|
||||
</Checkbox>
|
||||
<div style={{ marginLeft: 24 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>模拟执行,走完整流程但不写入数据库</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Checkbox checked={forceFull} onChange={(e) => setForceFull(e.target.checked)}>
|
||||
<Text strong>force-full</Text>
|
||||
</Checkbox>
|
||||
<div style={{ marginLeft: 24 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>强制全量,跳过 hash 去重和变更对比</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Checkbox checked={useLocalJson} onChange={(e) => setUseLocalJson(e.target.checked)}>
|
||||
<Text strong>本地 JSON</Text>
|
||||
</Checkbox>
|
||||
<div style={{ marginLeft: 24 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>离线模式,从本地 JSON 回放(等同 --data-source offline)</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* ---- 任务选择(含 DWD 表过滤) ---- */}
|
||||
<Card size="small" title="任务选择" style={cardStyle}>
|
||||
<TaskSelector
|
||||
layers={layers}
|
||||
selectedTasks={selectedTasks}
|
||||
onTasksChange={setSelectedTasks}
|
||||
selectedDwdTables={selectedDwdTables}
|
||||
onDwdTablesChange={setSelectedDwdTables}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ---- CLI 命令预览(内嵌可编辑) ---- */}
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<CodeOutlined />
|
||||
<span>CLI 命令预览</span>
|
||||
{cliEdited && <Text type="warning" style={{ fontSize: 12 }}>(已手动编辑)</Text>}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined spin={cliLoading} />}
|
||||
onClick={() => { setCliEdited(false); refreshCli(); }}
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
}
|
||||
style={cardStyle}
|
||||
>
|
||||
<TextArea
|
||||
value={cliCommand}
|
||||
onChange={(e) => { setCliCommand(e.target.value); setCliEdited(true); }}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
style={{
|
||||
fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace",
|
||||
fontSize: 13,
|
||||
background: "#1e1e1e",
|
||||
color: "#d4d4d4",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
placeholder="配置变更后自动生成 CLI 命令..."
|
||||
/>
|
||||
{cliEdited && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="已手动编辑命令,配置变更不会自动覆盖。点击「重新生成」恢复自动模式。"
|
||||
style={{ marginTop: 8 }}
|
||||
banner
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ---- 操作按钮 ---- */}
|
||||
<Card size="small" style={{ marginBottom: 24 }}>
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<SendOutlined />}
|
||||
loading={submitting}
|
||||
onClick={handleSubmitToQueue}
|
||||
>
|
||||
提交到队列
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={submitting}
|
||||
onClick={handleExecuteDirectly}
|
||||
>
|
||||
直接执行
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskConfig;
|
||||
255
apps/admin-web/src/pages/TaskManager.tsx
Normal file
255
apps/admin-web/src/pages/TaskManager.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 任务管理页面。
|
||||
*
|
||||
* 三个 Tab:队列、调度、历史
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Tabs, Table, Tag, Button, Popconfirm, Space, message, Drawer,
|
||||
Typography, Descriptions, Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
ReloadOutlined, DeleteOutlined, StopOutlined,
|
||||
UnorderedListOutlined, ClockCircleOutlined, HistoryOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { QueuedTask, ExecutionLog } from '../types';
|
||||
import {
|
||||
fetchQueue, fetchHistory, deleteFromQueue, cancelExecution,
|
||||
} from '../api/execution';
|
||||
import ScheduleTab from '../components/ScheduleTab';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 状态颜色映射 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: 'default',
|
||||
running: 'processing',
|
||||
success: 'success',
|
||||
failed: 'error',
|
||||
cancelled: 'warning',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 工具函数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function fmtTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number | null | undefined): string {
|
||||
if (ms == null) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const sec = ms / 1000;
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
const remainSec = Math.round(sec % 60);
|
||||
return `${min}m${remainSec}s`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 队列 Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const QueueTab: React.FC = () => {
|
||||
const [data, setData] = useState<QueuedTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setData(await fetchQueue()); }
|
||||
catch { message.error('加载队列失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try { await deleteFromQueue(id); message.success('已删除'); load(); }
|
||||
catch { message.error('删除失败'); }
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
try { await cancelExecution(id); message.success('已取消'); load(); }
|
||||
catch { message.error('取消失败'); }
|
||||
};
|
||||
|
||||
const columns: ColumnsType<QueuedTask> = [
|
||||
{
|
||||
title: '任务', dataIndex: ['config', 'tasks'], key: 'tasks',
|
||||
render: (tasks: string[]) => (
|
||||
<Text style={{ maxWidth: 300 }} ellipsis={{ tooltip: tasks?.join(', ') }}>
|
||||
{tasks?.join(', ') ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Flow', dataIndex: ['config', 'pipeline'], key: 'pipeline', width: 120,
|
||||
render: (v: string) => <Tag>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||
render: (s: string) => <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag>,
|
||||
},
|
||||
{ title: '位置', dataIndex: 'position', key: 'position', width: 60, align: 'center' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: fmtTime },
|
||||
{
|
||||
title: '操作', key: 'action', width: 100, align: 'center',
|
||||
render: (_: unknown, record: QueuedTask) => {
|
||||
if (record.status === 'pending') {
|
||||
return (
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
if (record.status === 'running') {
|
||||
return (
|
||||
<Popconfirm title="确认取消执行?" onConfirm={() => handleCancel(record.id)}>
|
||||
<Button type="link" danger icon={<StopOutlined />} size="small">取消</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">共 {data.length} 个任务</Text>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</div>
|
||||
<Table<QueuedTask>
|
||||
rowKey="id" columns={columns} dataSource={data}
|
||||
loading={loading} pagination={false} size="small"
|
||||
locale={{ emptyText: <Empty description="队列为空" /> }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 历史 Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const HistoryTab: React.FC = () => {
|
||||
const [data, setData] = useState<ExecutionLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<ExecutionLog | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setData(await fetchHistory()); }
|
||||
catch { message.error('加载历史记录失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const columns: ColumnsType<ExecutionLog> = [
|
||||
{
|
||||
title: '任务', dataIndex: 'task_codes', key: 'task_codes',
|
||||
render: (codes: string[]) => (
|
||||
<Text style={{ maxWidth: 300 }} ellipsis={{ tooltip: codes?.join(', ') }}>
|
||||
{codes?.join(', ') ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||
render: (s: string) => <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag>,
|
||||
},
|
||||
{ title: '开始时间', dataIndex: 'started_at', key: 'started_at', width: 170, render: fmtTime },
|
||||
{ title: '时长', dataIndex: 'duration_ms', key: 'duration_ms', width: 90, render: fmtDuration },
|
||||
{
|
||||
title: '退出码', dataIndex: 'exit_code', key: 'exit_code', width: 70, align: 'center',
|
||||
render: (v: number | null) => v != null ? (
|
||||
<Tag color={v === 0 ? 'success' : 'error'}>{v}</Tag>
|
||||
) : '—',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">最近 {data.length} 条记录</Text>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</div>
|
||||
<Table<ExecutionLog>
|
||||
rowKey="id" columns={columns} dataSource={data}
|
||||
loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||||
size="small"
|
||||
onRow={(record) => ({ onClick: () => setDetail(record), style: { cursor: 'pointer' } })}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="执行详情" open={!!detail} onClose={() => setDetail(null)}
|
||||
width={520}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="任务">{detail.task_codes?.join(', ')}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status] ?? 'default'}>{detail.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开始时间">{fmtTime(detail.started_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结束时间">{fmtTime(detail.finished_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时长">{fmtDuration(detail.duration_ms)}</Descriptions.Item>
|
||||
<Descriptions.Item label="退出码">
|
||||
{detail.exit_code != null ? (
|
||||
<Tag color={detail.exit_code === 0 ? 'success' : 'error'}>{detail.exit_code}</Tag>
|
||||
) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="命令">
|
||||
<code style={{ wordBreak: 'break-all', fontSize: 12 }}>{detail.command || '—'}</code>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 主组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TaskManager: React.FC = () => {
|
||||
const items = [
|
||||
{
|
||||
key: 'queue',
|
||||
label: <Space><UnorderedListOutlined />队列</Space>,
|
||||
children: <QueueTab />,
|
||||
},
|
||||
{
|
||||
key: 'schedule',
|
||||
label: <Space><ClockCircleOutlined />调度</Space>,
|
||||
children: <ScheduleTab />,
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
label: <Space><HistoryOutlined />历史</Space>,
|
||||
children: <HistoryTab />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<UnorderedListOutlined style={{ marginRight: 8 }} />
|
||||
任务管理
|
||||
</Title>
|
||||
<Tabs defaultActiveKey="queue" items={items} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskManager;
|
||||
137
apps/admin-web/src/store/authStore.ts
Normal file
137
apps/admin-web/src/store/authStore.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 认证状态管理 — Zustand store。
|
||||
*
|
||||
* - 存储 JWT 令牌和用户信息
|
||||
* - login / logout / hydrate 三个核心方法
|
||||
* - 令牌同步到 localStorage,与 client.ts 拦截器共用同一 key
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { apiClient } from "../api/client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 类型 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 当前登录用户信息(从 JWT payload 解码或登录响应中获取) */
|
||||
export interface AuthUser {
|
||||
user_id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
site_id: number;
|
||||
}
|
||||
|
||||
/** 后端 /api/auth/login 响应体 */
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
/** 用户名密码登录,成功后存储令牌到 state 和 localStorage */
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
/** 登出,清除 state 和 localStorage */
|
||||
logout: () => void;
|
||||
/** 从 localStorage 恢复状态(应用启动时调用) */
|
||||
hydrate: () => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 — 与 client.ts 保持一致 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ACCESS_TOKEN_KEY = "access_token";
|
||||
const REFRESH_TOKEN_KEY = "refresh_token";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 辅助:从 JWT payload 解析用户信息 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function parseJwtPayload(token: string): AuthUser | null {
|
||||
try {
|
||||
const base64 = token.split(".")[1];
|
||||
if (!base64) return null;
|
||||
const json = atob(base64);
|
||||
const payload = JSON.parse(json) as Record<string, unknown>;
|
||||
return {
|
||||
user_id: payload.user_id as number,
|
||||
username: payload.username as string,
|
||||
display_name: (payload.display_name as string) ?? "",
|
||||
site_id: payload.site_id as number,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Store */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, _get) => ({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
async login(username: string, password: string) {
|
||||
const { data } = await apiClient.post<LoginResponse>("/auth/login", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = data;
|
||||
|
||||
// 持久化到 localStorage
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, access_token);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refresh_token);
|
||||
|
||||
const user = parseJwtPayload(access_token);
|
||||
|
||||
set({
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
|
||||
hydrate() {
|
||||
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
|
||||
if (accessToken) {
|
||||
const user = parseJwtPayload(accessToken);
|
||||
set({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// 监听 axios 拦截器的强制登出事件,同步清除 Zustand 状态
|
||||
// 避免 localStorage 已清空但 isAuthenticated 仍为 true 导致白屏
|
||||
window.addEventListener("auth:force-logout", () => {
|
||||
useAuthStore.getState().logout();
|
||||
});
|
||||
133
apps/admin-web/src/types/index.ts
Normal file
133
apps/admin-web/src/types/index.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 前后端共享的 TypeScript 类型定义。
|
||||
* 与设计文档中的 Pydantic 模型和数据库表结构对应。
|
||||
*/
|
||||
|
||||
/** ETL 任务执行配置 */
|
||||
export interface TaskConfig {
|
||||
tasks: string[];
|
||||
/** 执行流程 Flow ID(对应 CLI --pipeline) */
|
||||
pipeline: string;
|
||||
/** 处理模式 */
|
||||
processing_mode: string;
|
||||
/** 传统模式兼容(已弃用) */
|
||||
pipeline_flow: string;
|
||||
dry_run: boolean;
|
||||
/** lookback / custom */
|
||||
window_mode: string;
|
||||
window_start: string | null;
|
||||
window_end: string | null;
|
||||
/** none / day */
|
||||
window_split: string | null;
|
||||
/** 1 / 10 / 30 */
|
||||
window_split_days: number | null;
|
||||
lookback_hours: number;
|
||||
overlap_seconds: number;
|
||||
fetch_before_verify: boolean;
|
||||
skip_ods_when_fetch_before_verify: boolean;
|
||||
ods_use_local_json: boolean;
|
||||
/** 门店 ID(由后端从 JWT 注入) */
|
||||
store_id: number | null;
|
||||
/** DWD 表级选择 */
|
||||
dwd_only_tables: string[] | null;
|
||||
/** 强制全量处理(跳过 hash 去重和变更对比) */
|
||||
force_full: boolean;
|
||||
extra_args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 执行流程(Flow)定义 */
|
||||
export interface PipelineDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 包含的层:ODS / DWD / DWS / INDEX */
|
||||
layers: string[];
|
||||
}
|
||||
|
||||
/** 处理模式定义 */
|
||||
export interface ProcessingModeDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 任务注册表中的任务定义 */
|
||||
export interface TaskDefinition {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** 业务域(会员、结算、助教等) */
|
||||
domain: string;
|
||||
requires_window: boolean;
|
||||
is_ods: boolean;
|
||||
is_dimension: boolean;
|
||||
default_enabled: boolean;
|
||||
/** 常用任务标记,false 表示工具类/手动类任务 */
|
||||
is_common: boolean;
|
||||
}
|
||||
|
||||
/** 调度配置 */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** 队列中的任务 */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** 执行历史记录 */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** 调度任务 */
|
||||
export interface ScheduledTask {
|
||||
id: string;
|
||||
site_id: number;
|
||||
name: string;
|
||||
task_codes: string[];
|
||||
task_config: TaskConfig;
|
||||
schedule_config: ScheduleConfig;
|
||||
enabled: boolean;
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
run_count: number;
|
||||
last_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 环境配置项 */
|
||||
export interface EnvConfigItem {
|
||||
key: string;
|
||||
value: string;
|
||||
is_sensitive: boolean;
|
||||
}
|
||||
|
||||
1
apps/admin-web/src/vite-env.d.ts
vendored
Normal file
1
apps/admin-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
apps/admin-web/tsconfig.json
Normal file
26
apps/admin-web/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* 打包 */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* 类型检查 */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
25
apps/admin-web/tsconfig.node.json
Normal file
25
apps/admin-web/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user