feat: 添加 points_audit_ledger 及 JSON 字段 Pydantic Schema 约束

This commit is contained in:
qzl
2026-04-10 12:28:18 +08:00
parent 46513829cd
commit 0ac8b81a66
34 changed files with 2595 additions and 1757 deletions
+27
View File
@@ -0,0 +1,27 @@
# Backend Tests Rules
This file governs `backend/tests/**` only.
## Scope & Precedence
- Inherits root `AGENTS.md` and `backend/AGENTS.md`.
- If rules conflict, apply the stricter one.
## Test Execution
- Use `uv run pytest ...` for all backend test commands.
- Unit tests should not depend on a running web process.
- Integration tests under `backend/tests/integration` are live API tests and must run against a started backend.
## Integration Tests (Required Precondition)
- Before running integration tests, start backend and workers with:
- `./infra/scripts/app.sh restart`
- Verify service readiness via `/health` before sending test requests.
- Integration tests may write test data to database, but must clean up created records after each test.
## Data Safety
- Test data must use isolated identifiers (unique email suffix, unique run_id/thread_id).
- Cleanup must include auth users and related bonus/audit records created by the test.
- Never hardcode production credentials or mutable shared user IDs in tests.
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
from collections.abc import AsyncIterator
import hashlib
import hmac
import os
import time
import httpx
import pytest
from sqlalchemy import text
from core.config.settings import config
from core.db.session import AsyncSessionLocal
@pytest.fixture(scope="session")
def api_base_url() -> str:
return os.environ.get("ERYAO_TEST_BASE_URL", "http://localhost:5775")
@pytest.fixture(scope="session")
def test_verify_code() -> str:
return os.environ.get("ERYAO_TEST__CODE", "123456")
@pytest.fixture
def unique_test_email() -> str:
base_email = os.environ.get("ERYAO_TEST__EMAIL", "test@example.com").strip().lower()
if "@" in base_email:
name, domain = base_email.split("@", 1)
else:
name, domain = base_email, "example.com"
return f"{name}+it{int(time.time() * 1000)}@{domain}"
@pytest.fixture
def test_identity(unique_test_email: str, test_verify_code: str) -> dict[str, str]:
return {"email": unique_test_email, "code": test_verify_code}
@pytest.fixture
async def api_client(api_base_url: str) -> AsyncIterator[httpx.AsyncClient]:
async with httpx.AsyncClient(base_url=api_base_url, timeout=30.0) as client:
try:
health = await client.get("/health")
if health.status_code != 200:
pytest.skip(f"API not ready: /health={health.status_code}")
except Exception as exc:
pytest.skip(f"API unavailable: {exc}")
yield client
@pytest.fixture
async def db_cleanup() -> AsyncIterator[list[str]]:
emails: list[str] = []
yield emails
if not emails:
return
hmac_key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip()
email_hashes = [
hmac.new(
hmac_key.encode("utf-8"), email.encode("utf-8"), hashlib.sha256
).hexdigest()
for email in emails
]
async with AsyncSessionLocal() as session:
await session.execute(
text(
"DELETE FROM points_audit_ledger WHERE lower(coalesce(user_email_snapshot, '')) = ANY(:emails)"
),
{"emails": emails},
)
await session.execute(
text(
"DELETE FROM register_bonus_claims WHERE email_hash = ANY(:email_hashes)"
),
{"email_hashes": email_hashes},
)
await session.execute(
text("DELETE FROM auth.users WHERE lower(email) = ANY(:emails)"),
{"emails": emails},
)
await session.commit()
@@ -0,0 +1,219 @@
from __future__ import annotations
import json
import time
import uuid
from typing import TypedDict
import httpx
import pytest
from sqlalchemy import select
from core.config.settings import config
from core.db.session import AsyncSessionLocal
from models.points_audit_ledger import PointsAuditLedger
from models.points_ledger import PointsLedger
from models.register_bonus_claims import RegisterBonusClaims
from models.user_points import UserPoints
class IdentityData(TypedDict):
email: str
code: str
async def _create_email_session(
client: httpx.AsyncClient,
*,
email: str,
code: str,
) -> dict[str, object]:
resp = await client.post(
"/api/v1/auth/email-session",
json={"email": email, "token": code},
)
resp.raise_for_status()
return resp.json()
async def _wait_terminal_event(
client: httpx.AsyncClient,
*,
access_token: str,
thread_id: str,
run_id: str,
timeout_s: int = 180,
) -> str:
headers = {"Authorization": f"Bearer {access_token}"}
params = {"runId": run_id, "idle_limit": 120}
started = time.time()
async with client.stream(
"GET",
f"/api/v1/agent/runs/{thread_id}/events",
headers=headers,
params=params,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if time.time() - started > timeout_s:
raise TimeoutError("SSE timed out")
if not line or not line.startswith("data: "):
continue
event = json.loads(line[6:])
event_type = event.get("type")
if event_type in {"RUN_FINISHED", "RUN_ERROR"}:
return str(event_type)
raise RuntimeError("No terminal SSE event")
def _build_run_payload(*, thread_id: str, run_id: str) -> dict[str, object]:
now = int(time.time() * 1000)
return {
"threadId": thread_id,
"runId": run_id,
"state": {},
"messages": [
{
"id": f"msg_{run_id}_user_0",
"role": "user",
"content": "今天适合做重要决策吗?",
}
],
"tools": [],
"context": [],
"forwardedProps": {
"runtime_mode": "chat",
"client_time": {
"device_timezone": "Asia/Shanghai",
"client_now_iso": "2026-04-10T12:00:00Z",
"client_epoch_ms": now,
},
"divinationPayload": {
"divinationMethod": "自动起卦",
"questionType": "运势",
"question": "今天适合做重要决策吗?",
"divinationTimeIso": "2026-04-10T12:00:00Z",
"yaoLines": ["少阳", "少阴", "老阳", "少阳", "老阴", "少阴"],
},
},
}
@pytest.mark.asyncio
async def test_register_run_delete_reregister_keeps_bonus_single_use(
api_client: httpx.AsyncClient,
test_identity: IdentityData,
db_cleanup: list[str],
) -> None:
email = str(test_identity["email"]).strip().lower()
db_cleanup.append(email)
bonus = int(config.points_policy.register_bonus_points)
first = await _create_email_session(
api_client,
email=email,
code=str(test_identity["code"]),
)
user1 = first.get("user")
assert isinstance(user1, dict)
user1_id = str(user1["id"])
token1 = str(first["access_token"])
headers1 = {"Authorization": f"Bearer {token1}"}
before_run = await api_client.get("/api/v1/points/balance", headers=headers1)
before_run.raise_for_status()
before_data = before_run.json()
assert int(before_data["balance"]) == bonus
thread_id = str(uuid.uuid4())
run_id = f"run_{int(time.time() * 1000)}"
enqueue = await api_client.post(
"/api/v1/agent/runs",
headers=headers1,
json=_build_run_payload(thread_id=thread_id, run_id=run_id),
)
enqueue.raise_for_status()
assert enqueue.status_code == 202
terminal = await _wait_terminal_event(
api_client,
access_token=token1,
thread_id=thread_id,
run_id=run_id,
)
assert terminal in {"RUN_FINISHED", "RUN_ERROR"}
after_run = await api_client.get("/api/v1/points/balance", headers=headers1)
after_run.raise_for_status()
after_data = after_run.json()
assert int(after_data["balance"]) == max(bonus - int(after_data["runCost"]), 0)
delete_resp = await api_client.delete("/api/v1/users/me", headers=headers1)
assert delete_resp.status_code == 204
second = await _create_email_session(
api_client,
email=email,
code=str(test_identity["code"]),
)
user2 = second.get("user")
assert isinstance(user2, dict)
user2_id = str(user2["id"])
token2 = str(second["access_token"])
assert user1_id != user2_id
headers2 = {"Authorization": f"Bearer {token2}"}
reregister_balance = await api_client.get(
"/api/v1/points/balance", headers=headers2
)
reregister_balance.raise_for_status()
re_data = reregister_balance.json()
assert int(re_data["balance"]) == 0
async with AsyncSessionLocal() as session:
points2 = (
await session.execute(
select(UserPoints).where(UserPoints.user_id == uuid.UUID(user2_id))
)
).scalar_one()
assert int(points2.lifetime_earned) == 0
run_ledger_rows = list(
(
await session.execute(
select(PointsLedger)
.where(PointsLedger.user_id == uuid.UUID(user1_id))
.order_by(PointsLedger.created_at.desc())
)
).scalars()
)
assert run_ledger_rows == []
run_audit_rows = list(
(
await session.execute(
select(PointsAuditLedger)
.where(
PointsAuditLedger.user_id_snapshot == uuid.UUID(user1_id),
PointsAuditLedger.run_id == run_id,
)
.order_by(PointsAuditLedger.created_at.desc())
)
).scalars()
)
assert run_audit_rows
assert run_audit_rows[0].run_id == run_id
assert run_audit_rows[0].billed_to in {"user", "platform"}
claim_rows = list(
(
await session.execute(
select(RegisterBonusClaims).where(
RegisterBonusClaims.user_email_snapshot == email
)
)
).scalars()
)
assert len(claim_rows) == 1
@@ -0,0 +1,190 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from uuid import UUID, uuid4
import pytest
from core.config.settings import config
from schemas.domain.points import AppendAuditLedgerCommand, ApplyPointsChangeCommand
from schemas.domain.points import PointsChargeSnapshot
from v1.points.service import PointsService
@dataclass
class _FakeAccount:
balance: int = 100
frozen_balance: int = 0
lifetime_earned: int = 0
lifetime_spent: int = 0
version: int = 0
class _FakePointsRepository:
def __init__(self, *, usage_snapshot: PointsChargeSnapshot | None) -> None:
self.account = _FakeAccount()
self.usage_snapshot = usage_snapshot
self.appended_ledger: list[ApplyPointsChangeCommand] = []
self.appended_audit: list[AppendAuditLedgerCommand] = []
self.claimed: bool = False
async def get_or_create_user_points_for_update(
self, *, user_id: UUID
) -> _FakeAccount:
del user_id
return self.account
async def has_ledger_event(self, *, user_id: UUID, event_id: str) -> bool:
del user_id, event_id
return False
async def append_ledger(
self,
*,
command: ApplyPointsChangeCommand,
balance_after: int,
) -> None:
del balance_after
self.appended_ledger.append(command)
async def append_audit_ledger(self, *, command: AppendAuditLedgerCommand) -> None:
self.appended_audit.append(command)
async def has_audit_event(self, *, event_id: str) -> bool:
del event_id
return False
async def get_run_usage_snapshot(
self,
*,
session_id: UUID,
run_id: str,
) -> PointsChargeSnapshot | None:
del session_id, run_id
return self.usage_snapshot
async def claim_register_bonus(
self,
*,
email_hash: str,
user_email_snapshot: str,
first_user_id: UUID,
grant_event_id: str,
) -> bool:
del email_hash, user_email_snapshot, first_user_id, grant_event_id
if self.claimed:
return False
self.claimed = True
return True
@pytest.mark.asyncio
async def test_consume_successful_run_points_writes_real_usage_to_audit() -> None:
usage = PointsChargeSnapshot(
message_id=uuid4(),
message_seq=3,
model_code="doubao-1.5-pro",
input_tokens=123,
output_tokens=456,
cost=Decimal("0.023456"),
)
repository = _FakePointsRepository(usage_snapshot=usage)
service = PointsService(repository=repository) # type: ignore[arg-type]
user_id = uuid4()
session_id = uuid4()
run_id = "run_123"
result = await service.consume_successful_run_points(
user_id=user_id,
session_id=session_id,
run_id=run_id,
operator_id=user_id,
user_email="User@Example.com",
)
assert result.charged is True
assert result.amount == 20
assert repository.account.balance == 80
assert len(repository.appended_ledger) == 1
assert len(repository.appended_audit) == 1
audit = repository.appended_audit[0]
assert audit.billed_to == "user"
assert audit.input_tokens == 123
assert audit.output_tokens == 456
assert audit.cost == Decimal("0.023456")
assert audit.direction == -1
assert audit.amount == 20
assert audit.user_email_snapshot == "user@example.com"
@pytest.mark.asyncio
async def test_record_failed_run_platform_cost_writes_platform_audit_only() -> None:
usage = PointsChargeSnapshot(
message_id=uuid4(),
message_seq=1,
model_code="doubao-1.5-pro",
input_tokens=100,
output_tokens=20,
cost=Decimal("0.012300"),
)
repository = _FakePointsRepository(usage_snapshot=usage)
service = PointsService(repository=repository) # type: ignore[arg-type]
result = await service.record_failed_run_platform_cost(
user_id=uuid4(),
session_id=uuid4(),
run_id="run_failed",
operator_id=None,
user_email="test@example.com",
failure_kind="failed",
)
assert result.audited is True
assert result.cost == Decimal("0.012300")
assert len(repository.appended_ledger) == 0
assert len(repository.appended_audit) == 1
audit = repository.appended_audit[0]
assert audit.billed_to == "platform"
assert audit.direction == 0
assert audit.amount == 0
assert audit.cost == Decimal("0.012300")
@pytest.mark.asyncio
async def test_grant_register_bonus_if_eligible_first_time_grants() -> None:
repository = _FakePointsRepository(usage_snapshot=None)
service = PointsService(repository=repository) # type: ignore[arg-type]
result = await service.grant_register_bonus_if_eligible(
user_id=uuid4(),
user_email="NewUser@Example.com",
)
expected_bonus = int(config.points_policy.register_bonus_points)
assert result.granted is True
assert result.amount == expected_bonus
assert repository.account.balance == 100 + expected_bonus
assert len(repository.appended_ledger) == 1
assert len(repository.appended_audit) == 1
assert repository.appended_audit[0].billed_to == "user"
assert repository.appended_audit[0].change_type.value == "register"
@pytest.mark.asyncio
async def test_grant_register_bonus_if_eligible_second_time_skips() -> None:
repository = _FakePointsRepository(usage_snapshot=None)
repository.claimed = True
service = PointsService(repository=repository) # type: ignore[arg-type]
result = await service.grant_register_bonus_if_eligible(
user_id=uuid4(),
user_email="dup@example.com",
)
assert result.granted is False
assert result.amount == 0
assert len(repository.appended_ledger) == 0
assert len(repository.appended_audit) == 0