#322 C2 门禁落地: analytics↔ods 隔离棘轮检查器 + CI job
Some checks failed
backend-test / commit-message-lint (push) Has been cancelled
backend-test / analytics-ods-isolation (push) Has been cancelled
backend-test / backend-lint-test (push) Has been cancelled
backend-test / frontend-lint-test (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Some checks failed
backend-test / commit-message-lint (push) Has been cancelled
backend-test / analytics-ods-isolation (push) Has been cancelled
backend-test / backend-lint-test (push) Has been cancelled
backend-test / frontend-lint-test (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
- scripts/check_analytics_ods_isolation.py: 检 ods_prefix/sql_table/orm_model 三类违规, 与基线棘轮比对(新增违规阻断 CI, 减少则提示收敛) - scripts/analytics_ods_isolation_baseline.txt: 80 条存量基线(orm_model 64 + sql_table 16) - .gitea/workflows/test.yml: 新增 analytics-ods-isolation job(纯 python3, 不依赖 docker)
This commit is contained in:
@@ -61,6 +61,23 @@ jobs:
|
||||
bash scripts/check-commit-issue.sh --head-only
|
||||
fi
|
||||
|
||||
# #319 契约 C2: analytics 层禁止直读 ods 源表(P2 逐域切换的棘轮门禁)。
|
||||
# 纯 python3 静态扫描, 不依赖 docker; 只阻断「新增」违规, 存量违规入基线逐步收敛。
|
||||
analytics-ods-isolation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码 (显式 clone)
|
||||
run: |
|
||||
rm -rf /workspace/ci-repo
|
||||
git clone --quiet http://127.0.0.1:3000/tanzk/csctalon.git /workspace/ci-repo
|
||||
cd /workspace/ci-repo && git log --oneline -1
|
||||
|
||||
- name: 校验 analytics↔ods 隔离 (C2 棘轮)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd /workspace/ci-repo
|
||||
python3 scripts/check_analytics_ods_isolation.py
|
||||
|
||||
backend-lint-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
82
scripts/analytics_ods_isolation_baseline.txt
Normal file
82
scripts/analytics_ods_isolation_baseline.txt
Normal file
@@ -0,0 +1,82 @@
|
||||
# C2 棘轮基线: analytics 读 ods 源表的存量违规 (#319 C2 / P2 逐域收敛)
|
||||
# 每行一签: <文件>|<类型>|<token>;P2 每切换一个域, 该域条目应减少。
|
||||
backend/app/services/analytics/_core.py|orm_model|BmsOrder
|
||||
backend/app/services/analytics/_core.py|orm_model|CardComplaint
|
||||
backend/app/services/analytics/_core.py|orm_model|DeviceCombo
|
||||
backend/app/services/analytics/_core.py|orm_model|ExternalComplaint
|
||||
backend/app/services/analytics/_core.py|orm_model|Refund
|
||||
backend/app/services/analytics/_core.py|orm_model|RefundRecordReport
|
||||
backend/app/services/analytics/_core.py|orm_model|WalletWithdrawal
|
||||
backend/app/services/analytics/cash.py|orm_model|ManualRefund
|
||||
backend/app/services/analytics/cash.py|orm_model|Refund
|
||||
backend/app/services/analytics/cash.py|orm_model|RefundRecordReport
|
||||
backend/app/services/analytics/cash.py|orm_model|WalletWithdrawal
|
||||
backend/app/services/analytics/category.py|orm_model|DeviceCombo
|
||||
backend/app/services/analytics/category.py|orm_model|DeviceFlowUsage
|
||||
backend/app/services/analytics/category.py|orm_model|Refund
|
||||
backend/app/services/analytics/category.py|sql_table|device_combos
|
||||
backend/app/services/analytics/complaint.py|orm_model|BmsOrder
|
||||
backend/app/services/analytics/complaint.py|orm_model|CsRecord
|
||||
backend/app/services/analytics/complaint.py|orm_model|Refund
|
||||
backend/app/services/analytics/reception.py|orm_model|CsRecord
|
||||
backend/app/services/analytics/reception.py|orm_model|Refund
|
||||
backend/app/services/analytics/refund.py|orm_model|BmsOrder
|
||||
backend/app/services/analytics/refund.py|orm_model|ManualRefund
|
||||
backend/app/services/analytics/refund.py|sql_table|cs_records
|
||||
backend/app/services/analytics/refund.py|sql_table|device_combos
|
||||
backend/app/services/analytics/refund.py|sql_table|manual_refunds
|
||||
backend/app/services/analytics/refund.py|sql_table|refund_record_reports
|
||||
backend/app/services/analytics/refund.py|sql_table|refunds
|
||||
backend/app/services/analytics/refund.py|sql_table|shanmao_refunds
|
||||
backend/app/services/analytics/refund.py|sql_table|wallet_withdrawals
|
||||
backend/app/services/analytics/refund_chain.py|orm_model|ManualRefund
|
||||
backend/app/services/analytics/refund_chain.py|orm_model|Refund
|
||||
backend/app/services/analytics/refund_chain.py|orm_model|WalletWithdrawal
|
||||
backend/app/services/analytics/refund_chain.py|sql_table|refunds
|
||||
backend/app/services/analytics/refund_chain.py|sql_table|wallet_withdrawals
|
||||
backend/app/services/analytics/rr_views.py|orm_model|CardComplaint
|
||||
backend/app/services/analytics/rr_views.py|orm_model|CsRecord
|
||||
backend/app/services/analytics/rr_views.py|orm_model|ExternalComplaint
|
||||
backend/app/services/analytics/rr_views.py|sql_table|refunds
|
||||
backend/app/services/analytics/today/_kpi.py|orm_model|CardComplaint
|
||||
backend/app/services/analytics/today/_kpi.py|orm_model|CsRecord
|
||||
backend/app/services/analytics/today/_kpi.py|orm_model|DeviceCombo
|
||||
backend/app/services/analytics/today/_kpi.py|orm_model|ExternalComplaint
|
||||
backend/app/services/analytics/today/_kpi.py|orm_model|Refund
|
||||
backend/app/services/analytics/today/_kpi.py|orm_model|RefundRecordReport
|
||||
backend/app/services/analytics/today/_kpi.py|orm_model|WalletWithdrawal
|
||||
backend/app/services/analytics/today/_queue.py|orm_model|CardComplaint
|
||||
backend/app/services/analytics/today/_queue.py|orm_model|CsRecord
|
||||
backend/app/services/analytics/today/_queue.py|orm_model|ExternalComplaint
|
||||
backend/app/services/analytics/today/_queue.py|orm_model|Refund
|
||||
backend/app/services/analytics/today/_queue.py|orm_model|RefundRecordReport
|
||||
backend/app/services/analytics/today/_queue.py|orm_model|WalletWithdrawal
|
||||
backend/app/services/analytics/today/_reception.py|orm_model|CardComplaint
|
||||
backend/app/services/analytics/today/_reception.py|orm_model|CsRecord
|
||||
backend/app/services/analytics/today/_reception.py|orm_model|ExternalComplaint
|
||||
backend/app/services/analytics/today/_reception.py|orm_model|Refund
|
||||
backend/app/services/analytics/today/_reception.py|orm_model|RefundRecordReport
|
||||
backend/app/services/analytics/today/_reception.py|orm_model|WalletWithdrawal
|
||||
backend/app/services/analytics/today/_reception.py|sql_table|card_complaints
|
||||
backend/app/services/analytics/today/_reception.py|sql_table|cs_records
|
||||
backend/app/services/analytics/today/_reception.py|sql_table|external_complaints
|
||||
backend/app/services/analytics/today/_refund.py|orm_model|CardComplaint
|
||||
backend/app/services/analytics/today/_refund.py|orm_model|CsRecord
|
||||
backend/app/services/analytics/today/_refund.py|orm_model|ExternalComplaint
|
||||
backend/app/services/analytics/today/_refund.py|orm_model|Refund
|
||||
backend/app/services/analytics/today/_refund.py|orm_model|RefundRecordReport
|
||||
backend/app/services/analytics/today/_refund.py|orm_model|WalletWithdrawal
|
||||
backend/app/services/analytics/today/_staff.py|orm_model|CardComplaint
|
||||
backend/app/services/analytics/today/_staff.py|orm_model|CsRecord
|
||||
backend/app/services/analytics/today/_staff.py|orm_model|ExternalComplaint
|
||||
backend/app/services/analytics/today/_staff.py|orm_model|Refund
|
||||
backend/app/services/analytics/today/_staff.py|orm_model|RefundRecordReport
|
||||
backend/app/services/analytics/today/_staff.py|orm_model|WalletWithdrawal
|
||||
backend/app/services/analytics/today/_staff.py|sql_table|cs_records
|
||||
backend/app/services/analytics/today/_staff.py|sql_table|refunds
|
||||
backend/app/services/analytics/today/_trend.py|orm_model|CardComplaint
|
||||
backend/app/services/analytics/today/_trend.py|orm_model|CsRecord
|
||||
backend/app/services/analytics/today/_trend.py|orm_model|ExternalComplaint
|
||||
backend/app/services/analytics/today/_trend.py|orm_model|Refund
|
||||
backend/app/services/analytics/today/_trend.py|orm_model|RefundRecordReport
|
||||
backend/app/services/analytics/today/_trend.py|orm_model|WalletWithdrawal
|
||||
145
scripts/check_analytics_ods_isolation.py
Normal file
145
scripts/check_analytics_ods_isolation.py
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""C2 闸门: analytics 层禁止读 ods 源表 (#319 契约 C2, P2 落地)
|
||||
|
||||
背景: #319 将 21 张信源表移入 `ods` schema, 并加了连接级 search_path=public,ods
|
||||
兜底裸 SQL。C2 要求 `services/analytics/**` 不得依赖该兜底直读 ods 源表, 须改读
|
||||
public 干净层(宽表/字典)。P2 按 refund→complaint→reception 逐域切换。
|
||||
|
||||
本检查器用「棘轮(ratchet)」模式落地门禁:
|
||||
- 扫描 analytics 目录, 检出三类违规:
|
||||
ods_prefix 裸 SQL/文本出现 `ods.` 前缀表名
|
||||
sql_table 裸 SQL 以 FROM/JOIN/UPDATE/... 直读 21 张 ods 表之一(非限定名)
|
||||
orm_model import 了 schema=ods 的 ORM 模型类
|
||||
- 与基线文件 `scripts/analytics_ods_isolation_baseline.txt` 比对:
|
||||
新增违规 → 退出码 1(阻断 CI); 减少违规 → 提示可收敛基线(不阻断)
|
||||
- 基线随 P2 逐域切换逐步收敛, 最终清空即 C2 完全生效。
|
||||
|
||||
用法:
|
||||
python3 scripts/check_analytics_ods_isolation.py # 校验(CI 用)
|
||||
python3 scripts/check_analytics_ods_isolation.py --update-baseline # 收敛后重生成基线
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MODELS_DIR = ROOT / "backend" / "app" / "models"
|
||||
ANALYTICS_DIR = ROOT / "backend" / "app" / "services" / "analytics"
|
||||
BASELINE_PATH = Path(__file__).resolve().parent / "analytics_ods_isolation_baseline.txt"
|
||||
|
||||
# 21 张信源表(权威来源: alembic e7f8a9b0c1d2_issue319_move_source_tables_to_ods.py)
|
||||
ODS_TABLES: tuple[str, ...] = (
|
||||
"bms_orders", "device_combos", "refunds", "manual_refunds", "merchant_refunds",
|
||||
"shanmao_refunds", "refund_record_reports", "card_complaints", "external_complaints",
|
||||
"cs_records", "scrm_sessions", "scrm_session_transfers", "scrm_operators",
|
||||
"scrm_staff_profiles", "wallet_withdrawals", "device_flow_usage", "return_trackings",
|
||||
"wechat_registrations", "wechat_accounts", "dept_info_syncs", "attendance",
|
||||
)
|
||||
|
||||
_SQL_READ = re.compile(
|
||||
r"(?i)\b(?:FROM|JOIN|UPDATE|INSERT\s+INTO|DELETE\s+FROM|TABLE)\s+(?:ods\.)?("
|
||||
+ "|".join(re.escape(t) for t in ODS_TABLES)
|
||||
+ r")\b"
|
||||
)
|
||||
_ODS_PREFIX = re.compile(r"\bods\.[a-z_]+")
|
||||
|
||||
|
||||
def load_ods_model_classes() -> dict[str, str]:
|
||||
"""解析 models/*.py, 返回 {类名: 表名} —— 仅含 schema=ods 的类。
|
||||
|
||||
逐行顺序解析: `class X` 开启新类, `__tablename__=...` 与 `"schema": "ods"`
|
||||
归属当前类(模型的列定义在类体内、下一个 class 之前)。
|
||||
"""
|
||||
classes: dict[str, str] = {}
|
||||
tablenames: dict[str, str] = {}
|
||||
for f in sorted(MODELS_DIR.glob("*.py")):
|
||||
cur: str | None = None
|
||||
for line in f.read_text(encoding="utf-8").splitlines():
|
||||
mc = re.match(r"\s*class\s+(\w+)\s*[\(:]", line)
|
||||
if mc:
|
||||
cur = mc.group(1)
|
||||
mt = re.search(r"__tablename__\s*=\s*[\"']([^\"']+)[\"']", line)
|
||||
if mt and cur:
|
||||
tablenames[cur] = mt.group(1)
|
||||
if re.search(r"[\"']schema[\"']\s*:\s*[\"']ods[\"']", line) and cur:
|
||||
classes[cur] = tablenames.get(cur, cur)
|
||||
return classes
|
||||
|
||||
|
||||
def scan() -> set[str]:
|
||||
"""返回违规签名集合, 形如 `<relpath>|<kind>|<token>`(不含行号, 稳于编辑)。"""
|
||||
ods_classes = load_ods_model_classes()
|
||||
violations: set[str] = set()
|
||||
|
||||
for f in sorted(ANALYTICS_DIR.rglob("*.py")):
|
||||
rel = f.relative_to(ROOT).as_posix()
|
||||
text = f.read_text(encoding="utf-8")
|
||||
|
||||
# 1) 显式 ods. 前缀
|
||||
for m in _ODS_PREFIX.finditer(text):
|
||||
violations.add(f"{rel}|ods_prefix|{m.group(0)}")
|
||||
|
||||
# 2) 裸 SQL 直读 ods 表
|
||||
for m in _SQL_READ.finditer(text):
|
||||
violations.add(f"{rel}|sql_table|{m.group(1).lower()}")
|
||||
|
||||
# 3) import / 使用 schema=ods 的 ORM 模型类
|
||||
for cn in ods_classes:
|
||||
if re.search(rf"\b{re.escape(cn)}\b", text):
|
||||
violations.add(f"{rel}|orm_model|{cn}")
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def read_baseline() -> set[str]:
|
||||
if not BASELINE_PATH.exists():
|
||||
return set()
|
||||
return {
|
||||
ln.strip()
|
||||
for ln in BASELINE_PATH.read_text(encoding="utf-8").splitlines()
|
||||
if ln.strip() and not ln.startswith("#")
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--update-baseline", action="store_true", help="按当前扫描结果重写基线")
|
||||
args = ap.parse_args()
|
||||
|
||||
current = scan()
|
||||
|
||||
if args.update_baseline:
|
||||
BASELINE_PATH.write_text(
|
||||
"# C2 棘轮基线: analytics 读 ods 源表的存量违规 (#319 C2 / P2 逐域收敛)\n"
|
||||
"# 每行一签: <文件>|<类型>|<token>;P2 每切换一个域, 该域条目应减少。\n"
|
||||
+ "\n".join(sorted(current)) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"基线已更新: {len(current)} 条 → {BASELINE_PATH.relative_to(ROOT)}")
|
||||
return 0
|
||||
|
||||
baseline = read_baseline()
|
||||
new = sorted(current - baseline)
|
||||
gone = sorted(baseline - current)
|
||||
|
||||
print(f"C2 analytics↔ods 隔离: 当前违规 {len(current)} · 基线 {len(baseline)}")
|
||||
if gone:
|
||||
print(f"✅ 已收敛 {len(gone)} 条(可运行 --update-baseline 收缩基线):")
|
||||
for g in gone:
|
||||
print(f" - {g}")
|
||||
if new:
|
||||
print(f"❌ 新增 {len(new)} 条违规(analytics 不得直读 ods 源表, 见 #319 C2):")
|
||||
for n in new:
|
||||
print(f" + {n}")
|
||||
print(" 修复: 改读 public 干净层, 或若属既有面请并入基线(须在 PR 中说明)。")
|
||||
return 1
|
||||
print("✅ 无新增违规, 门禁通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user