Files
video-gen/video-gen-api/alembic/versions/7f3c8a2d9e41_add_credit_subscription_llm_billing.py
T
2026-08-11 09:24:18 +08:00

1038 lines
45 KiB
Python

"""add dynamic credit ledger, subscriptions and llm billing
Revision ID: 7f3c8a2d9e41
Revises: 20da1d353914
Create Date: 2026-08-06 15:43:00+08:00
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "7f3c8a2d9e41"
down_revision: str | None = "20da1d353914"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# 1. 统一积分产品:订阅套餐 / 积分增值包。
op.create_table(
"credit_products",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("product_code", sa.String(length=64), nullable=False),
sa.Column("product_type", sa.String(length=24), nullable=False),
sa.Column("name", sa.String(length=96), nullable=False),
sa.Column("description", sa.String(length=512), nullable=True),
sa.Column("features_json", sa.JSON(), nullable=True),
sa.Column("tier_code", sa.String(length=32), nullable=True),
sa.Column("tier_rank", sa.Integer(), nullable=True),
sa.Column("billing_cycle", sa.String(length=24), nullable=True),
sa.Column("monthly_grant_credits", sa.Numeric(precision=20, scale=2), nullable=True),
sa.Column("first_purchase_price", sa.Numeric(precision=20, scale=2), nullable=True),
sa.Column("regular_price", sa.Numeric(precision=20, scale=2), nullable=True),
sa.Column("activity_price", sa.Numeric(precision=20, scale=2), nullable=True),
sa.Column("activity_start_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("activity_end_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("grant_credits", sa.Numeric(precision=20, scale=2), nullable=True),
sa.Column("validity_months", sa.Integer(), nullable=True),
sa.Column(
"price",
sa.Numeric(precision=20, scale=2),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"credit_level",
sa.String(length=32),
nullable=False,
server_default=sa.text("'general'"),
),
sa.Column(
"currency",
sa.String(length=8),
nullable=False,
server_default=sa.text("'CNY'"),
),
sa.Column(
"is_active",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
sa.Column(
"sort_order",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint("price >= 0", name="ck_credit_products_price_nonnegative"),
sa.CheckConstraint(
"first_purchase_price IS NULL OR first_purchase_price >= 0",
name="ck_credit_products_first_price_nonnegative",
),
sa.CheckConstraint(
"regular_price IS NULL OR regular_price >= 0",
name="ck_credit_products_regular_price_nonnegative",
),
sa.CheckConstraint(
"activity_price IS NULL OR activity_price >= 0",
name="ck_credit_products_activity_price_nonnegative",
),
sa.CheckConstraint(
"monthly_grant_credits IS NULL OR monthly_grant_credits > 0",
name="ck_credit_products_monthly_grant_positive",
),
sa.CheckConstraint(
"grant_credits IS NULL OR grant_credits > 0",
name="ck_credit_products_grant_positive",
),
sa.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 = 1 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",
),
sa.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",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("uq_credit_products_code", "credit_products", ["product_code"], unique=True)
op.create_index(
"ix_credit_products_public",
"credit_products",
["product_type", "is_active", "sort_order"],
unique=False,
)
op.create_index(
"ix_credit_products_product_type",
"credit_products",
["product_type"],
unique=False,
)
# 2. 用户首充状态;删除旧单列余额。
op.add_column(
"users",
sa.Column("first_membership_paid_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_users_first_membership_paid_at",
"users",
["first_membership_paid_at"],
unique=False,
)
# 3. 扩展支付订单产品、订阅及升级快照。
op.alter_column(
"payment_orders",
"amount",
existing_type=sa.Float(),
type_=sa.Numeric(precision=20, scale=2),
existing_nullable=False,
postgresql_using="amount::numeric(20,2)",
)
op.alter_column(
"payment_orders",
"credits",
existing_type=sa.Float(),
type_=sa.Numeric(precision=20, scale=2),
existing_nullable=False,
server_default=sa.text("0"),
postgresql_using="credits::numeric(20,2)",
)
op.alter_column(
"payment_orders",
"status",
existing_type=sa.String(length=16),
type_=sa.String(length=32),
existing_nullable=False,
)
op.alter_column(
"payment_orders",
"refund_amount",
existing_type=sa.Float(),
type_=sa.Numeric(precision=20, scale=2),
existing_nullable=True,
postgresql_using="refund_amount::numeric(20,2)",
)
op.add_column("payment_orders", sa.Column("product_id", sa.String(length=32), nullable=True))
op.add_column("payment_orders", sa.Column("product_type", sa.String(length=24), nullable=True))
op.add_column("payment_orders", sa.Column("purchase_scene", sa.String(length=24), nullable=True))
op.add_column("payment_orders", sa.Column("price_type", sa.String(length=24), nullable=True))
op.add_column("payment_orders", sa.Column("product_code_snapshot", sa.String(length=64), nullable=True))
op.add_column("payment_orders", sa.Column("product_name_snapshot", sa.String(length=96), nullable=True))
op.add_column("payment_orders", sa.Column("product_snapshot_json", sa.JSON(), nullable=True))
op.add_column("payment_orders", sa.Column("subscription_id", sa.String(length=32), nullable=True))
op.add_column("payment_orders", sa.Column("source_subscription_id", sa.String(length=32), nullable=True))
op.add_column("payment_orders", sa.Column("upgrade_period_ids_json", sa.JSON(), nullable=True))
op.add_column(
"payment_orders",
sa.Column("target_price_snapshot", sa.Numeric(precision=20, scale=2), nullable=True),
)
op.add_column(
"payment_orders",
sa.Column("deduction_amount_snapshot", sa.Numeric(precision=20, scale=2), nullable=True),
)
op.add_column(
"payment_orders",
sa.Column("payable_amount_snapshot", sa.Numeric(precision=20, scale=2), nullable=True),
)
op.add_column("payment_orders", sa.Column("fulfillment_status", sa.String(length=32), nullable=True))
op.add_column("payment_orders", sa.Column("fulfilled_at", sa.DateTime(timezone=True), nullable=True))
op.create_foreign_key(
"fk_payment_orders_product_id_credit_products",
"payment_orders",
"credit_products",
["product_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_payment_orders_product_id", "payment_orders", ["product_id"], unique=False)
op.create_index("ix_payment_orders_product_type", "payment_orders", ["product_type"], unique=False)
op.create_index("ix_payment_orders_subscription_id", "payment_orders", ["subscription_id"], unique=False)
op.create_index(
"ix_payment_orders_source_subscription_id",
"payment_orders",
["source_subscription_id"],
unique=False,
)
op.create_index(
"ix_payment_orders_fulfillment_status",
"payment_orders",
["fulfillment_status"],
unique=False,
)
# 4. 用户订阅。
op.create_table(
"user_credit_subscriptions",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("user_id", sa.String(length=32), nullable=False),
sa.Column("product_id", sa.String(length=32), nullable=True),
sa.Column("payment_order_id", sa.String(length=32), nullable=False),
sa.Column(
"status",
sa.String(length=32),
nullable=False,
server_default=sa.text("'pending'"),
),
sa.Column("purchase_scene", sa.String(length=24), nullable=False),
sa.Column("tier_code", sa.String(length=32), nullable=False),
sa.Column("tier_rank", sa.Integer(), nullable=False),
sa.Column("billing_cycle", sa.String(length=24), nullable=False),
sa.Column("anchor_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("next_grant_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("monthly_grant_credits_snapshot", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("grant_count", sa.Integer(), nullable=False),
sa.Column("granted_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("paid_amount_snapshot", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("product_snapshot_json", sa.JSON(), nullable=False),
sa.Column("source_subscription_id", sa.String(length=32), nullable=True),
sa.Column("upgrade_order_id", sa.String(length=32), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["product_id"], ["credit_products.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["payment_order_id"], ["payment_orders.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(
["source_subscription_id"],
["user_credit_subscriptions.id"],
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(["upgrade_order_id"], ["payment_orders.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_user_credit_subscriptions_current",
"user_credit_subscriptions",
["user_id", "status", "expires_at"],
unique=False,
)
op.create_index(
"ix_user_credit_subscriptions_expire_due",
"user_credit_subscriptions",
["status", "expires_at", "id"],
unique=False,
)
op.create_index(
"ix_user_credit_subscriptions_grant_due",
"user_credit_subscriptions",
["status", "next_grant_at", "id"],
unique=False,
)
op.create_index(
"uq_user_credit_subscriptions_payment",
"user_credit_subscriptions",
["payment_order_id"],
unique=True,
)
op.create_index(
"ix_user_credit_subscriptions_user_id",
"user_credit_subscriptions",
["user_id"],
unique=False,
)
# 5. 用户动态积分余额。
op.create_table(
"user_credit_balances",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("user_id", sa.String(length=32), nullable=False),
sa.Column("credit_level", sa.String(length=32), nullable=False),
sa.Column("credit_level_rank", sa.Integer(), nullable=False, server_default=sa.text("20")),
sa.Column("source_type", sa.String(length=48), nullable=False),
sa.Column("source_id", sa.String(length=64), nullable=True),
sa.Column("product_id", sa.String(length=32), nullable=True),
sa.Column("payment_order_id", sa.String(length=32), nullable=True),
sa.Column("subscription_id", sa.String(length=32), nullable=True),
# 外键在周期表创建后补上,解决双向依赖。
sa.Column("subscription_period_id", sa.String(length=32), nullable=True),
sa.Column("grant_record_id", sa.String(length=32), nullable=True),
sa.Column("grant_amount", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("unspent_amount", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("consumed_amount", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("expired_amount", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("revoked_amount", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("status", sa.String(length=24), nullable=False, server_default=sa.text("'active'")),
sa.Column("expired_processed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("biz_key", sa.String(length=180), nullable=False),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint("grant_amount >= 0", name="ck_user_credit_balances_grant_nonnegative"),
sa.CheckConstraint("unspent_amount >= 0", name="ck_user_credit_balances_unspent_nonnegative"),
sa.CheckConstraint("consumed_amount >= 0", name="ck_user_credit_balances_consumed_nonnegative"),
sa.CheckConstraint("expired_amount >= 0", name="ck_user_credit_balances_expired_nonnegative"),
sa.CheckConstraint("revoked_amount >= 0", name="ck_user_credit_balances_revoked_nonnegative"),
sa.CheckConstraint(
"grant_amount = unspent_amount + consumed_amount + expired_amount + revoked_amount",
name="ck_user_credit_balances_amount_reconciled",
),
sa.CheckConstraint("expires_at > valid_from", name="ck_user_credit_balances_valid_window"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["product_id"], ["credit_products.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["payment_order_id"], ["payment_orders.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(
["subscription_id"],
["user_credit_subscriptions.id"],
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(["grant_record_id"], ["credit_records.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_user_credit_balances_spendable",
"user_credit_balances",
["user_id", "credit_level_rank", "expires_at", "valid_from", "id"],
unique=False,
postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
)
op.create_index(
"ix_user_credit_balances_expire_due",
"user_credit_balances",
["expires_at", "id"],
unique=False,
postgresql_where=sa.text(
"unspent_amount > 0 AND expired_processed_at IS NULL AND revoked_at IS NULL"
),
)
op.create_index(
"ix_user_credit_balances_source",
"user_credit_balances",
["source_type", "source_id"],
unique=False,
)
op.create_index(
"ix_user_credit_balances_payment",
"user_credit_balances",
["payment_order_id"],
unique=False,
)
op.create_index(
"ix_user_credit_balances_subscription",
"user_credit_balances",
["subscription_id", "subscription_period_id"],
unique=False,
)
op.create_index(
"uq_user_credit_balances_user_biz_key",
"user_credit_balances",
["user_id", "biz_key"],
unique=True,
)
op.create_index("ix_user_credit_balances_user_id", "user_credit_balances", ["user_id"], unique=False)
op.create_index("ix_user_credit_balances_source_type", "user_credit_balances", ["source_type"], unique=False)
op.create_index("ix_user_credit_balances_source_id", "user_credit_balances", ["source_id"], unique=False)
op.create_index("ix_user_credit_balances_grant_record_id", "user_credit_balances", ["grant_record_id"], unique=False)
op.create_index("ix_user_credit_balances_valid_from", "user_credit_balances", ["valid_from"], unique=False)
op.create_index("ix_user_credit_balances_expires_at", "user_credit_balances", ["expires_at"], unique=False)
# 6. 订阅月度发放周期。
op.create_table(
"user_credit_subscription_periods",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("subscription_id", sa.String(length=32), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("grant_credits", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("allocated_paid_amount", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default=sa.text("'scheduled'")),
sa.Column("issued_balance_id", sa.String(length=32), nullable=True),
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("upgrade_order_id", sa.String(length=32), nullable=True),
sa.Column("reserved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.ForeignKeyConstraint(
["subscription_id"],
["user_credit_subscriptions.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["issued_balance_id"],
["user_credit_balances.id"],
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["upgrade_order_id"],
["payment_orders.id"],
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"uq_user_credit_subscription_periods_sequence",
"user_credit_subscription_periods",
["subscription_id", "sequence"],
unique=True,
)
op.create_index(
"ix_user_credit_subscription_periods_due",
"user_credit_subscription_periods",
["status", "scheduled_at", "id"],
unique=False,
)
op.create_index(
"ix_user_credit_subscription_periods_upgrade",
"user_credit_subscription_periods",
["upgrade_order_id", "status"],
unique=False,
)
op.create_index(
"uq_user_credit_subscription_periods_balance",
"user_credit_subscription_periods",
["issued_balance_id"],
unique=True,
)
op.create_foreign_key(
"fk_user_credit_balances_subscription_period_id",
"user_credit_balances",
"user_credit_subscription_periods",
["subscription_period_id"],
["id"],
ondelete="SET NULL",
)
# 7. 积分消费/退款来源分摊。
op.create_table(
"credit_record_allocations",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("credit_record_id", sa.String(length=32), nullable=False),
sa.Column("credit_balance_id", sa.String(length=32), nullable=False),
sa.Column("user_id", sa.String(length=32), nullable=False),
sa.Column("source_allocation_id", sa.String(length=32), nullable=True),
sa.Column("allocation_action", sa.String(length=48), nullable=False),
sa.Column("amount", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("request_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("credit_level_snapshot", sa.String(length=32), nullable=False),
sa.Column("source_type_snapshot", sa.String(length=48), nullable=False),
sa.Column("source_id_snapshot", sa.String(length=64), nullable=True),
sa.Column("valid_from_snapshot", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at_snapshot", sa.DateTime(timezone=True), nullable=False),
sa.Column("unspent_before", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("unspent_after", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("consumed_before", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("consumed_after", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.ForeignKeyConstraint(["credit_record_id"], ["credit_records.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["credit_balance_id"],
["user_credit_balances.id"],
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["source_allocation_id"],
["credit_record_allocations.id"],
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_credit_record_allocations_record",
"credit_record_allocations",
["credit_record_id", "id"],
unique=False,
)
op.create_index(
"ix_credit_record_allocations_balance",
"credit_record_allocations",
["credit_balance_id", "created_at"],
unique=False,
)
op.create_index(
"ix_credit_record_allocations_user_time",
"credit_record_allocations",
["user_id", "created_at"],
unique=False,
)
op.create_index(
"ix_credit_record_allocations_source_allocation",
"credit_record_allocations",
["source_allocation_id"],
unique=False,
)
op.create_index(
"ix_credit_record_allocations_allocation_action",
"credit_record_allocations",
["allocation_action"],
unique=False,
)
# 8. 扩展积分流水。
op.drop_index("uq_credit_records_user_refund_target", table_name="credit_records")
op.alter_column(
"credit_records",
"type",
existing_type=sa.String(length=16),
type_=sa.String(length=24),
existing_nullable=False,
)
op.alter_column(
"credit_records",
"amount",
existing_type=sa.Float(),
type_=sa.Numeric(precision=20, scale=2),
existing_nullable=False,
postgresql_using="amount::numeric(20,2)",
)
op.alter_column(
"credit_records",
"balance_after",
existing_type=sa.Float(),
type_=sa.Numeric(precision=20, scale=2),
existing_nullable=False,
postgresql_using="balance_after::numeric(20,2)",
)
op.alter_column(
"credit_records",
"description",
existing_type=sa.String(length=256),
type_=sa.String(length=512),
existing_nullable=False,
)
op.alter_column(
"credit_records",
"biz_key",
existing_type=sa.String(length=160),
type_=sa.String(length=180),
existing_nullable=True,
)
op.alter_column(
"credit_records",
"refund_for_biz_key",
existing_type=sa.String(length=160),
type_=sa.String(length=180),
existing_nullable=True,
)
op.alter_column(
"credit_records",
"charge_action",
existing_type=sa.String(length=16),
type_=sa.String(length=24),
existing_nullable=True,
)
op.add_column(
"credit_records",
sa.Column(
"balance_delta",
sa.Numeric(precision=20, scale=2),
nullable=False,
server_default=sa.text("0"),
),
)
op.add_column(
"credit_records",
sa.Column(
"expired_amount",
sa.Numeric(precision=20, scale=2),
nullable=False,
server_default=sa.text("0"),
),
)
op.add_column("credit_records", sa.Column("request_time", sa.DateTime(timezone=True), nullable=True))
op.add_column("credit_records", sa.Column("refund_kind", sa.String(length=32), nullable=True))
op.add_column("credit_records", sa.Column("scene_name_snapshot", sa.String(length=128), nullable=True))
op.add_column("credit_records", sa.Column("credit_level_snapshot", sa.String(length=32), nullable=True))
op.add_column("credit_records", sa.Column("llm_call_count", sa.Integer(), nullable=True))
op.add_column("credit_records", sa.Column("llm_success_call_count", sa.Integer(), nullable=True))
op.add_column("credit_records", sa.Column("llm_failed_call_count", sa.Integer(), nullable=True))
op.add_column("credit_records", sa.Column("llm_billing_execution_id", sa.String(length=32), nullable=True))
op.create_index("ix_credit_records_request_time", "credit_records", ["request_time"], unique=False)
op.create_index("ix_credit_records_refund_kind", "credit_records", ["refund_kind"], unique=False)
op.create_index(
"ix_credit_records_llm_billing_execution_id",
"credit_records",
["llm_billing_execution_id"],
unique=False,
)
op.create_index(
"uq_credit_records_user_refund_target_kind",
"credit_records",
["user_id", "refund_for_biz_key", "refund_kind"],
unique=True,
postgresql_where=sa.text("type = 'refund' AND refund_for_biz_key IS NOT NULL"),
)
# 9. LLM 场景固定预扣及逐次调用审计。
op.create_table(
"llm_billing_policies",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("scene_code", sa.String(length=64), nullable=False),
sa.Column("scene_name", sa.String(length=128), nullable=False),
sa.Column("pre_deduct_credits", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("version", sa.Integer(), nullable=False, server_default=sa.text("1")),
sa.Column("created_by", sa.String(length=32), nullable=True),
sa.Column("updated_by", sa.String(length=32), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"uq_llm_billing_policies_scene",
"llm_billing_policies",
["scene_code"],
unique=True,
)
op.create_table(
"llm_billing_executions",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("user_id", sa.String(length=32), nullable=False),
sa.Column("scene_code", sa.String(length=64), nullable=False),
sa.Column("scene_name_snapshot", sa.String(length=128), nullable=False),
sa.Column("owner_type", sa.String(length=64), nullable=False),
sa.Column("owner_id", sa.String(length=64), nullable=False),
sa.Column("business_attempt_no", sa.Integer(), nullable=False),
sa.Column("model_config_id", sa.String(length=32), nullable=True),
sa.Column("model_name_snapshot", sa.String(length=128), nullable=True),
sa.Column("provider_snapshot", sa.String(length=64), nullable=True),
sa.Column("model_parameters_snapshot", sa.JSON(), nullable=True),
sa.Column("billing_policy_id", sa.String(length=32), nullable=True),
sa.Column("billing_policy_version", sa.Integer(), nullable=True),
sa.Column("request_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("pre_deduct_credits", sa.Numeric(precision=20, scale=2), nullable=False),
sa.Column("credit_record_id", sa.String(length=32), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default=sa.text("'pre_deducted'")),
sa.Column("total_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("successful_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("failed_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_input_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_output_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("refund_available_credits", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("refund_expired_credits", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("final_error_message", sa.String(length=1000), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("refunded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["model_config_id"], ["model_configs.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["billing_policy_id"], ["llm_billing_policies.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["credit_record_id"], ["credit_records.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"uq_llm_billing_executions_business_attempt",
"llm_billing_executions",
["user_id", "scene_code", "owner_type", "owner_id", "business_attempt_no"],
unique=True,
)
op.create_index(
"uq_llm_billing_executions_credit_record",
"llm_billing_executions",
["credit_record_id"],
unique=True,
)
op.create_index(
"ix_llm_billing_executions_status_time",
"llm_billing_executions",
["status", "created_at"],
unique=False,
)
op.create_index("ix_llm_billing_executions_user_id", "llm_billing_executions", ["user_id"], unique=False)
op.create_index("ix_llm_billing_executions_scene_code", "llm_billing_executions", ["scene_code"], unique=False)
op.create_table(
"llm_call_attempts",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("billing_execution_id", sa.String(length=32), nullable=False),
sa.Column("call_sequence", sa.Integer(), nullable=False),
sa.Column("retry_sequence", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("model_config_id", sa.String(length=32), nullable=True),
sa.Column("model_name_snapshot", sa.String(length=128), nullable=True),
sa.Column("provider_snapshot", sa.String(length=64), nullable=True),
sa.Column("provider_request_id", sa.String(length=160), nullable=True),
sa.Column("request_started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("response_received_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=24), nullable=False, server_default=sa.text("'started'")),
sa.Column("input_tokens", sa.Integer(), nullable=True),
sa.Column("output_tokens", sa.Integer(), nullable=True),
sa.Column("total_tokens", sa.Integer(), nullable=True),
sa.Column("token_usage_id", sa.String(length=32), nullable=True),
sa.Column("http_status", sa.Integer(), nullable=True),
sa.Column("provider_error_code", sa.String(length=128), nullable=True),
sa.Column("error_message", sa.String(length=1000), nullable=True),
sa.Column("token_unavailable_reason", sa.String(length=256), nullable=True),
sa.Column("postprocess_status", sa.String(length=32), nullable=True),
sa.Column("postprocess_error", sa.String(length=1000), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.ForeignKeyConstraint(
["billing_execution_id"],
["llm_billing_executions.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(["model_config_id"], ["model_configs.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["token_usage_id"], ["token_usage.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"uq_llm_call_attempts_sequence",
"llm_call_attempts",
["billing_execution_id", "call_sequence"],
unique=True,
)
op.create_index(
"ix_llm_call_attempts_execution_time",
"llm_call_attempts",
["billing_execution_id", "created_at"],
unique=False,
)
op.create_index(
"ix_llm_call_attempts_provider_request",
"llm_call_attempts",
["provider_request_id"],
unique=False,
)
def downgrade() -> None:
# 恢复 users.credits 与旧充值包表。
op.drop_index("ix_llm_call_attempts_provider_request", table_name="llm_call_attempts")
op.drop_index("ix_llm_call_attempts_execution_time", table_name="llm_call_attempts")
op.drop_index("uq_llm_call_attempts_sequence", table_name="llm_call_attempts")
op.drop_table("llm_call_attempts")
op.drop_index("ix_llm_billing_executions_scene_code", table_name="llm_billing_executions")
op.drop_index("ix_llm_billing_executions_user_id", table_name="llm_billing_executions")
op.drop_index("ix_llm_billing_executions_status_time", table_name="llm_billing_executions")
op.drop_index("uq_llm_billing_executions_credit_record", table_name="llm_billing_executions")
op.drop_index("uq_llm_billing_executions_business_attempt", table_name="llm_billing_executions")
op.drop_table("llm_billing_executions")
op.drop_index("uq_llm_billing_policies_scene", table_name="llm_billing_policies")
op.drop_table("llm_billing_policies")
op.drop_index("uq_credit_records_user_refund_target_kind", table_name="credit_records")
op.drop_index("ix_credit_records_llm_billing_execution_id", table_name="credit_records")
op.drop_index("ix_credit_records_refund_kind", table_name="credit_records")
op.drop_index("ix_credit_records_request_time", table_name="credit_records")
op.drop_column("credit_records", "llm_billing_execution_id")
op.drop_column("credit_records", "llm_failed_call_count")
op.drop_column("credit_records", "llm_success_call_count")
op.drop_column("credit_records", "llm_call_count")
op.drop_column("credit_records", "credit_level_snapshot")
op.drop_column("credit_records", "scene_name_snapshot")
op.drop_column("credit_records", "refund_kind")
op.drop_column("credit_records", "request_time")
op.drop_column("credit_records", "expired_amount")
op.drop_column("credit_records", "balance_delta")
op.alter_column(
"credit_records",
"charge_action",
existing_type=sa.String(length=24),
type_=sa.String(length=16),
existing_nullable=True,
)
op.alter_column(
"credit_records",
"refund_for_biz_key",
existing_type=sa.String(length=180),
type_=sa.String(length=160),
existing_nullable=True,
)
op.alter_column(
"credit_records",
"biz_key",
existing_type=sa.String(length=180),
type_=sa.String(length=160),
existing_nullable=True,
)
op.alter_column(
"credit_records",
"description",
existing_type=sa.String(length=512),
type_=sa.String(length=256),
existing_nullable=False,
)
op.alter_column(
"credit_records",
"balance_after",
existing_type=sa.Numeric(precision=20, scale=2),
type_=sa.Float(),
existing_nullable=False,
postgresql_using="balance_after::double precision",
)
op.alter_column(
"credit_records",
"amount",
existing_type=sa.Numeric(precision=20, scale=2),
type_=sa.Float(),
existing_nullable=False,
postgresql_using="amount::double precision",
)
op.alter_column(
"credit_records",
"type",
existing_type=sa.String(length=24),
type_=sa.String(length=16),
existing_nullable=False,
)
op.create_index(
"uq_credit_records_user_refund_target",
"credit_records",
["user_id", "refund_for_biz_key"],
unique=True,
postgresql_where=sa.text("type = 'refund' AND refund_for_biz_key IS NOT NULL"),
)
op.drop_index("ix_credit_record_allocations_allocation_action", table_name="credit_record_allocations")
op.drop_index("ix_credit_record_allocations_source_allocation", table_name="credit_record_allocations")
op.drop_index("ix_credit_record_allocations_user_time", table_name="credit_record_allocations")
op.drop_index("ix_credit_record_allocations_balance", table_name="credit_record_allocations")
op.drop_index("ix_credit_record_allocations_record", table_name="credit_record_allocations")
op.drop_table("credit_record_allocations")
op.drop_constraint(
"fk_user_credit_balances_subscription_period_id",
"user_credit_balances",
type_="foreignkey",
)
op.drop_index("uq_user_credit_subscription_periods_balance", table_name="user_credit_subscription_periods")
op.drop_index("ix_user_credit_subscription_periods_upgrade", table_name="user_credit_subscription_periods")
op.drop_index("ix_user_credit_subscription_periods_due", table_name="user_credit_subscription_periods")
op.drop_index("uq_user_credit_subscription_periods_sequence", table_name="user_credit_subscription_periods")
op.drop_table("user_credit_subscription_periods")
op.drop_index("ix_user_credit_balances_expires_at", table_name="user_credit_balances")
op.drop_index("ix_user_credit_balances_valid_from", table_name="user_credit_balances")
op.drop_index("ix_user_credit_balances_grant_record_id", table_name="user_credit_balances")
op.drop_index("ix_user_credit_balances_source_id", table_name="user_credit_balances")
op.drop_index("ix_user_credit_balances_source_type", table_name="user_credit_balances")
op.drop_index("ix_user_credit_balances_user_id", table_name="user_credit_balances")
op.drop_index("uq_user_credit_balances_user_biz_key", table_name="user_credit_balances")
op.drop_index("ix_user_credit_balances_subscription", table_name="user_credit_balances")
op.drop_index("ix_user_credit_balances_payment", table_name="user_credit_balances")
op.drop_index("ix_user_credit_balances_source", table_name="user_credit_balances")
op.drop_index("ix_user_credit_balances_expire_due", table_name="user_credit_balances")
op.drop_index("ix_user_credit_balances_spendable", table_name="user_credit_balances")
op.drop_table("user_credit_balances")
op.drop_index("ix_user_credit_subscriptions_user_id", table_name="user_credit_subscriptions")
op.drop_index("uq_user_credit_subscriptions_payment", table_name="user_credit_subscriptions")
op.drop_index("ix_user_credit_subscriptions_grant_due", table_name="user_credit_subscriptions")
op.drop_index("ix_user_credit_subscriptions_expire_due", table_name="user_credit_subscriptions")
op.drop_index("ix_user_credit_subscriptions_current", table_name="user_credit_subscriptions")
op.drop_table("user_credit_subscriptions")
op.drop_index("ix_payment_orders_fulfillment_status", table_name="payment_orders")
op.drop_index("ix_payment_orders_source_subscription_id", table_name="payment_orders")
op.drop_index("ix_payment_orders_subscription_id", table_name="payment_orders")
op.drop_index("ix_payment_orders_product_type", table_name="payment_orders")
op.drop_index("ix_payment_orders_product_id", table_name="payment_orders")
op.drop_constraint(
"fk_payment_orders_product_id_credit_products",
"payment_orders",
type_="foreignkey",
)
op.drop_column("payment_orders", "fulfilled_at")
op.drop_column("payment_orders", "fulfillment_status")
op.drop_column("payment_orders", "payable_amount_snapshot")
op.drop_column("payment_orders", "deduction_amount_snapshot")
op.drop_column("payment_orders", "target_price_snapshot")
op.drop_column("payment_orders", "upgrade_period_ids_json")
op.drop_column("payment_orders", "source_subscription_id")
op.drop_column("payment_orders", "subscription_id")
op.drop_column("payment_orders", "product_snapshot_json")
op.drop_column("payment_orders", "product_name_snapshot")
op.drop_column("payment_orders", "product_code_snapshot")
op.drop_column("payment_orders", "price_type")
op.drop_column("payment_orders", "purchase_scene")
op.drop_column("payment_orders", "product_type")
op.drop_column("payment_orders", "product_id")
op.alter_column(
"payment_orders",
"refund_amount",
existing_type=sa.Numeric(precision=20, scale=2),
type_=sa.Float(),
existing_nullable=True,
postgresql_using="refund_amount::double precision",
)
op.alter_column(
"payment_orders",
"status",
existing_type=sa.String(length=32),
type_=sa.String(length=16),
existing_nullable=False,
)
op.alter_column(
"payment_orders",
"credits",
existing_type=sa.Numeric(precision=20, scale=2),
type_=sa.Float(),
existing_nullable=False,
server_default=None,
postgresql_using="credits::double precision",
)
op.alter_column(
"payment_orders",
"amount",
existing_type=sa.Numeric(precision=20, scale=2),
type_=sa.Float(),
existing_nullable=False,
postgresql_using="amount::double precision",
)
op.drop_index("ix_users_first_membership_paid_at", table_name="users")
op.drop_column("users", "first_membership_paid_at")
op.drop_index("ix_credit_products_product_type", table_name="credit_products")
op.drop_index("ix_credit_products_public", table_name="credit_products")
op.drop_index("uq_credit_products_code", table_name="credit_products")
op.drop_table("credit_products")