feat: 添加 points_audit_ledger 及 JSON 字段 Pydantic Schema 约束
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user