feat: 强化用量计费状态机,新增钱包每日消费汇总分类账

- 将 usage.billing_status 默认值从 settled 改为 pending,完善
  pending -> settled/void 的状态转换逻辑,确保终态不可逆
- 新增 WalletDailyUsageLedger 模型和聚合服务,按账单日汇总
  每个钱包的消费金额、请求数和 token 用量
- 前端钱包中心页面集成每日消费流水展示,支持与充值记录混合
  排序和分页
- 新增两个数据库迁移:修复历史数据状态一致性、创建每日汇总表
- 补充计费状态机单元测试

Closes #218

Co-authored-by: LewisPen <LewisPen@nyadoo.com>
This commit is contained in:
fawney19
2026-03-11 15:05:11 +08:00
parent 6235c772ac
commit 04ab4bd9f2
25 changed files with 1212 additions and 121 deletions

View File

@@ -378,6 +378,7 @@ async def test_event_to_record_body_deserialization(monkeypatch: Any) -> None:
event = build_usage_event(
event_type=UsageEventType.COMPLETED,
request_id="req-body-test",
timestamp_ms=1_700_000_000_123,
data={
"user_id": "user-1",
"api_key_id": "key-1",
@@ -396,6 +397,7 @@ async def test_event_to_record_body_deserialization(monkeypatch: Any) -> None:
assert record["request_body"]["messages"][0]["content"] == "hello"
assert isinstance(record["response_body"], dict)
assert record["response_body"]["choices"][0]["message"]["content"] == "hi"
assert record["finalized_at"] is not None
@pytest.mark.asyncio

View File

@@ -0,0 +1,85 @@
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from src.services.task.video.cancel import VideoTaskCancelService
from src.services.usage.service import UsageService
class _DummyQuery:
def __init__(self, obj: Any) -> None:
self._obj = obj
def filter(self, *args: Any, **kwargs: Any) -> "_DummyQuery":
return self
def with_for_update(self) -> "_DummyQuery":
return self
def first(self) -> Any:
return self._obj
def test_update_settled_billing_rejects_terminal_usage() -> None:
usage = SimpleNamespace(
request_id="req-1",
billing_status="settled",
finalized_at="2026-03-06T00:00:00+00:00",
total_cost_usd=1.23,
)
db = MagicMock()
db.query.return_value = _DummyQuery(usage)
updated = UsageService.update_settled_billing(
db,
request_id="req-1",
total_cost_usd=2.34,
status="completed",
)
assert updated is False
assert usage.total_cost_usd == 1.23
def test_update_usage_status_skips_terminal_usage() -> None:
usage = SimpleNamespace(
request_id="req-1",
billing_status="settled",
finalized_at="2026-03-06T00:00:00+00:00",
status="completed",
error_message=None,
provider_name="demo",
)
db = MagicMock()
db.query.return_value = _DummyQuery(usage)
result = UsageService.update_usage_status(
db=db,
request_id="req-1",
status="failed",
error_message="boom",
)
assert result is usage
assert usage.status == "completed"
assert usage.error_message is None
db.commit.assert_not_called()
@pytest.mark.asyncio
async def test_task_cancel_rejects_terminal_task() -> None:
task = SimpleNamespace(
id="t1",
user_id="u1",
status="completed",
request_id="req-1",
)
svc = VideoTaskCancelService(MagicMock())
with pytest.raises(HTTPException) as exc:
await svc.cancel_task(task=task, task_id="t1")
assert exc.value.status_code == 409

View File

@@ -54,8 +54,8 @@ def _make_db() -> MagicMock:
usage_obj = SimpleNamespace(
id="usage-1",
request_id="req-1",
billing_status="settled", # finalize_submitted already settled
request_metadata=None, # no billing_updated_at yet
billing_status="pending",
request_metadata=None,
)
q_usage = MagicMock()

View File

@@ -48,3 +48,33 @@ async def test_poll_task_status_routes_gemini_video_to_gemini(
assert result.status == VideoStatus.PROCESSING
assert poll_gemini.await_count == 1
assert poll_openai.await_count == 0
@pytest.mark.asyncio
async def test_update_task_after_poll_skips_terminal_cancelled_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
finalize = AsyncMock()
adapter = VideoTaskPollerAdapter(finalize_video_task_fn=finalize)
cancelled_task = SimpleNamespace(
id="t1",
status=VideoStatus.CANCELLED.value,
)
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = None
session.get.return_value = cancelled_task
monkeypatch.setattr("src.services.task.video.poller_adapter.create_session", lambda: session)
await adapter.update_task_after_poll(
task_id="t1",
result=InternalVideoPollResult(status=VideoStatus.COMPLETED),
ctx=None,
redis_client=None,
)
finalize.assert_not_awaited()
session.commit.assert_not_called()