fix: reuse configured clients for finance stats

This commit is contained in:
SmartUp Developer
2026-07-03 15:42:29 +08:00
parent 5e99abcbc5
commit a1e2c059b9
4 changed files with 224 additions and 171 deletions
+63 -82
View File
@@ -1,12 +1,14 @@
"""Finance service — daily revenue vs cost reconciliation. """Finance service — daily revenue vs cost reconciliation.
Architecture: Architecture:
- Website revenue: HTTP GET /usage/stats via website's existing auth config - Website revenue: Sub2ApiWebsiteClient._request("/usage/stats", params=...)
- Upstream cost (Sub2API): HTTP GET /usage/stats via upstream's bearer token Reuses all existing error handling, logging, auth (api_key / bearer).
- Upstream cost (New-API/Nox-API): HTTP GET /api/log/stat via UpstreamClient - Upstream cost (Sub2API): UpstreamClient._send_request(url, params=...)
(handles login_password / nox_token / new_api_token auth) within the client context — retains cookies, 401 refresh, external API log.
- Upstream cost (New-API/Nox-API): same UpstreamClient pattern.
No DB writes. All data fetched live. Each item fails independently. No DB writes. Each item fails independently; failures do NOT contribute to
totals, and the UI must treat any failed item as incomplete accounting data.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,12 +17,12 @@ import logging
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Any from typing import Any
import httpx
import pytz import pytz
from app.models.upstream import Upstream from app.models.upstream import Upstream
from app.models.website import Website from app.models.website import Website
from app.services.upstream_client import UpstreamClient from app.services.upstream_client import UpstreamClient
from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,12 +41,19 @@ def classify_upstream(upstream: Upstream) -> str:
auth = upstream.auth_type or "" auth = upstream.auth_type or ""
if auth in ("new_api_token", "nox_token"): if auth in ("new_api_token", "nox_token"):
return "new_api" return "new_api"
if auth == "login_password": if auth in ("cookie", "login_password"):
try: try:
cfg = json.loads(upstream.auth_config_json or "{}") cfg = json.loads(upstream.auth_config_json or "{}")
except Exception: except Exception:
cfg = {} cfg = {}
if "/api/user/login" in (cfg.get("login_path") or ""): if (
prefix == ""
and (
auth == "cookie"
or "/api/user/login" in (cfg.get("login_path") or "")
or cfg.get("provider") in {"new-api", "nox-api"}
)
):
return "new_api" return "new_api"
return "unknown" return "unknown"
@@ -64,20 +73,6 @@ def date_to_shanghai_timestamps(d: date) -> tuple[int, int]:
return int(start.timestamp()), int(end.timestamp()) return int(start.timestamp()), int(end.timestamp())
def _build_website_headers(auth_type: str, auth_config: dict) -> dict[str, str]:
headers = {"Accept": "application/json", "User-Agent": "SmartUp/1.0"}
if auth_type == "api_key":
key = auth_config.get("key") or auth_config.get("api_key") or ""
header_name = auth_config.get("header") or "x-api-key"
if key:
headers[header_name] = key
elif auth_type == "bearer":
token = auth_config.get("token") or ""
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# Website revenue # Website revenue
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@@ -85,35 +80,39 @@ def _build_website_headers(auth_type: str, auth_config: dict) -> dict[str, str]:
def fetch_website_revenue(website: Website, target_date: date) -> tuple[float, str | None]: def fetch_website_revenue(website: Website, target_date: date) -> tuple[float, str | None]:
"""Fetch website revenue for target_date. """Fetch website revenue for target_date.
Calls GET /usage/stats on the Sub2API admin endpoint with the website's Uses Sub2ApiWebsiteClient (existing auth, error handling, external API log).
existing auth config (same as all other website operations in SmartUp). GET /usage/stats?start_date=...&end_date=...&timezone=Asia/Shanghai&nocache=true
Returns (amount, error_message). error_message is None on success.
""" """
date_str = target_date.isoformat() date_str = target_date.isoformat()
try: try:
auth_config = json.loads(website.auth_config_json or "{}") with Sub2ApiWebsiteClient(
prefix = (website.api_prefix or "").strip("/") base_url=website.base_url,
prefix_part = f"/{prefix}" if prefix else "" api_prefix=website.api_prefix,
url = f"{website.base_url.rstrip('/')}{prefix_part}/usage/stats" auth_type=website.auth_type,
auth_config=json.loads(website.auth_config_json or "{}"),
timeout=float(website.timeout_seconds),
target_id=website.id,
target_name=website.name,
) as c:
data = c._request(
"GET",
"/usage/stats",
params={ params={
"start_date": date_str, "start_date": date_str,
"end_date": date_str, "end_date": date_str,
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
"nocache": "true", "nocache": "true",
} },
headers = _build_website_headers(website.auth_type, auth_config) )
resp = httpx.get(url, params=params, headers=headers, timeout=float(website.timeout_seconds)) # Sub2API wraps payload in `data` key: {data: {total_actual_cost: ...}}
resp.raise_for_status()
data = resp.json()
# Sub2API wraps in data: { total_actual_cost, ... }
payload = data.get("data", data) if isinstance(data, dict) else {} payload = data.get("data", data) if isinstance(data, dict) else {}
if isinstance(payload, dict): if isinstance(payload, dict):
cost = payload.get("total_actual_cost") cost = payload.get("total_actual_cost")
if cost is not None: if cost is not None:
return float(cost), None return float(cost), None
return 0.0, "响应中找不到 total_actual_cost 字段" return 0.0, "响应中找不到 total_actual_cost 字段"
except httpx.HTTPStatusError as e: except WebsiteError as e:
return 0.0, f"HTTP {e.response.status_code}: {e.response.text[:200]}" return 0.0, str(e)
except Exception as e: except Exception as e:
return 0.0, str(e) return 0.0, str(e)
@@ -125,26 +124,32 @@ def fetch_website_revenue(website: Website, target_date: date) -> tuple[float, s
def fetch_upstream_cost_sub2api(upstream: Upstream, target_date: date) -> tuple[float, str | None]: def fetch_upstream_cost_sub2api(upstream: Upstream, target_date: date) -> tuple[float, str | None]:
"""Fetch cost for Sub2API-type upstream via /usage/stats (user-facing endpoint). """Fetch cost for Sub2API-type upstream via /usage/stats (user-facing endpoint).
Sub2API's /usage/stats auto-adds 1 day to end_date internally, so passing Calls UpstreamClient._send_request inside the context so that cookies,
the same date for start and end gives the full day's data. 401 refresh, and external API logging are all preserved.
Sub2API's end_date is exclusive (+1 day internally), so passing the same
date for start and end yields the full day's data.
""" """
date_str = target_date.isoformat() date_str = target_date.isoformat()
try:
auth_config = json.loads(upstream.auth_config_json or "{}") auth_config = json.loads(upstream.auth_config_json or "{}")
prefix = (upstream.api_prefix or "").strip("/") try:
prefix_part = f"/{prefix}" if prefix else "" with UpstreamClient(
url = f"{upstream.base_url.rstrip('/')}{prefix_part}/usage/stats" base_url=upstream.base_url,
api_prefix=upstream.api_prefix,
auth_type=upstream.auth_type,
auth_config=auth_config,
timeout=float(upstream.timeout_seconds),
on_auth_config_update=lambda _: None, # read-only, no DB write
target_id=upstream.id,
target_name=upstream.name,
) as client:
client.ensure_authenticated()
params = { params = {
"start_date": date_str, "start_date": date_str,
"end_date": date_str, "end_date": date_str,
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
} }
headers = {"Accept": "application/json", "User-Agent": "SmartUp/1.0"} resp = client._send_request("GET", client._url("/usage/stats"), params=params)
token = auth_config.get("token") or ""
if token:
headers["Authorization"] = f"Bearer {token}"
resp = httpx.get(url, params=params, headers=headers, timeout=float(upstream.timeout_seconds))
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
payload = data.get("data", data) if isinstance(data, dict) else {} payload = data.get("data", data) if isinstance(data, dict) else {}
@@ -153,8 +158,6 @@ def fetch_upstream_cost_sub2api(upstream: Upstream, target_date: date) -> tuple[
if cost is not None: if cost is not None:
return float(cost), None return float(cost), None
return 0.0, "响应中找不到 total_actual_cost 字段" return 0.0, "响应中找不到 total_actual_cost 字段"
except httpx.HTTPStatusError as e:
return 0.0, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
except Exception as e: except Exception as e:
return 0.0, str(e) return 0.0, str(e)
@@ -166,13 +169,16 @@ def fetch_upstream_cost_sub2api(upstream: Upstream, target_date: date) -> tuple[
def fetch_upstream_cost_new_api(upstream: Upstream, target_date: date) -> tuple[float, str | None]: def fetch_upstream_cost_new_api(upstream: Upstream, target_date: date) -> tuple[float, str | None]:
"""Fetch cost for New-API/Nox-API upstream via /api/log/stat. """Fetch cost for New-API/Nox-API upstream via /api/log/stat.
Uses UpstreamClient for auth (handles login_password refresh, nox_token, etc.). Keeps UpstreamClient open for the full request so that:
Timestamp range: 00:00:00 to 23:59:59 Asia/Shanghai (closed interval, as per - login_password token refresh is available if the stat call gets 401
New-API source: created_at <= endTimestamp). - cookies are maintained throughout
- external API call is logged via _send_request → _do_request
Timestamp range: 00:00:00 to 23:59:59 Asia/Shanghai (closed interval per
New-API source: `created_at <= endTimestamp`).
""" """
start_ts, end_ts = date_to_shanghai_timestamps(target_date) start_ts, end_ts = date_to_shanghai_timestamps(target_date)
auth_config = json.loads(upstream.auth_config_json or "{}") auth_config = json.loads(upstream.auth_config_json or "{}")
try: try:
with UpstreamClient( with UpstreamClient(
base_url=upstream.base_url, base_url=upstream.base_url,
@@ -180,36 +186,14 @@ def fetch_upstream_cost_new_api(upstream: Upstream, target_date: date) -> tuple[
auth_type=upstream.auth_type, auth_type=upstream.auth_type,
auth_config=auth_config, auth_config=auth_config,
timeout=float(upstream.timeout_seconds), timeout=float(upstream.timeout_seconds),
on_auth_config_update=lambda _: None, # read-only, no DB writes on_auth_config_update=lambda _: None, # read-only, no DB write
target_id=upstream.id, target_id=upstream.id,
target_name=upstream.name, target_name=upstream.name,
) as client: ) as client:
client.ensure_authenticated() client.ensure_authenticated()
# Get quota_per_unit from /api/status (cached within this context)
quota_per_unit = client._new_api_quota_per_unit() quota_per_unit = client._new_api_quota_per_unit()
# Build auth headers after authentication
token = client._token or auth_config.get("token") or ""
user_id = (
client._new_api_user
or auth_config.get("new_api_user")
or auth_config.get("user_id")
or ""
)
# Make the stats call with params (UpstreamClient._request doesn't support params)
prefix = (upstream.api_prefix or "").strip("/")
prefix_part = f"/{prefix}" if prefix else ""
url = f"{upstream.base_url.rstrip('/')}{prefix_part}/api/log/stat"
params = {"type": 2, "start_timestamp": start_ts, "end_timestamp": end_ts} params = {"type": 2, "start_timestamp": start_ts, "end_timestamp": end_ts}
headers = {"Accept": "application/json", "User-Agent": "SmartUp/1.0"} resp = client._send_request("GET", client._url("/api/log/stat"), params=params)
if token:
headers["Authorization"] = f"Bearer {token}"
if user_id:
headers["New-Api-User"] = user_id
headers["Nox-Api-User"] = user_id
resp = httpx.get(url, params=params, headers=headers, timeout=float(upstream.timeout_seconds))
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
payload = data.get("data", data) if isinstance(data, dict) else {} payload = data.get("data", data) if isinstance(data, dict) else {}
@@ -219,9 +203,6 @@ def fetch_upstream_cost_new_api(upstream: Upstream, target_date: date) -> tuple[
cost = float(quota) / max(quota_per_unit, 1) cost = float(quota) / max(quota_per_unit, 1)
return round(cost, 6), None return round(cost, 6), None
return 0.0, "响应中找不到 quota 字段" return 0.0, "响应中找不到 quota 字段"
except httpx.HTTPStatusError as e:
return 0.0, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
except Exception as e: except Exception as e:
return 0.0, str(e) return 0.0, str(e)
+2 -2
View File
@@ -190,7 +190,7 @@ class Sub2ApiWebsiteClient:
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
return headers return headers
def _request(self, method: str, path: str, body: Any = None) -> Any: def _request(self, method: str, path: str, body: Any = None, params: dict | None = None) -> Any:
url = self._url(path) url = self._url(path)
started = time.monotonic() started = time.monotonic()
status_code: int | None = None status_code: int | None = None
@@ -198,7 +198,7 @@ class Sub2ApiWebsiteClient:
error_msg: str | None = None error_msg: str | None = None
try: try:
try: try:
resp = self._client.request(method, url, json=body, headers=self._headers()) resp = self._client.request(method, url, json=body, params=params, headers=self._headers())
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:
error_type, error_msg = type(exc).__name__, str(exc)[:500] error_type, error_msg = type(exc).__name__, str(exc)[:500]
raise WebsiteError(_friendly_connection_error(exc)) from exc raise WebsiteError(_friendly_connection_error(exc)) from exc
+123 -51
View File
@@ -3,14 +3,14 @@ from __future__ import annotations
import json import json
from datetime import date from datetime import date
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock
import httpx import httpx
import pytest
from app.services.finance_service import ( from app.services.finance_service import (
classify_upstream, classify_upstream,
date_to_shanghai_timestamps, date_to_shanghai_timestamps,
fetch_upstream_cost_sub2api,
fetch_upstream_cost_new_api, fetch_upstream_cost_new_api,
fetch_website_revenue, fetch_website_revenue,
get_daily_summary, get_daily_summary,
@@ -45,6 +45,52 @@ class FakeWebsite:
self.enabled = True self.enabled = True
class FakeWebsiteClientCtx:
def __init__(self, response: dict | None = None, raise_exc: Exception | None = None):
self.response = response or {"data": {"total_actual_cost": 123.45}}
self.raise_exc = raise_exc
self.calls: list[dict] = []
def __enter__(self): return self
def __exit__(self, *a): pass
def _request(self, method, path, body=None, params=None):
self.calls.append({"method": method, "path": path, "body": body, "params": params})
if self.raise_exc:
raise self.raise_exc
return self.response
class FakeUpstreamClientCtx:
"""Context manager mock for UpstreamClient."""
def __init__(
self,
token="tok",
user_id="u1",
quota_per_unit=500000,
response: dict | None = None,
raise_exc: Exception | None = None,
):
self._token = token
self._new_api_user = user_id
self._quota_per_unit = quota_per_unit
self.response = response or {"data": {"quota": 5_000_000}}
self.raise_exc = raise_exc
self.calls: list[dict] = []
def __enter__(self): return self
def __exit__(self, *a): pass
def ensure_authenticated(self): pass
def _new_api_quota_per_unit(self): return self._quota_per_unit
def _url(self, path): return f"http://up.test/{path.lstrip('/')}"
def _send_request(self, method, url, **kwargs):
self.calls.append({"method": method, "url": url, **kwargs})
if self.raise_exc:
raise self.raise_exc
return _make_mock_response(self.response)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# classify_upstream tests # classify_upstream tests
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@@ -64,6 +110,15 @@ def test_classify_upstream_nox_token_type():
assert classify_upstream(u) == "new_api" assert classify_upstream(u) == "new_api"
def test_classify_upstream_new_api_by_cookie_mode():
u = FakeUpstream(
api_prefix="",
auth_type="cookie",
auth_config_json=json.dumps({"cookie_string": "sid=x", "user_id": "7"}),
)
assert classify_upstream(u) == "new_api"
def test_classify_upstream_new_api_by_login_path(): def test_classify_upstream_new_api_by_login_path():
u = FakeUpstream( u = FakeUpstream(
api_prefix="", api_prefix="",
@@ -120,20 +175,33 @@ def _make_mock_response(json_body: dict, status_code: int = 200):
def test_website_revenue_success(monkeypatch): def test_website_revenue_success(monkeypatch):
w = FakeWebsite() w = FakeWebsite()
mock_resp = _make_mock_response({"data": {"total_actual_cost": 123.45}}) client = FakeWebsiteClientCtx({"data": {"total_actual_cost": 123.45}})
monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp) monkeypatch.setattr("app.services.finance_service.Sub2ApiWebsiteClient", lambda **kw: client)
amount, err = fetch_website_revenue(w, date(2026, 7, 2)) amount, err = fetch_website_revenue(w, date(2026, 7, 2))
assert err is None assert err is None
assert abs(amount - 123.45) < 1e-6 assert abs(amount - 123.45) < 1e-6
assert client.calls == [{
"method": "GET",
"path": "/usage/stats",
"body": None,
"params": {
"start_date": "2026-07-02",
"end_date": "2026-07-02",
"timezone": "Asia/Shanghai",
"nocache": "true",
},
}]
def test_website_revenue_nested_data(monkeypatch): def test_website_revenue_nested_data(monkeypatch):
"""total_actual_cost directly in response root (no wrapping data key).""" """total_actual_cost directly in response root (no wrapping data key)."""
w = FakeWebsite() w = FakeWebsite()
mock_resp = _make_mock_response({"total_actual_cost": 55.5}) monkeypatch.setattr(
monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp) "app.services.finance_service.Sub2ApiWebsiteClient",
lambda **kw: FakeWebsiteClientCtx({"total_actual_cost": 55.5}),
)
amount, err = fetch_website_revenue(w, date(2026, 7, 2)) amount, err = fetch_website_revenue(w, date(2026, 7, 2))
@@ -143,8 +211,10 @@ def test_website_revenue_nested_data(monkeypatch):
def test_website_revenue_no_field(monkeypatch): def test_website_revenue_no_field(monkeypatch):
w = FakeWebsite() w = FakeWebsite()
mock_resp = _make_mock_response({"data": {"some_other_field": 99}}) monkeypatch.setattr(
monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp) "app.services.finance_service.Sub2ApiWebsiteClient",
lambda **kw: FakeWebsiteClientCtx({"data": {"some_other_field": 99}}),
)
amount, err = fetch_website_revenue(w, date(2026, 7, 2)) amount, err = fetch_website_revenue(w, date(2026, 7, 2))
@@ -155,11 +225,10 @@ def test_website_revenue_no_field(monkeypatch):
def test_website_revenue_http_error(monkeypatch): def test_website_revenue_http_error(monkeypatch):
w = FakeWebsite() w = FakeWebsite()
bad_resp = MagicMock() monkeypatch.setattr(
bad_resp.status_code = 403 "app.services.finance_service.Sub2ApiWebsiteClient",
bad_resp.text = "Forbidden" lambda **kw: FakeWebsiteClientCtx(raise_exc=Exception("HTTP 403: Forbidden")),
exc = httpx.HTTPStatusError("403 Forbidden", request=MagicMock(), response=bad_resp) )
monkeypatch.setattr("httpx.get", lambda *a, **kw: (_ for _ in ()).throw(exc))
amount, err = fetch_website_revenue(w, date(2026, 7, 2)) amount, err = fetch_website_revenue(w, date(2026, 7, 2))
@@ -170,7 +239,10 @@ def test_website_revenue_http_error(monkeypatch):
def test_website_revenue_connection_error(monkeypatch): def test_website_revenue_connection_error(monkeypatch):
w = FakeWebsite() w = FakeWebsite()
monkeypatch.setattr("httpx.get", lambda *a, **kw: (_ for _ in ()).throw(Exception("connection refused"))) monkeypatch.setattr(
"app.services.finance_service.Sub2ApiWebsiteClient",
lambda **kw: FakeWebsiteClientCtx(raise_exc=Exception("connection refused")),
)
amount, err = fetch_website_revenue(w, date(2026, 7, 2)) amount, err = fetch_website_revenue(w, date(2026, 7, 2))
@@ -179,20 +251,25 @@ def test_website_revenue_connection_error(monkeypatch):
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# fetch_upstream_cost_new_api tests # upstream cost tests
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
class FakeUpstreamClientCtx: def test_upstream_cost_sub2api_uses_upstream_client(monkeypatch):
"""Context manager mock for UpstreamClient.""" u = FakeUpstream(api_prefix="api/v1", auth_type="bearer")
def __init__(self, token="tok", user_id="u1", quota_per_unit=500000): client = FakeUpstreamClientCtx(response={"data": {"total_actual_cost": 42.0}})
self._token = token monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: client)
self._new_api_user = user_id
self._quota_per_unit = quota_per_unit
def __enter__(self): return self amount, err = fetch_upstream_cost_sub2api(u, date(2026, 7, 2))
def __exit__(self, *a): pass
def ensure_authenticated(self): pass assert err is None
def _new_api_quota_per_unit(self): return self._quota_per_unit assert abs(amount - 42.0) < 1e-6
assert client.calls[0]["method"] == "GET"
assert client.calls[0]["url"] == "http://up.test/usage/stats"
assert client.calls[0]["params"] == {
"start_date": "2026-07-02",
"end_date": "2026-07-02",
"timezone": "Asia/Shanghai",
}
def test_upstream_cost_new_api_success(monkeypatch): def test_upstream_cost_new_api_success(monkeypatch):
@@ -200,21 +277,25 @@ def test_upstream_cost_new_api_success(monkeypatch):
mock_client_instance = FakeUpstreamClientCtx(quota_per_unit=500000) mock_client_instance = FakeUpstreamClientCtx(quota_per_unit=500000)
monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: mock_client_instance) monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: mock_client_instance)
mock_resp = _make_mock_response({"data": {"quota": 5_000_000}})
monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp)
amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2)) amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2))
assert err is None assert err is None
assert abs(amount - 10.0) < 1e-6 # 5_000_000 / 500_000 = 10.0 assert abs(amount - 10.0) < 1e-6 # 5_000_000 / 500_000 = 10.0
assert mock_client_instance.calls[0]["method"] == "GET"
assert mock_client_instance.calls[0]["url"] == "http://up.test/api/log/stat"
assert mock_client_instance.calls[0]["params"] == {
"type": 2,
"start_timestamp": 1782921600,
"end_timestamp": 1783007999,
}
def test_upstream_cost_new_api_missing_quota(monkeypatch): def test_upstream_cost_new_api_missing_quota(monkeypatch):
u = FakeUpstream(api_prefix="", auth_type="new_api_token") u = FakeUpstream(api_prefix="", auth_type="new_api_token")
monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: FakeUpstreamClientCtx()) monkeypatch.setattr(
"app.services.finance_service.UpstreamClient",
mock_resp = _make_mock_response({"data": {}}) lambda **kw: FakeUpstreamClientCtx(response={"data": {}}),
monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp) )
amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2)) amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2))
@@ -225,13 +306,15 @@ def test_upstream_cost_new_api_missing_quota(monkeypatch):
def test_upstream_cost_new_api_http_error(monkeypatch): def test_upstream_cost_new_api_http_error(monkeypatch):
u = FakeUpstream(api_prefix="", auth_type="new_api_token") u = FakeUpstream(api_prefix="", auth_type="new_api_token")
monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: FakeUpstreamClientCtx())
bad_resp = MagicMock() bad_resp = MagicMock()
bad_resp.status_code = 401 bad_resp.status_code = 401
bad_resp.text = "Unauthorized" bad_resp.text = "Unauthorized"
exc = httpx.HTTPStatusError("401", request=MagicMock(), response=bad_resp) exc = httpx.HTTPStatusError("401", request=MagicMock(), response=bad_resp)
monkeypatch.setattr("httpx.get", lambda *a, **kw: (_ for _ in ()).throw(exc)) monkeypatch.setattr(
"app.services.finance_service.UpstreamClient",
lambda **kw: FakeUpstreamClientCtx(raise_exc=exc),
)
amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2)) amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2))
@@ -249,16 +332,10 @@ def test_get_daily_summary_website_failure_excluded_from_total(monkeypatch):
w_ok = FakeWebsite(id=1, name="OK") w_ok = FakeWebsite(id=1, name="OK")
w_fail = FakeWebsite(id=2, name="Fail") w_fail = FakeWebsite(id=2, name="Fail")
call_count = {"n": 0} def mock_fetch_website(row, _date):
def mock_get(*a, **kw): return (50.0, None) if row.id == 1 else (0.0, "timeout")
call_count["n"] += 1
url = a[0] if a else kw.get("url", "") monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", mock_fetch_website)
# First call (OK website) returns valid data
if call_count["n"] == 1:
return _make_mock_response({"data": {"total_actual_cost": 50.0}})
# Second call (Fail website) raises
raise Exception("timeout")
monkeypatch.setattr("httpx.get", mock_get)
result = get_daily_summary([w_ok, w_fail], [], date(2026, 7, 2)) result = get_daily_summary([w_ok, w_fail], [], date(2026, 7, 2))
@@ -289,13 +366,8 @@ def test_get_daily_summary_all_success(monkeypatch):
w = FakeWebsite() w = FakeWebsite()
u = FakeUpstream(api_prefix="api/v1", auth_type="bearer") # sub2api u = FakeUpstream(api_prefix="api/v1", auth_type="bearer") # sub2api
call_count = {"n": 0} monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None))
def mock_get(*a, **kw): monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None))
call_count["n"] += 1
if call_count["n"] == 1:
return _make_mock_response({"data": {"total_actual_cost": 100.0}})
return _make_mock_response({"data": {"total_actual_cost": 40.0}})
monkeypatch.setattr("httpx.get", mock_get)
result = get_daily_summary([w], [u], date(2026, 7, 2)) result = get_daily_summary([w], [u], date(2026, 7, 2))
+2 -2
View File
@@ -49,7 +49,7 @@
<div class="stat-card failed" :class="summary.failed_count > 0 ? 'has-failures' : ''"> <div class="stat-card failed" :class="summary.failed_count > 0 ? 'has-failures' : ''">
<div class="stat-label">失败项</div> <div class="stat-label">失败项</div>
<div class="stat-value">{{ summary.failed_count }}</div> <div class="stat-value">{{ summary.failed_count }}</div>
<div class="stat-sub">{{ summary.failed_count > 0 ? '部分数据缺失,利润可能偏低' : '全部统计成功' }}</div> <div class="stat-sub">{{ summary.failed_count > 0 ? '数据不完整,净收入仅供参考' : '全部统计成功' }}</div>
</div> </div>
</div> </div>
@@ -213,7 +213,7 @@ async function loadData() {
const data: DailySummary = await resp.json() const data: DailySummary = await resp.json()
summary.value = data summary.value = data
if (data.failed_count > 0) { if (data.failed_count > 0) {
ElMessage.warning(`${data.failed_count} 个数据源统计失败,利润数据可能偏低`) ElMessage.warning(`${data.failed_count} 个数据源统计失败,净收入仅供参考`)
} else { } else {
ElMessage.success('对账数据加载成功') ElMessage.success('对账数据加载成功')
} }