"""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")