fix finance balance delta recharges

This commit is contained in:
SmartUp Developer
2026-07-03 20:05:28 +08:00
parent bb7ed5a7e0
commit 1670503720
12 changed files with 745 additions and 24 deletions
+5 -1
View File
@@ -41,7 +41,7 @@ def get_db():
def init_db(): def init_db():
"""Create all tables.""" """Create all tables."""
# import models so SQLAlchemy registers them # import models so SQLAlchemy registers them
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key, external_api_log, finance_daily_summary # noqa: F401 from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key, external_api_log, finance_daily_summary, upstream_recharge_event # noqa: F401
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_ensure_indexes() _ensure_indexes()
_migrate_custom_pages() _migrate_custom_pages()
@@ -60,6 +60,8 @@ _NEW_INDEXES = [
"CREATE INDEX IF NOT EXISTS ix_key_upstream_name ON upstream_generated_keys(upstream_id, key_name)", "CREATE INDEX IF NOT EXISTS ix_key_upstream_name ON upstream_generated_keys(upstream_id, key_name)",
"CREATE INDEX IF NOT EXISTS ix_external_api_logs_created_at_desc ON external_api_logs(created_at DESC)", "CREATE INDEX IF NOT EXISTS ix_external_api_logs_created_at_desc ON external_api_logs(created_at DESC)",
"CREATE INDEX IF NOT EXISTS ix_external_api_logs_direction_created ON external_api_logs(direction, created_at DESC)", "CREATE INDEX IF NOT EXISTS ix_external_api_logs_direction_created ON external_api_logs(direction, created_at DESC)",
"CREATE INDEX IF NOT EXISTS ix_balance_snap_upstream_captured ON upstream_balance_snapshots(upstream_id, captured_at DESC)",
"CREATE INDEX IF NOT EXISTS ix_recharge_upstream_date ON upstream_recharge_events(upstream_id, recharge_date)",
] ]
@@ -132,6 +134,8 @@ def _migrate_upstreams():
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_alert_threshold FLOAT")) conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_alert_threshold FLOAT"))
if "balance_alert_notified" not in columns: if "balance_alert_notified" not in columns:
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_alert_notified BOOLEAN NOT NULL DEFAULT 0")) conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_alert_notified BOOLEAN NOT NULL DEFAULT 0"))
if "finance_cost_mode" not in columns:
conn.execute(text("ALTER TABLE upstreams ADD COLUMN finance_cost_mode VARCHAR(32) NOT NULL DEFAULT 'usage_stats'"))
def _migrate_upstream_generated_keys(): def _migrate_upstream_generated_keys():
+14 -1
View File
@@ -1,5 +1,5 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import Index, Integer, Text, DateTime, ForeignKey, text from sqlalchemy import Float, Index, Integer, Text, DateTime, ForeignKey, text
from sqlalchemy.orm import mapped_column, Mapped from sqlalchemy.orm import mapped_column, Mapped
from app.database import Base from app.database import Base
@@ -15,3 +15,16 @@ class UpstreamRateSnapshot(Base):
__table_args__ = ( __table_args__ = (
Index("ix_snapshot_upstream_captured", "upstream_id", text("captured_at DESC")), Index("ix_snapshot_upstream_captured", "upstream_id", text("captured_at DESC")),
) )
class UpstreamBalanceSnapshot(Base):
__tablename__ = "upstream_balance_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
upstream_id: Mapped[int] = mapped_column(Integer, ForeignKey("upstreams.id", ondelete="CASCADE"), index=True)
balance: Mapped[float] = mapped_column(Float, nullable=False)
captured_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
__table_args__ = (
Index("ix_balance_snap_upstream_captured", "upstream_id", text("captured_at DESC")),
)
+2
View File
@@ -35,6 +35,8 @@ class Upstream(Base):
# Balance alert # Balance alert
balance_alert_threshold: Mapped[Optional[float]] = mapped_column(Float, nullable=True) balance_alert_threshold: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
balance_alert_notified: Mapped[bool] = mapped_column(Boolean, default=False) balance_alert_notified: Mapped[bool] = mapped_column(Boolean, default=False)
# Finance cost mode: "usage_stats" (default) or "balance_delta"
finance_cost_mode: Mapped[str] = mapped_column(String(32), default="usage_stats")
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
@@ -0,0 +1,24 @@
from datetime import date, datetime, timezone
from sqlalchemy import Date, DateTime, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class UpstreamRechargeEvent(Base):
__tablename__ = "upstream_recharge_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
upstream_id: Mapped[int] = mapped_column(Integer, ForeignKey("upstreams.id", ondelete="CASCADE"), index=True)
recharge_date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[float] = mapped_column(Float, nullable=False)
note: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
Index("ix_recharge_upstream_date", "upstream_id", "recharge_date"),
)
+120 -5
View File
@@ -4,18 +4,19 @@ from __future__ import annotations
import json import json
import logging import logging
import re import re
from datetime import datetime, timezone from datetime import date as date_type, datetime, timezone
from typing import Any, List from typing import Any, List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db from app.database import SessionLocal, get_db
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.upstream import Upstream from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey from app.models.upstream_key import UpstreamGeneratedKey
from app.models.upstream_recharge_event import UpstreamRechargeEvent
from app.models.snapshot import UpstreamRateSnapshot from app.models.snapshot import UpstreamRateSnapshot
from app.schemas.upstream import ( from app.schemas.upstream import (
GenerateKeysByGroupsRequest, GenerateKeysByGroupsRequest,
@@ -23,10 +24,12 @@ from app.schemas.upstream import (
GeneratedUpstreamKeyResponse, GeneratedUpstreamKeyResponse,
UpstreamCreate, UpstreamUpdate, UpstreamResponse, SnapshotResponse, TestResult, UpstreamCreate, UpstreamUpdate, UpstreamResponse, SnapshotResponse, TestResult,
UpstreamBatchActionItem, UpstreamBatchActionSummary, UpstreamBatchActionResponse, UpstreamBatchActionItem, UpstreamBatchActionSummary, UpstreamBatchActionResponse,
UpstreamRechargeCreate, UpstreamRechargeUpdate, UpstreamRechargeResponse,
) )
from app.services.upstream_client import UpstreamClient, UpstreamError, _PendingKeyError, build_snapshot, build_new_api_token_name, mask_secret, _extract_key_value from app.services.upstream_client import UpstreamClient, UpstreamError, _PendingKeyError, build_snapshot, build_new_api_token_name, mask_secret, _extract_key_value
from app.services.auth_config import MASK, mask_auth_config, normalize_auth_config from app.services.auth_config import MASK, mask_auth_config, normalize_auth_config
from app.services.snapshot_service import diff_snapshots from app.services.finance_service import today_shanghai
from app.services.snapshot_service import diff_snapshots, write_balance_snapshot
from app.services import scheduler as sched_svc from app.services import scheduler as sched_svc
from app.services import webhook_service from app.services import webhook_service
from app.services import website_sync from app.services import website_sync
@@ -138,11 +141,29 @@ def _to_response(u: Upstream) -> UpstreamResponse:
balance_response_path=u.balance_response_path or "", balance_response_path=u.balance_response_path or "",
balance_divisor=u.balance_divisor or 1.0, balance_divisor=u.balance_divisor or 1.0,
balance_alert_threshold=u.balance_alert_threshold, balance_alert_threshold=u.balance_alert_threshold,
finance_cost_mode=u.finance_cost_mode or "usage_stats",
created_at=u.created_at, created_at=u.created_at,
updated_at=u.updated_at, updated_at=u.updated_at,
) )
def _recharge_response(row: UpstreamRechargeEvent) -> UpstreamRechargeResponse:
return UpstreamRechargeResponse(
id=row.id,
upstream_id=row.upstream_id,
date=row.recharge_date,
amount=row.amount,
note=row.note or "",
created_at=row.created_at,
updated_at=row.updated_at,
)
def _validate_recharge_date(value: date_type) -> None:
if value > today_shanghai():
raise HTTPException(422, "date 不允许是未来日期")
@router.get("", response_model=List[UpstreamResponse]) @router.get("", response_model=List[UpstreamResponse])
def list_upstreams(db: Session = Depends(get_db), _=Depends(get_current_user)): def list_upstreams(db: Session = Depends(get_db), _=Depends(get_current_user)):
return [_to_response(u) for u in db.query(Upstream).order_by(Upstream.id).all()] return [_to_response(u) for u in db.query(Upstream).order_by(Upstream.id).all()]
@@ -694,6 +715,7 @@ def _test_upstream_core(db: Session, u: Upstream) -> UpstreamBatchActionItem:
raw_balance = client.get_balance(u.balance_endpoint, u.balance_response_path) raw_balance = client.get_balance(u.balance_endpoint, u.balance_response_path)
if raw_balance is not None: if raw_balance is not None:
u.balance = raw_balance / (u.balance_divisor or 1.0) u.balance = raw_balance / (u.balance_divisor or 1.0)
write_balance_snapshot(db, u.id, u.balance)
u.balance_updated_at = datetime.now(timezone.utc) if raw_balance is not None else None u.balance_updated_at = datetime.now(timezone.utc) if raw_balance is not None else None
except Exception as exc: except Exception as exc:
logger.warning("test-all: upstream %s balance failed: %s", u.name, exc) logger.warning("test-all: upstream %s balance failed: %s", u.name, exc)
@@ -738,6 +760,7 @@ def _check_now_core(db: Session, u: Upstream) -> tuple[str, bool]:
raw_balance = client.get_balance(u.balance_endpoint, u.balance_response_path) raw_balance = client.get_balance(u.balance_endpoint, u.balance_response_path)
if raw_balance is not None: if raw_balance is not None:
u.balance = raw_balance / (u.balance_divisor or 1.0) u.balance = raw_balance / (u.balance_divisor or 1.0)
write_balance_snapshot(db, u.id, u.balance)
u.balance_updated_at = datetime.now(timezone.utc) if raw_balance is not None else None u.balance_updated_at = datetime.now(timezone.utc) if raw_balance is not None else None
except Exception as exc: except Exception as exc:
logger.warning("check-now: upstream %s balance failed: %s", u.name, exc) logger.warning("check-now: upstream %s balance failed: %s", u.name, exc)
@@ -919,6 +942,7 @@ def create_upstream(
balance_response_path=body.balance_response_path, balance_response_path=body.balance_response_path,
balance_divisor=body.balance_divisor, balance_divisor=body.balance_divisor,
balance_alert_threshold=body.balance_alert_threshold, balance_alert_threshold=body.balance_alert_threshold,
finance_cost_mode=body.finance_cost_mode,
) )
db.add(u) db.add(u)
db.commit() db.commit()
@@ -935,6 +959,99 @@ def get_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_
return _to_response(u) return _to_response(u)
@router.get("/{uid}/recharges", response_model=List[UpstreamRechargeResponse])
def list_recharges(
uid: int,
date: date_type | None = QueryParam(None),
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u:
raise HTTPException(404, "upstream not found")
query = db.query(UpstreamRechargeEvent).filter(UpstreamRechargeEvent.upstream_id == uid)
if date is not None:
query = query.filter(UpstreamRechargeEvent.recharge_date == date)
rows = query.order_by(UpstreamRechargeEvent.recharge_date.desc(), UpstreamRechargeEvent.id.desc()).all()
return [_recharge_response(row) for row in rows]
@router.post("/{uid}/recharges", response_model=UpstreamRechargeResponse, status_code=201)
def create_recharge(
uid: int,
body: UpstreamRechargeCreate,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u:
raise HTTPException(404, "upstream not found")
_validate_recharge_date(body.date)
row = UpstreamRechargeEvent(
upstream_id=uid,
recharge_date=body.date,
amount=body.amount,
note=body.note or "",
)
db.add(row)
db.commit()
db.refresh(row)
return _recharge_response(row)
@router.put("/{uid}/recharges/{rid}", response_model=UpstreamRechargeResponse)
def update_recharge(
uid: int,
rid: int,
body: UpstreamRechargeUpdate,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u:
raise HTTPException(404, "upstream not found")
row = (
db.query(UpstreamRechargeEvent)
.filter(UpstreamRechargeEvent.id == rid, UpstreamRechargeEvent.upstream_id == uid)
.first()
)
if not row:
raise HTTPException(404, "recharge event not found")
data = body.model_dump(exclude_unset=True)
if "date" in data:
_validate_recharge_date(data["date"])
row.recharge_date = data["date"]
if "amount" in data:
row.amount = data["amount"]
if "note" in data:
row.note = data["note"] or ""
row.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return _recharge_response(row)
@router.delete("/{uid}/recharges/{rid}", status_code=204)
def delete_recharge(
uid: int,
rid: int,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u:
raise HTTPException(404, "upstream not found")
row = (
db.query(UpstreamRechargeEvent)
.filter(UpstreamRechargeEvent.id == rid, UpstreamRechargeEvent.upstream_id == uid)
.first()
)
if not row:
raise HTTPException(404, "recharge event not found")
db.delete(row)
db.commit()
@router.put("/{uid}", response_model=UpstreamResponse) @router.put("/{uid}", response_model=UpstreamResponse)
def update_upstream( def update_upstream(
uid: int, uid: int,
@@ -1029,8 +1146,6 @@ def latest_snapshot(uid: int, db: Session = Depends(get_db), _=Depends(get_curre
) )
from fastapi import Query as QueryParam
@router.get("/{uid}/snapshots", response_model=List[SnapshotResponse]) @router.get("/{uid}/snapshots", response_model=List[SnapshotResponse])
def list_snapshots( def list_snapshots(
uid: int, uid: int,
+27 -2
View File
@@ -1,5 +1,5 @@
from datetime import datetime from datetime import date, datetime
from typing import Optional, Any from typing import Optional, Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -37,6 +37,7 @@ class UpstreamCreate(BaseModel):
balance_response_path: str = "" balance_response_path: str = ""
balance_divisor: float = 1.0 balance_divisor: float = 1.0
balance_alert_threshold: Optional[float] = None balance_alert_threshold: Optional[float] = None
finance_cost_mode: Literal["usage_stats", "balance_delta"] = "usage_stats"
class UpstreamUpdate(BaseModel): class UpstreamUpdate(BaseModel):
@@ -54,6 +55,7 @@ class UpstreamUpdate(BaseModel):
balance_response_path: Optional[str] = None balance_response_path: Optional[str] = None
balance_divisor: Optional[float] = None balance_divisor: Optional[float] = None
balance_alert_threshold: Optional[float] = None balance_alert_threshold: Optional[float] = None
finance_cost_mode: Optional[Literal["usage_stats", "balance_delta"]] = None
class UpstreamResponse(BaseModel): class UpstreamResponse(BaseModel):
@@ -77,12 +79,35 @@ class UpstreamResponse(BaseModel):
balance_response_path: str = "" balance_response_path: str = ""
balance_divisor: float = 1.0 balance_divisor: float = 1.0
balance_alert_threshold: Optional[float] = None balance_alert_threshold: Optional[float] = None
finance_cost_mode: Literal["usage_stats", "balance_delta"] = "usage_stats"
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class UpstreamRechargeCreate(BaseModel):
date: date
amount: float = Field(gt=0)
note: str = ""
class UpstreamRechargeUpdate(BaseModel):
date: Optional[date] = None
amount: Optional[float] = Field(default=None, gt=0)
note: Optional[str] = None
class UpstreamRechargeResponse(BaseModel):
id: int
upstream_id: int
date: date
amount: float
note: str = ""
created_at: datetime
updated_at: datetime
class SnapshotResponse(BaseModel): class SnapshotResponse(BaseModel):
id: int id: int
upstream_id: int upstream_id: int
+109 -10
View File
@@ -21,10 +21,13 @@ from typing import Any
import pytz import pytz
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.upstream import Upstream from app.models.upstream import Upstream
from app.models.website import Website from app.models.website import Website
from app.models.snapshot import UpstreamBalanceSnapshot
from app.models.upstream_recharge_event import UpstreamRechargeEvent
from app.services.upstream_client import UpstreamClient from app.services.upstream_client import UpstreamClient
from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError
@@ -81,6 +84,13 @@ def date_to_shanghai_timestamps(d: date) -> tuple[int, int]:
return int(start.timestamp()), int(end.timestamp()) return int(start.timestamp()), int(end.timestamp())
def date_to_shanghai_utc_range(d: date) -> tuple[datetime, datetime]:
"""Return UTC datetimes for [start, next_day_start) of a Shanghai calendar day."""
start = SHANGHAI_TZ.localize(datetime(d.year, d.month, d.day, 0, 0, 0))
end = start + timedelta(days=1)
return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# Website revenue # Website revenue
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@@ -229,6 +239,86 @@ def fetch_upstream_cost_new_api(upstream: Upstream, target_date: date) -> tuple[
return 0.0, str(e) return 0.0, str(e)
# ─────────────────────────────────────────────
# Balance-delta cost (local balance snapshots)
# ─────────────────────────────────────────────
def fetch_upstream_cost_balance_delta(
upstream: Upstream,
target_date: date,
db: Session,
) -> tuple[float, str | None]:
"""Compute daily cost from local balance snapshots for balance_delta upstreams.
Algorithm:
1. Find the most recent snapshot BEFORE the start of target_date (baseline).
2. Find snapshots DURING target_date and use the last one as ending balance.
3. Sum manual recharge events for target_date.
4. cost = baseline + recharge_total - ending. Negative values fail.
"""
start_dt, end_dt = date_to_shanghai_utc_range(target_date)
# Baseline: latest snapshot before start of target date
baseline = (
db.query(UpstreamBalanceSnapshot)
.filter(
UpstreamBalanceSnapshot.upstream_id == upstream.id,
UpstreamBalanceSnapshot.captured_at < start_dt,
)
.order_by(UpstreamBalanceSnapshot.captured_at.desc())
.first()
)
# Intra-day snapshots during target date
intra_day = (
db.query(UpstreamBalanceSnapshot)
.filter(
UpstreamBalanceSnapshot.upstream_id == upstream.id,
UpstreamBalanceSnapshot.captured_at >= start_dt,
UpstreamBalanceSnapshot.captured_at < end_dt,
)
.order_by(UpstreamBalanceSnapshot.captured_at.asc())
.all()
)
if baseline is None:
return 0.0, "缺少目标日期前的余额基线样本,无法进行差分统计"
if not intra_day:
return 0.0, "目标日期内无余额样本,无法进行差分统计"
recharge_total = (
db.query(func.coalesce(func.sum(UpstreamRechargeEvent.amount), 0.0))
.filter(
UpstreamRechargeEvent.upstream_id == upstream.id,
UpstreamRechargeEvent.recharge_date == target_date,
)
.scalar()
)
recharge_total = float(recharge_total or 0.0)
max_balance_rise = 0.0
prev = float(baseline.balance)
for snap in intra_day:
current = float(snap.balance)
if current > prev:
max_balance_rise = max(max_balance_rise, current - prev)
prev = current
if max_balance_rise > 0 and recharge_total <= 0:
return 0.0, "检测到余额上涨,但当天无充值记录;请补录充值后重新对账"
if max_balance_rise > 0 and recharge_total + 1e-9 < max_balance_rise:
return 0.0, "当天充值总额小于观察到的最大余额上涨,充值金额可能漏填"
ending = float(intra_day[-1].balance)
total = float(baseline.balance) + recharge_total - ending
if total < -1e-9:
return 0.0, "余额差分计算为负数,请检查余额样本或补录充值记录"
return round(max(total, 0.0), 6), None
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# Orchestration # Orchestration
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@@ -237,6 +327,7 @@ def get_daily_summary(
websites: list[Website], websites: list[Website],
upstreams: list[Upstream], upstreams: list[Upstream],
target_date: date, target_date: date,
db: Session | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Compute daily revenue vs cost summary. """Compute daily revenue vs cost summary.
@@ -264,16 +355,24 @@ def get_daily_summary(
total_revenue += amount total_revenue += amount
for u in upstreams: for u in upstreams:
utype = classify_upstream(u) cost_mode = getattr(u, "finance_cost_mode", "usage_stats")
if utype == "sub2api": if cost_mode == "balance_delta":
amount, err = fetch_upstream_cost_sub2api(u, target_date) if db is None:
elif utype == "new_api": amount, err = 0.0, "余额差分统计需要数据库连接"
amount, err = fetch_upstream_cost_new_api(u, target_date) else:
amount, err = fetch_upstream_cost_balance_delta(u, target_date, db)
utype = classify_upstream(u)
else: else:
amount, err = 0.0, ( utype = classify_upstream(u)
f"未知上游类型(auth_type={u.auth_type}, api_prefix={u.api_prefix})," 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 = { item = {
"id": u.id, "id": u.id,
@@ -339,7 +438,7 @@ def compute_daily_summary(
"""Compute a fresh daily summary (no DB write). Used for compare.""" """Compute a fresh daily summary (no DB write). Used for compare."""
websites = db.query(Website).filter(Website.enabled == True).all() websites = db.query(Website).filter(Website.enabled == True).all()
upstreams = db.query(Upstream).filter(Upstream.enabled == True).all() upstreams = db.query(Upstream).filter(Upstream.enabled == True).all()
return get_daily_summary(websites, upstreams, target_date) return get_daily_summary(websites, upstreams, target_date, db=db)
def _load_summary(row: Any) -> dict[str, Any]: def _load_summary(row: Any) -> dict[str, Any]:
+58 -1
View File
@@ -16,7 +16,7 @@ from app.models.upstream import Upstream
from app.models.snapshot import UpstreamRateSnapshot from app.models.snapshot import UpstreamRateSnapshot
from app.services.auth_config import normalize_auth_config from app.services.auth_config import normalize_auth_config
from app.services.upstream_client import UpstreamClient, build_snapshot from app.services.upstream_client import UpstreamClient, build_snapshot
from app.services.snapshot_service import diff_snapshots, prune_snapshots from app.services.snapshot_service import diff_snapshots, prune_snapshots, write_balance_snapshot
from app.services import webhook_service from app.services import webhook_service
from app.services import website_sync from app.services import website_sync
from app.config import get_settings from app.config import get_settings
@@ -102,6 +102,7 @@ def _check_upstream(upstream_id: int) -> None:
if balance is not None: if balance is not None:
upstream.balance = balance upstream.balance = balance
upstream.balance_updated_at = datetime.now(timezone.utc) upstream.balance_updated_at = datetime.now(timezone.utc)
write_balance_snapshot(db, upstream.id, balance)
# ── 余额告警阈值检查 ── # ── 余额告警阈值检查 ──
threshold = upstream.balance_alert_threshold threshold = upstream.balance_alert_threshold
if threshold is not None and threshold > 0: if threshold is not None and threshold > 0:
@@ -376,6 +377,16 @@ def start_scheduler() -> None:
max_instances=1, max_instances=1,
misfire_grace_time=3600, misfire_grace_time=3600,
) )
# Daily balance snapshot capture at 23:59 Asia/Shanghai
_scheduler.add_job(
_capture_balance_snapshots,
trigger=CronTrigger(timezone="Asia/Shanghai", hour=23, minute=59),
id="balance_snapshot_daily",
replace_existing=True,
coalesce=True,
max_instances=1,
misfire_grace_time=600,
)
logger.info("scheduler started with %d upstream job(s)", len(upstreams)) logger.info("scheduler started with %d upstream job(s)", len(upstreams))
finally: finally:
db.close() db.close()
@@ -416,6 +427,52 @@ def _compute_finance_daily_summary() -> None:
db.close() db.close()
def _capture_balance_snapshots() -> None:
"""Fetch balance for all balance_delta-mode upstreams and write a snapshot.
Runs daily at 23:59 Asia/Shanghai to capture the end-of-day boundary balance.
Only writes a snapshot if balance fetch succeeds. Does NOT update the
upstream.balance field (that's the regular check's job).
"""
db = SessionLocal()
try:
upstreams = db.query(Upstream).filter(
Upstream.enabled == True,
Upstream.finance_cost_mode == "balance_delta",
Upstream.balance_endpoint != "",
Upstream.balance_response_path != "",
).all()
for u in upstreams:
try:
auth_config = json.loads(u.auth_config_json or "{}")
with UpstreamClient(
base_url=u.base_url,
api_prefix=u.api_prefix,
auth_type=u.auth_type,
auth_config=auth_config,
timeout=float(u.timeout_seconds),
on_auth_config_update=lambda updated: _persist_auth_config_update(u, updated),
target_id=u.id,
target_name=u.name,
) as client:
client.ensure_authenticated()
raw_balance = client.get_balance(u.balance_endpoint, u.balance_response_path)
if raw_balance is not None:
balance = raw_balance / (u.balance_divisor or 1.0)
write_balance_snapshot(db, u.id, balance)
db.commit()
logger.info("balance boundary snapshot for upstream %s: %.4f", u.name, balance)
else:
logger.warning("balance boundary snapshot: upstream %s returned None", u.name)
except Exception as exc:
db.rollback()
logger.warning("balance boundary snapshot failed for upstream %s: %s", u.name, exc)
except Exception:
logger.exception("failed to capture balance boundary snapshots")
finally:
db.close()
def stop_scheduler() -> None: def stop_scheduler() -> None:
if _scheduler.running: if _scheduler.running:
_scheduler.shutdown(wait=True) _scheduler.shutdown(wait=True)
+13 -2
View File
@@ -1,9 +1,10 @@
"""Snapshot diff logic.""" """Snapshot diff logic and balance snapshot writes."""
from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.snapshot import UpstreamRateSnapshot from app.models.snapshot import UpstreamBalanceSnapshot, UpstreamRateSnapshot
def diff_snapshots( def diff_snapshots(
@@ -43,6 +44,16 @@ def diff_snapshots(
return changes return changes
def write_balance_snapshot(db: Session, upstream_id: int, balance: float) -> None:
"""Write a balance snapshot row. Call only on successful balance fetch."""
row = UpstreamBalanceSnapshot(
upstream_id=upstream_id,
balance=balance,
captured_at=datetime.now(timezone.utc),
)
db.add(row)
def prune_snapshots(db: Session, upstream_id: int, keep: int) -> None: def prune_snapshots(db: Session, upstream_id: int, keep: int) -> None:
if keep <= 0: if keep <= 0:
return return
+168 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import datetime as _dt import datetime as _dt
import json import json
from datetime import date from datetime import date
from datetime import datetime, timezone
from unittest.mock import MagicMock from unittest.mock import MagicMock
import httpx import httpx
@@ -13,6 +14,7 @@ from app.services.finance_service import (
date_to_shanghai_timestamps, date_to_shanghai_timestamps,
fetch_upstream_cost_sub2api, fetch_upstream_cost_sub2api,
fetch_upstream_cost_new_api, fetch_upstream_cost_new_api,
fetch_upstream_cost_balance_delta,
fetch_website_revenue, fetch_website_revenue,
get_daily_summary, get_daily_summary,
) )
@@ -31,6 +33,7 @@ class FakeUpstream:
self.auth_type = kw.get("auth_type", "bearer") self.auth_type = kw.get("auth_type", "bearer")
self.auth_config_json = kw.get("auth_config_json", json.dumps({"token": "tok"})) self.auth_config_json = kw.get("auth_config_json", json.dumps({"token": "tok"}))
self.timeout_seconds = kw.get("timeout_seconds", 30) self.timeout_seconds = kw.get("timeout_seconds", 30)
self.finance_cost_mode = kw.get("finance_cost_mode", "usage_stats")
self.enabled = True self.enabled = True
@@ -407,6 +410,163 @@ def test_get_daily_summary_all_success(monkeypatch):
assert abs(result["margin_percent"] - 60.0) < 1e-6 assert abs(result["margin_percent"] - 60.0) < 1e-6
def _add_balance_snapshot(db, upstream_id: int, balance: float, captured_at: datetime):
from app.models.snapshot import UpstreamBalanceSnapshot
row = UpstreamBalanceSnapshot(
upstream_id=upstream_id,
balance=balance,
captured_at=captured_at,
)
db.add(row)
db.commit()
return row
def _add_recharge_event(db, upstream_id: int, recharge_date: date, amount: float, note: str = ""):
from app.models.upstream_recharge_event import UpstreamRechargeEvent
row = UpstreamRechargeEvent(
upstream_id=upstream_id,
recharge_date=recharge_date,
amount=amount,
note=note,
)
db.add(row)
db.commit()
return row
def test_balance_delta_cost_uses_manual_recharge_and_ending_balance():
"""Balance-delta cost is baseline + recharge_total - ending."""
db = _make_inmemory_db()
u = FakeUpstream(id=1, finance_cost_mode="balance_delta")
_add_balance_snapshot(db, 1, 100.0, datetime(2026, 7, 1, 15, 55, tzinfo=timezone.utc))
_add_balance_snapshot(db, 1, 90.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc))
_add_balance_snapshot(db, 1, 110.0, datetime(2026, 7, 1, 18, 0, tzinfo=timezone.utc))
_add_balance_snapshot(db, 1, 80.0, datetime(2026, 7, 2, 15, 59, tzinfo=timezone.utc))
_add_recharge_event(db, 1, date(2026, 7, 2), 20.0)
amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db)
assert err is None
assert abs(amount - 40.0) < 1e-6
db.close()
def test_balance_delta_balance_rise_without_recharge_fails():
db = _make_inmemory_db()
u = FakeUpstream(id=1, finance_cost_mode="balance_delta")
_add_balance_snapshot(db, 1, 100.0, datetime(2026, 7, 1, 15, 55, tzinfo=timezone.utc))
_add_balance_snapshot(db, 1, 120.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc))
amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db)
assert amount == 0.0
assert "无充值记录" in err
db.close()
def test_balance_delta_recharge_less_than_observed_rise_fails():
db = _make_inmemory_db()
u = FakeUpstream(id=1, finance_cost_mode="balance_delta")
_add_balance_snapshot(db, 1, 100.0, datetime(2026, 7, 1, 15, 55, tzinfo=timezone.utc))
_add_balance_snapshot(db, 1, 130.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc))
_add_recharge_event(db, 1, date(2026, 7, 2), 20.0)
amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db)
assert amount == 0.0
assert "充值金额可能漏填" in err
db.close()
def test_balance_delta_missing_baseline_fails():
"""Balance-delta requires a pre-day baseline sample."""
db = _make_inmemory_db()
u = FakeUpstream(id=1, finance_cost_mode="balance_delta")
_add_balance_snapshot(db, 1, 90.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc))
amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db)
assert amount == 0.0
assert "基线样本" in err
db.close()
def test_get_daily_summary_balance_delta_failure_excluded_from_total():
"""Incomplete balance-delta data must not be counted as zero-cost success."""
db = _make_inmemory_db()
u = FakeUpstream(id=1, api_prefix="api/v1", auth_type="bearer", finance_cost_mode="balance_delta")
result = get_daily_summary([], [u], date(2026, 7, 2), db=db)
assert result["failed_count"] == 1
assert result["total_cost"] == 0.0
assert result["upstream_items"][0]["status"] == "failed"
assert "基线样本" in result["upstream_items"][0]["error"]
db.close()
def test_upstream_recharge_crud_api_validates_and_scopes_to_upstream():
from fastapi.testclient import TestClient
from app.database import get_db
from app.main import app
from app.models.upstream import Upstream
from app.utils.auth import get_current_user
db = _make_inmemory_db()
db.add_all([
Upstream(id=1, name="U1", base_url="http://u1", api_prefix="api/v1", auth_type="bearer", auth_config_json="{}"),
Upstream(id=2, name="U2", base_url="http://u2", api_prefix="api/v1", auth_type="bearer", auth_config_json="{}"),
])
db.commit()
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[get_current_user] = lambda: None
try:
client = TestClient(app)
bad_amount = client.post("/api/upstreams/1/recharges", json={"date": "2026-07-02", "amount": 0})
assert bad_amount.status_code == 422
future = client.post("/api/upstreams/1/recharges", json={"date": "2999-01-01", "amount": 10})
assert future.status_code == 422
created = client.post(
"/api/upstreams/1/recharges",
json={"date": "2026-07-02", "amount": 50.5, "note": "manual"},
)
assert created.status_code == 201
body = created.json()
rid = body["id"]
assert body["date"] == "2026-07-02"
assert body["amount"] == 50.5
listed = client.get("/api/upstreams/1/recharges", params={"date": "2026-07-02"})
assert listed.status_code == 200
assert [row["id"] for row in listed.json()] == [rid]
wrong_upstream = client.put(f"/api/upstreams/2/recharges/{rid}", json={"amount": 60})
assert wrong_upstream.status_code == 404
updated = client.put(f"/api/upstreams/1/recharges/{rid}", json={"amount": 60, "note": "fixed"})
assert updated.status_code == 200
assert updated.json()["amount"] == 60
assert updated.json()["note"] == "fixed"
deleted = client.delete(f"/api/upstreams/1/recharges/{rid}")
assert deleted.status_code == 204
empty = client.get("/api/upstreams/1/recharges", params={"date": "2026-07-02"})
assert empty.json() == []
finally:
app.dependency_overrides.clear()
db.close()
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# DB-backed snapshot tests (FinanceDailySummary) # DB-backed snapshot tests (FinanceDailySummary)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@@ -415,14 +575,21 @@ def _make_inmemory_db():
"""Create an isolated in-memory SQLite session with all tables.""" """Create an isolated in-memory SQLite session with all tables."""
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base from app.database import Base
# Import models so they register with Base.metadata # Import models so they register with Base.metadata
from app.models import finance_daily_summary # noqa: F401 from app.models import finance_daily_summary # noqa: F401
from app.models import snapshot # noqa: F401
from app.models import upstream_recharge_event # noqa: F401
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog # noqa: F401 from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog # noqa: F401
from app.models.upstream import Upstream # noqa: F401 from app.models.upstream import Upstream # noqa: F401
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine) Session = sessionmaker(bind=engine)
return Session() return Session()
+22
View File
@@ -68,6 +68,8 @@ export const authApi = {
} }
// ——— Upstreams ——— // ——— Upstreams ———
export type FinanceCostMode = 'usage_stats' | 'balance_delta'
export interface UpstreamData { export interface UpstreamData {
id: number id: number
name: string name: string
@@ -89,6 +91,7 @@ export interface UpstreamData {
balance_response_path: string balance_response_path: string
balance_divisor: number balance_divisor: number
balance_alert_threshold: number | null balance_alert_threshold: number | null
finance_cost_mode: FinanceCostMode
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -108,6 +111,7 @@ export interface UpstreamForm {
balance_response_path: string balance_response_path: string
balance_divisor: number balance_divisor: number
balance_alert_threshold: number | null balance_alert_threshold: number | null
finance_cost_mode: FinanceCostMode
} }
export interface GeneratedUpstreamKey { export interface GeneratedUpstreamKey {
@@ -128,6 +132,16 @@ export interface GeneratedUpstreamKey {
has_key_value: boolean has_key_value: boolean
} }
export interface UpstreamRecharge {
id: number
upstream_id: number
date: string
amount: number
note: string
created_at: string
updated_at: string
}
export interface GenerateKeysByGroupsForm { export interface GenerateKeysByGroupsForm {
group_ids: string[] group_ids: string[]
name_prefix: string name_prefix: string
@@ -169,6 +183,14 @@ export const upstreamsApi = {
test: (id: number) => api.post<{ success: boolean; message: string; detail?: string }>(`/api/upstreams/${id}/test`), test: (id: number) => api.post<{ success: boolean; message: string; detail?: string }>(`/api/upstreams/${id}/test`),
checkNow: (id: number) => api.post<{ success: boolean; message: string }>(`/api/upstreams/${id}/check-now`), checkNow: (id: number) => api.post<{ success: boolean; message: string }>(`/api/upstreams/${id}/check-now`),
generatedKeys: (id: number) => api.get<GeneratedUpstreamKey[]>(`/api/upstreams/${id}/generated-keys`), generatedKeys: (id: number) => api.get<GeneratedUpstreamKey[]>(`/api/upstreams/${id}/generated-keys`),
listRecharges: (id: number, date?: string) =>
api.get<UpstreamRecharge[]>(`/api/upstreams/${id}/recharges`, { params: date ? { date } : undefined }),
createRecharge: (id: number, data: { date: string; amount: number; note?: string }) =>
api.post<UpstreamRecharge>(`/api/upstreams/${id}/recharges`, data),
updateRecharge: (id: number, rechargeId: number, data: { date?: string; amount?: number; note?: string }) =>
api.put<UpstreamRecharge>(`/api/upstreams/${id}/recharges/${rechargeId}`, data),
deleteRecharge: (id: number, rechargeId: number) =>
api.delete(`/api/upstreams/${id}/recharges/${rechargeId}`),
generateKeysByGroups: (id: number, data: GenerateKeysByGroupsForm) => generateKeysByGroups: (id: number, data: GenerateKeysByGroupsForm) =>
api.post<{ success: boolean; message: string; items: GeneratedUpstreamKey[] }>(`/api/upstreams/${id}/keys/generate-by-groups`, data), api.post<{ success: boolean; message: string; items: GeneratedUpstreamKey[] }>(`/api/upstreams/${id}/keys/generate-by-groups`, data),
latestSnapshot: (id: number) => api.get(`/api/upstreams/${id}/snapshots/latest`), latestSnapshot: (id: number) => api.get(`/api/upstreams/${id}/snapshots/latest`),
+183 -1
View File
@@ -247,6 +247,15 @@
<el-input-number v-model="form.balance_alert_threshold" :min="0" :precision="2" :value-on-clear="null" style="width: 100%" /> <el-input-number v-model="form.balance_alert_threshold" :min="0" :precision="2" :value-on-clear="null" style="width: 100%" />
<div class="form-hint">余额低于此值时发送 Webhook 通知留空/0 表示不监控</div> <div class="form-hint">余额低于此值时发送 Webhook 通知留空/0 表示不监控</div>
</el-form-item> </el-form-item>
<el-form-item label="财务计费模式">
<el-select v-model="form.finance_cost_mode" style="width: 100%">
<el-option label="远端 usage 统计" value="usage_stats" />
<el-option label="余额差分统计" value="balance_delta" />
</el-select>
<div class="form-hint">
远端会清理使用记录的上游可选余额差分需配置余额接口首个完整采样日后才可用于对账
</div>
</el-form-item>
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="检测间隔(秒)"> <el-form-item label="检测间隔(秒)">
@@ -310,6 +319,76 @@
<span>{{ detailUpstream.last_error }}</span> <span>{{ detailUpstream.last_error }}</span>
</div> </div>
<div class="section-title">
<el-icon><Plus /></el-icon>
充值记录
<span class="section-sub">列表合计 {{ formatBalance(rechargeTotal) }}</span>
</div>
<el-alert
v-if="detailUpstream?.finance_cost_mode !== 'balance_delta'"
class="recharge-alert"
type="info"
:closable="false"
show-icon
title="该上游当前使用远端 usage 统计,通常不需要录入充值。"
/>
<el-form class="recharge-form" label-position="top">
<el-row :gutter="10">
<el-col :span="8">
<el-form-item label="日期">
<el-date-picker
v-model="rechargeForm.date"
type="date"
value-format="YYYY-MM-DD"
:disabled-date="disableFutureDate"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="金额">
<el-input-number v-model="rechargeForm.amount" :min="0.01" :precision="4" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="备注">
<el-input v-model="rechargeForm.note" maxlength="200" show-word-limit placeholder="可选" />
</el-form-item>
</el-col>
</el-row>
<div class="recharge-actions">
<el-button v-if="editingRechargeId" size="small" @click="resetRechargeForm">取消编辑</el-button>
<el-button
size="small"
type="primary"
:loading="rechargeSaving"
:disabled="!rechargeForm.date || !rechargeForm.amount || rechargeForm.amount <= 0"
@click="saveRecharge"
>
{{ editingRechargeId ? '保存充值' : '添加充值' }}
</el-button>
</div>
</el-form>
<el-table :data="recharges" v-loading="rechargeLoading" size="small" class="recharge-table">
<el-table-column prop="date" label="日期" width="120" />
<el-table-column label="金额" width="120">
<template #default="{ row }"><span class="mono">{{ formatBalance(row.amount) }}</span></template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="120" align="right">
<template #default="{ row }">
<el-button size="small" text title="编辑充值记录" @click="editRecharge(row)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button size="small" text type="danger" title="删除充值记录" @click="confirmDeleteRecharge(row)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<div class="section-title"> <div class="section-title">
<el-icon><Key /></el-icon> <el-icon><Key /></el-icon>
已创建 Key 已创建 Key
@@ -463,7 +542,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus' import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer, Key } from '@element-plus/icons-vue' import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer, Key } from '@element-plus/icons-vue'
import { upstreamsApi, type AuthCaptureCandidate, type GeneratedUpstreamKey, type UpstreamData, type UpstreamBatchActionResponse } from '@/api' import { upstreamsApi, type AuthCaptureCandidate, type FinanceCostMode, type GeneratedUpstreamKey, type UpstreamData, type UpstreamBatchActionResponse, type UpstreamRecharge } from '@/api'
import AuthCaptureDialog from '@/components/AuthCaptureDialog.vue' import AuthCaptureDialog from '@/components/AuthCaptureDialog.vue'
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([]) const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
@@ -551,6 +630,7 @@ const defaultForm = () => ({
balance_response_path: platformDefaults.sub2api.balance_response_path, balance_response_path: platformDefaults.sub2api.balance_response_path,
balance_divisor: platformDefaults.sub2api.balance_divisor, balance_divisor: platformDefaults.sub2api.balance_divisor,
balance_alert_threshold: null as number | null, balance_alert_threshold: null as number | null,
finance_cost_mode: 'usage_stats' as FinanceCostMode,
}) })
const form = ref(defaultForm()) const form = ref(defaultForm())
const rules = { const rules = {
@@ -862,11 +942,20 @@ const detailVisible = ref(false)
const detailUpstream = ref<UpstreamData | null>(null) const detailUpstream = ref<UpstreamData | null>(null)
const snapshots = ref<any[]>([]) const snapshots = ref<any[]>([])
const generatedKeys = ref<GeneratedUpstreamKey[]>([]) const generatedKeys = ref<GeneratedUpstreamKey[]>([])
const recharges = ref<UpstreamRecharge[]>([])
const snapshotLoading = ref(false) const snapshotLoading = ref(false)
const keysLoading = ref(false) const keysLoading = ref(false)
const rechargeLoading = ref(false)
const rechargeSaving = ref(false)
const expandedId = ref<number | null>(null) const expandedId = ref<number | null>(null)
const snapshotOffset = ref(0) const snapshotOffset = ref(0)
const snapshotLimit = 20 const snapshotLimit = 20
const editingRechargeId = ref<number | null>(null)
const rechargeForm = ref({
date: dayjs().format('YYYY-MM-DD'),
amount: null as number | null,
note: '',
})
const keyDialogVisible = ref(false) const keyDialogVisible = ref(false)
const keyTarget = ref<UpstreamData | null>(null) const keyTarget = ref<UpstreamData | null>(null)
@@ -900,6 +989,10 @@ const healthyRate = computed(() => {
const pendingChecks = computed(() => list.value.filter((item) => !item.last_checked_at).length) const pendingChecks = computed(() => list.value.filter((item) => !item.last_checked_at).length)
const rechargeTotal = computed(() =>
recharges.value.reduce((sum, item) => sum + (Number(item.amount) || 0), 0),
)
function usesTokenEndpointUpstream(row: UpstreamData | null) { function usesTokenEndpointUpstream(row: UpstreamData | null) {
if (!row) return false if (!row) return false
return row.api_prefix === '' return row.api_prefix === ''
@@ -1006,6 +1099,7 @@ function openEdit(row: UpstreamData) {
balance_response_path: row.balance_response_path || '', balance_response_path: row.balance_response_path || '',
balance_divisor: row.balance_divisor ?? 1.0, balance_divisor: row.balance_divisor ?? 1.0,
balance_alert_threshold: row.balance_alert_threshold ?? null, balance_alert_threshold: row.balance_alert_threshold ?? null,
finance_cost_mode: row.finance_cost_mode || 'usage_stats',
} }
drawerVisible.value = true drawerVisible.value = true
} }
@@ -1080,8 +1174,10 @@ function openDetail(row: UpstreamData) {
detailUpstream.value = row detailUpstream.value = row
snapshots.value = [] snapshots.value = []
generatedKeys.value = [] generatedKeys.value = []
recharges.value = []
snapshotOffset.value = 0 snapshotOffset.value = 0
expandedId.value = null expandedId.value = null
resetRechargeForm()
_groupRowsCache.clear() _groupRowsCache.clear()
detailVisible.value = true detailVisible.value = true
} }
@@ -1100,6 +1196,7 @@ async function loadGeneratedKeys() {
async function loadSnapshots() { async function loadSnapshots() {
if (!detailUpstream.value) return if (!detailUpstream.value) return
loadGeneratedKeys() loadGeneratedKeys()
loadRecharges()
snapshotLoading.value = true snapshotLoading.value = true
try { try {
const res = await upstreamsApi.listSnapshots(detailUpstream.value.id, snapshotLimit, snapshotOffset.value) const res = await upstreamsApi.listSnapshots(detailUpstream.value.id, snapshotLimit, snapshotOffset.value)
@@ -1115,6 +1212,79 @@ async function loadSnapshots() {
} }
} }
function resetRechargeForm() {
editingRechargeId.value = null
rechargeForm.value = {
date: dayjs().format('YYYY-MM-DD'),
amount: null,
note: '',
}
}
function disableFutureDate(time: Date) {
return dayjs(time).isAfter(dayjs(), 'day')
}
async function loadRecharges() {
if (!detailUpstream.value) return
rechargeLoading.value = true
try {
const res = await upstreamsApi.listRecharges(detailUpstream.value.id)
recharges.value = res.data
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '加载充值记录失败')
} finally {
rechargeLoading.value = false
}
}
function editRecharge(row: UpstreamRecharge) {
editingRechargeId.value = row.id
rechargeForm.value = {
date: row.date,
amount: row.amount,
note: row.note || '',
}
}
async function saveRecharge() {
if (!detailUpstream.value || !rechargeForm.value.date || !rechargeForm.value.amount || rechargeForm.value.amount <= 0) return
rechargeSaving.value = true
try {
const payload = {
date: rechargeForm.value.date,
amount: rechargeForm.value.amount,
note: rechargeForm.value.note,
}
if (editingRechargeId.value) {
await upstreamsApi.updateRecharge(detailUpstream.value.id, editingRechargeId.value, payload)
ElMessage.success('充值记录已更新')
} else {
await upstreamsApi.createRecharge(detailUpstream.value.id, payload)
ElMessage.success('充值记录已添加')
}
resetRechargeForm()
await loadRecharges()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存充值记录失败')
} finally {
rechargeSaving.value = false
}
}
async function confirmDeleteRecharge(row: UpstreamRecharge) {
if (!detailUpstream.value) return
try {
await ElMessageBox.confirm(`确认删除 ${row.date} 的充值记录?`, '删除确认', { type: 'warning' })
await upstreamsApi.deleteRecharge(detailUpstream.value.id, row.id)
ElMessage.success('充值记录已删除')
if (editingRechargeId.value === row.id) resetRechargeForm()
await loadRecharges()
} catch {
// noop
}
}
async function openKeyGenerate(row: UpstreamData) { async function openKeyGenerate(row: UpstreamData) {
keyTarget.value = row keyTarget.value = row
keyResults.value = [] keyResults.value = []
@@ -1520,6 +1690,18 @@ onMounted(loadList)
font-weight: 500; font-weight: 500;
} }
.recharge-alert,
.recharge-form,
.recharge-table {
margin-bottom: 1rem;
}
.recharge-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.snapshot-list { .snapshot-list {
display: grid; display: grid;
gap: 0.7rem; gap: 0.7rem;