Blog

把云账单 Dashboard 从‘能看’做到‘可解释’

解释云费用变化归因、云厂商原生账期状态、安全诊断与可恢复错误的精确重试。

  • cloud-cost
  • volcengine
  • sqlite
  • python

把云账单 Dashboard 从“能看”做到“可解释”:费用归因、原生状态与安全诊断

这组修改解决了什么

此前的火山引擎账单链路已经完成了账单事实表、余额快照和采集运行账本的重构,解决了重复累计、分页不完整和费用日期混淆等基础正确性问题。但 Dashboard 和故障排查仍有三个明显缺口:

  1. 页面能展示费用总额,却不能回答“本月为什么比上月多或少”;
  2. “已同步”只是采集状态,不是费用中心里的账单业务状态;
  3. API 出错时,默认日志看不到 RequestId、错误码和安全的错误摘要,永久错误也会像瞬时错误一样重试。

这组提交围绕同一条链路补齐了费用治理、账期状态和采集可观测性:

flowchart TD
  gate["run_after_review.py"] --> collect["billing collection"]
  collect --> detail["ListBillDetail"]
  detail --> billFacts["账单明细"]
  billFacts --> dailyCosts["billing_daily_costs"]
  collect --> bill["ListBill"]
  bill --> nativeStatus["原生状态"]
  nativeStatus --> periodStatuses["billing_period_statuses"]
  collect --> balanceApi["QueryBalanceAcct"]
  balanceApi --> balance["余额快照"]

  dailyCosts --> query["SQLite 只读查询"]
  periodStatuses --> query
  balance --> query
  query --> attribution["本月累计 / 上月同期 / 产品变化归因"]
  query --> statusPolicy["当前账期策略 / 历史原生状态 / 金额推导回退"]
  query --> ranking["Top 10 产品与账单明细"]

  attribution --> server["FastAPI 服务端渲染"]
  statusPolicy --> server
  ranking --> server
  server --> dashboard["响应式 Dashboard"]

整个改造没有把云 API 调用放进 Dashboard。采集任务仍是唯一访问云端的组件,页面只读 SQLite;正式入口仍由运行前门禁限制为 ListBill、ListBillDetail 和 QueryBalanceAcct 三个只读动作。

一、费用变化归因:先把比较口径定义清楚

费用治理模块的入口是 dashboard_queries.cost_governance()。它没有直接用系统当天作为比较终点,而是先从已经通过切换条件的 billing_daily_costs 中找到最大 expense_date,再把它作为参考日期。

这样做是为了避免采集延迟造成假结论。例如今天是 7 月 15 日,但本地账单事实只同步到 7 月 13 日,如果直接按今天计算,就会把 14、15 日误当成零费用。使用最大费用日期后,页面表达的是“当前已知数据截至哪一天”,而不是猜测尚未到达的数据。

比较区间由 _month_comparison_bounds() 生成:

参考日期:2026-07-15
本月累计:2026-07-01 -> 2026-07-15
上月同期:2026-06-01 -> 2026-06-15

遇到 3 月 31 日这类日期,上月同期会截断到 2 月最后一天。产品、账号和资源筛选会同时作用于两个区间;页面的开始日期不会改变“月初至今”的固定口径,结束日期则会限制参考日期。

查询按 service_name 聚合本月和上月同期的 payable_amount_micros,然后计算:

变化金额 = 本月累计 - 上月同期
环比 = 变化金额 / 上月同期 * 100%

这里有两个重要边界:

  • 上月同期没有数据时,变化金额和环比都不伪装成零;
  • 上月金额小于或等于零时,不展示可能误导的百分比,但仍保留可解释的金额信息。

产品归因列表按“变化金额绝对值”排序,因此费用增长和费用下降都能进入前列,而不是只看到增长。Top 产品同时扩展到 10 条,并且只有总费用大于零时才计算占比。

二、账期状态:从金额推导过渡到云厂商原生状态

第一步:先移除错误语义

最初的页面把最近三个月统一标记为“已同步”。这个词只能说明采集任务完成,无法说明账单是出账中、未结清还是已结清。

第一阶段先基于已有账单事实投影业务状态:

条件 状态
当前账号范围内的最大账期 出账中
历史账期且欠费金额大于 0 未结清
历史账期且欠费金额等于 0 已结清

最大账期只受账号范围影响,不受页面日期、产品或资源筛选影响。这样即使用户通过日期筛选隐藏当前月,历史月份也不会被误判成“出账中”。

第二步:接入 ListBill.PayStatus

金额推导能改善语义,但仍然不是云厂商返回的权威状态。最终实现新增了真正的 ListBill 调用,通过 fetch_bill_period_status() 读取 PayStatus,并归一化为 Dashboard 的三个稳定状态:

火山引擎状态 Dashboard 状态
CompletedSettle / 已结清 settled / 已结清
PartSettle / 未结清 unsettled / 未结清
Unsettle / 未结算 / 出账中 in_progress / 出账中

一个账期可能返回多行状态。聚合时采用保守优先级:

出账中 > 未结清 > 已结清

也就是说,只要任一行仍在出账,就不会把整个账期提前显示为已结清。

第三步:持久化并保留可审计来源

原生状态写入新的 billing_period_statuses 表,以 provider + account_id + bill_period 为主键,保存:

  • 归一化状态码和中文状态;
  • 原始 PayStatus 集合;
  • 观测时间;
  • 经过清洗的 RequestId;
  • 产生这条状态的 run_id。

Dashboard 查询会把状态表与账单事实按 provider、账号、账期和同一次 run_id 连接,避免旧采集运行留下的状态覆盖已经替换的新账单事实。

当前实现的决策顺序是:

  1. 当前最大账期始终按账单周期策略显示“出账中”,来源标记为 billing_cycle;
  2. 历史账期只有在当前聚合范围内所有账号都有原生状态时,才使用 provider 状态;
  3. 原生状态覆盖不完整时,按欠费金额回退推导,来源标记为 derived。

这种设计避免了“部分账号有状态、部分账号没有状态”时产生过度乐观的聚合结果。

status_source 目前用于查询结果和排障,HTML 页面只渲染最终状态文字与颜色,并没有把来源标签直接展示给用户。

三、采集隔离:状态接口失败不能拖垮账单事实

每个账期的采集顺序是:

flowchart TD
  detail["ListBillDetail 成功"] --> replace["原子替换账单明细"]
  replace --> status["ListBill 获取原生账期状态"]
  status -->|成功| upsert["upsert 原生状态"]
  status -->|失败| fallback["记录 fallback 日志"]
  fallback --> keep["保留账单明细"]

原生状态是增强信息,不应成为账单事实提交的前置条件。因此,ListBill 最终失败时不会删除或回滚已经成功同步的 ListBillDetail 数据,也不会写入伪造的 provider 状态行。Dashboard 会自然落到金额推导路径。ListBill 合法返回零行时也不会创建 provider 状态行,而是静默进入同一回退路径;这不是异常,所以不一定产生 billing_status_fallback 告警。

账单成本和余额仍然是两个独立提交单元:成本成功、余额失败时运行状态为 partial,已经同步的成本不会丢失;反过来也一样。

四、重试策略:只重试真正可能恢复的错误

原来的重试包装器会把各种异常都尝试三次,认证失败、权限不足或参数错误也会产生无意义请求。

现在 BillingApiError 和 BillingProtocolError 都携带显式的 retryable 决策。只有以下情况会进入重试:

  • 网络传输错误和超时;
  • HTTP 429;
  • HTTP 5xx;
  • 明确列入白名单的临时 provider 错误码,例如 throttling、internal error 和 service unavailable。

认证、授权、参数校验以及其他永久性 4xx 会在第一次失败后停止。每一次决定都会记录 action、当前次数、最大次数、是否可重试、是否继续重试、RequestId、错误码和安全错误摘要。

重试逻辑仍保持线性等待:第 n 次失败后等待 RETRY_DELAY_SECONDS * n。这里没有引入复杂调度器,目的是在轻量采集任务内获得足够清晰的恢复行为。

五、安全诊断:日志可读,但不泄露请求上下文

新增的 billing_diagnostics.py 统一服务于 API 适配器、重试层和 SQLite 仓储边界,主要做四件事:

  1. 把控制字符和多行文本压成单行,避免日志注入或破坏采集格式;
  2. 清洗 Authorization、credential、AK/SK、signature、token、password 等字段;
  3. 隐藏凭据文件路径和错误消息中的长账号类数字;
  4. 对 RequestId、错误码、错误消息和 HTTP 正文摘录设置长度上限。

RequestId 使用独立清洗函数,不套用普通错误消息的“长数字脱敏”规则。原因是某些合法 RequestId 本身就是长数字,如果一概替换,排查云厂商请求就失去了最关键的关联信息。

HTTP 错误正文最多读取固定字节数。JSON 响应优先解析 ResponseMetadata.Error;只有 RequestId、没有标准 Error 对象的 JSON,以及非 JSON 错误正文,也会生成 HTTP_<status> 错误并保留经过清洗、截断的摘要。原始无限长正文、授权头和凭据不会进入日志、异常或数据库。

两个脚本入口都显式配置了 INFO 日志格式,因此成功响应的 RequestId 和失败诊断不再只存在于 logging.extra 中,即使使用默认文本日志收集器也能直接看到。

六、展示层:让信息密度增加,但不破坏窄屏

服务端渲染把账单区域调整为:

flowchart TB
  dashboard["Dashboard"]
  dashboard --> overview["左列:近三月账单概览<br/>费用变化归因"]
  dashboard --> products["右列:Top 10 产品费用"]
  overview --> details["下方:Top 资源 + 账单明细"]
  products --> details

桌面端让右侧 Top 10 与左侧两块内容自然等高;900px 以下切换为单列。表格保留必要的最小宽度,但横向滚动只发生在卡片内部的 .table-wrap,不再把整个页面撑出视口。

账期状态由固定白名单把 status_code 映射到 CSS 类,展示蓝色“出账中”、橙色“未结清”和绿色“已结清”。颜色旁始终保留文字,避免只依赖颜色传递信息;所有动态文本继续经过 HTML 转义。

七、提交与实现顺序

这组修改不是一次性堆进一个大提交,而是按设计、查询、状态和可观测性逐步收敛:

提交 作用
5c4cb72 设计账单诊断、重试和响应式修复边界
5747cca 设计费用治理 Dashboard 的指标与布局
b354427 收紧费用比较模块的展示文案
ce05cd2 实现本月/上月同期查询、变化归因与 Top 10 口径
b30162d 设计账期业务状态与筛选稳定性
d67f3b3 先用账单金额推导三个业务状态
17443a9 完成状态渲染、费用归因、等高布局和窄屏滚动
2a1bd43 加入原生 ListBill 状态、持久化、回退、安全诊断和精确重试

这种顺序保留了一个重要演进过程:先修正页面语义,再用云厂商原生字段替换推导逻辑,同时把推导逻辑降级为明确的容错路径。

八、验证结果

完整单元测试使用仓库当前代码重新执行:

python -m unittest discover -s tests -v

共运行 110 个测试,结果为 OK。覆盖范围包括:

  • 月末截断、筛选规则、缺失和非正基数;
  • 产品变化排序、Top 10 和占比边界;
  • 当前账期策略、原生状态保守聚合、跨账号完整覆盖与 derived 回退;
  • 原生状态失败后保留成本事实;
  • 瞬时错误重试和永久错误首次失败;
  • JSON、非 JSON、RequestId-only HTTP 错误解析;
  • 日志与 SQLite 仓储边界的脱敏;
  • 状态白名单、HTML 转义、桌面等高和窄屏内部滚动;
  • review gate 只允许三个只读账单动作。

结果

这组修改的价值不只是 Dashboard 多了一个表格和几个彩色状态。它把“费用是多少”“为什么变化”“账单处于什么阶段”“失败后能否定位”“增强接口失败时是否保住核心事实”连成了一条可解释、可回退、可审计的实现链路。

最终边界仍然保持轻量:采集任务只读调用三个账单 API,SQLite 保存账单事实和最后一次有效状态,Dashboard 不接触凭据也不访问云端。复杂度主要用在数据语义、失败隔离和诊断安全上,而不是引入新的基础设施。