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
|
||||
Reference in New Issue
Block a user