## P1 数据库基础 - zqyy_app: 创建 auth/biz schema、FDW 连接 etl_feiqiu - etl_feiqiu: 创建 app schema RLS 视图、商品库存预警表 - 清理 assistant_abolish 残留数据 ## P2 ETL/DWS 扩展 - 新增 DWS 助教订单贡献度表 (dws.assistant_order_contribution) - 新增 assistant_order_contribution_task 任务及 RLS 视图 - member_consumption 增加充值字段、assistant_daily 增加处罚字段 - 更新 ODS/DWD/DWS 任务文档及业务规则文档 - 更新 consistency_checker、flow_runner、task_registry 等核心模块 ## P3 小程序鉴权系统 - 新增 xcx_auth 路由/schema(微信登录 + JWT) - 新增 wechat/role/matching/application 服务层 - zqyy_app 鉴权表迁移 + 角色权限种子数据 - auth/dependencies.py 支持小程序 JWT 鉴权 ## 文档与审计 - 新增 DOCUMENTATION-MAP 文档导航 - 新增 7 份 BD_Manual 数据库变更文档 - 更新 DDL 基线快照(etl_feiqiu 6 schema + zqyy_app auth) - 新增全栈集成审计记录、部署检查清单更新 - 新增 BACKLOG 路线图、FDW→Core 迁移计划 ## Kiro 工程化 - 新增 5 个 Spec(P1/P2/P3/全栈集成/核心业务) - 新增审计自动化脚本(agent_on_stop/build_audit_context/compliance_prescan) - 新增 6 个 Hook(合规检查/会话日志/提交审计等) - 新增 doc-map steering 文件 ## 运维与测试 - 新增 ops 脚本:迁移验证/API 健康检查/ETL 监控/集成报告 - 新增属性测试:test_dws_contribution / test_auth_system - 清理过期 export 报告文件 - 更新 .gitignore 排除规则
394 lines
12 KiB
Python
394 lines
12 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""DWS 层扩展验证脚本 — 影子跑数验证。
|
||
|
||
对照 Requirements 9.1–9.4 验证三个 DWS 表的结构完整性和数据合理性:
|
||
1. dws_assistant_order_contribution — 四项统计一致性
|
||
2. dws_member_consumption_summary — 充值窗口字段
|
||
3. dws_assistant_daily_detail — 定档折算惩罚字段
|
||
4. RLS 视图 + FDW 外部表存在性
|
||
|
||
用法:
|
||
cd apps/etl/connectors/feiqiu
|
||
python scripts/verify_dws_extensions.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from dotenv import load_dotenv
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. 加载根 .env(遵循 testing-env.md 规范)
|
||
# ---------------------------------------------------------------------------
|
||
_ROOT = Path(__file__).resolve().parents[5] # scripts/ → feiqiu/ → connectors/ → etl/ → apps/ → 根目录
|
||
load_dotenv(_ROOT / ".env")
|
||
|
||
PG_DSN = os.environ.get("PG_DSN")
|
||
if not PG_DSN:
|
||
raise RuntimeError("PG_DSN 未设置,请检查 .env 配置")
|
||
|
||
APP_DB_DSN = os.environ.get("APP_DB_DSN")
|
||
if not APP_DB_DSN:
|
||
raise RuntimeError("APP_DB_DSN 未设置,请检查 .env 配置")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. 数据库连接
|
||
# ---------------------------------------------------------------------------
|
||
try:
|
||
import psycopg2
|
||
except ImportError:
|
||
print("ERROR: psycopg2 未安装,请执行 uv pip install psycopg2-binary")
|
||
sys.exit(1)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 辅助函数
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class _Result:
|
||
"""单条验证结果。"""
|
||
|
||
def __init__(self, name: str):
|
||
self.name = name
|
||
self.passed = True
|
||
self.details: list[str] = []
|
||
|
||
def fail(self, msg: str) -> None:
|
||
self.passed = False
|
||
self.details.append(f" FAIL: {msg}")
|
||
|
||
def ok(self, msg: str) -> None:
|
||
self.details.append(f" OK: {msg}")
|
||
|
||
def __str__(self) -> str:
|
||
status = "PASS" if self.passed else "FAIL"
|
||
header = f"[{status}] {self.name}"
|
||
if self.details:
|
||
return header + "\n" + "\n".join(self.details)
|
||
return header
|
||
|
||
|
||
def _query(conn, sql: str, params=None) -> list[tuple]:
|
||
with conn.cursor() as cur:
|
||
cur.execute(sql, params)
|
||
return cur.fetchall()
|
||
|
||
|
||
def _query_one(conn, sql: str, params=None):
|
||
rows = _query(conn, sql, params)
|
||
return rows[0] if rows else None
|
||
|
||
|
||
def _column_exists(conn, schema: str, table: str, column: str) -> bool:
|
||
row = _query_one(
|
||
conn,
|
||
"""
|
||
SELECT 1 FROM information_schema.columns
|
||
WHERE table_schema = %s AND table_name = %s AND column_name = %s
|
||
""",
|
||
(schema, table, column),
|
||
)
|
||
return row is not None
|
||
|
||
|
||
def _table_exists(conn, schema: str, table: str) -> bool:
|
||
row = _query_one(
|
||
conn,
|
||
"""
|
||
SELECT 1 FROM information_schema.tables
|
||
WHERE table_schema = %s AND table_name = %s
|
||
""",
|
||
(schema, table),
|
||
)
|
||
return row is not None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 验证 1:dws_assistant_order_contribution 四项统计(Req 9.1, 9.2)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def verify_contribution_table(conn) -> _Result:
|
||
r = _Result("验证 1:dws_assistant_order_contribution 表结构与数据")
|
||
|
||
# 1a. 表存在
|
||
if not _table_exists(conn, "dws", "dws_assistant_order_contribution"):
|
||
r.fail("表 dws.dws_assistant_order_contribution 不存在")
|
||
return r
|
||
r.ok("表存在")
|
||
|
||
# 1b. 关键字段存在
|
||
required_cols = [
|
||
"contribution_id", "site_id", "tenant_id", "assistant_id",
|
||
"assistant_nickname", "stat_date",
|
||
"order_gross_revenue", "order_net_revenue",
|
||
"time_weighted_revenue", "time_weighted_net_revenue",
|
||
"order_count", "total_service_seconds",
|
||
"created_at", "updated_at",
|
||
]
|
||
missing = [c for c in required_cols
|
||
if not _column_exists(conn, "dws", "dws_assistant_order_contribution", c)]
|
||
if missing:
|
||
r.fail(f"缺少字段: {', '.join(missing)}")
|
||
else:
|
||
r.ok(f"全部 {len(required_cols)} 个字段存在")
|
||
|
||
# 1c. 唯一索引存在
|
||
idx_row = _query_one(
|
||
conn,
|
||
"""
|
||
SELECT indexname FROM pg_indexes
|
||
WHERE schemaname = 'dws'
|
||
AND tablename = 'dws_assistant_order_contribution'
|
||
AND indexname = 'idx_aoc_site_assistant_date'
|
||
""",
|
||
)
|
||
if idx_row:
|
||
r.ok("唯一索引 idx_aoc_site_assistant_date 存在")
|
||
else:
|
||
r.fail("唯一索引 idx_aoc_site_assistant_date 不存在")
|
||
|
||
# 1d. 数据行数(信息性,不判 FAIL)
|
||
row = _query_one(conn, "SELECT COUNT(*) FROM dws.dws_assistant_order_contribution")
|
||
count = row[0] if row else 0
|
||
r.ok(f"当前数据行数: {count}")
|
||
|
||
# 1e. 如果有数据,检查四项统计非负
|
||
if count > 0:
|
||
neg_row = _query_one(
|
||
conn,
|
||
"""
|
||
SELECT COUNT(*) FROM dws.dws_assistant_order_contribution
|
||
WHERE order_gross_revenue < 0
|
||
OR order_net_revenue < 0
|
||
OR time_weighted_revenue < 0
|
||
OR time_weighted_net_revenue < 0
|
||
""",
|
||
)
|
||
neg_count = neg_row[0] if neg_row else 0
|
||
if neg_count > 0:
|
||
r.fail(f"存在 {neg_count} 条四项统计为负值的记录")
|
||
else:
|
||
r.ok("四项统计数值均非负")
|
||
|
||
return r
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 验证 2:dws_member_consumption_summary 充值窗口字段(Req 9.3)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def verify_consumption_fields(conn) -> _Result:
|
||
r = _Result("验证 2:dws_member_consumption_summary 充值窗口字段")
|
||
|
||
if not _table_exists(conn, "dws", "dws_member_consumption_summary"):
|
||
r.fail("表 dws.dws_member_consumption_summary 不存在")
|
||
return r
|
||
r.ok("表存在")
|
||
|
||
new_cols = [
|
||
"recharge_count_30d", "recharge_count_60d", "recharge_count_90d",
|
||
"recharge_amount_30d", "recharge_amount_60d", "recharge_amount_90d",
|
||
"avg_ticket_amount",
|
||
]
|
||
missing = [c for c in new_cols
|
||
if not _column_exists(conn, "dws", "dws_member_consumption_summary", c)]
|
||
if missing:
|
||
r.fail(f"缺少新增字段: {', '.join(missing)}")
|
||
else:
|
||
r.ok(f"全部 {len(new_cols)} 个新增字段存在")
|
||
|
||
# 如果有数据,检查充值金额和次均消费非负
|
||
row = _query_one(conn, "SELECT COUNT(*) FROM dws.dws_member_consumption_summary")
|
||
count = row[0] if row else 0
|
||
r.ok(f"当前数据行数: {count}")
|
||
|
||
if count > 0:
|
||
neg_row = _query_one(
|
||
conn,
|
||
"""
|
||
SELECT COUNT(*) FROM dws.dws_member_consumption_summary
|
||
WHERE recharge_amount_30d < 0
|
||
OR recharge_amount_60d < 0
|
||
OR recharge_amount_90d < 0
|
||
OR avg_ticket_amount < 0
|
||
""",
|
||
)
|
||
neg_count = neg_row[0] if neg_row else 0
|
||
if neg_count > 0:
|
||
r.fail(f"存在 {neg_count} 条充值金额或次均消费为负值的记录")
|
||
else:
|
||
r.ok("充值金额和次均消费均非负")
|
||
|
||
return r
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 验证 3:dws_assistant_daily_detail 惩罚字段(Req 9.4)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def verify_penalty_fields(conn) -> _Result:
|
||
r = _Result("验证 3:dws_assistant_daily_detail 惩罚字段")
|
||
|
||
if not _table_exists(conn, "dws", "dws_assistant_daily_detail"):
|
||
r.fail("表 dws.dws_assistant_daily_detail 不存在")
|
||
return r
|
||
r.ok("表存在")
|
||
|
||
new_cols = ["penalty_minutes", "penalty_reason", "is_exempt", "per_hour_contribution"]
|
||
missing = [c for c in new_cols
|
||
if not _column_exists(conn, "dws", "dws_assistant_daily_detail", c)]
|
||
if missing:
|
||
r.fail(f"缺少新增字段: {', '.join(missing)}")
|
||
else:
|
||
r.ok(f"全部 {len(new_cols)} 个惩罚字段存在")
|
||
|
||
# 检查 is_exempt 字段类型为 boolean
|
||
type_row = _query_one(
|
||
conn,
|
||
"""
|
||
SELECT data_type FROM information_schema.columns
|
||
WHERE table_schema = 'dws'
|
||
AND table_name = 'dws_assistant_daily_detail'
|
||
AND column_name = 'is_exempt'
|
||
""",
|
||
)
|
||
if type_row and type_row[0] == "boolean":
|
||
r.ok("is_exempt 字段类型为 boolean")
|
||
elif type_row:
|
||
r.fail(f"is_exempt 字段类型为 {type_row[0]},预期 boolean")
|
||
|
||
# 如果有数据,检查 penalty_minutes >= 0
|
||
row = _query_one(conn, "SELECT COUNT(*) FROM dws.dws_assistant_daily_detail")
|
||
count = row[0] if row else 0
|
||
r.ok(f"当前数据行数: {count}")
|
||
|
||
if count > 0:
|
||
neg_row = _query_one(
|
||
conn,
|
||
"""
|
||
SELECT COUNT(*) FROM dws.dws_assistant_daily_detail
|
||
WHERE penalty_minutes < 0
|
||
""",
|
||
)
|
||
neg_count = neg_row[0] if neg_row else 0
|
||
if neg_count > 0:
|
||
r.fail(f"存在 {neg_count} 条 penalty_minutes 为负值的记录")
|
||
else:
|
||
r.ok("penalty_minutes 均非负")
|
||
|
||
return r
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 验证 4:RLS 视图和 FDW 映射(Req 7, 8)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def verify_rls_views(conn_etl) -> _Result:
|
||
r = _Result("验证 4a:RLS 视图存在性(ETL 库 app schema)")
|
||
|
||
views = [
|
||
"v_dws_assistant_order_contribution",
|
||
"v_dws_member_consumption_summary",
|
||
"v_dws_assistant_daily_detail",
|
||
]
|
||
for v in views:
|
||
if _table_exists(conn_etl, "app", v):
|
||
r.ok(f"视图 app.{v} 存在")
|
||
else:
|
||
r.fail(f"视图 app.{v} 不存在")
|
||
|
||
return r
|
||
|
||
|
||
def verify_fdw_tables(conn_app) -> _Result:
|
||
r = _Result("验证 4b:FDW 外部表存在性(业务库 fdw_etl schema)")
|
||
|
||
# FDW 外部表名与 RLS 视图名一致,带 v_ 前缀
|
||
tables = [
|
||
"v_dws_assistant_order_contribution",
|
||
"v_dws_member_consumption_summary",
|
||
"v_dws_assistant_daily_detail",
|
||
]
|
||
for t in tables:
|
||
row = _query_one(
|
||
conn_app,
|
||
"""
|
||
SELECT 1 FROM information_schema.tables
|
||
WHERE table_schema = 'fdw_etl' AND table_name = %s
|
||
""",
|
||
(t,),
|
||
)
|
||
if row:
|
||
r.ok(f"外部表 fdw_etl.{t} 存在")
|
||
else:
|
||
r.fail(f"外部表 fdw_etl.{t} 不存在")
|
||
|
||
return r
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 主函数
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main() -> int:
|
||
results: list[_Result] = []
|
||
|
||
# 连接 ETL 测试库
|
||
try:
|
||
conn_etl = psycopg2.connect(PG_DSN)
|
||
conn_etl.autocommit = True
|
||
except Exception as e:
|
||
print(f"ERROR: 无法连接 ETL 库 ({PG_DSN[:40]}...): {e}")
|
||
return 1
|
||
|
||
# 连接业务测试库
|
||
try:
|
||
conn_app = psycopg2.connect(APP_DB_DSN)
|
||
conn_app.autocommit = True
|
||
except Exception as e:
|
||
print(f"ERROR: 无法连接业务库 ({APP_DB_DSN[:40]}...): {e}")
|
||
conn_etl.close()
|
||
return 1
|
||
|
||
try:
|
||
print("=" * 60)
|
||
print("DWS 层扩展验证 — 影子跑数验证")
|
||
print("=" * 60)
|
||
print()
|
||
|
||
# ETL 库验证
|
||
results.append(verify_contribution_table(conn_etl))
|
||
results.append(verify_consumption_fields(conn_etl))
|
||
results.append(verify_penalty_fields(conn_etl))
|
||
results.append(verify_rls_views(conn_etl))
|
||
|
||
# 业务库验证
|
||
results.append(verify_fdw_tables(conn_app))
|
||
|
||
# 输出结果
|
||
for r in results:
|
||
print(r)
|
||
print()
|
||
|
||
# 汇总
|
||
total = len(results)
|
||
passed = sum(1 for r in results if r.passed)
|
||
failed = total - passed
|
||
print("=" * 60)
|
||
print(f"汇总: {passed}/{total} 通过, {failed} 失败")
|
||
print("=" * 60)
|
||
|
||
return 0 if failed == 0 else 1
|
||
|
||
finally:
|
||
conn_etl.close()
|
||
conn_app.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|