This commit is contained in:
2026-08-13 09:38:36 +08:00
124 changed files with 11236 additions and 5549 deletions
+9 -1
View File
@@ -17,6 +17,11 @@ from app.models.video_engine import VideoEngine
from app.models.credit_ratio import CreditRatio
from app.models.menu_config import MenuConfig
from app.models.recharge_package import RechargePackage
from app.models.credit import (
CreditProduct, CreditRecordAllocation, UserCreditBalance,
UserCreditSubscription, UserCreditSubscriptionPeriod,
)
from app.models.llm_billing import LlmBillingPolicyModel, LlmBillingExecution, LlmCallAttempt
from app.models.operation_log import OperationLog
from app.models.chat_generation_task import ChatGenerationTask
from app.models.chat_generation_task_event import ChatGenerationTaskEvent
@@ -47,7 +52,10 @@ __all__ = [
"User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
"MenuConfig", "RechargePackage", "CreditProduct", "CreditRecordAllocation", "UserCreditBalance",
"UserCreditSubscription", "UserCreditSubscriptionPeriod",
"LlmBillingPolicyModel", "LlmBillingExecution", "LlmCallAttempt",
"OperationLog", "ContactRequest",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "VideoUpscaleTask",
"GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat",
"UserResourceCapacityConfig",
@@ -0,0 +1,13 @@
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
__all__ = [
"CreditRecordAllocation",
"UserCreditBalance",
"CreditProduct",
"UserCreditSubscription",
"UserCreditSubscriptionPeriod",
]
@@ -0,0 +1,47 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class CreditRecordAllocation(Base, TimestampMixin):
__tablename__ = "credit_record_allocations"
__table_args__ = (
Index("ix_credit_record_allocations_record", "credit_record_id", "id"),
Index("ix_credit_record_allocations_balance", "credit_balance_id", "created_at"),
Index("ix_credit_record_allocations_user_time", "user_id", "created_at"),
Index("ix_credit_record_allocations_source_allocation", "source_allocation_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
credit_record_id: Mapped[str] = mapped_column(
String(32), ForeignKey("credit_records.id", ondelete="CASCADE"), nullable=False
)
credit_balance_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_balances.id", ondelete="RESTRICT"), nullable=False
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
source_allocation_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_record_allocations.id", ondelete="SET NULL"), nullable=True
)
allocation_action: Mapped[str] = mapped_column(String(48), nullable=False, index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
request_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
credit_level_snapshot: Mapped[str] = mapped_column(String(32), nullable=False)
source_type_snapshot: Mapped[str] = mapped_column(String(48), nullable=False)
source_id_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
valid_from_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
unspent_before: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
unspent_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
consumed_before: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
consumed_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
@@ -0,0 +1,94 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, JSON, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_balance import CreditBalanceStatus
from app.models.base import Base, TimestampMixin
class UserCreditBalance(Base, TimestampMixin):
__tablename__ = "user_credit_balances"
__table_args__ = (
CheckConstraint("grant_amount >= 0", name="ck_user_credit_balances_grant_nonnegative"),
CheckConstraint("unspent_amount >= 0", name="ck_user_credit_balances_unspent_nonnegative"),
CheckConstraint("consumed_amount >= 0", name="ck_user_credit_balances_consumed_nonnegative"),
CheckConstraint("expired_amount >= 0", name="ck_user_credit_balances_expired_nonnegative"),
CheckConstraint("revoked_amount >= 0", name="ck_user_credit_balances_revoked_nonnegative"),
CheckConstraint(
"grant_amount = unspent_amount + consumed_amount + expired_amount + revoked_amount",
name="ck_user_credit_balances_amount_reconciled",
),
CheckConstraint("expires_at > valid_from", name="ck_user_credit_balances_valid_window"),
Index(
"ix_user_credit_balances_spendable",
"user_id",
"credit_level_rank",
"expires_at",
"valid_from",
"id",
postgresql_where=text("unspent_amount > 0 AND revoked_at IS NULL"),
),
Index(
"ix_user_credit_balances_expire_due",
"expires_at",
"id",
postgresql_where=text(
"unspent_amount > 0 AND expired_processed_at IS NULL AND revoked_at IS NULL"
),
),
Index("ix_user_credit_balances_source", "source_type", "source_id"),
Index("ix_user_credit_balances_payment", "payment_order_id"),
Index("ix_user_credit_balances_subscription", "subscription_id", "subscription_period_id"),
Index("uq_user_credit_balances_user_biz_key", "user_id", "biz_key", unique=True),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
credit_level: Mapped[str] = mapped_column(String(32), nullable=False)
credit_level_rank: Mapped[int] = mapped_column(nullable=False, default=20, server_default="20")
source_type: Mapped[str] = mapped_column(String(48), nullable=False, index=True)
source_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
)
payment_order_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
subscription_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="SET NULL"), nullable=True
)
subscription_period_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_subscription_periods.id", ondelete="SET NULL"), nullable=True
)
grant_record_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_records.id", ondelete="SET NULL"), nullable=True, index=True
)
grant_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
unspent_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
consumed_amount: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
expired_amount: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
revoked_amount: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
status: Mapped[str] = mapped_column(
String(24), nullable=False, default=CreditBalanceStatus.ACTIVE.value, server_default=CreditBalanceStatus.ACTIVE.value
)
expired_processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
biz_key: Mapped[str] = mapped_column(String(180), nullable=False)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
@@ -0,0 +1,84 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Boolean, CheckConstraint, DateTime, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_balance import CreditLevel
from app.enums.credit_product import CreditProductType
from app.models.base import Base, TimestampMixin
class CreditProduct(Base, TimestampMixin):
__tablename__ = "credit_products"
__table_args__ = (
Index("uq_credit_products_code", "product_code", unique=True),
Index("ix_credit_products_public", "product_type", "is_active", "sort_order"),
CheckConstraint("price >= 0", name="ck_credit_products_price_nonnegative"),
CheckConstraint("first_purchase_price IS NULL OR first_purchase_price >= 0", name="ck_credit_products_first_price_nonnegative"),
CheckConstraint("regular_price IS NULL OR regular_price >= 0", name="ck_credit_products_regular_price_nonnegative"),
CheckConstraint("activity_price IS NULL OR activity_price >= 0", name="ck_credit_products_activity_price_nonnegative"),
CheckConstraint("monthly_grant_credits IS NULL OR monthly_grant_credits > 0", name="ck_credit_products_monthly_grant_positive"),
CheckConstraint("grant_credits IS NULL OR grant_credits > 0", name="ck_credit_products_grant_positive"),
CheckConstraint(
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
name="ck_credit_products_type_required_fields",
),
CheckConstraint(
"(activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL) "
"OR (activity_price IS NOT NULL AND activity_start_at IS NOT NULL "
"AND activity_end_at IS NOT NULL AND activity_end_at > activity_start_at)",
name="ck_credit_products_activity_window",
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
product_code: Mapped[str] = mapped_column(String(64), nullable=False)
product_type: Mapped[str] = mapped_column(String(24), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(96), nullable=False)
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
features_json: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 订阅套餐字段
tier_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
tier_rank: Mapped[int | None] = mapped_column(nullable=True)
billing_cycle: Mapped[str | None] = mapped_column(String(24), nullable=True)
monthly_grant_credits: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
first_purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
regular_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
activity_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
activity_start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
activity_end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
renewal_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
# 积分增值包字段;有效期按自然月配置,范围1-36个月。
grant_credits: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
validity_months: Mapped[int | None] = mapped_column(nullable=True)
price: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
credit_level: Mapped[str] = mapped_column(
String(32), nullable=False, default=CreditLevel.GENERAL.value, server_default=CreditLevel.GENERAL.value
)
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="CNY", server_default="CNY")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
sort_order: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
@property
def is_subscription(self) -> bool:
return self.product_type == CreditProductType.SUBSCRIPTION.value
@property
def is_credit_addon(self) -> bool:
return self.product_type == CreditProductType.CREDIT_ADDON.value
@@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_subscription import CreditSubscriptionStatus
from app.models.base import Base, TimestampMixin
class UserCreditSubscription(Base, TimestampMixin):
__tablename__ = "user_credit_subscriptions"
__table_args__ = (
Index("ix_user_credit_subscriptions_current", "user_id", "status", "expires_at"),
Index("ix_user_credit_subscriptions_expire_due", "status", "expires_at", "id"),
Index("ix_user_credit_subscriptions_grant_due", "status", "next_grant_at", "id"),
Index("uq_user_credit_subscriptions_payment", "payment_order_id", unique=True),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
)
payment_order_id: Mapped[str] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="RESTRICT"), nullable=False
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=CreditSubscriptionStatus.PENDING.value,
server_default=CreditSubscriptionStatus.PENDING.value,
)
purchase_scene: Mapped[str] = mapped_column(String(24), nullable=False)
tier_code: Mapped[str] = mapped_column(String(32), nullable=False)
tier_rank: Mapped[int] = mapped_column(nullable=False)
billing_cycle: Mapped[str] = mapped_column(String(24), nullable=False)
anchor_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
next_grant_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
monthly_grant_credits_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
grant_count: Mapped[int] = mapped_column(nullable=False)
granted_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
paid_amount_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
product_snapshot_json: Mapped[dict] = mapped_column(JSON, nullable=False)
source_subscription_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="SET NULL"), nullable=True
)
upgrade_order_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
@@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_subscription import CreditSubscriptionPeriodStatus
from app.models.base import Base, TimestampMixin
class UserCreditSubscriptionPeriod(Base, TimestampMixin):
__tablename__ = "user_credit_subscription_periods"
__table_args__ = (
Index("uq_user_credit_subscription_periods_sequence", "subscription_id", "sequence", unique=True),
Index("ix_user_credit_subscription_periods_due", "status", "scheduled_at", "id"),
Index("ix_user_credit_subscription_periods_upgrade", "upgrade_order_id", "status"),
Index("uq_user_credit_subscription_periods_balance", "issued_balance_id", unique=True),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
subscription_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="CASCADE"), nullable=False
)
sequence: Mapped[int] = mapped_column(nullable=False)
scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
grant_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
allocated_paid_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=CreditSubscriptionPeriodStatus.SCHEDULED.value,
server_default=CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
issued_balance_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_balances.id", ondelete="SET NULL"), nullable=True
)
issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
upgrade_order_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
reserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+25 -21
View File
@@ -1,4 +1,9 @@
from sqlalchemy import Float, ForeignKey, Index, Integer, String, text
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
@@ -7,14 +12,11 @@ from app.models.base import Base, TimestampMixin
class CreditRecord(Base, TimestampMixin):
__tablename__ = "credit_records"
__table_args__ = (
# 正式计费幂等键:同一用户同一个业务流水只能写入一次。
# PostgreSQL/MySQL/SQLite 对 nullable unique 的处理都允许多条 NULL,兼容历史数据。
Index("uq_credit_records_user_biz_key", "user_id", "biz_key", unique=True),
Index("ix_credit_records_user_refund_for_biz_key", "user_id", "refund_for_biz_key"),
Index(
"uq_credit_records_user_refund_target",
"user_id",
"refund_for_biz_key",
"uq_credit_records_user_refund_target_kind",
"user_id", "refund_for_biz_key", "refund_kind",
unique=True,
postgresql_where=text("type = 'refund' AND refund_for_biz_key IS NOT NULL"),
),
@@ -31,30 +33,30 @@ class CreditRecord(Base, TimestampMixin):
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
type: Mapped[str] = mapped_column(String(16), index=True)
amount: Mapped[float] = mapped_column(Float)
balance_after: Mapped[float] = mapped_column(Float)
description: Mapped[str] = mapped_column(String(256))
type: Mapped[str] = mapped_column(String(24), index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
balance_delta: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
expired_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
balance_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
description: Mapped[str] = mapped_column(String(512))
related_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
request_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
# 当前积分流水自己的业务幂等键。
# 例如:generation_record:{record_id}:attempt:1:media:charge
biz_key: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
biz_key: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
refund_for_biz_key: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
refund_kind: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
# 如果当前流水是退款,记录它退的是哪一次扣费。
# 例如:generation_record:{record_id}:attempt:1:media:charge
refund_for_biz_key: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
# 账务快照字段:保证业务步骤/资源软删后,流水仍可独立展示。
owner_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
owner_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
attempt_no: Mapped[int | None] = mapped_column(Integer, nullable=True)
charge_kind: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
charge_action: Mapped[str | None] = mapped_column(String(16), nullable=True)
charge_action: Mapped[str | None] = mapped_column(String(24), nullable=True)
credit_subject: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
media_type: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
billing_scene: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
scene_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
credit_level_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_module: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_project_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
@@ -65,7 +67,10 @@ class CreditRecord(Base, TimestampMixin):
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_success_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_failed_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_billing_execution_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
engine_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
@@ -75,6 +80,5 @@ class CreditRecord(Base, TimestampMixin):
user_type_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
frontend_user_kind_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
# 交易流水发生时的团队归属冷备快照;用户后续改团队不影响历史流水展示与筛选。
team_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
team_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
@@ -0,0 +1,5 @@
from app.models.llm_billing.call_attempt import LlmCallAttempt
from app.models.llm_billing.execution import LlmBillingExecution
from app.models.llm_billing.policy import LlmBillingPolicyModel
__all__ = ["LlmCallAttempt", "LlmBillingExecution", "LlmBillingPolicyModel"]
@@ -0,0 +1,50 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.llm_billing import LlmCallAttemptStatus
from app.models.base import Base, TimestampMixin
class LlmCallAttempt(Base, TimestampMixin):
__tablename__ = "llm_call_attempts"
__table_args__ = (
Index("uq_llm_call_attempts_sequence", "billing_execution_id", "call_sequence", unique=True),
Index("ix_llm_call_attempts_execution_time", "billing_execution_id", "created_at"),
Index("ix_llm_call_attempts_provider_request", "provider_request_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
billing_execution_id: Mapped[str] = mapped_column(
String(32), ForeignKey("llm_billing_executions.id", ondelete="CASCADE"), nullable=False
)
call_sequence: Mapped[int] = mapped_column(nullable=False)
retry_sequence: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
model_config_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("model_configs.id", ondelete="SET NULL"), nullable=True
)
model_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
provider_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
provider_request_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
request_started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
response_received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(
String(24), nullable=False, default=LlmCallAttemptStatus.STARTED.value,
server_default=LlmCallAttemptStatus.STARTED.value,
)
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
token_usage_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("token_usage.id", ondelete="SET NULL"), nullable=True
)
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
provider_error_code: Mapped[str | None] = mapped_column(String(128), nullable=True)
error_message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
token_unavailable_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
postprocess_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
postprocess_error: Mapped[str | None] = mapped_column(String(1000), nullable=True)
@@ -0,0 +1,71 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.llm_billing import LlmBillingExecutionStatus
from app.models.base import Base, TimestampMixin
class LlmBillingExecution(Base, TimestampMixin):
__tablename__ = "llm_billing_executions"
__table_args__ = (
Index(
"uq_llm_billing_executions_business_attempt",
"user_id", "scene_code", "owner_type", "owner_id", "business_attempt_no",
unique=True,
),
Index("uq_llm_billing_executions_credit_record", "credit_record_id", unique=True),
Index("ix_llm_billing_executions_status_time", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
scene_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
scene_name_snapshot: Mapped[str] = mapped_column(String(128), nullable=False)
owner_type: Mapped[str] = mapped_column(String(64), nullable=False)
owner_id: Mapped[str] = mapped_column(String(64), nullable=False)
business_attempt_no: Mapped[int] = mapped_column(nullable=False)
model_config_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("model_configs.id", ondelete="SET NULL"), nullable=True
)
model_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
provider_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
model_parameters_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True)
billing_policy_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("llm_billing_policies.id", ondelete="SET NULL"), nullable=True
)
billing_policy_version: Mapped[int | None] = mapped_column(nullable=True)
request_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
pre_deduct_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
credit_record_id: Mapped[str] = mapped_column(
String(32), ForeignKey("credit_records.id", ondelete="RESTRICT"), nullable=False
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=LlmBillingExecutionStatus.PRE_DEDUCTED.value,
server_default=LlmBillingExecutionStatus.PRE_DEDUCTED.value,
)
total_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
successful_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
failed_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
total_input_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
total_output_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
total_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
refund_available_credits: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
refund_expired_credits: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
final_error_message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,22 @@
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import Boolean, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class LlmBillingPolicyModel(Base, TimestampMixin):
__tablename__ = "llm_billing_policies"
__table_args__ = (Index("uq_llm_billing_policies_scene", "scene_code", unique=True),)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
scene_code: Mapped[str] = mapped_column(String(64), nullable=False)
scene_name: Mapped[str] = mapped_column(String(128), nullable=False)
pre_deduct_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
version: Mapped[int] = mapped_column(nullable=False, default=1, server_default="1")
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
+31 -15
View File
@@ -1,6 +1,9 @@
from datetime import datetime
from __future__ import annotations
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Index
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
@@ -8,27 +11,40 @@ from app.models.base import Base, TimestampMixin
class PaymentOrder(Base, TimestampMixin):
__tablename__ = "payment_orders"
__table_args__ = (
Index("idx_payorder_user_status_created", "user_id", "status", "created_at"),
Index("idx_payorder_status_created", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), unique=True)
amount: Mapped[float] = mapped_column(Float)
credits: Mapped[float] = mapped_column(Float)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
payment_method: Mapped[str] = mapped_column(String(16))
status: Mapped[str] = mapped_column(String(16), default="pending")
paid_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
status: Mapped[str] = mapped_column(String(32), default="pending")
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refund_amount: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
__table_args__ = (
Index('idx_payorder_user_status_created', 'user_id', 'status', 'created_at'),
Index('idx_payorder_status_created', 'status', 'created_at'),
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True, index=True
)
product_type: Mapped[str | None] = mapped_column(String(24), nullable=True, index=True)
purchase_scene: Mapped[str | None] = mapped_column(String(24), nullable=True)
price_type: Mapped[str | None] = mapped_column(String(24), nullable=True)
product_code_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
product_name_snapshot: Mapped[str | None] = mapped_column(String(96), nullable=True)
product_snapshot_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
subscription_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
source_subscription_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
upgrade_period_ids_json: Mapped[list | None] = mapped_column(JSON, nullable=True)
target_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
deduction_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
payable_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
fulfillment_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
fulfilled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+6 -16
View File
@@ -1,19 +1,9 @@
from sqlalchemy import Boolean, Integer, String, Float
from sqlalchemy.orm import Mapped, mapped_column
"""兼容旧导入路径。
from app.models.base import Base, TimestampMixin
充值产品已统一迁移为 credit_products 表;新代码应直接导入 CreditProduct。
"""
from app.models.credit.product import CreditProduct
RechargePackage = CreditProduct
class RechargePackage(Base, TimestampMixin):
__tablename__ = "recharge_packages"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(64))
credits: Mapped[float] = mapped_column(Float)
price: Mapped[float] = mapped_column(Float)
bonus_credits: Mapped[float] = mapped_column(Float, default=0.0)
description: Mapped[str | None] = mapped_column(String(256), nullable=True)
package_type: Mapped[str] = mapped_column(String(32), default="normal")
is_gift: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
__all__ = ["CreditProduct", "RechargePackage"]
+14 -12
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, Integer, String, JSON
from sqlalchemy import Boolean, DateTime, Integer, String, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.user import FrontendUserKind
@@ -9,19 +9,17 @@ from app.models.base import Base, TimestampMixin
class User(Base, TimestampMixin):
__tablename__ = "users"
__allow_unmapped__ = True
id: Mapped[str] = mapped_column(String(32), primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)
phone: Mapped[str | None] = mapped_column(String(20), unique=True, nullable=True)
# 短信注册用户允许先没有密码,后续通过 /auth/set-password 设置。
hashed_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
avatar: Mapped[str | None] = mapped_column(String(512), nullable=True)
credits: Mapped[float] = mapped_column(Float, default=0.0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
user_type: Mapped[str] = mapped_column(String(16), default="frontend", index=True)
# 仅前台用户有业务意义;默认外部用户。取消内部标记时也设置回 external。
frontend_user_kind: Mapped[str] = mapped_column(
String(16),
default=FrontendUserKind.EXTERNAL.value,
@@ -29,17 +27,13 @@ class User(Base, TimestampMixin):
index=True,
nullable=False,
)
# 当前归属团队,仅前台用户有业务意义;不影响 frontend_user_kind 内部/外部设置。
team_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
last_login_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
password_set_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
password_set_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
first_membership_paid_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
private_portrait_asset_limit: Mapped[int] = mapped_column(
Integer, default=50, server_default="50", nullable=False
)
@@ -49,6 +43,14 @@ class User(Base, TimestampMixin):
Integer, default=0, server_default="0", nullable=False
)
@property
def credits(self) -> float:
return float(getattr(self, "_credits_snapshot", 0.0) or 0.0)
@credits.setter
def credits(self, value: float | int) -> None:
self._credits_snapshot = round(float(value or 0.0), 2)
@property
def must_set_password(self) -> bool:
return self.user_type == "frontend" and not self.hashed_password