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
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.agent_chat_message import AgentChatMessage
from models.agent_chat_session import AgentChatSession
from schemas.domain.chat_session import SessionStateSnapshot
from schemas.enums import AgentChatMessageRole, AgentChatSessionStatus
@@ -80,13 +81,13 @@ class SessionRepository:
*,
chat_session: AgentChatSession,
status: AgentChatSessionStatus,
state_snapshot: dict[str, object],
state_snapshot: SessionStateSnapshot,
message_delta: int,
token_delta: int = 0,
cost_delta: Decimal = Decimal("0"),
) -> None:
chat_session.status = status
chat_session.state_snapshot = state_snapshot
chat_session.state_snapshot = state_snapshot.model_dump(mode="json")
chat_session.last_activity_at = datetime.now(timezone.utc)
chat_session.message_count += message_delta
chat_session.total_tokens += token_delta
+3 -1
View File
@@ -16,6 +16,7 @@ from schemas.agent.system_agent import AgentType
from schemas.agent.runtime_models import AgentOutput, FollowUpOutput, ToolAgentOutput
from schemas.agent.visibility import SystemVisibilityBit, bit_mask
from schemas.domain.chat_message import AgentChatMessageMetadata
from schemas.domain.chat_session import SessionStateSnapshot
class EventStore(Protocol):
@@ -382,11 +383,12 @@ class SqlAlchemyEventStore:
token_delta: int = 0,
cost_delta: Decimal = Decimal("0"),
) -> None:
snapshot = (
snapshot_raw = (
chat_session.state_snapshot
if isinstance(chat_session.state_snapshot, dict)
else {}
)
snapshot = SessionStateSnapshot.model_validate(snapshot_raw)
await session_repo.update_runtime_state(
chat_session=chat_session,
status=status,
+30 -24
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import base64
import json
from typing import Any, cast
@@ -53,7 +54,7 @@ _RUNTIME_AGENT_OUTPUT_ADAPTER = TypeAdapter(RuntimeAgentOutput)
def _serialize_tool_agent_output(
*,
metadata: AgentChatMessageMetadata | dict[str, object] | None,
metadata: AgentChatMessageMetadata | None,
) -> str | None:
if metadata is None:
return None
@@ -80,30 +81,13 @@ def _serialize_tool_agent_output(
def _serialize_assistant_context_from_metadata(
*,
metadata: AgentChatMessageMetadata | dict[str, object] | None,
metadata: AgentChatMessageMetadata | None,
fallback_content: str,
) -> str:
if metadata is None:
return fallback_content
try:
resolved_metadata = (
metadata
if isinstance(metadata, AgentChatMessageMetadata)
else AgentChatMessageMetadata.model_validate(metadata)
)
except Exception:
return fallback_content
agent_output = resolved_metadata.agent_output
if agent_output is None and isinstance(metadata, dict):
raw = metadata.get("agent_output")
if raw is not None:
try:
agent_output = _RUNTIME_AGENT_OUTPUT_ADAPTER.validate_python(raw)
except Exception:
return fallback_content
agent_output = metadata.agent_output
if agent_output is None:
return fallback_content
@@ -226,11 +210,11 @@ async def _build_recent_context_messages(
content_raw = msg.get("content", "")
content: str = content_raw if isinstance(content_raw, str) else ""
metadata_raw = msg.get("metadata")
metadata: AgentChatMessageMetadata | dict[str, object] | None
metadata: AgentChatMessageMetadata | None
if isinstance(metadata_raw, AgentChatMessageMetadata):
metadata = metadata_raw
elif isinstance(metadata_raw, dict):
metadata = metadata_raw
metadata = AgentChatMessageMetadata.model_validate(metadata_raw)
else:
metadata = None
@@ -391,6 +375,7 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
context_config=runtime_config.context,
)
points_service = PointsService(repository=PointsRepository(session))
try:
await runtime.run(
run_input=run_input,
@@ -399,15 +384,36 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
runtime_config=runtime_config,
cancel_checker=_cancel_checker,
)
points_service = PointsService(repository=PointsRepository(session))
await points_service.consume_successful_run_points(
user_id=owner_id,
session_id=UUID(thread_id),
run_id=run_id,
operator_id=owner_id,
user_email=owner_email,
)
await session.commit()
except asyncio.CancelledError:
await points_service.record_failed_run_platform_cost(
user_id=owner_id,
session_id=UUID(thread_id),
run_id=run_id,
operator_id=owner_id,
user_email=owner_email,
failure_kind="canceled",
)
await session.commit()
raise
except Exception:
await points_service.record_failed_run_platform_cost(
user_id=owner_id,
session_id=UUID(thread_id),
run_id=run_id,
operator_id=owner_id,
user_email=owner_email,
failure_kind="failed",
)
await session.commit()
raise
finally:
delete_fn = getattr(redis_client, "delete", None)
if callable(delete_fn):
+17
View File
@@ -207,6 +207,22 @@ class AgentRuntimeSettings(BaseModel):
attachment_content_cache_max_base64_bytes: int = 262144
class PointsPolicySettings(BaseModel):
register_bonus_points: int = Field(default=60, ge=0, le=1_000_000)
register_bonus_hmac_key: SecretStr = SecretStr("")
@model_validator(mode="after")
def validate_hmac_key(self) -> "PointsPolicySettings":
key = self.register_bonus_hmac_key.get_secret_value().strip()
if not key:
raise ValueError("points_policy.register_bonus_hmac_key must not be empty")
if key.upper() == "CHANGE_ME":
raise ValueError(
"points_policy.register_bonus_hmac_key must not be CHANGE_ME"
)
return self
def _resolve_env_file() -> str:
current = Path(__file__).resolve()
for parent in [current, *current.parents]:
@@ -233,6 +249,7 @@ class Settings(BaseSettings):
test: TestSettings = Field(default_factory=TestSettings)
taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings)
agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings)
points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings)
@computed_field
@property
+4
View File
@@ -6,8 +6,10 @@ from .auth_user import AuthUser
from .invite_code import InviteCode
from .llm import Llm
from .llm_factory import LlmFactory
from .points_audit_ledger import PointsAuditLedger
from .points_ledger import PointsLedger
from .profile import Profile
from .register_bonus_claims import RegisterBonusClaims
from .system_agents import SystemAgents
from .user_points import UserPoints
@@ -18,8 +20,10 @@ __all__ = [
"InviteCode",
"Llm",
"LlmFactory",
"PointsAuditLedger",
"PointsLedger",
"Profile",
"RegisterBonusClaims",
"SystemAgents",
"UserPoints",
]
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import uuid
from decimal import Decimal
from sqlalchemy import (
BigInteger,
CheckConstraint,
Index,
Integer,
JSON,
Numeric,
SmallInteger,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, TimestampMixin
class PointsAuditLedger(TimestampMixin, Base):
__tablename__ = "points_audit_ledger"
__table_args__ = (
CheckConstraint(
"amount >= 0", name="ck_points_audit_ledger_amount_non_negative"
),
CheckConstraint(
"direction in (1, 0, -1)", name="ck_points_audit_ledger_direction_valid"
),
CheckConstraint(
"balance_after >= 0",
name="ck_points_audit_ledger_balance_after_non_negative",
),
CheckConstraint(
"change_type in ('register', 'consume', 'grant', 'adjust')",
name="ck_points_audit_ledger_change_type",
),
CheckConstraint(
"biz_type is null or biz_type = 'chat'",
name="ck_points_audit_ledger_biz_type",
),
CheckConstraint(
"billed_to in ('user', 'platform')",
name="ck_points_audit_ledger_billed_to",
),
CheckConstraint(
"jsonb_typeof(metadata) = 'object'",
name="ck_points_audit_ledger_metadata_object",
),
UniqueConstraint("event_id", name="uq_points_audit_ledger_event_id"),
Index(
"ix_points_audit_ledger_user_id_created_at",
"user_id_snapshot",
text("created_at DESC"),
),
Index(
"ix_points_audit_ledger_change_type_created_at",
"change_type",
text("created_at DESC"),
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
event_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_id_snapshot: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
user_email_snapshot: Mapped[str | None] = mapped_column(Text, nullable=True)
change_type: Mapped[str] = mapped_column(String(16), nullable=False)
biz_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
biz_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
direction: Mapped[int] = mapped_column(SmallInteger, nullable=False)
amount: Mapped[int] = mapped_column(BigInteger, nullable=False)
balance_after: Mapped[int] = mapped_column(BigInteger, nullable=False)
billed_to: Mapped[str] = mapped_column(String(16), nullable=False)
run_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
request_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cost: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False, default=0)
metadata_json: Mapped[dict[str, object]] = mapped_column(
"metadata",
JSON().with_variant(JSONB, "postgresql"),
nullable=False,
server_default=text("'{}'::jsonb"),
default=dict,
)
@@ -0,0 +1,33 @@
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, TimestampMixin
class RegisterBonusClaims(TimestampMixin, Base):
__tablename__ = "register_bonus_claims"
__table_args__ = (
UniqueConstraint("email_hash", name="uq_register_bonus_claims_email_hash"),
UniqueConstraint(
"grant_event_id", name="uq_register_bonus_claims_grant_event_id"
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
email_hash: Mapped[str] = mapped_column(String(64), nullable=False)
user_email_snapshot: Mapped[str] = mapped_column(Text, nullable=False)
first_user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("auth.users.id", ondelete="SET NULL"),
nullable=True,
)
grant_event_id: Mapped[str] = mapped_column(String(64), nullable=False)
+3 -7
View File
@@ -44,21 +44,17 @@ class AgentChatMessage(BaseModel):
output_tokens: int = Field(default=0, ge=0)
cost: Decimal = Field(default=Decimal("0"))
latency_ms: int | None = Field(default=None, ge=0)
metadata: AgentChatMessageMetadata | dict[str, object] | None = None
metadata: AgentChatMessageMetadata | None = None
timestamp: datetime
def extract_user_message_attachments(
metadata: AgentChatMessageMetadata | dict[str, object] | None,
metadata: AgentChatMessageMetadata | None,
) -> list[UserMessageAttachment]:
if metadata is None:
return []
if isinstance(metadata, AgentChatMessageMetadata):
raw_value: Any = metadata.user_message_attachments
else:
raw_value = metadata.get("user_message_attachments")
raw_value: Any = metadata.user_message_attachments
if raw_value is None:
return []
@@ -0,0 +1,9 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class SessionStateSnapshot(BaseModel):
model_config = ConfigDict(extra="allow")
pass
+37
View File
@@ -126,3 +126,40 @@ class ApplyPointsChangeCommand(BaseModel):
if self.biz_type != PointsBizType.CHAT or self.biz_id is None:
raise ValueError("adjust must use chat binding")
return self
class AuditLedgerMetadata(BaseModel):
model_config = ConfigDict(extra="forbid")
source: str = Field(min_length=1, max_length=64)
email_hash: str | None = Field(default=None, max_length=128)
usage_missing: bool | None = Field(default=None)
failure_kind: Literal["failed", "canceled"] | None = Field(default=None)
operator_id: str | None = Field(default=None, max_length=64)
@model_validator(mode="after")
def validate_at_least_source(self) -> "AuditLedgerMetadata":
if not self.source:
raise ValueError("source is required")
return self
class AppendAuditLedgerCommand(BaseModel):
model_config = ConfigDict(extra="forbid")
event_id: str = Field(min_length=1, max_length=64)
user_id_snapshot: UUID | None = None
user_email_snapshot: str | None = None
change_type: PointsChangeType
biz_type: PointsBizType | None = None
biz_id: UUID | None = None
direction: Literal[1, 0, -1]
amount: int = Field(ge=0)
balance_after: int = Field(ge=0)
billed_to: Literal["user", "platform"]
run_id: str | None = Field(default=None, max_length=128)
request_id: str | None = Field(default=None, max_length=128)
input_tokens: int = Field(ge=0)
output_tokens: int = Field(ge=0)
cost: Decimal = Field(ge=Decimal("0"))
metadata: AuditLedgerMetadata
+4 -16
View File
@@ -65,19 +65,14 @@ def convert_message_to_history(
def _convert_user_attachments(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
metadata: AgentChatMessageMetadata | None,
get_signed_url_fn: Callable[[dict[str, str]], str] | None,
) -> list[dict[str, str]]:
"""转换用户附件为临时访问 URL 列表"""
if not metadata or not get_signed_url_fn:
return []
if isinstance(metadata, AgentChatMessageMetadata):
resolved = extract_user_message_attachments(metadata)
elif isinstance(metadata, dict):
resolved = extract_user_message_attachments(metadata)
else:
return []
resolved = extract_user_message_attachments(metadata)
signed_attachments: list[dict[str, str]] = []
for attachment in resolved:
@@ -94,20 +89,13 @@ def _convert_user_attachments(
def _extract_worker_agent_output(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
metadata: AgentChatMessageMetadata | None,
) -> dict[str, Any] | None:
"""提取 assistant 消息的结构化 agent_output。"""
if not metadata:
return None
if isinstance(metadata, AgentChatMessageMetadata):
agent_output = metadata.agent_output
else:
agent_output_data = metadata.get("agent_output")
if not agent_output_data:
return None
agent_output = _RUNTIME_AGENT_OUTPUT_ADAPTER.validate_python(agent_output_data)
agent_output = metadata.agent_output
if not agent_output:
return None
+15 -1
View File
@@ -1,8 +1,12 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from core.config.settings import config
from core.db import get_db
from v1.auth.rate_limit import enforce_rate_limit
from v1.auth.dependencies import get_auth_service
from v1.auth.schemas import (
@@ -13,6 +17,8 @@ from v1.auth.schemas import (
SessionResponse,
)
from v1.auth.service import AuthService
from v1.points.repository import PointsRepository
from v1.points.service import PointsService
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -46,6 +52,7 @@ async def create_email_session(
payload: EmailSessionCreateRequest,
request: Request,
service: AuthService = Depends(get_auth_service),
session: AsyncSession = Depends(get_db),
) -> SessionResponse:
client_ip = _client_ip(request)
await enforce_rate_limit(
@@ -60,7 +67,14 @@ async def create_email_session(
limit=20,
window_seconds=300,
)
return await service.create_email_session(payload)
result = await service.create_email_session(payload)
points_service = PointsService(repository=PointsRepository(session))
await points_service.grant_register_bonus_if_eligible(
user_id=UUID(result.user.id),
user_email=result.user.email,
)
await session.commit()
return result
@router.post("/sessions/refresh", response_model=SessionResponse)
+98 -1
View File
@@ -1,14 +1,23 @@
from __future__ import annotations
from decimal import Decimal
from uuid import UUID
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.agent_chat_message import AgentChatMessage
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
from schemas.domain.points import ApplyPointsChangeCommand
from schemas.domain.points import (
AppendAuditLedgerCommand,
ApplyPointsChangeCommand,
PointsChargeSnapshot,
)
from schemas.enums import AgentChatMessageRole
class PointsRepository:
@@ -57,6 +66,72 @@ class PointsRepository:
self._session.add(entry)
await self._session.flush()
async def has_audit_event(self, *, event_id: str) -> bool:
stmt = select(PointsAuditLedger.id).where(
PointsAuditLedger.event_id == event_id
)
row = (await self._session.execute(stmt)).scalar_one_or_none()
return row is not None
async def append_audit_ledger(self, *, command: AppendAuditLedgerCommand) -> None:
entry = PointsAuditLedger(
event_id=command.event_id,
user_id_snapshot=command.user_id_snapshot,
user_email_snapshot=command.user_email_snapshot,
change_type=command.change_type.value,
biz_type=command.biz_type.value if command.biz_type is not None else None,
biz_id=command.biz_id,
direction=command.direction,
amount=command.amount,
balance_after=command.balance_after,
billed_to=command.billed_to,
run_id=command.run_id,
request_id=command.request_id,
input_tokens=command.input_tokens,
output_tokens=command.output_tokens,
cost=command.cost,
metadata_json=command.metadata,
)
self._session.add(entry)
await self._session.flush()
async def get_run_usage_snapshot(
self,
*,
session_id: UUID,
run_id: str,
) -> PointsChargeSnapshot | None:
stmt = (
select(AgentChatMessage)
.where(
AgentChatMessage.session_id == session_id,
AgentChatMessage.role == AgentChatMessageRole.ASSISTANT,
AgentChatMessage.deleted_at.is_(None),
)
.order_by(AgentChatMessage.seq.desc())
.limit(20)
)
messages = list((await self._session.execute(stmt)).scalars().all())
message = None
for candidate in messages:
metadata = candidate.metadata_json or {}
if metadata.get("run_id") == run_id:
message = candidate
break
if message is None:
return None
cost_value = message.cost if message.cost is not None else Decimal("0")
return PointsChargeSnapshot(
message_id=message.id,
message_seq=max(int(message.seq), 1),
model_code=(message.model_code or "agent_run").strip() or "agent_run",
input_tokens=max(int(message.input_tokens), 0),
output_tokens=max(int(message.output_tokens), 0),
cost=Decimal(str(cost_value)),
)
async def get_user_points(self, *, user_id: UUID) -> UserPoints:
insert_stmt = (
insert(UserPoints)
@@ -67,3 +142,25 @@ class PointsRepository:
stmt = select(UserPoints).where(UserPoints.user_id == user_id)
return (await self._session.execute(stmt)).scalar_one()
async def claim_register_bonus(
self,
*,
email_hash: str,
user_email_snapshot: str,
first_user_id: UUID,
grant_event_id: str,
) -> bool:
stmt = (
insert(RegisterBonusClaims)
.values(
email_hash=email_hash,
user_email_snapshot=user_email_snapshot,
first_user_id=first_user_id,
grant_event_id=grant_event_id,
)
.on_conflict_do_nothing(index_elements=[RegisterBonusClaims.email_hash])
.returning(RegisterBonusClaims.id)
)
inserted_id = (await self._session.execute(stmt)).scalar_one_or_none()
return inserted_id is not None
+251 -10
View File
@@ -3,10 +3,19 @@ from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
import hashlib
import hmac
from typing import Literal
from uuid import UUID, uuid4
from core.config.settings import config
from core.http.errors import ApiProblemError, problem_payload
from schemas.domain.points import ConsumeLedgerMetadata, PointsChargeSnapshot
from schemas.domain.points import (
AppendAuditLedgerCommand,
AuditLedgerMetadata,
ConsumeLedgerMetadata,
RegisterLedgerMetadata,
PointsChargeSnapshot,
)
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
from schemas.domain.points import ApplyPointsChangeCommand
from v1.points.repository import PointsRepository
@@ -31,10 +40,128 @@ class PointsBalanceResult:
can_run: bool
@dataclass(frozen=True)
class PlatformCostAuditResult:
audited: bool
event_id: str
cost: Decimal
@dataclass(frozen=True)
class RegisterBonusResult:
granted: bool
amount: int
balance_after: int
event_id: str
class PointsService:
def __init__(self, repository: PointsRepository) -> None:
self._repository = repository
async def grant_register_bonus_if_eligible(
self,
*,
user_id: UUID,
user_email: str,
) -> RegisterBonusResult:
normalized_email = self._normalize_email(user_email)
if not normalized_email:
return RegisterBonusResult(
granted=False,
amount=0,
balance_after=0,
event_id="",
)
bonus_points = int(config.points_policy.register_bonus_points)
if bonus_points <= 0:
account = await self._repository.get_or_create_user_points_for_update(
user_id=user_id
)
return RegisterBonusResult(
granted=False,
amount=0,
balance_after=int(account.balance),
event_id="",
)
email_hash = self._build_register_bonus_email_hash(normalized_email)
event_hash = hashlib.sha1(
f"{normalized_email}:{email_hash}".encode("utf-8")
).hexdigest()
event_id = f"register.bonus:{event_hash}"
claimed = await self._repository.claim_register_bonus(
email_hash=email_hash,
user_email_snapshot=normalized_email,
first_user_id=user_id,
grant_event_id=event_id,
)
account = await self._repository.get_or_create_user_points_for_update(
user_id=user_id
)
if not claimed:
return RegisterBonusResult(
granted=False,
amount=0,
balance_after=int(account.balance),
event_id=event_id,
)
balance = int(account.balance)
account.balance = balance + bonus_points
account.lifetime_earned = int(account.lifetime_earned) + bonus_points
account.version = int(account.version) + 1
metadata = RegisterLedgerMetadata(
operator_type=PointsOperatorType.SYSTEM,
run_id=event_id,
ext={
"source": "register_bonus_policy",
"email_hash": email_hash,
},
)
command = ApplyPointsChangeCommand(
user_id=user_id,
change_type=PointsChangeType.REGISTER,
event_id=event_id,
amount=bonus_points,
direction=1,
operator_id=None,
metadata=metadata,
)
await self._repository.append_ledger(
command=command,
balance_after=int(account.balance),
)
await self._repository.append_audit_ledger(
command=AppendAuditLedgerCommand(
event_id=event_id,
user_id_snapshot=user_id,
user_email_snapshot=normalized_email,
change_type=PointsChangeType.REGISTER,
direction=1,
amount=bonus_points,
balance_after=int(account.balance),
billed_to="user",
run_id=event_id,
input_tokens=0,
output_tokens=0,
cost=Decimal("0"),
metadata=AuditLedgerMetadata(
source="register_bonus_policy",
email_hash=email_hash,
),
)
)
return RegisterBonusResult(
granted=True,
amount=bonus_points,
balance_after=int(account.balance),
event_id=event_id,
)
async def ensure_run_points_available(
self,
*,
@@ -84,6 +211,7 @@ class PointsService:
session_id: UUID,
run_id: str,
operator_id: UUID | None,
user_email: str | None = None,
) -> RunChargeResult:
event_source = f"{session_id}:{run_id}".encode("utf-8")
event_hash = hashlib.sha1(event_source).hexdigest()
@@ -122,18 +250,28 @@ class PointsService:
account.lifetime_spent = int(account.lifetime_spent) + RUN_POINTS_COST
account.version = int(account.version) + 1
usage_snapshot = await self._repository.get_run_usage_snapshot(
session_id=session_id,
run_id=run_id,
)
usage_missing = usage_snapshot is None
charge_snapshot = usage_snapshot or PointsChargeSnapshot(
message_id=uuid4(),
message_seq=1,
model_code="agent_run",
input_tokens=0,
output_tokens=0,
cost=Decimal("0"),
)
metadata = ConsumeLedgerMetadata(
operator_type=PointsOperatorType.USER,
run_id=run_id,
charge=PointsChargeSnapshot(
message_id=uuid4(),
message_seq=1,
model_code="agent_run",
input_tokens=0,
output_tokens=0,
cost=Decimal("0"),
),
ext={"source": "run_success"},
charge=charge_snapshot,
ext={
"source": "run_success",
"usage_missing": usage_missing,
},
)
command = ApplyPointsChangeCommand(
user_id=user_id,
@@ -150,9 +288,112 @@ class PointsService:
command=command,
balance_after=int(account.balance),
)
await self._repository.append_audit_ledger(
command=AppendAuditLedgerCommand(
event_id=event_id,
user_id_snapshot=user_id,
user_email_snapshot=(user_email or "").strip().lower() or None,
change_type=PointsChangeType.CONSUME,
biz_type=PointsBizType.CHAT,
biz_id=session_id,
direction=-1,
amount=RUN_POINTS_COST,
balance_after=int(account.balance),
billed_to="user",
run_id=run_id,
input_tokens=charge_snapshot.input_tokens,
output_tokens=charge_snapshot.output_tokens,
cost=charge_snapshot.cost,
metadata=AuditLedgerMetadata(
source="run_success",
usage_missing=usage_missing,
),
)
)
return RunChargeResult(
charged=True,
amount=RUN_POINTS_COST,
balance_after=int(account.balance),
event_id=event_id,
)
async def record_failed_run_platform_cost(
self,
*,
user_id: UUID,
session_id: UUID,
run_id: str,
operator_id: UUID | None,
user_email: str | None = None,
failure_kind: Literal["failed", "canceled"],
) -> PlatformCostAuditResult:
event_source = f"{session_id}:{run_id}:{failure_kind}".encode("utf-8")
event_hash = hashlib.sha1(event_source).hexdigest()
event_kind = "fail" if failure_kind == "failed" else "cancel"
event_id = f"chat.run.{event_kind}:{event_hash}"
if await self._repository.has_audit_event(event_id=event_id):
return PlatformCostAuditResult(
audited=False,
event_id=event_id,
cost=Decimal("0"),
)
usage_snapshot = await self._repository.get_run_usage_snapshot(
session_id=session_id,
run_id=run_id,
)
if usage_snapshot is None or usage_snapshot.cost <= Decimal("0"):
return PlatformCostAuditResult(
audited=False,
event_id=event_id,
cost=Decimal("0"),
)
account = await self._repository.get_or_create_user_points_for_update(
user_id=user_id
)
await self._repository.append_audit_ledger(
command=AppendAuditLedgerCommand(
event_id=event_id,
user_id_snapshot=user_id,
user_email_snapshot=(user_email or "").strip().lower() or None,
change_type=PointsChangeType.CONSUME,
biz_type=PointsBizType.CHAT,
biz_id=session_id,
direction=0,
amount=0,
balance_after=int(account.balance),
billed_to="platform",
run_id=run_id,
input_tokens=usage_snapshot.input_tokens,
output_tokens=usage_snapshot.output_tokens,
cost=usage_snapshot.cost,
metadata=AuditLedgerMetadata(
source=f"run_{failure_kind}",
failure_kind=failure_kind,
operator_id=str(operator_id) if operator_id is not None else None,
),
)
)
return PlatformCostAuditResult(
audited=True,
event_id=event_id,
cost=usage_snapshot.cost,
)
@staticmethod
def _normalize_email(email: str) -> str:
return email.strip().lower()
@staticmethod
def _build_register_bonus_email_hash(normalized_email: str) -> str:
key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip()
if not key:
raise RuntimeError("points_policy.register_bonus_hmac_key is required")
digest = hmac.new(
key=key.encode("utf-8"),
msg=normalized_email.encode("utf-8"),
digestmod=hashlib.sha256,
)
return digest.hexdigest()