feat: 财务对账页面 — GET /api/finance/daily-summary + Finance.vue

This commit is contained in:
SmartUp Developer
2026-07-03 15:28:06 +08:00
parent 9997b9d56a
commit 5e99abcbc5
7 changed files with 1006 additions and 1 deletions
+301
View File
@@ -0,0 +1,301 @@
"""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)
No DB writes. All data fetched live. Each item fails independently.
"""
from __future__ import annotations
import json
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
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 == "login_password":
try:
cfg = json.loads(upstream.auth_config_json or "{}")
except Exception:
cfg = {}
if "/api/user/login" in (cfg.get("login_path") or ""):
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())
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
# ─────────────────────────────────────────────
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.
"""
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]}"
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).
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.
"""
date_str = target_date.isoformat()
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]}"
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.
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).
"""
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 writes
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]}"
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,
}