"""成本口径路由(cost measure routing)— 2026-07 成本口径改造 PR4。 单一真源:哪个角色、哪个面,读哪一列。 两个口径: * **业务折前成本(list,研发口径,默认)** —— 白名单行的实际执行价 (spot / 阶梯保留 = 部门功劳;SP / RI / CUD / EDP / 赠金 / 调账 / 税 等公司层面项剥离)。所有研发可见的面(账号账单页、成本分析、效率 洞察、移动端、MCP、导出、通知)一律读它。 * **实付(invoice,财务口径)** —— 与云厂商账单对得上的净额。只允许 出现在保密圈(finance / cloud_admin / global_admin)的专属面。 保密红线:任何研发可见的响应里,**绝不同框返回两个口径**——原价与 实付一相除,公司折扣率就泄露了。请求 ``measure=invoice`` 且无权限 → 403(由 router 层执行 :func:`resolve_measure` 的返回值)。 过渡语义(回填窗口):研发面读取用 :func:`fact_engineering_amount` / :func:`daily_engineering_amount` —— ``COALESCE(list, invoice)``, **且只在 ``cost_bucket='usage'`` 的白名单行上**(见下)。 双口径回填完成前,历史 usage 行 list 为 NULL,COALESCE 回落到与今日 页面完全相同的实付数(非新增泄露——那本就是切换前所有人看到的数); 回填完成后(以及新增数据),已接入口径的云(AWS / GCP / 阿里云) 每一行都携带 list,表达式退化为纯 list。未接入的云(Azure 待 Cost Exports、腾讯/金山/UCloud 未接账单)长期回落实付并在「成本口径」 说明页明示。 行白名单(``cost_bucket='usage'``)——2026-08-15 修复,见下 ======================================================= 研发口径**只统计 usage 桶**。其余桶(``adjustment`` 退款/调账、 ``commitment_fee`` SP/RI/CUD 承诺费、``commitment_discount`` / ``contract_discount`` 折扣行、``credit`` 赠金、``tax``、``support``、 ``other``)一律计 0,**不参与**折前成本。 这本是 :class:`~app.domains.cmdb.models.CmdbCostFact` 建模时就写死的 契约(``amount_usd_list`` 列注释:「其余行恒 NULL —— 研发口径读侧就是 SUM(amount_usd_list),无需行过滤,防止未知行类型泄入」)。但后加的 过渡性 ``COALESCE(list, invoice)`` 恰好击穿了这个「NULL 防泄入」设计: 非 usage 行 list 为 NULL → 回落到实付 → 公司层面的钱漏进研发口径。 2026-08-15 在生产上实测的泄漏量(2026 YTD): =========================== ========== ================== integration / 桶 行数 漏入折前的实付额 =========================== ========== ================== aws-primary contract_discount 2,359,779 −$11,830,577(EDP 折扣) aws-primary commitment_fee 450 +$6,619,665(SP 承诺费) gcp-primary commitment_discount 4,481 −$997,633(CUD 抵扣) gcp-primary credit 927 −$303,864(赠金) alicloud-intl adjustment 22,094 −$215,544(退款/调账) =========================== ========== ================== 用户可见症状(触发本次修复):云成本分析「多维下钻」按订阅筛 AliCloud (海外) RD master、窗口 7/17–8/14,总成本显示 **−$2,601.65** ——正常用量毛额约 $10,628,被 7/21 集中处理的 244 笔退款(−$13,229.89) 冲成负数。同期订阅详情页「本月花费」$8,199.71 因当月恰无调账行而正常, 两个页面读同一张表却对不上账。 与财务口径一致 ============== 该白名单与云管理/财务给出的「业务 List Cost」口径逐条吻合:保留 Pay-As-You-Go / Purchase / Renewal / Upgrade-Downgrade(= usage 桶); 排除 Refund / Reconciliation / Round-off Discount(= adjustment / other); 排除 Savings Plan、Resource Plan 本身的购买费(= commitment_fee); 排除集中采购 Support(= support);不减 invoice_discount / deducted_by_coupons(= contract_discount / credit 不参与相减); 不使用实付字段。对账实现见 ``cmdb.cost_recon.aliyun_bq`` cron。 被排除的行**不是消失**:它们仍在库里、仍进财务「成本口径」页的分桶 瀑布(``month_cost_measure_rows`` 按 ``cost_bucket`` GROUP BY),并由 :func:`excluded_bucket_summary` 在研发可见面上以「笔数 + 说明」呈现。 金额只对保密圈(finance / cloud_admin / global_admin)开放 —— 研发面 同框给出 list 与非 usage 桶的实付额即可反推公司折扣率,与本模块开头 的保密红线冲突。 ``daily_engineering_amount``(``cmdb_cloud_billing_daily`` 日粒度) **不需要**同样的守卫:日表没有 bucket 维度,而写侧 (``alicloud_intl_cloud.py`` 累加 ``total_cost_usd_list`` 前先判 ``_classify_bss_bucket(...) == COST_BUCKET_USAGE``)已经是白名单纯净的; list 为 NULL 时回落的是该日实付净额,语义与未接入云一致。 """ from __future__ import annotations from collections.abc import Iterable from typing import Any from sqlalchemy import case, func from app.core.masking import ( AUDIT_COST_BASIS_KEY, COST_BASIS_INVOICE, COST_BASIS_LIST, ) from app.core.rbac import ( can_view_invoice_measure as _rbac_can_view_invoice_measure, ) from app.domains.cmdb.models import CmdbCloudBillingDaily, CmdbCostFact from app.integrations.driver_base import COST_BUCKET_USAGE COST_MEASURE_LIST = "list" COST_MEASURE_INVOICE = "invoice" _VALID_MEASURES = frozenset({COST_MEASURE_LIST, COST_MEASURE_INVOICE}) # 实付口径可见角色(保密圈)。management 是否纳入待财务确认(方案 # 确认项②)——确认前从严排除。auditor 的治理读面同样只看研发口径。 # # The predicate itself moved to ``app.core.rbac`` (2026-07-31): the # finance ledger reads need the same boundary, and a domain importing # another domain is not allowed. Re-exported here so every existing # ``from app.domains.cmdb.cost_view import can_view_invoice_measure`` # keeps working and there is still exactly ONE definition. can_view_invoice_measure = _rbac_can_view_invoice_measure def audit_cost_basis_detail(measure: str | None) -> dict[str, str]: """写侧助手:把评估口径写进 ``audit_logs.detail``。 任何把绝对金额写进 audit detail 的成本告警,**必须** 展开这个 dict 到 detail 里 —— 读侧 (:func:`app.core.masking.redact_invoice_basis_amounts`) 只在行内 marker 明确为 ``list`` 时才把金额放给保密圈外的读者(auditor / management);没有 marker 一律按实付脱敏。 入参接受两套写法:口径路由的 ``'list'`` / ``'invoice'``,以及 尖峰评估器内部用的 ``'net'``(== 实付)。其它值一律归到实付 ——「无法证明是 list」就当实付,方向从严。 """ m = (measure or "").strip().lower() return { AUDIT_COST_BASIS_KEY: ( COST_BASIS_LIST if m == COST_MEASURE_LIST else COST_BASIS_INVOICE ), } def resolve_measure( requested: str | None, role_names: Iterable[str], ) -> str | None: """归一化 ``measure`` 请求参数。 * ``None`` / ``''`` / ``'list'`` → ``'list'``(默认,人人可读)。 * ``'invoice'`` → 有权限返回 ``'invoice'``;无权限返回 ``None`` —— 调用方(router)必须以 403 拒绝,**不得**静默降级为 list (静默降级会让保密圈用户误把研发口径当实付做决策)。 * 其它值 → ValueError(422 由 router 转译)。 """ m = (requested or COST_MEASURE_LIST).strip().lower() if m not in _VALID_MEASURES: raise ValueError(f"unknown cost measure {requested!r}") if m == COST_MEASURE_INVOICE and not can_view_invoice_measure(role_names): return None return m # ── SQL 列/表达式选择器 ───────────────────────────────────────────── def engineering_row_clause() -> Any: """研发口径的**行白名单**谓词:``cost_bucket = 'usage'``。 单一真源。任何自己拼 ``SUM(amount_usd_list)`` / 自己判断「哪些行 算研发成本」的新查询都必须用它,别再写一份字面量 —— 这正是本次 泄漏的成因(读侧与建模契约各写各的)。 ``cost_bucket`` 非空、默认 ``'usage'``(迁移前存量行的正确值), 所以谓词不需要 NULL 分支;同时它已在覆盖索引 ``ix_facts_date_measure(date, integration_id, cost_bucket, amount_usd, amount_usd_list, amount_usd_public)`` 的第 3 列里, 加这个判断不会让财务分桶月聚合回表。 """ return CmdbCostFact.cost_bucket == COST_BUCKET_USAGE def non_engineering_row_clause() -> Any: """:func:`engineering_row_clause` 的补集 —— 被研发口径排除的行。 可见性通道(``cost_service.excluded_bucket_summary``)用它,保证 「计入总额的行」与「解释给读者听的被排除行」严格互补,不会因为 两边各写一份谓词而出现既不计入、也不解释的黑洞行。 """ return CmdbCostFact.cost_bucket != COST_BUCKET_USAGE def fact_engineering_amount() -> Any: """研发口径的 fact 金额表达式。 ``usage`` 行 → ``COALESCE(list, invoice)``;其余桶 → 0。 白名单与泄漏实测数据见模块 docstring。``ELSE 0`` 而非 ``ELSE NULL``:调用方几乎都是 ``SUM(...)``,NULL 会把「这行按口径 不计入」和「这段数据没接入口径」混成同一个信号;0 是前者的准确 表达。需要区分「未接入」的地方(财务 ``month_cost_measure_rows`` 的 ``paired_*`` 列)走的是原始列,不经本表达式。 """ return case( ( engineering_row_clause(), func.coalesce( CmdbCostFact.amount_usd_list, CmdbCostFact.amount_usd, ), ), else_=0, ) def daily_engineering_amount() -> Any: """研发口径的日汇总金额表达式(billing_daily 粒度)。""" return func.coalesce( CmdbCloudBillingDaily.total_cost_usd_list, CmdbCloudBillingDaily.total_cost_usd, ) def fact_amount_expr(measure: str) -> Any: """按口径返回 fact 金额表达式(router 已完成权限判定)。""" if measure == COST_MEASURE_INVOICE: return CmdbCostFact.amount_usd return fact_engineering_amount() def daily_amount_expr(measure: str) -> Any: if measure == COST_MEASURE_INVOICE: return CmdbCloudBillingDaily.total_cost_usd return daily_engineering_amount()