fix: reuse configured clients for finance stats
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user