283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""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 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.
|
|
|
|
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/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)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# 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,
|
|
}
|