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
+109 -10
View File
@@ -21,10 +21,13 @@ from typing import Any
import pytz
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.models.upstream import Upstream
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.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())
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
# ─────────────────────────────────────────────
@@ -229,6 +239,86 @@ def fetch_upstream_cost_new_api(upstream: Upstream, target_date: date) -> tuple[
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
# ─────────────────────────────────────────────
@@ -237,6 +327,7 @@ def get_daily_summary(
websites: list[Website],
upstreams: list[Upstream],
target_date: date,
db: Session | None = None,
) -> dict[str, Any]:
"""Compute daily revenue vs cost summary.
@@ -264,16 +355,24 @@ def get_daily_summary(
total_revenue += amount
for u in upstreams:
utype = classify_upstream(u)
if utype == "sub2api":
amount, err = fetch_upstream_cost_sub2api(u, target_date)
elif utype == "new_api":
amount, err = fetch_upstream_cost_new_api(u, target_date)
cost_mode = getattr(u, "finance_cost_mode", "usage_stats")
if cost_mode == "balance_delta":
if db is None:
amount, err = 0.0, "余额差分统计需要数据库连接"
else:
amount, err = fetch_upstream_cost_balance_delta(u, target_date, db)
utype = classify_upstream(u)
else:
amount, err = 0.0, (
f"未知上游类型(auth_type={u.auth_type}, api_prefix={u.api_prefix}),"
"无法统计消费。请检查上游配置。"
)
utype = classify_upstream(u)
if utype == "sub2api":
amount, err = fetch_upstream_cost_sub2api(u, target_date)
elif utype == "new_api":
amount, err = fetch_upstream_cost_new_api(u, target_date)
else:
amount, err = 0.0, (
f"未知上游类型(auth_type={u.auth_type}, api_prefix={u.api_prefix}),"
"无法统计消费。请检查上游配置。"
)
item = {
"id": u.id,
@@ -339,7 +438,7 @@ def compute_daily_summary(
"""Compute a fresh daily summary (no DB write). Used for compare."""
websites = db.query(Website).filter(Website.enabled == True).all()
upstreams = db.query(Upstream).filter(Upstream.enabled == True).all()
return get_daily_summary(websites, upstreams, target_date)
return get_daily_summary(websites, upstreams, target_date, db=db)
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.services.auth_config import normalize_auth_config
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 website_sync
from app.config import get_settings
@@ -102,6 +102,7 @@ def _check_upstream(upstream_id: int) -> None:
if balance is not None:
upstream.balance = balance
upstream.balance_updated_at = datetime.now(timezone.utc)
write_balance_snapshot(db, upstream.id, balance)
# ── 余额告警阈值检查 ──
threshold = upstream.balance_alert_threshold
if threshold is not None and threshold > 0:
@@ -376,6 +377,16 @@ def start_scheduler() -> None:
max_instances=1,
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))
finally:
db.close()
@@ -416,6 +427,52 @@ def _compute_finance_daily_summary() -> None:
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:
if _scheduler.running:
_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 sqlalchemy.orm import Session
from app.models.snapshot import UpstreamRateSnapshot
from app.models.snapshot import UpstreamBalanceSnapshot, UpstreamRateSnapshot
def diff_snapshots(
@@ -43,6 +44,16 @@ def diff_snapshots(
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:
if keep <= 0:
return