2026-01-31 19:11:25 +08:00
|
|
|
|
"""
|
|
|
|
|
|
FormulaEngine - 配置驱动的安全计费表达式引擎
|
|
|
|
|
|
|
|
|
|
|
|
目标:
|
|
|
|
|
|
- 支持 billing_rules.expression 的安全求值(AST 白名单)
|
|
|
|
|
|
- 支持 dimension_mappings(dimension/matrix/tiered/constant)
|
|
|
|
|
|
- 支持 required/allow_zero 机制,避免维度缺失导致静默少收
|
|
|
|
|
|
|
|
|
|
|
|
注意:该模块不直接依赖数据库;规则查找、维度采集在上层服务完成。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import ast
|
2026-02-03 18:48:39 +08:00
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
from functools import lru_cache
|
2026-01-31 19:11:25 +08:00
|
|
|
|
from typing import Any, Iterable, Literal
|
|
|
|
|
|
|
2026-02-14 16:34:52 +08:00
|
|
|
|
from src.core.logger import logger
|
2026-02-03 18:48:39 +08:00
|
|
|
|
from src.services.billing.precision import DECIMAL_CONTEXT_PRECISION, to_decimal
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
class UnsafeExpressionError(ValueError):
|
|
|
|
|
|
"""表达式包含不安全/不支持的 AST 结构。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExpressionEvaluationError(RuntimeError):
|
|
|
|
|
|
"""表达式在安全求值阶段失败(如 NameError/ZeroDivision)。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BillingIncompleteError(RuntimeError):
|
|
|
|
|
|
"""required 维度缺失且 strict_mode=true 时抛出,用于上层拒绝请求/标记任务失败。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, message: str, *, missing_required: list[str]):
|
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
|
self.missing_required = missing_required
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class FormulaEvaluationResult:
|
|
|
|
|
|
status: Literal["complete", "incomplete"]
|
2026-02-03 18:48:39 +08:00
|
|
|
|
cost: Decimal
|
|
|
|
|
|
resolved_dimensions: dict[str, Any]
|
|
|
|
|
|
resolved_variables: dict[str, Any]
|
|
|
|
|
|
cost_breakdown: dict[str, Decimal] = field(default_factory=dict)
|
|
|
|
|
|
tier_index: int | None = None
|
|
|
|
|
|
tier_info: dict[str, Any] | None = None
|
|
|
|
|
|
missing_required: list[str] = field(default_factory=list)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
error: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_ALLOWED_BINOPS = (
|
|
|
|
|
|
ast.Add,
|
|
|
|
|
|
ast.Sub,
|
|
|
|
|
|
ast.Mult,
|
|
|
|
|
|
ast.Div,
|
|
|
|
|
|
ast.Pow,
|
|
|
|
|
|
ast.FloorDiv,
|
|
|
|
|
|
ast.Mod,
|
|
|
|
|
|
)
|
|
|
|
|
|
_ALLOWED_UNARYOPS = (ast.UAdd, ast.USub)
|
|
|
|
|
|
_ALLOWED_OP_NODES = _ALLOWED_BINOPS + _ALLOWED_UNARYOPS
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
# Allowed function names used in expressions.
|
|
|
|
|
|
_ALLOWED_FUNC_NAMES = frozenset(("min", "max", "abs", "round", "int", "float"))
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
def _iter_ast_nodes(node: ast.AST) -> Iterable[ast.AST]:
|
|
|
|
|
|
yield node
|
|
|
|
|
|
for child in ast.iter_child_nodes(node):
|
|
|
|
|
|
yield from _iter_ast_nodes(child)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-12 11:41:42 +08:00
|
|
|
|
@lru_cache(maxsize=256)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
def _validate_expression_cached(expression: str) -> ast.Expression:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Parse + validate an expression and cache the resulting AST.
|
|
|
|
|
|
|
|
|
|
|
|
This is a hot path (called for every billing evaluation and many collector transforms),
|
|
|
|
|
|
so we cache validated ASTs to avoid repeated ast.parse + whitelist scans.
|
|
|
|
|
|
"""
|
2026-01-31 19:11:25 +08:00
|
|
|
|
try:
|
|
|
|
|
|
tree = ast.parse(expression, mode="eval")
|
|
|
|
|
|
except SyntaxError as exc:
|
|
|
|
|
|
raise UnsafeExpressionError(f"Invalid expression syntax: {exc}") from exc
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
for node in _iter_ast_nodes(tree):
|
|
|
|
|
|
if isinstance(node, ast.Expression):
|
|
|
|
|
|
continue
|
|
|
|
|
|
# 运算符节点本身也会出现在 iter_child_nodes 中
|
|
|
|
|
|
if isinstance(node, _ALLOWED_OP_NODES):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(node, ast.Constant):
|
|
|
|
|
|
# 仅允许数字常量(bool 是 int 子类,需要显式排除)
|
|
|
|
|
|
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
|
|
|
|
|
raise UnsafeExpressionError("Only int/float constants are allowed")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(node, ast.BinOp):
|
|
|
|
|
|
if not isinstance(node.op, _ALLOWED_BINOPS):
|
|
|
|
|
|
raise UnsafeExpressionError(f"Operator not allowed: {type(node.op).__name__}")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(node, ast.UnaryOp):
|
|
|
|
|
|
if not isinstance(node.op, _ALLOWED_UNARYOPS):
|
|
|
|
|
|
raise UnsafeExpressionError(f"Unary operator not allowed: {type(node.op).__name__}")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(node, ast.Name):
|
|
|
|
|
|
# 防御:拒绝双下划线变量名
|
|
|
|
|
|
if node.id.startswith("__"):
|
|
|
|
|
|
raise UnsafeExpressionError("Dunder names are not allowed")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(node, ast.Load):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(node, ast.keyword):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(node, ast.Call):
|
|
|
|
|
|
if not isinstance(node.func, ast.Name):
|
|
|
|
|
|
raise UnsafeExpressionError("Only direct function calls are allowed")
|
|
|
|
|
|
func_name = node.func.id
|
|
|
|
|
|
if func_name not in _ALLOWED_FUNC_NAMES:
|
|
|
|
|
|
raise UnsafeExpressionError(f"Function not allowed: {func_name}")
|
|
|
|
|
|
if any(k.arg is None for k in node.keywords):
|
|
|
|
|
|
raise UnsafeExpressionError("**kwargs is not allowed")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# 明确禁止的/不需要的节点类型(属性访问、下标、推导式、比较等)
|
|
|
|
|
|
if isinstance(
|
|
|
|
|
|
node,
|
|
|
|
|
|
(
|
|
|
|
|
|
ast.Attribute,
|
|
|
|
|
|
ast.Subscript,
|
|
|
|
|
|
ast.Compare,
|
|
|
|
|
|
ast.BoolOp,
|
|
|
|
|
|
ast.IfExp,
|
|
|
|
|
|
ast.Lambda,
|
|
|
|
|
|
ast.Dict,
|
|
|
|
|
|
ast.List,
|
|
|
|
|
|
ast.Tuple,
|
|
|
|
|
|
ast.Set,
|
|
|
|
|
|
ast.ListComp,
|
|
|
|
|
|
ast.SetComp,
|
|
|
|
|
|
ast.DictComp,
|
|
|
|
|
|
ast.GeneratorExp,
|
|
|
|
|
|
ast.Await,
|
|
|
|
|
|
ast.Yield,
|
|
|
|
|
|
ast.YieldFrom,
|
|
|
|
|
|
),
|
|
|
|
|
|
):
|
|
|
|
|
|
raise UnsafeExpressionError(f"AST node not allowed: {type(node).__name__}")
|
|
|
|
|
|
|
|
|
|
|
|
raise UnsafeExpressionError(f"AST node not allowed: {type(node).__name__}")
|
|
|
|
|
|
|
|
|
|
|
|
assert isinstance(tree, ast.Expression)
|
|
|
|
|
|
return tree
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_variable_names(expression: str) -> set[str]:
|
|
|
|
|
|
"""提取表达式中出现的变量名(不含函数名)。"""
|
|
|
|
|
|
tree = _validate_expression_cached(expression)
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
names: set[str] = set()
|
|
|
|
|
|
for node in _iter_ast_nodes(tree):
|
|
|
|
|
|
if isinstance(node, ast.Name):
|
|
|
|
|
|
names.add(node.id)
|
|
|
|
|
|
if isinstance(node, ast.Call):
|
|
|
|
|
|
# Call 的函数名会以 ast.Name 出现,需要从结果中过滤掉
|
|
|
|
|
|
if isinstance(node.func, ast.Name):
|
|
|
|
|
|
names.discard(node.func.id)
|
|
|
|
|
|
return names
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SafeExpressionEvaluator:
|
|
|
|
|
|
"""AST 白名单 + 无 builtins 的安全求值器。"""
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
|
# Decimal-friendly allowed functions (return Decimal)
|
|
|
|
|
|
self.ALLOWED_FUNCS: dict[str, Any] = {
|
|
|
|
|
|
"min": self._min,
|
|
|
|
|
|
"max": self._max,
|
|
|
|
|
|
"abs": self._abs,
|
|
|
|
|
|
"round": self._round,
|
|
|
|
|
|
"int": self._int,
|
|
|
|
|
|
"float": self._float,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _min(*args: Any) -> Decimal:
|
|
|
|
|
|
return min(to_decimal(a) for a in args)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _max(*args: Any) -> Decimal:
|
|
|
|
|
|
return max(to_decimal(a) for a in args)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _abs(x: Any) -> Decimal:
|
|
|
|
|
|
return abs(to_decimal(x))
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _round(x: Any, ndigits: Any = 0) -> Decimal:
|
|
|
|
|
|
# Round Decimal returns Decimal; coerce ndigits to int safely.
|
2026-01-31 19:11:25 +08:00
|
|
|
|
try:
|
2026-02-03 18:48:39 +08:00
|
|
|
|
n = int(ndigits)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
n = 0
|
|
|
|
|
|
return round(to_decimal(x), n)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _int(x: Any) -> Decimal:
|
|
|
|
|
|
return to_decimal(int(to_decimal(x)))
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _float(x: Any) -> Decimal:
|
|
|
|
|
|
# Keep numeric chain in Decimal even if caller used float()
|
|
|
|
|
|
return to_decimal(float(to_decimal(x)))
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
def validate(self, expression: str) -> ast.Expression:
|
|
|
|
|
|
return _validate_expression_cached(expression)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
def eval_decimal(self, expression: str, variables: dict[str, Any]) -> Decimal:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Evaluate expression into Decimal.
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
We avoid Python eval() here to ensure:
|
|
|
|
|
|
- float literals don't leak binary float arithmetic
|
|
|
|
|
|
- all arithmetic stays within Decimal
|
|
|
|
|
|
"""
|
2026-01-31 19:11:25 +08:00
|
|
|
|
tree = self.validate(expression)
|
|
|
|
|
|
try:
|
2026-02-03 18:48:39 +08:00
|
|
|
|
with _decimal_context(DECIMAL_CONTEXT_PRECISION):
|
|
|
|
|
|
return _eval_decimal(tree.body, variables or {}, self.ALLOWED_FUNCS)
|
|
|
|
|
|
except NameError:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except ExpressionEvaluationError:
|
|
|
|
|
|
raise
|
2026-01-31 19:11:25 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ExpressionEvaluationError(str(exc)) from exc
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
def eval_number(self, expression: str, variables: dict[str, Any]) -> float:
|
|
|
|
|
|
"""Backward-compatible float evaluation (used by DimensionCollector transforms)."""
|
|
|
|
|
|
value = self.eval_decimal(expression, variables)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
try:
|
|
|
|
|
|
return float(value)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ExpressionEvaluationError(f"Expression result is not numeric: {value!r}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
class _decimal_context:
|
|
|
|
|
|
def __init__(self, prec: int):
|
|
|
|
|
|
self.prec = prec
|
|
|
|
|
|
|
|
|
|
|
|
def __enter__(self) -> None:
|
|
|
|
|
|
from decimal import getcontext
|
|
|
|
|
|
|
|
|
|
|
|
self._ctx = getcontext().copy()
|
|
|
|
|
|
getcontext().prec = self.prec
|
|
|
|
|
|
|
|
|
|
|
|
def __exit__(self, exc_type: type | None, exc: BaseException | None, tb: Any) -> None:
|
|
|
|
|
|
from decimal import setcontext
|
|
|
|
|
|
|
|
|
|
|
|
# Restore full context to avoid leaking settings.
|
|
|
|
|
|
setcontext(self._ctx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _eval_decimal(node: ast.AST, variables: dict[str, Any], funcs: dict[str, Any]) -> Decimal:
|
|
|
|
|
|
if isinstance(node, ast.Constant):
|
|
|
|
|
|
return to_decimal(node.value)
|
|
|
|
|
|
if isinstance(node, ast.Name):
|
|
|
|
|
|
if node.id not in variables:
|
|
|
|
|
|
raise NameError(node.id)
|
|
|
|
|
|
return to_decimal(variables[node.id])
|
|
|
|
|
|
if isinstance(node, ast.UnaryOp):
|
|
|
|
|
|
v = _eval_decimal(node.operand, variables, funcs)
|
|
|
|
|
|
if isinstance(node.op, ast.UAdd):
|
|
|
|
|
|
return v
|
|
|
|
|
|
if isinstance(node.op, ast.USub):
|
|
|
|
|
|
return -v
|
|
|
|
|
|
raise ExpressionEvaluationError(f"Unary operator not allowed: {type(node.op).__name__}")
|
|
|
|
|
|
if isinstance(node, ast.BinOp):
|
|
|
|
|
|
left = _eval_decimal(node.left, variables, funcs)
|
|
|
|
|
|
right = _eval_decimal(node.right, variables, funcs)
|
|
|
|
|
|
if isinstance(node.op, ast.Add):
|
|
|
|
|
|
return left + right
|
|
|
|
|
|
if isinstance(node.op, ast.Sub):
|
|
|
|
|
|
return left - right
|
|
|
|
|
|
if isinstance(node.op, ast.Mult):
|
|
|
|
|
|
return left * right
|
|
|
|
|
|
if isinstance(node.op, ast.Div):
|
|
|
|
|
|
return left / right
|
|
|
|
|
|
if isinstance(node.op, ast.FloorDiv):
|
|
|
|
|
|
return left // right
|
|
|
|
|
|
if isinstance(node.op, ast.Mod):
|
|
|
|
|
|
return left % right
|
|
|
|
|
|
if isinstance(node.op, ast.Pow):
|
|
|
|
|
|
# Decimal power is only well-defined for integer exponents here.
|
|
|
|
|
|
try:
|
|
|
|
|
|
exp_int = int(right)
|
|
|
|
|
|
if to_decimal(exp_int) != right:
|
|
|
|
|
|
raise ValueError("non-integer exponent")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ExpressionEvaluationError("Pow only supports integer exponents") from exc
|
|
|
|
|
|
return left**exp_int
|
|
|
|
|
|
raise ExpressionEvaluationError(f"Operator not allowed: {type(node.op).__name__}")
|
|
|
|
|
|
if isinstance(node, ast.Call):
|
|
|
|
|
|
if not isinstance(node.func, ast.Name):
|
|
|
|
|
|
raise ExpressionEvaluationError("Only direct function calls are allowed")
|
|
|
|
|
|
func_name = node.func.id
|
|
|
|
|
|
func = funcs.get(func_name)
|
|
|
|
|
|
if func is None:
|
|
|
|
|
|
raise ExpressionEvaluationError(f"Function not allowed: {func_name}")
|
|
|
|
|
|
args = [_eval_decimal(a, variables, funcs) for a in node.args]
|
|
|
|
|
|
kwargs = {
|
|
|
|
|
|
kw.arg: _eval_decimal(kw.value, variables, funcs) for kw in node.keywords if kw.arg
|
|
|
|
|
|
}
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = func(*args, **kwargs)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ExpressionEvaluationError(str(exc)) from exc
|
|
|
|
|
|
return to_decimal(result)
|
|
|
|
|
|
|
|
|
|
|
|
raise ExpressionEvaluationError(f"AST node not allowed: {type(node).__name__}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
class FormulaEngine:
|
|
|
|
|
|
"""计费表达式引擎:解析 dimension_mappings 并进行安全求值。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
|
self._evaluator = SafeExpressionEvaluator()
|
|
|
|
|
|
|
|
|
|
|
|
def evaluate(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
expression: str,
|
|
|
|
|
|
variables: dict[str, Any] | None,
|
|
|
|
|
|
dimensions: dict[str, Any] | None,
|
|
|
|
|
|
dimension_mappings: dict[str, dict[str, Any]] | None,
|
|
|
|
|
|
strict_mode: bool = False,
|
|
|
|
|
|
) -> FormulaEvaluationResult:
|
|
|
|
|
|
dims = dimensions or {}
|
|
|
|
|
|
mappings = dimension_mappings or {}
|
|
|
|
|
|
resolved: dict[str, Any] = dict(variables or {})
|
|
|
|
|
|
|
|
|
|
|
|
missing_required: list[str] = []
|
2026-02-03 18:48:39 +08:00
|
|
|
|
tier_index: int | None = None
|
|
|
|
|
|
tier_info: dict[str, Any] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
computed: dict[str, dict[str, Any]] = {}
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
# 1) Resolve non-computed mappings first
|
2026-01-31 19:11:25 +08:00
|
|
|
|
for var_name, mapping in mappings.items():
|
|
|
|
|
|
source = (mapping.get("source") or "constant").lower()
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if source == "computed":
|
|
|
|
|
|
computed[var_name] = mapping
|
|
|
|
|
|
continue
|
|
|
|
|
|
# Explicit constant mapping is fallback-only when variable already exists.
|
2026-01-31 19:11:25 +08:00
|
|
|
|
if source == "constant" and var_name in resolved:
|
|
|
|
|
|
continue
|
2026-02-03 18:48:39 +08:00
|
|
|
|
value, is_missing, tier_meta = self._resolve_mapping(var_name, mapping, dims)
|
|
|
|
|
|
if tier_meta and tier_index is None:
|
|
|
|
|
|
tier_index = tier_meta.get("tier_index")
|
|
|
|
|
|
tier_info = tier_meta.get("tier_info")
|
2026-01-31 19:11:25 +08:00
|
|
|
|
if is_missing:
|
|
|
|
|
|
missing_required.append(var_name)
|
|
|
|
|
|
continue
|
|
|
|
|
|
resolved[var_name] = value
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
# 2) Resolve computed mappings (iterative dependency resolution)
|
|
|
|
|
|
if computed:
|
|
|
|
|
|
unresolved = dict(computed)
|
|
|
|
|
|
for _ in range(max(4, len(unresolved) + 1)):
|
|
|
|
|
|
progressed = False
|
|
|
|
|
|
for var_name, mapping in list(unresolved.items()):
|
|
|
|
|
|
if var_name in resolved:
|
|
|
|
|
|
unresolved.pop(var_name, None)
|
|
|
|
|
|
continue
|
|
|
|
|
|
value, status = self._try_resolve_computed(var_name, mapping, dims, resolved)
|
|
|
|
|
|
if status == "pending":
|
|
|
|
|
|
continue
|
|
|
|
|
|
unresolved.pop(var_name, None)
|
|
|
|
|
|
if status == "missing_required":
|
|
|
|
|
|
missing_required.append(var_name)
|
|
|
|
|
|
continue
|
2026-02-14 16:34:52 +08:00
|
|
|
|
if status == "error":
|
|
|
|
|
|
# 求值异常但非 required,使用 default 值继续
|
|
|
|
|
|
resolved[var_name] = value
|
|
|
|
|
|
progressed = True
|
|
|
|
|
|
continue
|
2026-02-03 18:48:39 +08:00
|
|
|
|
resolved[var_name] = value
|
|
|
|
|
|
progressed = True
|
|
|
|
|
|
if not progressed:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
# any remaining unresolved computed vars
|
|
|
|
|
|
for var_name, mapping in unresolved.items():
|
|
|
|
|
|
required = bool(mapping.get("required", False))
|
|
|
|
|
|
default = mapping.get("default", 0)
|
2026-02-14 16:34:52 +08:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[FormulaEngine] computed 维度 '{}' 在迭代后仍未解析, required={}",
|
|
|
|
|
|
var_name,
|
|
|
|
|
|
required,
|
|
|
|
|
|
)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if required:
|
|
|
|
|
|
missing_required.append(var_name)
|
|
|
|
|
|
else:
|
|
|
|
|
|
resolved[var_name] = default
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
# required 维度缺失:直接标记 incomplete(并由 strict_mode 决定是否抛错)
|
|
|
|
|
|
if missing_required:
|
|
|
|
|
|
if strict_mode:
|
|
|
|
|
|
raise BillingIncompleteError(
|
|
|
|
|
|
f"Missing required dimensions: {missing_required}",
|
|
|
|
|
|
missing_required=missing_required,
|
|
|
|
|
|
)
|
|
|
|
|
|
return FormulaEvaluationResult(
|
|
|
|
|
|
status="incomplete",
|
2026-02-03 18:48:39 +08:00
|
|
|
|
cost=Decimal("0"),
|
|
|
|
|
|
resolved_dimensions=dims,
|
|
|
|
|
|
resolved_variables=resolved,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
missing_required=missing_required,
|
2026-02-03 18:48:39 +08:00
|
|
|
|
tier_index=tier_index,
|
|
|
|
|
|
tier_info=tier_info,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
# 3) Evaluate total cost
|
2026-01-31 19:11:25 +08:00
|
|
|
|
try:
|
2026-02-03 18:48:39 +08:00
|
|
|
|
cost = self._evaluator.eval_decimal(expression, resolved)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
if cost < 0:
|
|
|
|
|
|
return FormulaEvaluationResult(
|
|
|
|
|
|
status="incomplete",
|
2026-02-03 18:48:39 +08:00
|
|
|
|
cost=Decimal("0"),
|
|
|
|
|
|
resolved_dimensions=dims,
|
|
|
|
|
|
resolved_variables=resolved,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
missing_required=[],
|
2026-02-03 18:48:39 +08:00
|
|
|
|
tier_index=tier_index,
|
|
|
|
|
|
tier_info=tier_info,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
error="negative_cost",
|
|
|
|
|
|
)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
|
|
|
|
|
|
breakdown = self._extract_cost_breakdown(resolved)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
return FormulaEvaluationResult(
|
|
|
|
|
|
status="complete",
|
|
|
|
|
|
cost=cost,
|
2026-02-03 18:48:39 +08:00
|
|
|
|
resolved_dimensions=dims,
|
|
|
|
|
|
resolved_variables=resolved,
|
|
|
|
|
|
cost_breakdown=breakdown,
|
|
|
|
|
|
tier_index=tier_index,
|
|
|
|
|
|
tier_info=tier_info,
|
|
|
|
|
|
missing_required=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
except NameError as exc:
|
|
|
|
|
|
# expression references missing vars
|
|
|
|
|
|
if strict_mode:
|
|
|
|
|
|
raise ExpressionEvaluationError(f"Missing variable: {exc}") from exc
|
|
|
|
|
|
return FormulaEvaluationResult(
|
|
|
|
|
|
status="incomplete",
|
|
|
|
|
|
cost=Decimal("0"),
|
|
|
|
|
|
resolved_dimensions=dims,
|
|
|
|
|
|
resolved_variables=resolved,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
missing_required=[],
|
2026-02-03 18:48:39 +08:00
|
|
|
|
tier_index=tier_index,
|
|
|
|
|
|
tier_info=tier_info,
|
|
|
|
|
|
error=f"missing_variable:{exc}",
|
2026-01-31 19:11:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
except (UnsafeExpressionError, ExpressionEvaluationError) as exc:
|
|
|
|
|
|
if strict_mode:
|
|
|
|
|
|
raise
|
|
|
|
|
|
return FormulaEvaluationResult(
|
|
|
|
|
|
status="incomplete",
|
2026-02-03 18:48:39 +08:00
|
|
|
|
cost=Decimal("0"),
|
|
|
|
|
|
resolved_dimensions=dims,
|
|
|
|
|
|
resolved_variables=resolved,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
missing_required=[],
|
2026-02-03 18:48:39 +08:00
|
|
|
|
tier_index=tier_index,
|
|
|
|
|
|
tier_info=tier_info,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
error=str(exc),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
def _extract_cost_breakdown(self, resolved: dict[str, Any]) -> dict[str, Decimal]:
|
|
|
|
|
|
breakdown: dict[str, Decimal] = {}
|
|
|
|
|
|
for k, v in resolved.items():
|
|
|
|
|
|
if not k.endswith("_cost"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
breakdown[k] = to_decimal(v)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
return breakdown
|
|
|
|
|
|
|
|
|
|
|
|
def _try_resolve_computed(
|
|
|
|
|
|
self,
|
|
|
|
|
|
var_name: str,
|
|
|
|
|
|
mapping: dict[str, Any],
|
|
|
|
|
|
dims: dict[str, Any],
|
|
|
|
|
|
resolved: dict[str, Any],
|
|
|
|
|
|
) -> tuple[Any, Literal["ok", "pending", "missing_required"]]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Try resolve a computed mapping.
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(value, status)
|
|
|
|
|
|
- ok: value computed
|
|
|
|
|
|
- pending: missing dependencies, retry later
|
|
|
|
|
|
- missing_required: required=true and cannot resolve
|
|
|
|
|
|
"""
|
|
|
|
|
|
required = bool(mapping.get("required", False))
|
|
|
|
|
|
default = mapping.get("default", 0)
|
|
|
|
|
|
expr = mapping.get("expression") or mapping.get("transform_expression")
|
|
|
|
|
|
if not expr:
|
|
|
|
|
|
return (None, "missing_required") if required else (default, "ok")
|
|
|
|
|
|
# Computed vars can reference both resolved variables and raw dims.
|
|
|
|
|
|
env: dict[str, Any] = {}
|
|
|
|
|
|
env.update(dims)
|
|
|
|
|
|
env.update(resolved)
|
|
|
|
|
|
try:
|
|
|
|
|
|
value = self._evaluator.eval_decimal(str(expr), env)
|
|
|
|
|
|
return value, "ok"
|
|
|
|
|
|
except NameError:
|
|
|
|
|
|
# dependency not ready yet
|
|
|
|
|
|
return (None, "pending") if required else (default, "pending")
|
2026-02-14 16:34:52 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[FormulaEngine] computed 维度 '{}' 求值异常: {}, expression={!r}",
|
|
|
|
|
|
var_name,
|
|
|
|
|
|
exc,
|
|
|
|
|
|
expr,
|
|
|
|
|
|
)
|
|
|
|
|
|
return (None, "missing_required") if required else (default, "error")
|
2026-02-03 18:48:39 +08:00
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
def _resolve_mapping(
|
|
|
|
|
|
self,
|
|
|
|
|
|
var_name: str,
|
|
|
|
|
|
mapping: dict[str, Any],
|
|
|
|
|
|
dims: dict[str, Any],
|
2026-02-03 18:48:39 +08:00
|
|
|
|
) -> tuple[Any, bool, dict[str, Any] | None]:
|
2026-01-31 19:11:25 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Returns:
|
2026-02-14 16:34:52 +08:00
|
|
|
|
(value, is_missing_required, tier_meta)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
说明:
|
|
|
|
|
|
- is_missing_required 仅在 required=true 且缺失时为 True
|
|
|
|
|
|
- required=false 的缺失会使用 default 或 0 兜底,并返回 is_missing_required=False
|
|
|
|
|
|
"""
|
|
|
|
|
|
source = (mapping.get("source") or "constant").lower()
|
2026-02-14 16:34:52 +08:00
|
|
|
|
|
|
|
|
|
|
if source == "constant":
|
|
|
|
|
|
return self._resolve_constant(mapping)
|
|
|
|
|
|
if source == "dimension":
|
|
|
|
|
|
return self._resolve_dimension(var_name, mapping, dims)
|
|
|
|
|
|
if source == "matrix":
|
|
|
|
|
|
return self._resolve_matrix(var_name, mapping, dims)
|
|
|
|
|
|
if source == "tiered":
|
|
|
|
|
|
return self._resolve_tiered(var_name, mapping, dims)
|
|
|
|
|
|
# 未知 source:视为配置错误,但不直接中断计费(返回 default)
|
|
|
|
|
|
return mapping.get("default", 0), False, None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _resolve_constant(
|
|
|
|
|
|
mapping: dict[str, Any],
|
|
|
|
|
|
) -> tuple[Any, bool, dict[str, Any] | None]:
|
|
|
|
|
|
"""constant 默认行为:由 variables 提供;dimension_mappings 显式 constant 时仅做兜底"""
|
|
|
|
|
|
return mapping.get("default", 0), False, None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _resolve_dimension(
|
|
|
|
|
|
var_name: str,
|
|
|
|
|
|
mapping: dict[str, Any],
|
|
|
|
|
|
dims: dict[str, Any],
|
|
|
|
|
|
) -> tuple[Any, bool, dict[str, Any] | None]:
|
|
|
|
|
|
"""解析 dimension source:从 dims 中取值并尝试转换为 Decimal"""
|
2026-01-31 19:11:25 +08:00
|
|
|
|
required = bool(mapping.get("required", False))
|
|
|
|
|
|
allow_zero = bool(mapping.get("allow_zero", False))
|
|
|
|
|
|
default = mapping.get("default", 0)
|
|
|
|
|
|
|
|
|
|
|
|
def _missing() -> tuple[Any, bool]:
|
|
|
|
|
|
if required:
|
|
|
|
|
|
return None, True
|
|
|
|
|
|
return default, False
|
|
|
|
|
|
|
2026-02-14 16:34:52 +08:00
|
|
|
|
key = mapping.get("key") or var_name
|
|
|
|
|
|
raw = dims.get(key)
|
|
|
|
|
|
if raw is None:
|
|
|
|
|
|
v, m = _missing()
|
|
|
|
|
|
return v, m, None
|
|
|
|
|
|
if isinstance(raw, str):
|
|
|
|
|
|
if raw == "":
|
2026-02-03 18:48:39 +08:00
|
|
|
|
v, m = _missing()
|
|
|
|
|
|
return v, m, None
|
2026-01-31 19:11:25 +08:00
|
|
|
|
try:
|
2026-02-03 18:48:39 +08:00
|
|
|
|
num = to_decimal(raw)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
if num == 0 and not allow_zero:
|
2026-02-03 18:48:39 +08:00
|
|
|
|
v, m = _missing()
|
|
|
|
|
|
return v, m, None
|
|
|
|
|
|
return num, False, None
|
2026-01-31 19:11:25 +08:00
|
|
|
|
except Exception:
|
2026-02-14 16:34:52 +08:00
|
|
|
|
return raw, False, None
|
|
|
|
|
|
if isinstance(raw, (int, float, Decimal)):
|
|
|
|
|
|
num = to_decimal(raw)
|
|
|
|
|
|
if num == 0 and not allow_zero:
|
2026-02-03 18:48:39 +08:00
|
|
|
|
v, m = _missing()
|
|
|
|
|
|
return v, m, None
|
2026-02-14 16:34:52 +08:00
|
|
|
|
return num, False, None
|
|
|
|
|
|
try:
|
|
|
|
|
|
num = to_decimal(raw)
|
|
|
|
|
|
if num == 0 and not allow_zero:
|
2026-02-03 18:48:39 +08:00
|
|
|
|
v, m = _missing()
|
|
|
|
|
|
return v, m, None
|
2026-02-14 16:34:52 +08:00
|
|
|
|
return num, False, None
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
v, m = _missing()
|
|
|
|
|
|
return v, m, None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _resolve_matrix(
|
|
|
|
|
|
var_name: str,
|
|
|
|
|
|
mapping: dict[str, Any],
|
|
|
|
|
|
dims: dict[str, Any],
|
|
|
|
|
|
) -> tuple[Any, bool, dict[str, Any] | None]:
|
|
|
|
|
|
"""解析 matrix source:从 map 中按 key 查找值"""
|
|
|
|
|
|
required = bool(mapping.get("required", False))
|
|
|
|
|
|
default = mapping.get("default", 0)
|
|
|
|
|
|
|
|
|
|
|
|
def _missing() -> tuple[Any, bool]:
|
2026-01-31 19:11:25 +08:00
|
|
|
|
if required:
|
2026-02-14 16:34:52 +08:00
|
|
|
|
return None, True
|
|
|
|
|
|
return default, False
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-14 16:34:52 +08:00
|
|
|
|
key = mapping.get("key") or var_name
|
|
|
|
|
|
raw = dims.get(key)
|
|
|
|
|
|
if raw is None or raw == "":
|
|
|
|
|
|
v, m = _missing()
|
|
|
|
|
|
return v, m, None
|
|
|
|
|
|
raw_key = str(raw)
|
|
|
|
|
|
matrix = mapping.get("map") or {}
|
|
|
|
|
|
if raw_key in matrix:
|
2026-01-31 19:11:25 +08:00
|
|
|
|
try:
|
2026-02-14 16:34:52 +08:00
|
|
|
|
return to_decimal(matrix[raw_key]), False, None
|
2026-01-31 19:11:25 +08:00
|
|
|
|
except Exception:
|
2026-02-14 16:34:52 +08:00
|
|
|
|
return matrix[raw_key], False, None
|
|
|
|
|
|
if required:
|
|
|
|
|
|
return None, True, None
|
|
|
|
|
|
return default, False, None
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-14 16:34:52 +08:00
|
|
|
|
def _resolve_tiered(
|
|
|
|
|
|
self,
|
|
|
|
|
|
var_name: str,
|
|
|
|
|
|
mapping: dict[str, Any],
|
|
|
|
|
|
dims: dict[str, Any],
|
|
|
|
|
|
) -> tuple[Any, bool, dict[str, Any] | None]:
|
|
|
|
|
|
"""解析 tiered source:按阶梯匹配值"""
|
|
|
|
|
|
required = bool(mapping.get("required", False))
|
|
|
|
|
|
allow_zero = bool(mapping.get("allow_zero", False))
|
|
|
|
|
|
default = mapping.get("default", 0)
|
|
|
|
|
|
|
|
|
|
|
|
def _missing() -> tuple[Any, bool]:
|
|
|
|
|
|
if required:
|
|
|
|
|
|
return None, True
|
|
|
|
|
|
return default, False
|
2026-02-03 18:48:39 +08:00
|
|
|
|
|
2026-02-14 16:34:52 +08:00
|
|
|
|
tier_key = mapping.get("tier_key")
|
|
|
|
|
|
if not tier_key:
|
|
|
|
|
|
v, m = _missing()
|
|
|
|
|
|
return v, m, None
|
|
|
|
|
|
raw_tier_value = dims.get(tier_key)
|
|
|
|
|
|
if raw_tier_value is None:
|
|
|
|
|
|
v, m = _missing()
|
|
|
|
|
|
return v, m, None
|
|
|
|
|
|
try:
|
|
|
|
|
|
tier_value = to_decimal(raw_tier_value)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
v, m = _missing()
|
|
|
|
|
|
return v, m, None
|
|
|
|
|
|
|
|
|
|
|
|
if tier_value == 0 and not allow_zero:
|
|
|
|
|
|
v, m = _missing()
|
|
|
|
|
|
return v, m, None
|
|
|
|
|
|
|
|
|
|
|
|
# Optional TTL override (legacy: Claude cache pricing)
|
|
|
|
|
|
ttl_key = mapping.get("ttl_key")
|
|
|
|
|
|
ttl_value_key = mapping.get("ttl_value_key")
|
|
|
|
|
|
ttl_minutes: Decimal | None = None
|
|
|
|
|
|
if ttl_key and ttl_value_key and dims.get(ttl_key) is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
ttl_minutes = to_decimal(dims.get(ttl_key))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
ttl_minutes = None
|
|
|
|
|
|
|
|
|
|
|
|
tiers = mapping.get("tiers") or []
|
|
|
|
|
|
# tiers: [{up_to: 128000, value: 2.5}, {up_to: null, value: 1.25}]
|
|
|
|
|
|
for idx, tier in enumerate(tiers):
|
|
|
|
|
|
up_to = tier.get("up_to")
|
|
|
|
|
|
if up_to is None:
|
|
|
|
|
|
value = to_decimal(tier.get("value", default))
|
|
|
|
|
|
if (
|
|
|
|
|
|
ttl_minutes is not None
|
|
|
|
|
|
and ttl_value_key
|
|
|
|
|
|
and isinstance(tier.get("cache_ttl_pricing"), list)
|
|
|
|
|
|
):
|
|
|
|
|
|
value = self._resolve_ttl_pricing(
|
|
|
|
|
|
tier.get("cache_ttl_pricing") or [],
|
|
|
|
|
|
ttl_minutes,
|
|
|
|
|
|
str(ttl_value_key),
|
|
|
|
|
|
fallback=value,
|
|
|
|
|
|
)
|
|
|
|
|
|
return value, False, {"tier_index": idx, "tier_info": dict(tier)}
|
|
|
|
|
|
try:
|
|
|
|
|
|
if tier_value <= to_decimal(up_to):
|
2026-02-03 18:48:39 +08:00
|
|
|
|
value = to_decimal(tier.get("value", default))
|
|
|
|
|
|
if (
|
|
|
|
|
|
ttl_minutes is not None
|
|
|
|
|
|
and ttl_value_key
|
|
|
|
|
|
and isinstance(tier.get("cache_ttl_pricing"), list)
|
|
|
|
|
|
):
|
|
|
|
|
|
value = self._resolve_ttl_pricing(
|
|
|
|
|
|
tier.get("cache_ttl_pricing") or [],
|
|
|
|
|
|
ttl_minutes,
|
|
|
|
|
|
str(ttl_value_key),
|
|
|
|
|
|
fallback=value,
|
|
|
|
|
|
)
|
|
|
|
|
|
return value, False, {"tier_index": idx, "tier_info": dict(tier)}
|
2026-02-14 16:34:52 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
# up_to 配置异常:忽略并继续
|
|
|
|
|
|
continue
|
|
|
|
|
|
# 无匹配:使用最后一个或 default
|
|
|
|
|
|
if tiers:
|
|
|
|
|
|
last = tiers[-1]
|
|
|
|
|
|
value = to_decimal(last.get("value", default))
|
|
|
|
|
|
if (
|
|
|
|
|
|
ttl_minutes is not None
|
|
|
|
|
|
and ttl_value_key
|
|
|
|
|
|
and isinstance(last.get("cache_ttl_pricing"), list)
|
|
|
|
|
|
):
|
|
|
|
|
|
value = self._resolve_ttl_pricing(
|
|
|
|
|
|
last.get("cache_ttl_pricing") or [],
|
|
|
|
|
|
ttl_minutes,
|
|
|
|
|
|
str(ttl_value_key),
|
|
|
|
|
|
fallback=value,
|
|
|
|
|
|
)
|
|
|
|
|
|
return value, False, {"tier_index": len(tiers) - 1, "tier_info": dict(last)}
|
2026-02-03 18:48:39 +08:00
|
|
|
|
return default, False, None
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_ttl_pricing(
|
|
|
|
|
|
self,
|
|
|
|
|
|
ttl_pricing: list[Any],
|
|
|
|
|
|
ttl_minutes: Decimal,
|
|
|
|
|
|
ttl_value_key: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
fallback: Decimal,
|
|
|
|
|
|
) -> Decimal:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Resolve TTL-dependent pricing (legacy: cache_ttl_pricing).
|
|
|
|
|
|
|
|
|
|
|
|
Rules:
|
|
|
|
|
|
- pick the first entry whose ttl_minutes >= requested ttl
|
|
|
|
|
|
- otherwise pick the last entry
|
|
|
|
|
|
- if missing/invalid, fallback to base value
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
entries = [e for e in ttl_pricing if isinstance(e, dict)]
|
|
|
|
|
|
if not entries:
|
|
|
|
|
|
return fallback
|
|
|
|
|
|
|
|
|
|
|
|
def _ttl_key(e: dict[str, Any]) -> Decimal:
|
|
|
|
|
|
return to_decimal(e.get("ttl_minutes") or 0)
|
|
|
|
|
|
|
|
|
|
|
|
entries_sorted = sorted(entries, key=_ttl_key)
|
|
|
|
|
|
chosen: dict[str, Any] = entries_sorted[-1]
|
|
|
|
|
|
for e in entries_sorted:
|
|
|
|
|
|
try:
|
|
|
|
|
|
if ttl_minutes <= to_decimal(e.get("ttl_minutes") or 0):
|
|
|
|
|
|
chosen = e
|
|
|
|
|
|
break
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
v = chosen.get(ttl_value_key)
|
|
|
|
|
|
if v is None:
|
|
|
|
|
|
return fallback
|
|
|
|
|
|
return to_decimal(v)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return fallback
|