fix: reuse configured clients for finance stats
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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('对账数据加载成功')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user