""" 数据流结构分析 — 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 """ from __future__ import annotations import argparse import os from datetime import datetime from pathlib import Path def build_parser() -> argparse.ArgumentParser: """ 构造 CLI 参数解析器。 参数: --date-from 数据获取起始日期 (YYYY-MM-DD) --date-to 数据获取截止日期 (YYYY-MM-DD) --limit 每端点最大记录数 (默认 200) --tables 要分析的表名列表 (逗号分隔,缺省=全部) """ parser = argparse.ArgumentParser( description="数据流结构分析 — 采集 API JSON 和 DB 表结构", ) parser.add_argument( "--date-from", type=str, default=None, help="数据获取起始日期 (YYYY-MM-DD),默认 30 天前", ) parser.add_argument( "--date-to", type=str, default=None, help="数据获取截止日期 (YYYY-MM-DD),默认今天", ) parser.add_argument( "--limit", type=int, default=200, help="每端点最大记录数 (默认 200)", ) parser.add_argument( "--tables", type=str, default=None, help="要分析的表名列表 (逗号分隔,缺省=全部)", ) return parser def resolve_output_dir() -> Path: """ 确定输出目录: 1. 从 .env 读取 SYSTEM_ANALYZE_ROOT 2. 确保目录存在(自动创建) """ from _env_paths import get_output_path return get_output_path("SYSTEM_ANALYZE_ROOT") def generate_output_filename(dt: "datetime") -> str: """生成输出文件名:dataflow_YYYY-MM-DD_HHMMSS.md""" return f"dataflow_{dt.strftime('%Y-%m-%d_%H%M%S')}.md" def main() -> None: """ 串联采集流程: 1. 解析 CLI 参数 2. 加载环境变量(.env 分层叠加) 3. 构造 AnalyzerConfig 4. 调用 collect_all_tables() 执行采集 5. 调用 dump_collection_results() 落盘 6. 输出采集摘要到 stdout """ from datetime import date as _date, datetime as _datetime, timedelta as _timedelta # ── 1. 解析 CLI 参数 ── parser = build_parser() args = parser.parse_args() # ── 2. 加载环境变量 ── # _env_paths 在 import 时已通过 Path(__file__).parents[2] / ".env" 绝对路径 # 加载了根 .env,无需再用相对路径 load_dotenv(避免 cwd 不在项目根时失效) output_dir = resolve_output_dir() # 触发 _env_paths import → 加载根 .env # ── 3. 构造基础参数 ── date_to = _date.fromisoformat(args.date_to) if args.date_to else _date.today() user_date_from = _date.fromisoformat(args.date_from) if args.date_from else None target_limit = args.limit tables_filter = [t.strip() for t in args.tables.split(",")] if args.tables else None # CHANGE 2026-02-21 | 遵循 testing-env.md:优先使用测试库 TEST_DB_DSN pg_dsn = os.environ.get("TEST_DB_DSN") or os.environ.get("PG_DSN", "") if not pg_dsn: raise RuntimeError("TEST_DB_DSN 和 PG_DSN 均未定义,请检查根 .env 配置") from dataflow_analyzer import AnalyzerConfig, ODS_SPECS, collect_all_tables, dump_collection_results # CHANGE 2026-02-21 | API 凭证缺失时提前报错,避免静默产出空报告 api_base = os.environ.get("API_BASE", "") api_token = os.environ.get("API_TOKEN", "") store_id = os.environ.get("STORE_ID", "") missing = [k for k, v in [("API_BASE", api_base), ("API_TOKEN", api_token), ("STORE_ID", store_id)] if not v] if missing: raise RuntimeError( f"API 凭证缺失:{', '.join(missing)}。" f"请在根 .env 中配置,参考 .env.template" ) base_kwargs = dict( date_to=date_to, limit=target_limit, output_dir=output_dir, pg_dsn=pg_dsn, api_base=api_base, api_token=api_token, store_id=store_id, ) # ── 4. 逐表自适应日期扩展采集 ── # CHANGE 2026-02-21 | 策略:10天 → 30天 → 90天,3 个档位 expand_days = [10, 30, 90] if user_date_from: # 用户显式指定了 date_from,不做自适应扩展 expand_days = [] initial_date_from = user_date_from else: initial_date_from = date_to - _timedelta(days=expand_days[0]) # 首轮采集 config = AnalyzerConfig(date_from=initial_date_from, tables=tables_filter, **base_kwargs) results = collect_all_tables(config, specs=ODS_SPECS) actual_date_from = initial_date_from # 自适应扩展:对不满 target_limit 的表逐步扩大日期范围 # CHANGE 2026-02-21 | 维表(time_fields=None)不参与时间扩展,其 API 不接受日期范围 _dim_tables = {s["table"] for s in ODS_SPECS if s.get("time_fields") is None} if not user_date_from: for days in expand_days[1:]: short_tables = [r.table_name for r in results if r.error is None and r.record_count < target_limit and r.table_name not in _dim_tables] if not short_tables: break # 所有表都满足了 wider_from = date_to - _timedelta(days=days) print(f" [自适应扩展] {len(short_tables)} 张表不足 {target_limit} 条,扩展至 {wider_from} ~ {date_to}") wider_config = AnalyzerConfig( date_from=wider_from, tables=short_tables, **base_kwargs) wider_results = collect_all_tables(wider_config, specs=ODS_SPECS) # 用更宽范围的结果替换不满的表(仅当新结果记录数更多时) wider_map = {r.table_name: r for r in wider_results} for idx, r in enumerate(results): if r.table_name in wider_map: new_r = wider_map[r.table_name] if new_r.record_count > r.record_count: results[idx] = new_r actual_date_from = wider_from # ── 5. 落盘 ── paths = dump_collection_results(results, output_dir) # ── 5.1 将实际使用的 date_from/date_to 追加写入 manifest ── import json as _json manifest_path = output_dir / "collection_manifest.json" if manifest_path.exists(): with open(manifest_path, "r", encoding="utf-8") as _f: manifest_data = _json.load(_f) manifest_data["date_from"] = str(actual_date_from) manifest_data["date_to"] = str(date_to) with open(manifest_path, "w", encoding="utf-8") as _f: _json.dump(manifest_data, _f, ensure_ascii=False, indent=2) # ── 6. 输出采集摘要 ── now = _datetime.now() filename = generate_output_filename(now) ok = sum(1 for r in results if r.error is None) fail = len(results) - ok total_records = sum(r.record_count for r in results) print(f"\n{'='*60}") print(f"数据流结构分析完成") print(f"{'='*60}") print(f" 输出目录: {output_dir}") print(f" 报告文件名: {filename}") print(f" 分析表数: {len(results)} ({ok} 成功, {fail} 失败)") print(f" 总记录数: {total_records}") print(f" 落盘路径:") for category, p in paths.items(): print(f" {category}: {p}") print(f"{'='*60}") if __name__ == "__main__": main()