"""个人/团队订阅多实例、团队席位与统一线下订单 Revision ID: 20260814_multi_sub_team Revises: 20260813_split_device_type Create Date: 2026-08-14 11:31:00 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql revision = "20260814_multi_sub_team" down_revision = "20260813_bank_scheduled" branch_labels = None depends_on = None def _columns(table_name: str) -> set[str]: inspector = sa.inspect(op.get_bind()) if table_name not in inspector.get_table_names(): return set() return {item["name"] for item in inspector.get_columns(table_name)} def _indexes(table_name: str) -> set[str]: inspector = sa.inspect(op.get_bind()) if table_name not in inspector.get_table_names(): return set() return {item["name"] for item in inspector.get_indexes(table_name)} def _constraints(table_name: str) -> set[str]: inspector = sa.inspect(op.get_bind()) if table_name not in inspector.get_table_names(): return set() names = {item.get("name") for item in inspector.get_check_constraints(table_name)} names |= {item.get("name") for item in inspector.get_unique_constraints(table_name)} names |= {item.get("name") for item in inspector.get_foreign_keys(table_name)} return {name for name in names if name} def _add_column(table: str, column: sa.Column) -> None: if column.name not in _columns(table): op.add_column(table, column) def _drop_column(table: str, name: str) -> None: if name in _columns(table): op.drop_column(table, name) def _drop_index(table: str, name: str) -> None: if name in _indexes(table): op.drop_index(name, table_name=table) def upgrade() -> None: bind = op.get_bind() inspector = sa.inspect(bind) tables = set(inspector.get_table_names()) # Product:上下架与软删除分离;保留续费开关;product_code 保持原全局唯一索引。 _add_column("credit_products", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)) if "uq_credit_products_code" not in _indexes("credit_products"): op.create_index("uq_credit_products_code", "credit_products", ["product_code"], unique=True) if "ix_credit_products_deleted_at" not in _indexes("credit_products"): op.create_index("ix_credit_products_deleted_at", "credit_products", ["deleted_at"], unique=False) # 旧版本同名索引不含 deleted_at,必须重建,不能只按名称判断存在。 _drop_index("credit_products", "ix_credit_products_public") op.create_index("ix_credit_products_public", "credit_products", ["product_type", "deleted_at", "is_active", "sort_order"]) _add_column("credit_products", sa.Column("renewal_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true"))) op.execute("UPDATE credit_products SET renewal_enabled=false WHERE product_type='credit_addon'") if "ck_credit_products_type_required_fields" in _constraints("credit_products"): op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check") op.create_check_constraint( "ck_credit_products_type_required_fields", "credit_products", "(product_type IN ('subscription', 'team_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)", ) # Subscription:个人/团队共表,多实例独立,不再保留升级链。 _add_column("user_credit_subscriptions", sa.Column("team_id", sa.String(32), nullable=True)) _add_column("user_credit_subscriptions", sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True)) _add_column("user_credit_subscriptions", sa.Column("product_type_snapshot", sa.String(24), nullable=True)) _add_column("user_credit_subscriptions", sa.Column("product_name_snapshot", sa.String(96), nullable=True)) _add_column("user_credit_subscriptions", sa.Column("monthly_total_credits_snapshot", sa.Numeric(20, 2), nullable=True)) _add_column("user_credit_subscriptions", sa.Column("quantity_snapshot", sa.Integer(), nullable=True, server_default="1")) _add_column("user_credit_subscriptions", sa.Column("first_purchase_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0")) _add_column("user_credit_subscriptions", sa.Column("regular_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0")) _add_column("user_credit_subscriptions", sa.Column("activity_price_snapshot", sa.Numeric(20, 2), nullable=True)) _add_column("user_credit_subscriptions", sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True, server_default="0")) if "fk_user_credit_subscriptions_team" not in _constraints("user_credit_subscriptions"): op.create_foreign_key( "fk_user_credit_subscriptions_team", "user_credit_subscriptions", "teams", ["team_id"], ["id"], ondelete="RESTRICT" ) op.execute( "UPDATE user_credit_subscriptions s SET " "product_type_snapshot = COALESCE(product_type_snapshot, 'subscription'), " "product_name_snapshot = COALESCE(product_name_snapshot, NULLIF(s.product_snapshot_json->>'name',''), '历史订阅套餐'), " "monthly_total_credits_snapshot = COALESCE(monthly_total_credits_snapshot, monthly_grant_credits_snapshot), " "quantity_snapshot = COALESCE(quantity_snapshot, 1), " "first_purchase_price_snapshot = COALESCE(first_purchase_price_snapshot, NULLIF(s.product_snapshot_json->>'first_purchase_price','')::numeric, 0), " "regular_price_snapshot = COALESCE(regular_price_snapshot, NULLIF(s.product_snapshot_json->>'regular_price','')::numeric, paid_amount_snapshot), " "activity_price_snapshot = COALESCE(activity_price_snapshot, NULLIF(s.product_snapshot_json->>'activity_price','')::numeric), " "actual_unit_price_snapshot = COALESCE(actual_unit_price_snapshot, paid_amount_snapshot)" ) for col in ( "product_type_snapshot", "product_name_snapshot", "monthly_total_credits_snapshot", "quantity_snapshot", "first_purchase_price_snapshot", "regular_price_snapshot", "actual_unit_price_snapshot", ): if col in _columns("user_credit_subscriptions"): op.alter_column("user_credit_subscriptions", col, nullable=False) _drop_column("user_credit_subscriptions", "source_subscription_id") _drop_column("user_credit_subscriptions", "upgrade_order_id") _drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_current") if "ix_user_credit_subscriptions_user_active" not in _indexes("user_credit_subscriptions"): op.create_index("ix_user_credit_subscriptions_user_active", "user_credit_subscriptions", ["user_id", "status", "expires_at"]) if "ix_user_credit_subscriptions_team_active" not in _indexes("user_credit_subscriptions"): op.create_index("ix_user_credit_subscriptions_team_active", "user_credit_subscriptions", ["team_id", "status", "expires_at"]) # Period:删除升级预留字段,补充窗口索引。 _drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_upgrade") _drop_column("user_credit_subscription_periods", "upgrade_order_id") _drop_column("user_credit_subscription_periods", "reserved_at") _drop_column("user_credit_subscription_periods", "revoked_at") if "ix_user_credit_subscription_periods_window" not in _indexes("user_credit_subscription_periods"): op.create_index( "ix_user_credit_subscription_periods_window", "user_credit_subscription_periods", ["subscription_id", "valid_from", "expires_at"], ) # Balance:增加个人/团队资金域。 _add_column("user_credit_balances", sa.Column("credit_scope", sa.String(16), nullable=True, server_default="personal")) _add_column("user_credit_balances", sa.Column("team_id", sa.String(32), nullable=True)) op.execute("UPDATE user_credit_balances SET credit_scope='personal' WHERE credit_scope IS NULL") op.alter_column("user_credit_balances", "credit_scope", nullable=False, server_default="personal") if "fk_user_credit_balances_team" not in _constraints("user_credit_balances"): op.create_foreign_key("fk_user_credit_balances_team", "user_credit_balances", "teams", ["team_id"], ["id"], ondelete="RESTRICT") if "ck_user_credit_balances_scope_fields" not in _constraints("user_credit_balances"): op.create_check_constraint( "ck_user_credit_balances_scope_fields", "user_credit_balances", "(credit_scope='personal' AND team_id IS NULL) OR " "(credit_scope='team' AND team_id IS NOT NULL AND subscription_id IS NOT NULL AND subscription_period_id IS NOT NULL)", ) _drop_index("user_credit_balances", "ix_user_credit_balances_spendable") op.create_index( "ix_user_credit_balances_spendable", "user_credit_balances", ["user_id", "credit_scope", "credit_level_rank", "expires_at", "valid_from", "id"], postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"), ) if "ix_user_credit_balances_team_spendable" not in _indexes("user_credit_balances"): op.create_index( "ix_user_credit_balances_team_spendable", "user_credit_balances", ["team_id", "subscription_id", "subscription_period_id", "credit_level_rank", "expires_at", "id"], postgresql_where=sa.text("credit_scope = 'team' AND unspent_amount > 0 AND revoked_at IS NULL"), ) # Allocation:资金来源、团队任期、Subscription/Period/Seat 全部冷备。 allocation_columns = [ sa.Column("credit_scope_snapshot", sa.String(16), nullable=True, server_default="personal"), sa.Column("team_id_snapshot", sa.String(32), nullable=True), sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True), sa.Column("subscription_id_snapshot", sa.String(32), nullable=True), sa.Column("subscription_period_id_snapshot", sa.String(32), nullable=True), sa.Column("seat_id_snapshot", sa.String(32), nullable=True), ] for column in allocation_columns: _add_column("credit_record_allocations", column) op.execute("UPDATE credit_record_allocations SET credit_scope_snapshot='personal' WHERE credit_scope_snapshot IS NULL") op.alter_column("credit_record_allocations", "credit_scope_snapshot", nullable=False, server_default="personal") for name, cols in ( ("ix_credit_record_allocations_team_time", ["team_id_snapshot", "created_at", "id"]), ("ix_credit_record_allocations_team_manager_time", ["team_id_snapshot", "team_manager_id_snapshot", "created_at", "id"]), ("ix_credit_record_allocations_team_period_user", ["subscription_period_id_snapshot", "user_id", "allocation_action"]), ): if name not in _indexes("credit_record_allocations"): op.create_index(name, "credit_record_allocations", cols) # PaymentOrder:线上/线下统一主表,删除升级价格字段。 payment_columns = [ sa.Column("order_source", sa.String(24), nullable=True, server_default="online_payment"), sa.Column("quantity", sa.Integer(), nullable=True, server_default="1"), sa.Column("quoted_unit_price_snapshot", sa.Numeric(20, 2), nullable=True), sa.Column("quoted_amount_snapshot", sa.Numeric(20, 2), nullable=True), sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True), sa.Column("team_id_snapshot", sa.String(32), nullable=True), sa.Column("operator_admin_id", sa.String(32), nullable=True), sa.Column("offline_trade_no", sa.String(128), nullable=True), sa.Column("offline_payment_detail", sa.String(128), nullable=True), sa.Column("remark", sa.String(512), nullable=True), sa.Column("refund_entitlement_status", sa.String(32), nullable=True), ] for column in payment_columns: _add_column("payment_orders", column) op.execute( "UPDATE payment_orders SET order_source=COALESCE(order_source,'online_payment'), quantity=COALESCE(quantity,1), " "quoted_amount_snapshot=COALESCE(quoted_amount_snapshot, amount), " "quoted_unit_price_snapshot=COALESCE(quoted_unit_price_snapshot, amount), " "actual_unit_price_snapshot=COALESCE(actual_unit_price_snapshot, amount)" ) op.alter_column("payment_orders", "order_source", nullable=False, server_default="online_payment") op.alter_column("payment_orders", "quantity", nullable=False, server_default="1") for col in ( "source_subscription_id", "upgrade_period_ids_json", "target_price_snapshot", "deduction_amount_snapshot", "payable_amount_snapshot", ): _drop_column("payment_orders", col) if "fk_payment_orders_team_snapshot" not in _constraints("payment_orders"): op.create_foreign_key("fk_payment_orders_team_snapshot", "payment_orders", "teams", ["team_id_snapshot"], ["id"], ondelete="RESTRICT") if "fk_payment_orders_operator_admin" not in _constraints("payment_orders"): op.create_foreign_key("fk_payment_orders_operator_admin", "payment_orders", "users", ["operator_admin_id"], ["id"], ondelete="SET NULL") if "ix_payorder_source_status_created" not in _indexes("payment_orders"): op.create_index("ix_payorder_source_status_created", "payment_orders", ["order_source", "status", "created_at"]) if "ix_payorder_team_status_created" not in _indexes("payment_orders"): op.create_index("ix_payorder_team_status_created", "payment_orders", ["team_id_snapshot", "status", "created_at"]) _add_column("teams", sa.Column("first_subscription_paid_at", sa.DateTime(timezone=True), nullable=True)) if "ix_teams_first_subscription_paid_at" not in _indexes("teams"): op.create_index("ix_teams_first_subscription_paid_at", "teams", ["first_subscription_paid_at"]) if bind.dialect.name == "postgresql": op.execute("CREATE SEQUENCE IF NOT EXISTS team_auto_name_seq START WITH 1 INCREMENT BY 1") # 避免历史上已经存在“团队000N”时从1开始产生重名;is_called=false 让下一次 nextval 直接返回 max+1。 op.execute( "SELECT setval('team_auto_name_seq', " "GREATEST(COALESCE(MAX((substring(name from '^团队([0-9]+)$'))::bigint), 0) + 1, 1), false) " "FROM teams WHERE name ~ '^团队[0-9]+$'" ) if "team_manager_history" not in tables: op.create_table( "team_manager_history", sa.Column("id", sa.String(32), primary_key=True), sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False), sa.Column("manager_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False), sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), ) op.create_index("ix_team_manager_history_team_time", "team_manager_history", ["team_id", "started_at", "ended_at"]) op.create_index("ix_team_manager_history_manager_time", "team_manager_history", ["manager_user_id", "started_at", "ended_at"]) op.create_index( "uq_team_manager_history_current", "team_manager_history", ["team_id"], unique=True, postgresql_where=sa.text("ended_at IS NULL"), ) op.execute( "INSERT INTO team_manager_history(id, team_id, manager_user_id, started_at, created_at, updated_at) " "SELECT md5(random()::text || clock_timestamp()::text), id, manager_id, COALESCE(created_at, now()), now(), now() " "FROM teams WHERE manager_id IS NOT NULL AND deleted_at IS NULL" ) if "team_subscription_seats" not in tables: op.create_table( "team_subscription_seats", sa.Column("id", sa.String(32), primary_key=True), sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False), sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False), sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False), sa.Column("monthly_allocated_credits", sa.Numeric(20, 2), nullable=False), sa.Column("created_by_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False), sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.CheckConstraint("monthly_allocated_credits > 0", name="ck_team_subscription_seat_allocation_positive"), ) op.create_index("ix_team_subscription_seats_subscription", "team_subscription_seats", ["subscription_id", "created_at"]) op.create_index("ix_team_subscription_seats_team_id", "team_subscription_seats", ["team_id"]) op.create_index("ix_team_subscription_seats_deleted_at", "team_subscription_seats", ["deleted_at"]) op.create_index("ix_team_subscription_seats_user", "team_subscription_seats", ["user_id", "subscription_id"]) op.create_index( "uq_team_subscription_seats_active_user", "team_subscription_seats", ["subscription_id", "user_id"], unique=True, postgresql_where=sa.text("deleted_at IS NULL AND cancelled_at IS NULL"), ) if "team_subscription_seat_usages" not in tables: op.create_table( "team_subscription_seat_usages", sa.Column("id", sa.String(32), primary_key=True), sa.Column("seat_id", sa.String(32), sa.ForeignKey("team_subscription_seats.id", ondelete="RESTRICT"), nullable=False), sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False), sa.Column("subscription_period_id", sa.String(32), sa.ForeignKey("user_credit_subscription_periods.id", ondelete="RESTRICT"), nullable=False), sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False), sa.Column("used_credits", sa.Numeric(20, 2), nullable=False, server_default="0"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.CheckConstraint("used_credits >= 0", name="ck_team_subscription_seat_usage_nonnegative"), ) op.create_index( "uq_team_subscription_seat_usage_period", "team_subscription_seat_usages", ["seat_id", "subscription_period_id"], unique=True, ) op.create_index("ix_team_subscription_seat_usage_member", "team_subscription_seat_usages", ["subscription_period_id", "user_id"]) def downgrade() -> None: bind = op.get_bind() inspector = sa.inspect(bind) tables = set(inspector.get_table_names()) if "team_subscription_seat_usages" in tables: op.drop_table("team_subscription_seat_usages") if "team_subscription_seats" in tables: op.drop_table("team_subscription_seats") if "team_manager_history" in tables: op.drop_table("team_manager_history") if bind.dialect.name == "postgresql": op.execute("DROP SEQUENCE IF EXISTS team_auto_name_seq") _drop_index("teams", "ix_teams_first_subscription_paid_at") _drop_column("teams", "first_subscription_paid_at") for name in ("ix_payorder_team_status_created", "ix_payorder_source_status_created"): _drop_index("payment_orders", name) for fk in ("fk_payment_orders_operator_admin", "fk_payment_orders_team_snapshot"): if fk in _constraints("payment_orders"): op.drop_constraint(fk, "payment_orders", type_="foreignkey") for col in ( "refund_entitlement_status", "remark", "offline_payment_detail", "offline_trade_no", "operator_admin_id", "team_id_snapshot", "actual_unit_price_snapshot", "quoted_amount_snapshot", "quoted_unit_price_snapshot", "quantity", "order_source", ): _drop_column("payment_orders", col) _add_column("payment_orders", sa.Column("source_subscription_id", sa.String(32), nullable=True)) if "ix_payment_orders_source_subscription_id" not in _indexes("payment_orders"): op.create_index("ix_payment_orders_source_subscription_id", "payment_orders", ["source_subscription_id"]) _add_column("payment_orders", sa.Column("upgrade_period_ids_json", sa.JSON(), nullable=True)) _add_column("payment_orders", sa.Column("target_price_snapshot", sa.Numeric(20, 2), nullable=True)) _add_column("payment_orders", sa.Column("deduction_amount_snapshot", sa.Numeric(20, 2), nullable=True)) _add_column("payment_orders", sa.Column("payable_amount_snapshot", sa.Numeric(20, 2), nullable=True)) for name in ( "ix_credit_record_allocations_team_period_user", "ix_credit_record_allocations_team_manager_time", "ix_credit_record_allocations_team_time", ): _drop_index("credit_record_allocations", name) for col in ( "seat_id_snapshot", "subscription_period_id_snapshot", "subscription_id_snapshot", "team_manager_id_snapshot", "team_id_snapshot", "credit_scope_snapshot", ): _drop_column("credit_record_allocations", col) _drop_index("user_credit_balances", "ix_user_credit_balances_team_spendable") _drop_index("user_credit_balances", "ix_user_credit_balances_spendable") if "ck_user_credit_balances_scope_fields" in _constraints("user_credit_balances"): op.drop_constraint("ck_user_credit_balances_scope_fields", "user_credit_balances", type_="check") if "fk_user_credit_balances_team" in _constraints("user_credit_balances"): op.drop_constraint("fk_user_credit_balances_team", "user_credit_balances", type_="foreignkey") _drop_column("user_credit_balances", "team_id") _drop_column("user_credit_balances", "credit_scope") op.create_index( "ix_user_credit_balances_spendable", "user_credit_balances", ["user_id", "credit_level_rank", "expires_at", "valid_from", "id"], postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"), ) _drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_window") _add_column("user_credit_subscription_periods", sa.Column("upgrade_order_id", sa.String(32), nullable=True)) if "fk_user_credit_subscription_periods_upgrade_order" not in _constraints("user_credit_subscription_periods"): op.create_foreign_key( "fk_user_credit_subscription_periods_upgrade_order", "user_credit_subscription_periods", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL" ) _add_column("user_credit_subscription_periods", sa.Column("reserved_at", sa.DateTime(timezone=True), nullable=True)) _add_column("user_credit_subscription_periods", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True)) op.create_index("ix_user_credit_subscription_periods_upgrade", "user_credit_subscription_periods", ["upgrade_order_id", "status"]) _drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_team_active") _drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_user_active") if "fk_user_credit_subscriptions_team" in _constraints("user_credit_subscriptions"): op.drop_constraint("fk_user_credit_subscriptions_team", "user_credit_subscriptions", type_="foreignkey") for col in ( "actual_unit_price_snapshot", "activity_price_snapshot", "regular_price_snapshot", "first_purchase_price_snapshot", "quantity_snapshot", "monthly_total_credits_snapshot", "product_name_snapshot", "product_type_snapshot", "team_manager_id_snapshot", "team_id", ): _drop_column("user_credit_subscriptions", col) _add_column("user_credit_subscriptions", sa.Column("source_subscription_id", sa.String(32), nullable=True)) _add_column("user_credit_subscriptions", sa.Column("upgrade_order_id", sa.String(32), nullable=True)) if "fk_user_credit_subscriptions_source_subscription" not in _constraints("user_credit_subscriptions"): op.create_foreign_key( "fk_user_credit_subscriptions_source_subscription", "user_credit_subscriptions", "user_credit_subscriptions", ["source_subscription_id"], ["id"], ondelete="SET NULL" ) if "fk_user_credit_subscriptions_upgrade_order" not in _constraints("user_credit_subscriptions"): op.create_foreign_key( "fk_user_credit_subscriptions_upgrade_order", "user_credit_subscriptions", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL" ) op.create_index("ix_user_credit_subscriptions_current", "user_credit_subscriptions", ["user_id", "status", "expires_at"]) if "ck_credit_products_type_required_fields" in _constraints("credit_products"): op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check") # 旧版本不认识 team_subscription。降级时保留商品与永久唯一 product_code, # 但将团队套餐转换为旧版可识别的 subscription 并强制下架,避免旧代码误售。 op.execute( "UPDATE credit_products " "SET product_type='subscription', is_active=false " "WHERE product_type='team_subscription'" ) op.create_check_constraint( "ck_credit_products_type_required_fields", "credit_products", "(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)", ) _add_column("credit_products", sa.Column("renewal_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true"))) op.execute("UPDATE credit_products SET renewal_enabled=false WHERE product_type='credit_addon'") _drop_index("credit_products", "ix_credit_products_public") op.create_index("ix_credit_products_public", "credit_products", ["product_type", "is_active", "sort_order"]) _drop_index("credit_products", "ix_credit_products_deleted_at") _drop_column("credit_products", "deleted_at")