diff --git a/backend/app/services/finance_service.py b/backend/app/services/finance_service.py index f1db83f..14eac7f 100644 --- a/backend/app/services/finance_service.py +++ b/backend/app/services/finance_service.py @@ -1,12 +1,14 @@ """Finance service — daily revenue vs cost reconciliation. Architecture: - - Website revenue: HTTP GET /usage/stats via website's existing auth config - - Upstream cost (Sub2API): HTTP GET /usage/stats via upstream's bearer token - - Upstream cost (New-API/Nox-API): HTTP GET /api/log/stat via UpstreamClient - (handles login_password / nox_token / new_api_token auth) + - Website revenue: Sub2ApiWebsiteClient._request("/usage/stats", params=...) + Reuses all existing error handling, logging, auth (api_key / bearer). + - Upstream cost (Sub2API): UpstreamClient._send_request(url, params=...) + 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 @@ -15,12 +17,12 @@ import logging from datetime import date, datetime, timedelta from typing import Any -import httpx import pytz from app.models.upstream import Upstream from app.models.website import Website from app.services.upstream_client import UpstreamClient +from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError logger = logging.getLogger(__name__) @@ -39,12 +41,19 @@ def classify_upstream(upstream: Upstream) -> str: auth = upstream.auth_type or "" if auth in ("new_api_token", "nox_token"): return "new_api" - if auth == "login_password": + if auth in ("cookie", "login_password"): try: cfg = json.loads(upstream.auth_config_json or "{}") except Exception: 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 "unknown" @@ -64,20 +73,6 @@ def date_to_shanghai_timestamps(d: date) -> tuple[int, int]: 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 # ───────────────────────────────────────────── @@ -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]: """Fetch website revenue for target_date. - Calls GET /usage/stats on the Sub2API admin endpoint with the website's - existing auth config (same as all other website operations in SmartUp). - Returns (amount, error_message). error_message is None on success. + Uses Sub2ApiWebsiteClient (existing auth, error handling, external API log). + GET /usage/stats?start_date=...&end_date=...&timezone=Asia/Shanghai&nocache=true """ date_str = target_date.isoformat() try: - auth_config = json.loads(website.auth_config_json or "{}") - prefix = (website.api_prefix or "").strip("/") - prefix_part = f"/{prefix}" if prefix else "" - url = f"{website.base_url.rstrip('/')}{prefix_part}/usage/stats" - params = { - "start_date": date_str, - "end_date": date_str, - "timezone": "Asia/Shanghai", - "nocache": "true", - } - headers = _build_website_headers(website.auth_type, auth_config) - resp = httpx.get(url, params=params, headers=headers, timeout=float(website.timeout_seconds)) - resp.raise_for_status() - data = resp.json() - # Sub2API wraps in data: { total_actual_cost, ... } - payload = data.get("data", data) if isinstance(data, dict) else {} - if isinstance(payload, dict): - cost = payload.get("total_actual_cost") - if cost is not None: - return float(cost), None - return 0.0, "响应中找不到 total_actual_cost 字段" - except httpx.HTTPStatusError as e: - return 0.0, f"HTTP {e.response.status_code}: {e.response.text[:200]}" + with Sub2ApiWebsiteClient( + base_url=website.base_url, + api_prefix=website.api_prefix, + 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={ + "start_date": date_str, + "end_date": date_str, + "timezone": "Asia/Shanghai", + "nocache": "true", + }, + ) + # Sub2API wraps payload in `data` key: {data: {total_actual_cost: ...}} + payload = data.get("data", data) if isinstance(data, dict) else {} + if isinstance(payload, dict): + cost = payload.get("total_actual_cost") + if cost is not None: + return float(cost), None + return 0.0, "响应中找不到 total_actual_cost 字段" + except WebsiteError as e: + return 0.0, str(e) except Exception as e: return 0.0, str(e) @@ -125,36 +124,40 @@ 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]: """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 - the same date for start and end gives the full day's data. + Calls UpstreamClient._send_request inside the context so that cookies, + 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() + auth_config = json.loads(upstream.auth_config_json or "{}") try: - auth_config = json.loads(upstream.auth_config_json or "{}") - prefix = (upstream.api_prefix or "").strip("/") - prefix_part = f"/{prefix}" if prefix else "" - url = f"{upstream.base_url.rstrip('/')}{prefix_part}/usage/stats" - params = { - "start_date": date_str, - "end_date": date_str, - "timezone": "Asia/Shanghai", - } - headers = {"Accept": "application/json", "User-Agent": "SmartUp/1.0"} - 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() - data = resp.json() - payload = data.get("data", data) if isinstance(data, dict) else {} - if isinstance(payload, dict): - cost = payload.get("total_actual_cost") - if cost is not None: - return float(cost), None - return 0.0, "响应中找不到 total_actual_cost 字段" - except httpx.HTTPStatusError as e: - return 0.0, f"HTTP {e.response.status_code}: {e.response.text[:200]}" + with UpstreamClient( + 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 = { + "start_date": date_str, + "end_date": date_str, + "timezone": "Asia/Shanghai", + } + resp = client._send_request("GET", client._url("/usage/stats"), params=params) + resp.raise_for_status() + data = resp.json() + payload = data.get("data", data) if isinstance(data, dict) else {} + if isinstance(payload, dict): + cost = payload.get("total_actual_cost") + if cost is not None: + return float(cost), None + return 0.0, "响应中找不到 total_actual_cost 字段" except Exception as 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]: """Fetch cost for New-API/Nox-API upstream via /api/log/stat. - Uses UpstreamClient for auth (handles login_password refresh, nox_token, etc.). - Timestamp range: 00:00:00 to 23:59:59 Asia/Shanghai (closed interval, as per - New-API source: created_at <= endTimestamp). + Keeps UpstreamClient open for the full request so that: + - login_password token refresh is available if the stat call gets 401 + - 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) auth_config = json.loads(upstream.auth_config_json or "{}") - try: with UpstreamClient( base_url=upstream.base_url, @@ -180,48 +186,23 @@ def fetch_upstream_cost_new_api(upstream: Upstream, target_date: date) -> tuple[ auth_type=upstream.auth_type, auth_config=auth_config, 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_name=upstream.name, ) as client: client.ensure_authenticated() - # Get quota_per_unit from /api/status (cached within this context) 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} - headers = {"Accept": "application/json", "User-Agent": "SmartUp/1.0"} - 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() - data = resp.json() - payload = data.get("data", data) if isinstance(data, dict) else {} - if isinstance(payload, dict): - quota = payload.get("quota") - if quota is not None: - cost = float(quota) / max(quota_per_unit, 1) - return round(cost, 6), None - return 0.0, "响应中找不到 quota 字段" - - except httpx.HTTPStatusError as e: - return 0.0, f"HTTP {e.response.status_code}: {e.response.text[:200]}" + params = {"type": 2, "start_timestamp": start_ts, "end_timestamp": end_ts} + resp = client._send_request("GET", client._url("/api/log/stat"), params=params) + resp.raise_for_status() + data = resp.json() + payload = data.get("data", data) if isinstance(data, dict) else {} + if isinstance(payload, dict): + quota = payload.get("quota") + if quota is not None: + cost = float(quota) / max(quota_per_unit, 1) + return round(cost, 6), None + return 0.0, "响应中找不到 quota 字段" except Exception as e: return 0.0, str(e) diff --git a/backend/app/services/website_client.py b/backend/app/services/website_client.py index f9fac59..5cc27bc 100644 --- a/backend/app/services/website_client.py +++ b/backend/app/services/website_client.py @@ -190,7 +190,7 @@ class Sub2ApiWebsiteClient: headers["Authorization"] = f"Bearer {token}" 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) started = time.monotonic() status_code: int | None = None @@ -198,7 +198,7 @@ class Sub2ApiWebsiteClient: error_msg: str | None = None 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: error_type, error_msg = type(exc).__name__, str(exc)[:500] raise WebsiteError(_friendly_connection_error(exc)) from exc diff --git a/backend/test_finance_daily_summary.py b/backend/test_finance_daily_summary.py index cfa6652..8687a6a 100644 --- a/backend/test_finance_daily_summary.py +++ b/backend/test_finance_daily_summary.py @@ -3,14 +3,14 @@ from __future__ import annotations import json from datetime import date -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import httpx -import pytest from app.services.finance_service import ( classify_upstream, date_to_shanghai_timestamps, + fetch_upstream_cost_sub2api, fetch_upstream_cost_new_api, fetch_website_revenue, get_daily_summary, @@ -45,6 +45,52 @@ class FakeWebsite: 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 # ───────────────────────────────────────────── @@ -64,6 +110,15 @@ def test_classify_upstream_nox_token_type(): 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(): u = FakeUpstream( api_prefix="", @@ -120,20 +175,33 @@ def _make_mock_response(json_body: dict, status_code: int = 200): def test_website_revenue_success(monkeypatch): w = FakeWebsite() - mock_resp = _make_mock_response({"data": {"total_actual_cost": 123.45}}) - monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp) + client = FakeWebsiteClientCtx({"data": {"total_actual_cost": 123.45}}) + monkeypatch.setattr("app.services.finance_service.Sub2ApiWebsiteClient", lambda **kw: client) amount, err = fetch_website_revenue(w, date(2026, 7, 2)) assert err is None 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): """total_actual_cost directly in response root (no wrapping data key).""" w = FakeWebsite() - mock_resp = _make_mock_response({"total_actual_cost": 55.5}) - monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp) + monkeypatch.setattr( + "app.services.finance_service.Sub2ApiWebsiteClient", + lambda **kw: FakeWebsiteClientCtx({"total_actual_cost": 55.5}), + ) 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): w = FakeWebsite() - mock_resp = _make_mock_response({"data": {"some_other_field": 99}}) - monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp) + monkeypatch.setattr( + "app.services.finance_service.Sub2ApiWebsiteClient", + lambda **kw: FakeWebsiteClientCtx({"data": {"some_other_field": 99}}), + ) 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): w = FakeWebsite() - bad_resp = MagicMock() - bad_resp.status_code = 403 - bad_resp.text = "Forbidden" - exc = httpx.HTTPStatusError("403 Forbidden", request=MagicMock(), response=bad_resp) - monkeypatch.setattr("httpx.get", lambda *a, **kw: (_ for _ in ()).throw(exc)) + monkeypatch.setattr( + "app.services.finance_service.Sub2ApiWebsiteClient", + lambda **kw: FakeWebsiteClientCtx(raise_exc=Exception("HTTP 403: Forbidden")), + ) 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): 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)) @@ -179,20 +251,25 @@ def test_website_revenue_connection_error(monkeypatch): # ───────────────────────────────────────────── -# fetch_upstream_cost_new_api tests +# upstream cost tests # ───────────────────────────────────────────── -class FakeUpstreamClientCtx: - """Context manager mock for UpstreamClient.""" - def __init__(self, token="tok", user_id="u1", quota_per_unit=500000): - self._token = token - self._new_api_user = user_id - self._quota_per_unit = quota_per_unit +def test_upstream_cost_sub2api_uses_upstream_client(monkeypatch): + u = FakeUpstream(api_prefix="api/v1", auth_type="bearer") + client = FakeUpstreamClientCtx(response={"data": {"total_actual_cost": 42.0}}) + monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: client) - 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 + amount, err = fetch_upstream_cost_sub2api(u, date(2026, 7, 2)) + + assert err is None + 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): @@ -200,21 +277,25 @@ def test_upstream_cost_new_api_success(monkeypatch): mock_client_instance = FakeUpstreamClientCtx(quota_per_unit=500000) 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)) assert err is None 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): u = FakeUpstream(api_prefix="", auth_type="new_api_token") - monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: FakeUpstreamClientCtx()) - - mock_resp = _make_mock_response({"data": {}}) - monkeypatch.setattr("httpx.get", lambda *a, **kw: mock_resp) + monkeypatch.setattr( + "app.services.finance_service.UpstreamClient", + lambda **kw: FakeUpstreamClientCtx(response={"data": {}}), + ) 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): u = FakeUpstream(api_prefix="", auth_type="new_api_token") - monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: FakeUpstreamClientCtx()) bad_resp = MagicMock() bad_resp.status_code = 401 bad_resp.text = "Unauthorized" 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)) @@ -249,16 +332,10 @@ def test_get_daily_summary_website_failure_excluded_from_total(monkeypatch): w_ok = FakeWebsite(id=1, name="OK") w_fail = FakeWebsite(id=2, name="Fail") - call_count = {"n": 0} - def mock_get(*a, **kw): - call_count["n"] += 1 - url = a[0] if a else kw.get("url", "") - # 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) + def mock_fetch_website(row, _date): + return (50.0, None) if row.id == 1 else (0.0, "timeout") + + monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", mock_fetch_website) 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() u = FakeUpstream(api_prefix="api/v1", auth_type="bearer") # sub2api - call_count = {"n": 0} - def mock_get(*a, **kw): - 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) + monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None)) + monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None)) result = get_daily_summary([w], [u], date(2026, 7, 2)) diff --git a/frontend/src/views/Finance.vue b/frontend/src/views/Finance.vue index 763ec2b..48f873e 100644 --- a/frontend/src/views/Finance.vue +++ b/frontend/src/views/Finance.vue @@ -49,7 +49,7 @@