Files
video-gen/video-gen-api/alembic/versions/20260814_multi_subscription_team_subscription.py
T
2026-08-14 15:23:46 +08:00

445 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""个人/团队订阅多实例、团队席位与统一线下订单
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")