"""Finance service — daily revenue vs cost reconciliation. Architecture: - 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. 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 import json import logging from datetime import date, datetime, timedelta from typing import Any 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__) _SHANGHAI = pytz.timezone("Asia/Shanghai") # ───────────────────────────────────────────── # Upstream type classification # ───────────────────────────────────────────── def classify_upstream(upstream: Upstream) -> str: """Return 'sub2api' | 'new_api' | 'unknown'.""" prefix = (upstream.api_prefix or "").strip("/") if prefix == "api/v1": return "sub2api" auth = upstream.auth_type or "" if auth in ("new_api_token", "nox_token"): return "new_api" if auth in ("cookie", "login_password"): try: cfg = json.loads(upstream.auth_config_json or "{}") except Exception: cfg = {} 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" # ───────────────────────────────────────────── # Date helpers # ───────────────────────────────────────────── def yesterday_shanghai() -> date: return datetime.now(_SHANGHAI).date() - timedelta(days=1) def date_to_shanghai_timestamps(d: date) -> tuple[int, int]: """Return (start_unix, end_unix) for 00:00:00..23:59:59 Asia/Shanghai on date d.""" start = _SHANGHAI.localize(datetime(d.year, d.month, d.day, 0, 0, 0)) end = _SHANGHAI.localize(datetime(d.year, d.month, d.day, 23, 59, 59)) return int(start.timestamp()), int(end.timestamp()) # ───────────────────────────────────────────── # Website revenue # ───────────────────────────────────────────── def fetch_website_revenue(website: Website, target_date: date) -> tuple[float, str | None]: """Fetch website revenue for target_date. 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: 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) # ───────────────────────────────────────────── # Upstream cost — Sub2API # ───────────────────────────────────────────── 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). 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: 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) # ───────────────────────────────────────────── # Upstream cost — New-API / Nox-API # ───────────────────────────────────────────── def _new_api_failure_message(data: Any) -> str | None: if not isinstance(data, dict): return None message = data.get("message") or data.get("detail") or data.get("error") if data.get("success") is False: return str(message or "上游返回 success=false") if message and data.get("error"): return str(message) return 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/self/stat. 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, 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() quota_per_unit = client._new_api_quota_per_unit() params = {"type": 2, "start_timestamp": start_ts, "end_timestamp": end_ts} resp = client._send_request("GET", client._url("/api/log/self/stat"), params=params) resp.raise_for_status() data = resp.json() failure_message = _new_api_failure_message(data) if failure_message: return 0.0, failure_message 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) # ───────────────────────────────────────────── # Orchestration # ───────────────────────────────────────────── def get_daily_summary( websites: list[Website], upstreams: list[Upstream], target_date: date, ) -> dict[str, Any]: """Compute daily revenue vs cost summary. Never raises — each website/upstream failure is recorded in the item's status/error fields. Failures do NOT contribute to totals (safe default: under-count rather than over-count profit). """ website_items: list[dict[str, Any]] = [] upstream_items: list[dict[str, Any]] = [] total_revenue = 0.0 total_cost = 0.0 for w in websites: amount, err = fetch_website_revenue(w, target_date) item: dict[str, Any] = { "id": w.id, "name": w.name, "upstream_type": "sub2api", "amount": round(amount, 6), "status": "failed" if err else "success", "error": err, } website_items.append(item) if not err: total_revenue += amount for u in upstreams: utype = classify_upstream(u) if utype == "sub2api": amount, err = fetch_upstream_cost_sub2api(u, target_date) elif utype == "new_api": amount, err = fetch_upstream_cost_new_api(u, target_date) else: amount, err = 0.0, ( f"未知上游类型(auth_type={u.auth_type}, api_prefix={u.api_prefix})," "无法统计消费。请检查上游配置。" ) item = { "id": u.id, "name": u.name, "upstream_type": utype, "amount": round(amount, 6), "status": "failed" if err else "success", "error": err, } upstream_items.append(item) if not err: total_cost += amount net = total_revenue - total_cost margin = (net / total_revenue * 100) if total_revenue > 0 else 0.0 failed_count = sum(1 for i in website_items + upstream_items if i["status"] == "failed") return { "date": target_date.isoformat(), "total_revenue": round(total_revenue, 6), "total_cost": round(total_cost, 6), "net_income": round(net, 6), "margin_percent": round(margin, 2), "website_items": website_items, "upstream_items": upstream_items, "failed_count": failed_count, "success": failed_count == 0, }