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
+2 -1
View File
@@ -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")
+42
View File
@@ -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)
+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,
}
+307
View File
@@ -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
+8
View File
@@ -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()
+1
View File
@@ -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' },
],
},
+345
View File
@@ -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>