团队积分V1
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
"""个人/团队订阅多实例、团队席位与统一线下订单
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""兼容恢复积分套餐续费开关
|
||||
|
||||
Revision ID: 20260814_restore_renewal
|
||||
Revises: 20260814_multi_sub_team
|
||||
Create Date: 2026-08-14 13:25:00
|
||||
|
||||
说明:
|
||||
- 修正版 20260814_multi_sub_team 已不再删除 renewal_enabled;新环境执行到本迁移时为 no-op。
|
||||
- 如果数据库已经执行过上一版会删除 renewal_enabled 的同 revision 迁移,本迁移负责安全补回字段。
|
||||
- 已经被旧迁移删除的历史 false 值无法从数据库自身恢复,补回时订阅套餐默认 true,积分增值包统一 false。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260814_restore_renewal"
|
||||
down_revision = "20260814_multi_sub_team"
|
||||
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 upgrade() -> None:
|
||||
if "renewal_enabled" not in _columns("credit_products"):
|
||||
op.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'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 当前修正版上一个 revision 本身就保留 renewal_enabled,降级到它时字段也应继续存在。
|
||||
pass
|
||||
@@ -0,0 +1,92 @@
|
||||
"""订阅实例业务流水号
|
||||
|
||||
Revision ID: 20260814_subscription_no
|
||||
Revises: 20260814_restore_renewal
|
||||
Create Date: 2026-08-14 14:23:00
|
||||
|
||||
说明:
|
||||
- 每张 user_credit_subscriptions 增加永久唯一 subscription_no。
|
||||
- 个人订阅前缀 PS,团队订阅前缀 TS。
|
||||
- 全局共用 PostgreSQL Sequence,避免 COUNT/MAX 并发冲突。
|
||||
- 历史记录按原 created_at 日期生成业务编号;编号只用于展示/客服定位,不参与业务排序和结算。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260814_subscription_no"
|
||||
down_revision = "20260814_restore_renewal"
|
||||
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 upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
raise RuntimeError("订阅实例流水号迁移当前仅支持 PostgreSQL")
|
||||
|
||||
op.execute("CREATE SEQUENCE IF NOT EXISTS credit_subscription_no_seq START WITH 1 INCREMENT BY 1")
|
||||
|
||||
if "subscription_no" not in _columns("user_credit_subscriptions"):
|
||||
op.add_column(
|
||||
"user_credit_subscriptions",
|
||||
sa.Column(
|
||||
"subscription_no",
|
||||
sa.String(length=32),
|
||||
nullable=True,
|
||||
comment="订阅业务实例编号,供用户/客服/开发定位",
|
||||
),
|
||||
)
|
||||
|
||||
# 历史数据一次性补号。日期使用东八区业务日期;Sequence 只保证唯一,不要求无跳号。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE user_credit_subscriptions
|
||||
SET subscription_no =
|
||||
CASE
|
||||
WHEN product_type_snapshot = 'team_subscription' THEN 'TS'
|
||||
ELSE 'PS'
|
||||
END
|
||||
|| to_char(COALESCE(created_at, start_at, now()) AT TIME ZONE 'Asia/Shanghai', 'YYYYMMDD')
|
||||
|| lpad(nextval('credit_subscription_no_seq')::text, 6, '0')
|
||||
WHERE subscription_no IS NULL OR btrim(subscription_no) = ''
|
||||
"""
|
||||
)
|
||||
|
||||
op.alter_column(
|
||||
"user_credit_subscriptions",
|
||||
"subscription_no",
|
||||
existing_type=sa.String(length=32),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
if "uq_user_credit_subscriptions_no" not in _indexes("user_credit_subscriptions"):
|
||||
op.create_index(
|
||||
"uq_user_credit_subscriptions_no",
|
||||
"user_credit_subscriptions",
|
||||
["subscription_no"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if "uq_user_credit_subscriptions_no" in _indexes("user_credit_subscriptions"):
|
||||
op.drop_index("uq_user_credit_subscriptions_no", table_name="user_credit_subscriptions")
|
||||
if "subscription_no" in _columns("user_credit_subscriptions"):
|
||||
op.drop_column("user_credit_subscriptions", "subscription_no")
|
||||
op.execute("DROP SEQUENCE IF EXISTS credit_subscription_no_seq")
|
||||
Reference in New Issue
Block a user