feat: 财务对账页面 — GET /api/finance/daily-summary + Finance.vue
This commit is contained in:
+2
-1
@@ -15,7 +15,7 @@ from app.models.admin_user import AdminUser
|
||||
from app.database import SessionLocal
|
||||
from app.utils.auth import hash_password, verify_password, validate_password_supported
|
||||
from app.services.scheduler import start_scheduler, stop_scheduler
|
||||
from app.routers import auth, upstreams, webhooks, logs, custom_pages, websites, auth_capture, external_api_logs
|
||||
from app.routers import auth, upstreams, webhooks, logs, custom_pages, websites, auth_capture, external_api_logs, finance
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -98,6 +98,7 @@ app.include_router(custom_pages.router)
|
||||
app.include_router(websites.router)
|
||||
app.include_router(auth_capture.router)
|
||||
app.include_router(external_api_logs.router)
|
||||
app.include_router(finance.router)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Finance router — daily revenue vs cost reconciliation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.upstream import Upstream
|
||||
from app.models.website import Website
|
||||
from app.services.finance_service import get_daily_summary, yesterday_shanghai
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/finance", tags=["finance"])
|
||||
|
||||
|
||||
@router.get("/daily-summary")
|
||||
def daily_summary(
|
||||
date: Optional[str] = Query(None, description="YYYY-MM-DD, defaults to yesterday Asia/Shanghai"),
|
||||
db: Session = Depends(get_db),
|
||||
_: Any = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Return daily revenue vs cost summary.
|
||||
|
||||
Never raises for individual item failures — each failed website/upstream
|
||||
is included in the response with status='failed' and an error message.
|
||||
Only raises HTTP errors for bad request parameters.
|
||||
"""
|
||||
if date:
|
||||
try:
|
||||
target = _dt.date.fromisoformat(date)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"Invalid date format: {date!r}. Expected YYYY-MM-DD.")
|
||||
else:
|
||||
target = yesterday_shanghai()
|
||||
|
||||
websites = db.query(Website).filter(Website.enabled == True).all()
|
||||
upstreams = db.query(Upstream).filter(Upstream.enabled == True).all()
|
||||
|
||||
return get_daily_summary(websites, upstreams, target)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Tests for finance daily summary service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.finance_service import (
|
||||
classify_upstream,
|
||||
date_to_shanghai_timestamps,
|
||||
fetch_upstream_cost_new_api,
|
||||
fetch_website_revenue,
|
||||
get_daily_summary,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Fake model helpers
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
class FakeUpstream:
|
||||
def __init__(self, **kw):
|
||||
self.id = kw.get("id", 1)
|
||||
self.name = kw.get("name", "U1")
|
||||
self.base_url = kw.get("base_url", "http://up.test")
|
||||
self.api_prefix = kw.get("api_prefix", "api/v1")
|
||||
self.auth_type = kw.get("auth_type", "bearer")
|
||||
self.auth_config_json = kw.get("auth_config_json", json.dumps({"token": "tok"}))
|
||||
self.timeout_seconds = kw.get("timeout_seconds", 30)
|
||||
self.enabled = True
|
||||
|
||||
|
||||
class FakeWebsite:
|
||||
def __init__(self, **kw):
|
||||
self.id = kw.get("id", 1)
|
||||
self.name = kw.get("name", "W1")
|
||||
self.base_url = kw.get("base_url", "http://web.test")
|
||||
self.api_prefix = kw.get("api_prefix", "api/v1/admin")
|
||||
self.auth_type = kw.get("auth_type", "api_key")
|
||||
self.auth_config_json = kw.get("auth_config_json", json.dumps({"key": "k1", "header": "x-api-key"}))
|
||||
self.timeout_seconds = kw.get("timeout_seconds", 30)
|
||||
self.enabled = True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# classify_upstream tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_classify_upstream_sub2api():
|
||||
u = FakeUpstream(api_prefix="api/v1", auth_type="bearer")
|
||||
assert classify_upstream(u) == "sub2api"
|
||||
|
||||
|
||||
def test_classify_upstream_new_api_by_token_type():
|
||||
u = FakeUpstream(api_prefix="", auth_type="new_api_token")
|
||||
assert classify_upstream(u) == "new_api"
|
||||
|
||||
|
||||
def test_classify_upstream_nox_token_type():
|
||||
u = FakeUpstream(api_prefix="", auth_type="nox_token")
|
||||
assert classify_upstream(u) == "new_api"
|
||||
|
||||
|
||||
def test_classify_upstream_new_api_by_login_path():
|
||||
u = FakeUpstream(
|
||||
api_prefix="",
|
||||
auth_type="login_password",
|
||||
auth_config_json=json.dumps({"email": "a@b.com", "password": "p", "login_path": "/api/user/login"}),
|
||||
)
|
||||
assert classify_upstream(u) == "new_api"
|
||||
|
||||
|
||||
def test_classify_upstream_unknown():
|
||||
u = FakeUpstream(api_prefix="", auth_type="bearer")
|
||||
assert classify_upstream(u) == "unknown"
|
||||
|
||||
|
||||
def test_classify_upstream_login_password_without_new_api_path():
|
||||
u = FakeUpstream(
|
||||
api_prefix="",
|
||||
auth_type="login_password",
|
||||
auth_config_json=json.dumps({"email": "a@b.com", "password": "p"}),
|
||||
)
|
||||
assert classify_upstream(u) == "unknown"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Date/timestamp tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_date_to_shanghai_timestamps():
|
||||
start_ts, end_ts = date_to_shanghai_timestamps(date(2026, 7, 2))
|
||||
# 2026-07-02 00:00:00 CST = 2026-07-01 16:00:00 UTC = 1782921600
|
||||
# 2026-07-02 23:59:59 CST = 2026-07-02 15:59:59 UTC = 1783007999
|
||||
assert start_ts == 1782921600
|
||||
assert end_ts == 1783007999
|
||||
assert end_ts - start_ts == 86399 # exactly one day minus one second
|
||||
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# fetch_website_revenue tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _make_mock_response(json_body: dict, status_code: int = 200):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = status_code
|
||||
mock_resp.json.return_value = json_body
|
||||
mock_resp.text = json.dumps(json_body)
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
if status_code >= 400:
|
||||
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"error", request=MagicMock(), response=mock_resp
|
||||
)
|
||||
return mock_resp
|
||||
|
||||
|
||||
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)
|
||||
|
||||
amount, err = fetch_website_revenue(w, date(2026, 7, 2))
|
||||
|
||||
assert err is None
|
||||
assert abs(amount - 123.45) < 1e-6
|
||||
|
||||
|
||||
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)
|
||||
|
||||
amount, err = fetch_website_revenue(w, date(2026, 7, 2))
|
||||
|
||||
assert err is None
|
||||
assert abs(amount - 55.5) < 1e-6
|
||||
|
||||
|
||||
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)
|
||||
|
||||
amount, err = fetch_website_revenue(w, date(2026, 7, 2))
|
||||
|
||||
assert amount == 0.0
|
||||
assert err is not None
|
||||
assert "total_actual_cost" in err
|
||||
|
||||
|
||||
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))
|
||||
|
||||
amount, err = fetch_website_revenue(w, date(2026, 7, 2))
|
||||
|
||||
assert amount == 0.0
|
||||
assert err is not None
|
||||
assert "403" in err
|
||||
|
||||
|
||||
def test_website_revenue_connection_error(monkeypatch):
|
||||
w = FakeWebsite()
|
||||
monkeypatch.setattr("httpx.get", lambda *a, **kw: (_ for _ in ()).throw(Exception("connection refused")))
|
||||
|
||||
amount, err = fetch_website_revenue(w, date(2026, 7, 2))
|
||||
|
||||
assert amount == 0.0
|
||||
assert err is not None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# fetch_upstream_cost_new_api 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 __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 test_upstream_cost_new_api_success(monkeypatch):
|
||||
u = FakeUpstream(api_prefix="", auth_type="new_api_token")
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2))
|
||||
|
||||
assert amount == 0.0
|
||||
assert err is not None
|
||||
assert "quota" in err
|
||||
|
||||
|
||||
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))
|
||||
|
||||
amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2))
|
||||
|
||||
assert amount == 0.0
|
||||
assert err is not None
|
||||
assert "401" in err
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# get_daily_summary integration-style tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_get_daily_summary_website_failure_excluded_from_total(monkeypatch):
|
||||
"""Failed websites must NOT be counted in total_revenue."""
|
||||
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)
|
||||
|
||||
result = get_daily_summary([w_ok, w_fail], [], date(2026, 7, 2))
|
||||
|
||||
assert abs(result["total_revenue"] - 50.0) < 1e-6
|
||||
assert result["total_cost"] == 0.0
|
||||
assert result["failed_count"] == 1
|
||||
assert result["success"] is False
|
||||
failed = [i for i in result["website_items"] if i["status"] == "failed"]
|
||||
ok = [i for i in result["website_items"] if i["status"] == "success"]
|
||||
assert len(failed) == 1
|
||||
assert len(ok) == 1
|
||||
|
||||
|
||||
def test_get_daily_summary_unknown_upstream_is_failed():
|
||||
"""Unknown upstream type must produce status='failed', not silently contribute 0."""
|
||||
u = FakeUpstream(api_prefix="", auth_type="bearer") # unknown type
|
||||
result = get_daily_summary([], [u], date(2026, 7, 2))
|
||||
|
||||
assert result["failed_count"] == 1
|
||||
assert result["success"] is False
|
||||
assert result["upstream_items"][0]["status"] == "failed"
|
||||
assert "未知上游类型" in result["upstream_items"][0]["error"]
|
||||
assert result["total_cost"] == 0.0 # unknown upstreams don't contribute
|
||||
|
||||
|
||||
def test_get_daily_summary_all_success(monkeypatch):
|
||||
"""When all items succeed, success=True, totals are correct."""
|
||||
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)
|
||||
|
||||
result = get_daily_summary([w], [u], date(2026, 7, 2))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["failed_count"] == 0
|
||||
assert abs(result["total_revenue"] - 100.0) < 1e-6
|
||||
assert abs(result["total_cost"] - 40.0) < 1e-6
|
||||
assert abs(result["net_income"] - 60.0) < 1e-6
|
||||
assert abs(result["margin_percent"] - 60.0) < 1e-6
|
||||
@@ -54,6 +54,13 @@
|
||||
<small>外部请求耗时、慢接口汇总</small>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link to="/finance" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><TrendCharts /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>财务对账</strong>
|
||||
<small>昨日收入、上游消费、净利润</small>
|
||||
</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -101,6 +108,7 @@ import {
|
||||
User,
|
||||
SwitchButton,
|
||||
Operation,
|
||||
TrendCharts,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -21,6 +21,7 @@ const router = createRouter({
|
||||
{ path: 'logs', component: () => import('@/views/NotificationLogs.vue') },
|
||||
{ path: 'external-api-logs', component: () => import('@/views/ExternalApiLogs.vue') },
|
||||
{ path: 'custom-pages', component: () => import('@/views/CustomPages.vue') },
|
||||
{ path: 'finance', component: () => import('@/views/Finance.vue') },
|
||||
{ path: 'page/:id', redirect: '/custom-pages' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div class="finance-page">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h1 class="page-title">
|
||||
<el-icon class="title-icon"><TrendCharts /></el-icon>
|
||||
财务对账
|
||||
</h1>
|
||||
<p class="page-subtitle">实时计算,数据不入库;失败项不计入利润,避免虚高</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-date-picker
|
||||
v-model="selectedDate"
|
||||
type="date"
|
||||
placeholder="选择日期(默认昨天)"
|
||||
:disabled-date="disabledDate"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="date-picker"
|
||||
/>
|
||||
<el-button type="primary" :loading="loading" @click="loadData" class="refresh-btn">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
<el-alert v-if="fetchError" :title="fetchError" type="error" show-icon :closable="false" class="error-alert" />
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div v-if="summary" class="summary-cards">
|
||||
<div class="stat-card revenue">
|
||||
<div class="stat-label">网站收入</div>
|
||||
<div class="stat-value">{{ formatAmount(summary.total_revenue) }}</div>
|
||||
<div class="stat-sub">{{ summary.date }}</div>
|
||||
</div>
|
||||
<div class="stat-card cost">
|
||||
<div class="stat-label">上游消费</div>
|
||||
<div class="stat-value">{{ formatAmount(summary.total_cost) }}</div>
|
||||
<div class="stat-sub">{{ enabledUpstreamCount }} 个上游</div>
|
||||
</div>
|
||||
<div class="stat-card net" :class="summary.net_income >= 0 ? 'positive' : 'negative'">
|
||||
<div class="stat-label">净收入</div>
|
||||
<div class="stat-value">{{ formatAmount(summary.net_income) }}</div>
|
||||
<div class="stat-sub">毛利率 {{ summary.margin_percent.toFixed(2) }}%</div>
|
||||
</div>
|
||||
<div class="stat-card failed" :class="summary.failed_count > 0 ? 'has-failures' : ''">
|
||||
<div class="stat-label">失败项</div>
|
||||
<div class="stat-value">{{ summary.failed_count }}</div>
|
||||
<div class="stat-sub">{{ summary.failed_count > 0 ? '部分数据缺失,利润可能偏低' : '全部统计成功' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<el-empty v-else-if="!loading && !fetchError" description="点击刷新加载昨日对账数据" class="empty-state">
|
||||
<el-button type="primary" @click="loadData">立即加载</el-button>
|
||||
</el-empty>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="8" animated class="skeleton" />
|
||||
|
||||
<!-- Tables -->
|
||||
<template v-if="summary && !loading">
|
||||
<!-- Website Revenue Detail -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
网站收入明细
|
||||
</span>
|
||||
<el-tag :type="allWebsitesOk ? 'success' : 'danger'" size="small">
|
||||
{{ summary.website_items.filter(i => i.status === 'success').length }} /
|
||||
{{ summary.website_items.length }} 成功
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-table :data="summary.website_items" class="detail-table" stripe>
|
||||
<el-table-column label="网站名称" prop="name" min-width="180" />
|
||||
<el-table-column label="收入" min-width="140" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.status === 'success'" class="amount-positive">
|
||||
{{ formatAmount(row.amount) }}
|
||||
</span>
|
||||
<span v-else class="amount-zero">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="错误信息" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.error" class="error-text">{{ row.error }}</span>
|
||||
<span v-else class="ok-text">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- Upstream Cost Detail -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">
|
||||
<el-icon><Connection /></el-icon>
|
||||
上游消费明细
|
||||
</span>
|
||||
<el-tag :type="allUpstreamsOk ? 'success' : 'danger'" size="small">
|
||||
{{ summary.upstream_items.filter(i => i.status === 'success').length }} /
|
||||
{{ summary.upstream_items.length }} 成功
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-table :data="summary.upstream_items" class="detail-table" stripe>
|
||||
<el-table-column label="上游名称" prop="name" min-width="180" />
|
||||
<el-table-column label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.upstream_type === 'sub2api' ? 'primary' : row.upstream_type === 'new_api' ? 'warning' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ row.upstream_type === 'sub2api' ? 'Sub2API' : row.upstream_type === 'new_api' ? 'New-API' : '未知' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="消费" min-width="140" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.status === 'success'" class="amount-cost">
|
||||
{{ formatAmount(row.amount) }}
|
||||
</span>
|
||||
<span v-else class="amount-zero">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="错误信息" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.error" class="error-text">{{ row.error }}</span>
|
||||
<span v-else class="ok-text">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { TrendCharts, Refresh, OfficeBuilding, Connection } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface FinanceItem {
|
||||
id: number
|
||||
name: string
|
||||
upstream_type: string
|
||||
amount: number
|
||||
status: 'success' | 'failed'
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface DailySummary {
|
||||
date: string
|
||||
total_revenue: number
|
||||
total_cost: number
|
||||
net_income: number
|
||||
margin_percent: number
|
||||
website_items: FinanceItem[]
|
||||
upstream_items: FinanceItem[]
|
||||
failed_count: number
|
||||
success: boolean
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const fetchError = ref<string | null>(null)
|
||||
const summary = ref<DailySummary | null>(null)
|
||||
const selectedDate = ref<string | null>(null)
|
||||
|
||||
const allWebsitesOk = computed(() => summary.value?.website_items.every(i => i.status === 'success') ?? false)
|
||||
const allUpstreamsOk = computed(() => summary.value?.upstream_items.every(i => i.status === 'success') ?? false)
|
||||
const enabledUpstreamCount = computed(() => summary.value?.upstream_items.length ?? 0)
|
||||
|
||||
function disabledDate(d: Date) {
|
||||
return d > new Date()
|
||||
}
|
||||
|
||||
function formatAmount(val: number): string {
|
||||
if (val === 0) return '$0.000000'
|
||||
return '$' + val.toFixed(6)
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
fetchError.value = null
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (selectedDate.value) params.set('date', selectedDate.value)
|
||||
const resp = await fetch(`/api/finance/daily-summary?${params}`, {
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}))
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`)
|
||||
}
|
||||
const data: DailySummary = await resp.json()
|
||||
summary.value = data
|
||||
if (data.failed_count > 0) {
|
||||
ElMessage.warning(`${data.failed_count} 个数据源统计失败,利润数据可能偏低`)
|
||||
} else {
|
||||
ElMessage.success('对账数据加载成功')
|
||||
}
|
||||
} catch (e: any) {
|
||||
fetchError.value = e.message || '请求失败'
|
||||
ElMessage.error(fetchError.value ?? '请求失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.finance-page {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0 0 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.title-icon {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.page-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.date-picker { width: 180px; }
|
||||
.refresh-btn { min-width: 90px; }
|
||||
|
||||
/* ── Cards ── */
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.stat-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); }
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat-sub {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.stat-card.net.positive .stat-value { color: #22c55e; }
|
||||
.stat-card.net.negative .stat-value { color: var(--el-color-danger); }
|
||||
.stat-card.failed.has-failures {
|
||||
border-color: var(--el-color-danger-light-5);
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
.stat-card.failed.has-failures .stat-value { color: var(--el-color-danger); }
|
||||
|
||||
/* ── Section ── */
|
||||
.section { margin-bottom: 32px; }
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.detail-table { border-radius: 8px; overflow: hidden; }
|
||||
|
||||
/* ── Amount styles ── */
|
||||
.amount-positive { color: #22c55e; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.amount-cost { color: var(--el-color-warning); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.amount-zero { color: var(--el-text-color-placeholder); }
|
||||
|
||||
/* ── Status ── */
|
||||
.error-text { color: var(--el-color-danger); font-size: 12px; word-break: break-all; }
|
||||
.ok-text { color: var(--el-text-color-placeholder); }
|
||||
|
||||
/* ── Misc ── */
|
||||
.error-alert { margin-bottom: 20px; }
|
||||
.skeleton { margin-top: 16px; }
|
||||
.empty-state { margin-top: 60px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user