统一计价引擎 apps/billing/pricing.py:1积分=¥0.1,平台成本(¥)与用户价(积分)双记账, 视频按火山真实 usage.total_tokens 结算(true-up,终结¥1/段倒贴)+ 首发×1.5毛利系数。 Team.price_multiplier 差异化调价(jimeng同款):挂牌价×系数两步HALF_UP取整,视频类 按下单时的价格/汇率快照结算(中途改配不影响在途任务)。 BillingConfig 单例(汇率/毛利系数/预留buffer,admin可调即刻生效)。存量数据 ×10 rescale 迁移(RunPython+atomic,MySQL 迁移不可中断重跑)。开户赠送归零(DEFAULT_TRIAL_CREDITS=0, 商业决策)。audit_billing 加 I9(卖亏审计)。271 条测试 + tsc/build 全绿。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
179 lines
9.2 KiB
Python
179 lines
9.2 KiB
Python
"""资金核算审计:针对真实 DB,逐条验证账目不变量,任何不平即报。
|
|
跑法:cd backend && DJANGO_SETTINGS_MODULE=airshelf.settings.development .venv/bin/python ../qa/audit_billing.py
|
|
不变量:
|
|
I1 余额自洽 balance == Σ(recharge+refund) - Σ(charge) + Σ(adjustment 带符号)
|
|
I2 冻结自洽 reserved_balance == Σ(amount of ACTIVE reservations)
|
|
I3 非负 & 可用 balance>=0, reserved>=0, balance>=reserved(可用余额>=0)
|
|
I4 预留闭环 每个 reservation 状态与其 ledger 一致;CHARGED 恰好 1 条 charge 且 amount<=预留
|
|
I5 失败不扣费 失败 task 的 reservation 不得为 CHARGED
|
|
I6 无重复扣费 同一 task 不得有 >1 条 charge ledger
|
|
I7 流水连续 按时间重放,recharge/charge 的 balance_after 与重放值一致
|
|
"""
|
|
import os, sys
|
|
from decimal import Decimal
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
|
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
|
|
import django # noqa: E402
|
|
|
|
django.setup()
|
|
|
|
from django.db.models import Sum # noqa: E402
|
|
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation # noqa: E402
|
|
from apps.ai.models import AITask # noqa: E402
|
|
|
|
Z = Decimal("0")
|
|
problems = []
|
|
|
|
|
|
def flag(account, code, msg):
|
|
problems.append((str(account.team), code, msg))
|
|
print(f" ✗ [{code}] {msg}")
|
|
|
|
|
|
def audit_account(acct):
|
|
team = acct.team
|
|
led = CreditLedger.objects.filter(team=team)
|
|
print(f"\n== team={team} | balance={acct.balance} reserved={acct.reserved_balance} ==")
|
|
|
|
def s(t):
|
|
return led.filter(ledger_type=t).aggregate(x=Sum("amount"))["x"] or Z
|
|
|
|
recharge, charge, refund, adjustment = s("recharge"), s("charge"), s("refund"), s("adjustment")
|
|
|
|
# I1 余额=流水终点:account.balance 应等于最后一条动-balance 流水的 balance_after(不依赖开户 genesis)
|
|
last_bal_led = led.filter(ledger_type__in=["recharge", "refund", "charge", "adjustment"]).order_by("created_at", "id").last()
|
|
if last_bal_led is not None:
|
|
if acct.balance != last_bal_led.balance_after:
|
|
flag(acct, "I1", f"账户余额与流水终点脱节:account.balance={acct.balance} 但末条动账流水 balance_after={last_bal_led.balance_after}")
|
|
else:
|
|
print(f" ✓ I1 余额=流水终点 {acct.balance}")
|
|
else:
|
|
print(f" · I1 无动账流水(纯开户额度 {acct.balance})")
|
|
|
|
# I2 冻结自洽
|
|
active_sum = CreditReservation.objects.filter(team=team, status="active").aggregate(x=Sum("amount"))["x"] or Z
|
|
if acct.reserved_balance != active_sum:
|
|
flag(acct, "I2", f"冻结不平:account.reserved_balance={acct.reserved_balance} 但 ACTIVE 预留之和={active_sum}(差 {acct.reserved_balance - active_sum})")
|
|
else:
|
|
print(f" ✓ I2 冻结自洽 ACTIVE 预留之和={active_sum}")
|
|
|
|
# I3 非负 & 可用
|
|
if acct.balance < Z:
|
|
flag(acct, "I3", f"余额为负 {acct.balance}")
|
|
if acct.reserved_balance < Z:
|
|
flag(acct, "I3", f"冻结为负 {acct.reserved_balance}")
|
|
if acct.balance < acct.reserved_balance:
|
|
flag(acct, "I3", f"可用余额为负:balance {acct.balance} < reserved {acct.reserved_balance}")
|
|
if acct.balance >= Z and acct.reserved_balance >= Z and acct.balance >= acct.reserved_balance:
|
|
print(f" ✓ I3 非负&可用余额>=0(可用 {acct.balance - acct.reserved_balance})")
|
|
|
|
# I4/I5/I6 预留闭环 + 失败不扣 + 无重复扣
|
|
i4 = i5 = i6 = True
|
|
for r in CreditReservation.objects.filter(team=team).select_related("task"):
|
|
charges = led.filter(task=r.task, ledger_type="charge")
|
|
charge_sum = charges.aggregate(x=Sum("amount"))["x"] or Z
|
|
adj = led.filter(task=r.task, ledger_type="adjustment").aggregate(x=Sum("amount"))["x"] or Z
|
|
net_charge = charge_sum - adj # 扣费扣除回冲后的净额
|
|
dup_reconciled = charges.count() > 1 and net_charge == r.amount
|
|
if charges.count() > 1:
|
|
if dup_reconciled:
|
|
print(f" · I6 task={str(r.task_id)[:8]} 历史双扣 {charges.count()} 笔,已被 adjustment 回冲(净扣 {net_charge} = 预留 {r.amount},账平)")
|
|
else:
|
|
i6 = False
|
|
flag(acct, "I6", f"task={str(r.task_id)[:8]} {charges.count()} 条扣费,净扣 {net_charge} != 预留 {r.amount}(未完全回冲,真多扣)")
|
|
if r.status == "charged":
|
|
if charges.count() != 1 and not dup_reconciled:
|
|
i4 = False
|
|
flag(acct, "I4", f"reservation(task={str(r.task_id)[:8]}) 状态 CHARGED 却有 {charges.count()} 条扣费")
|
|
elif net_charge > r.amount:
|
|
i4 = False
|
|
flag(acct, "I4", f"净扣费 {net_charge} > 预留 {r.amount}(task={str(r.task_id)[:8]})")
|
|
elif r.status == "active":
|
|
if charges.exists():
|
|
i4 = False
|
|
flag(acct, "I4", f"reservation ACTIVE 却已有扣费(task={str(r.task_id)[:8]})")
|
|
# I5 失败不扣费
|
|
task = r.task
|
|
if task and task.status in ("failed", "cancelled") and r.status == "charged":
|
|
i5 = False
|
|
flag(acct, "I5", f"失败/取消 task={str(r.task_id)[:8]}(status={task.status})却被扣费")
|
|
if i4:
|
|
print(" ✓ I4 预留状态与扣费/释放流水一致")
|
|
if i5:
|
|
print(" ✓ I5 失败/取消任务均未扣费")
|
|
if i6:
|
|
print(" ✓ I6 无重复扣费")
|
|
|
|
# I7 逐笔差分自洽(不依赖开户起点):每条动账流水的 balance_after 必须 = 前一条动账流水的 balance_after ± 本笔金额。
|
|
# reserve/release 不动 balance,其 balance_after 应 = 当时余额快照(上一条动账流水的 balance_after)。
|
|
i7 = True
|
|
prev_bal = None # 上一条动账后的余额
|
|
for L in led.order_by("created_at", "id"):
|
|
if L.ledger_type in ("recharge", "refund", "adjustment"):
|
|
expected = (prev_bal if prev_bal is not None else Z) + L.amount
|
|
if prev_bal is not None and L.balance_after != expected:
|
|
i7 = False
|
|
flag(acct, "I7", f"差分不符 {L.ledger_type}@{str(L.created_at)[:19]} amount={L.amount} 记录={L.balance_after} 应为前值{prev_bal}+{L.amount}={expected}")
|
|
prev_bal = L.balance_after
|
|
elif L.ledger_type == "charge":
|
|
if prev_bal is not None:
|
|
expected = prev_bal - L.amount
|
|
if L.balance_after != expected:
|
|
i7 = False
|
|
flag(acct, "I7", f"差分不符 charge@{str(L.created_at)[:19]} amount={L.amount} 记录={L.balance_after} 应为前值{prev_bal}-{L.amount}={expected}")
|
|
prev_bal = L.balance_after
|
|
else: # reserve / release:不动 balance,快照应等于上一条动账后的余额
|
|
if prev_bal is not None and L.balance_after != prev_bal:
|
|
i7 = False
|
|
flag(acct, "I7", f"reserve/release 余额快照漂移@{str(L.created_at)[:19]} 记录={L.balance_after} 应={prev_bal}")
|
|
if i7:
|
|
print(" ✓ I7 逐笔差分自洽(每条 balance_after = 前一条 ± 本笔金额)")
|
|
|
|
# I8 流水完整性(可审计):首条流水之前的隐含余额应为 0(开户额度也应有 genesis 流水)
|
|
first = led.order_by("created_at", "id").first()
|
|
if first is not None:
|
|
delta = first.amount if first.ledger_type in ("recharge", "refund", "adjustment") else (-first.amount if first.ledger_type == "charge" else Z)
|
|
opening = first.balance_after - delta
|
|
if opening != Z:
|
|
flag(acct, "I8", f"开户额度无流水凭证:首条流水前隐含余额={opening}(应为 0,缺 genesis 赠送流水)")
|
|
else:
|
|
print(" ✓ I8 流水完整(开户额度有凭证)")
|
|
|
|
# I9 毛利审计(积分制新增,先 warning 不计入失败):succeeded 且 base_cost>0(成本已知)的任务,
|
|
# 用户实收 actual_cost/points_per_yuan(¥)不应低于平台成本 base_cost —— 低于即「卖亏」。
|
|
# 注:充值赠送积分会稀释实际实现汇率(paid/credited < 名义 0.1),此处按名义汇率近似。
|
|
from apps.billing.pricing import get_billing_config
|
|
|
|
rate = get_billing_config().points_per_yuan
|
|
loss_tasks = []
|
|
for t in AITask.objects.filter(team=team, status="succeeded", base_cost__gt=0).only("id", "task_type", "actual_cost", "base_cost"):
|
|
revenue_yuan = (t.actual_cost or Z) / rate
|
|
if revenue_yuan < t.base_cost:
|
|
loss_tasks.append((t, revenue_yuan))
|
|
if loss_tasks:
|
|
for t, revenue_yuan in loss_tasks[:5]:
|
|
print(f" ⚠ I9 卖亏任务(warning) {t.task_type}:{t.id} 实收¥{revenue_yuan:.2f} < 成本¥{t.base_cost}")
|
|
if len(loss_tasks) > 5:
|
|
print(f" ⚠ I9 …另有 {len(loss_tasks) - 5} 条卖亏任务")
|
|
else:
|
|
print(" ✓ I9 无卖亏任务(base_cost>0 口径)")
|
|
|
|
|
|
def main():
|
|
accts = CreditAccount.objects.select_related("team").all()
|
|
print(f"审计 {accts.count()} 个团队账户…")
|
|
for a in accts:
|
|
audit_account(a)
|
|
print("\n" + "=" * 60)
|
|
if problems:
|
|
print(f"发现 {len(problems)} 处账目问题:")
|
|
for team, code, msg in problems:
|
|
print(f" [{code}] {team}: {msg}")
|
|
sys.exit(1)
|
|
print("✓ 全部账户账目自洽,无金额核算错误")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|