From 24fc21cb566ca5016213843d9645b99dbff35d39 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 04:14:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E5=AD=97=E6=8E=A8=E6=BC=94=EF=BC=9A?= =?UTF-8?q?=E7=AE=97=E5=87=BA=E6=97=A0=E7=A9=B7=E5=A4=A7=E3=80=81=E9=9D=9E?= =?UTF-8?q?=E6=95=B0=E6=97=B6=E8=AF=B4=E6=B8=85=E7=AE=97=E4=B8=8D=E5=87=BA?= =?UTF-8?q?=E6=9D=A5=EF=BC=8C=E4=B8=8D=E8=AE=A9=E6=8E=A5=E5=8F=A3=E5=9C=A8?= =?UTF-8?q?=20JSON=20=E7=BC=96=E7=A0=81=E6=97=B6=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改了输入之后溢出成无穷大(或算出非数),以前照 ok 交回,Starlette 编 JSON 时拒收这种浮点数,整个请求 500。 现在按计算出错处理(status: error,写明原因);老卡上本来就记着无穷大的值和输入,交回时换成 null(口径卡渲染成「—」)。 --- backend/app/engine/recompute.py | 12 ++++++++++-- backend/tests/test_recompute.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/backend/app/engine/recompute.py b/backend/app/engine/recompute.py index bf2bc524..8636d24e 100644 --- a/backend/app/engine/recompute.py +++ b/backend/app/engine/recompute.py @@ -473,6 +473,9 @@ def _compute(plan: _Plan, values: dict[str, Any], notes: dict[str, list[int]], if inp.row is not None and path in notes and path not in changes.inputs: return "excluded", None, f"{EXCLUDED}:{path} 所在的第 {inp.row + 1} 行已排除" value, reason = _evaluate(plan, values) + if reason is None and isinstance(value, float) and not math.isfinite(value): + # 溢出成无穷大、算出非数:口径卡渲染成「—」,这里也说清算不出来(JSON 里也放不下这种值) + reason = "计算结果不是有限的数值(溢出或非数),无法推演" return ("ok", value, None) if reason is None else ("error", None, reason) @@ -545,7 +548,7 @@ def _result(plan: _Plan, status: str, new: Any, reason: str | None, values: dict return { "id": metric.get("id"), "name": metric.get("name") or metric.get("id"), "unit": metric.get("unit") or "", "decimals": metric.get("decimals"), "format": metric.get("format") or DEFAULT_FORMAT, - "old": old, "new": new, "delta": delta, + "old": _finite(old), "new": _finite(new), "delta": delta, "rendered_old": _render(metric, old), "rendered_new": _render(metric, new) if known else None, "rendered_delta": rendered_delta, "changed": (not _same(old, new)) if known else None, @@ -562,6 +565,11 @@ def _render(metric: dict[str, Any], value: Any, conv: str | None = None) -> str: return MISSING +def _finite(value: Any) -> Any: + """无穷大、非数换成 None:老卡上可能记着(口径卡渲染成「—」),JSON 里放不下。""" + return None if isinstance(value, float) and not math.isfinite(value) else value + + def _same(a: Any, b: Any) -> bool: if is_number(a) and is_number(b): return a == b or (isinstance(a, float) and isinstance(b, float) and math.isnan(a) and math.isnan(b)) @@ -620,7 +628,7 @@ def _input_view(inp: _Input, values: dict[str, Any], notes: dict[str, list[int]] new = _outline(values.get(inp.path), inp.table) if inp.problem is None else None changed = inp.problem is None and not _same(old, new) else: - old, new = item.get("value"), values.get(inp.path) + old, new = _finite(item.get("value")), _finite(values.get(inp.path)) changed = not _same(old, new) view.update(old=old, new=new, changed=changed) if inp.path in notes: diff --git a/backend/tests/test_recompute.py b/backend/tests/test_recompute.py index 83081459..1b9b6594 100644 --- a/backend/tests/test_recompute.py +++ b/backend/tests/test_recompute.py @@ -218,6 +218,21 @@ def test_division_by_zero_is_explained(): assert wow["status"] == "error" and wow["new"] is None and wow["reason"] == "除以零" +def test_an_overflow_is_an_error_not_a_crash(): + """算出无穷大:说清算不出来;老卡上记着无穷大的也不往外送(JSON 放不下,接口会 500)。""" + import json + + result = whatif(CARD, Changes(inputs={"vars.kpi.refunds": 1e308, "vars.kpi.orders": 1e-10}), sources(), + metric="ratio") + [ratio] = result["metrics"] + assert ratio["status"] == "error" and ratio["new"] is None and "有限" in ratio["reason"] + card = {**CARD, "metrics": [metric("big", "vars.kpi.x * 2", "inf * 2", math.inf, + [scalar("vars.kpi.x", math.inf)], rendered="—")]} + big = whatif(card, Changes(), sources())["metrics"][0] + assert big["old"] is None and big["inputs"][0]["old"] is None and big["rendered_old"] == "—" + json.dumps([result, big], allow_nan=False) + + def test_a_missing_input_can_be_supplied(): card = {**CARD, "metrics": [metric("cost", "vars.kpi.cost / vars.kpi.orders", "vars.kpi.cost / 1234", None, [scalar("vars.kpi.cost", None), scalar("vars.kpi.orders", 1234)], decimals=2,