fix finance balance delta recharges
This commit is contained in:
@@ -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():
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user