This commit is contained in:
2026-08-14 15:36:22 +08:00
76 changed files with 7529 additions and 5483 deletions
@@ -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")
@@ -0,0 +1,25 @@
"""merge changes from remote
Revision ID: e7f2527691bb
Revises: 20260814_bank_tx, 20260814_subscription_no
Create Date: 2026-08-14 15:32:14.351079
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e7f2527691bb'
down_revision: Union[str, None] = ('20260814_bank_tx', '20260814_subscription_no')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+195 -104
View File
@@ -12,20 +12,22 @@ from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
CreditBalanceSourceType,
CreditScope,
)
from app.enums.credit_product import CreditProductType
from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.user import User
from app.schemas.credit_balance import AdminCreditDeductRequest, AdminCreditGrantRequest
from app.schemas.credit_product import CreditProductCreate, CreditProductRenewalUpdate, CreditProductUpdate
from app.schemas.credit_product import CreditProductCreate, CreditProductRenewalUpdate, CreditProductStatusUpdate, CreditProductUpdate
from app.schemas.credit_subscription import AdminOfflineSubscriptionCreate
from app.services.credit.ledger_service import deduct_credits, grant_credits
from app.services.credit.offline_subscription_service import create_offline_subscription_order
from app.services.credit.product_service import product_to_dict
from app.services.credit.query_service import (
apply_balance_status_filter,
effective_balance_status,
get_balance_summary,
)
from app.services.credit.query_service import apply_balance_status_filter, effective_balance_status, get_balance_summary
from app.services.credit.time_policy import add_natural_months, last_usable_at
from app.services.credit.utils import utc_now
from app.services.notification import create_notification
@@ -40,16 +42,17 @@ def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
mapping = {"features": "features_json"}
for key, value in payload.items():
setattr(product, mapping.get(key, key), value)
if product.product_type == "credit_addon" and product.validity_months is None:
# 兼容旧管理端未提交有效期的请求,新建增值包仍默认1个月;
# 更新时未提交该字段则保留原值。
product.validity_months = 1
if product.product_type == "subscription":
if product.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
product.price = product.regular_price or 0
product.grant_credits = None
product.validity_months = None
else:
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
product.renewal_enabled = False
if product.validity_months is None:
product.validity_months = 1
product.tier_code = None
product.tier_rank = None
product.billing_cycle = None
@@ -62,14 +65,17 @@ def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
def _validate_product_entity(product: CreditProduct) -> None:
if product.product_type == "subscription":
if product.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
required = {
"套餐等级编码": product.tier_code,
"套餐等级顺序": product.tier_rank,
"订阅周期": product.billing_cycle,
"每月积分": product.monthly_grant_credits,
"价格": product.first_purchase_price,
"原价": product.regular_price,
"价格": product.first_purchase_price,
"常规价格": product.regular_price,
}
missing = [label for label, value in required.items() if value is None]
if missing:
@@ -81,9 +87,9 @@ def _validate_product_entity(product: CreditProduct) -> None:
raise HTTPException(status_code=400, detail="配置活动价时必须同时配置活动开始和结束时间")
elif product.activity_end_at <= product.activity_start_at:
raise HTTPException(status_code=400, detail="活动结束时间必须晚于开始时间")
elif product.product_type == "credit_addon":
if product.grant_credits is None or product.price is None:
raise HTTPException(status_code=400, detail="积分增值包必须配置价格和积分数量")
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
if product.grant_credits is None:
raise HTTPException(status_code=400, detail="积分增值包必须配置积分数量")
if product.validity_months is None or not 1 <= int(product.validity_months) <= 36:
raise HTTPException(status_code=400, detail="积分增值包有效期必须为1-36个月")
else:
@@ -99,7 +105,9 @@ async def list_products(
stmt = select(CreditProduct)
if product_type:
stmt = stmt.where(CreditProduct.product_type == product_type)
result = await db.execute(stmt.order_by(CreditProduct.product_type, CreditProduct.sort_order, CreditProduct.id))
result = await db.execute(
stmt.order_by(CreditProduct.deleted_at.asc(), CreditProduct.product_type, CreditProduct.sort_order, CreditProduct.id)
)
return [product_to_dict(item) for item in result.scalars().all()]
@@ -109,19 +117,25 @@ async def create_product(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
exists = await db.execute(select(CreditProduct.id).where(CreditProduct.product_code == data.product_code).limit(1))
exists = await db.execute(
select(CreditProduct.id).where(CreditProduct.product_code == data.product_code).limit(1)
)
if exists.scalar_one_or_none():
raise HTTPException(status_code=409, detail="商品编码已存在")
raise HTTPException(status_code=409, detail="商品编码已被使用,商品编码永久唯一且不可复用")
product = CreditProduct(id=generate_id())
_apply_product_payload(product, data.model_dump())
_validate_product_entity(product)
db.add(product)
await db.flush()
snapshot = product_to_dict(product)
await log_operation(db, admin.id, admin.username, f"创建积分商品 {product.name}", "POST", "/admin/credit-management/products", detail=json.dumps(snapshot, ensure_ascii=False, default=str))
log_operation_event(domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_CREATED", user_id=admin.id, detail=snapshot)
# 商品保存后前端会立即使用返回值刷新列表。这里显式提交,避免依赖
# yield 依赖退出阶段提交时出现紧随其后的 GET 读到旧状态。
await log_operation(
db, admin.id, admin.username, f"创建积分商品 {product.name}", "POST",
"/admin/credit-management/products", detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
log_operation_event(
domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_CREATED",
user_id=admin.id, message="积分商品创建成功", detail={"product_id": product.id},
)
await db.commit()
return snapshot
@@ -133,33 +147,26 @@ async def update_product(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update())
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="商品已软删除,不能恢复或继续编辑")
before = product_to_dict(product)
payload = data.model_dump(exclude_unset=True)
new_code = payload.get("product_code")
if new_code and new_code != product.product_code:
duplicate = await db.execute(
select(CreditProduct.id).where(
CreditProduct.product_code == new_code, CreditProduct.id != product.id
).limit(1)
)
if duplicate.scalar_one_or_none():
raise HTTPException(status_code=409, detail="商品编码已存在")
_apply_product_payload(product, payload)
_apply_product_payload(product, data.model_dump(exclude_unset=True))
_validate_product_entity(product)
await db.flush()
after = product_to_dict(product)
await log_operation(db, admin.id, admin.username, f"更新积分商品 {product.name}", "PUT", f"/admin/credit-management/products/{product_id}", detail=json.dumps({"before": before, "after": after}, ensure_ascii=False, default=str))
log_operation_event(domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_UPDATED", user_id=admin.id, detail={"product_id": product_id})
await log_operation(
db, admin.id, admin.username, f"更新积分商品 {product.name}", "PUT",
f"/admin/credit-management/products/{product_id}",
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False, default=str),
)
await db.commit()
refreshed = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1))
persisted = refreshed.scalar_one_or_none()
if persisted is None:
raise HTTPException(status_code=404, detail="商品不存在")
return product_to_dict(persisted)
return after
@router.put("/products/{product_id}/renewal")
@@ -170,61 +177,151 @@ async def update_product_renewal(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct)
.where(CreditProduct.id == product_id)
.limit(1)
.with_for_update()
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.product_type != "subscription":
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="商品已软删除,不能修改续费开关")
if product.product_type not in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
raise HTTPException(status_code=400, detail="积分增值包不支持续费开关")
before = bool(product.renewal_enabled)
product.renewal_enabled = bool(data.renewal_enabled)
await db.flush()
after = product_to_dict(product)
await log_operation(
db,
admin.id,
admin.username,
f"{'开启' if product.renewal_enabled else '关闭'}积分商品续费 {product.name}",
"PUT",
f"/admin/credit-management/products/{product_id}/renewal",
detail=json.dumps(
{"before": before, "after": bool(product.renewal_enabled)},
ensure_ascii=False,
),
db, admin.id, admin.username,
f"{'开启' if product.renewal_enabled else '关闭'}积分套餐续费 {product.name}",
"PUT", f"/admin/credit-management/products/{product_id}/renewal",
detail=json.dumps({"before": before, "after": bool(product.renewal_enabled)}, ensure_ascii=False),
)
log_operation_event(
domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_RENEWAL_UPDATED",
user_id=admin.id, message="积分套餐续费开关已更新",
detail={"product_id": product.id, "renewal_enabled": bool(product.renewal_enabled)},
)
await db.commit()
return after
# 提交后重新查询,返回数据库真实持久化结果,避免前端误用事务内快照。
refreshed = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
@router.put("/products/{product_id}/status")
async def update_product_status(
product_id: str,
data: CreditProductStatusUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
persisted = refreshed.scalar_one_or_none()
if persisted is None:
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if bool(persisted.renewal_enabled) != bool(data.renewal_enabled):
raise HTTPException(status_code=500, detail="续费状态保存后校验失败")
return product_to_dict(persisted)
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="已软删除商品不能重新上架")
product.is_active = bool(data.is_active)
await db.flush()
await log_operation(
db, admin.id, admin.username,
f"{'上架' if product.is_active else '下架'}积分商品 {product.name}", "PUT",
f"/admin/credit-management/products/{product_id}/status",
)
await db.commit()
return product_to_dict(product)
@router.delete("/products/{product_id}")
async def disable_product(
async def soft_delete_product(
product_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update())
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
product.is_active = False
await db.flush()
await log_operation(db, admin.id, admin.username, f"下架积分商品 {product.name}", "DELETE", f"/admin/credit-management/products/{product_id}")
await db.commit()
return {"ok": True}
if product.deleted_at is None:
product.is_active = False
product.deleted_at = utc_now()
await db.flush()
await log_operation(
db, admin.id, admin.username, f"软删除积分商品 {product.name}", "DELETE",
f"/admin/credit-management/products/{product_id}",
)
await db.commit()
return {"ok": True, "message": "商品已软删除,商品编码永久保留且不能恢复"}
@router.post("/users/{user_id}/offline-subscriptions")
async def create_offline_subscription(
user_id: str,
data: AdminOfflineSubscriptionCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
order = await create_offline_subscription_order(
db,
target_user_id=user_id,
product_id=data.product_id,
operator_admin_id=admin.id,
payment_method=data.payment_method,
quantity=data.quantity,
actual_paid_amount=data.actual_paid_amount,
offline_trade_no=data.offline_trade_no,
offline_payment_detail=data.offline_payment_detail,
remark=data.remark,
)
order_no = str(order.order_no)
subscription_id = order.subscription_id
amount = float(order.amount)
await log_operation(
db, admin.id, admin.username, f"为用户 {user_id} 创建线下真实订阅成交", "POST",
f"/admin/credit-management/users/{user_id}/offline-subscriptions",
detail=json.dumps({"order_no": order_no, "subscription_id": subscription_id, "actual_paid_amount": amount}, ensure_ascii=False),
)
await db.commit()
return {"ok": True, "order_no": order_no, "subscription_id": subscription_id, "actual_paid_amount": amount}
except Exception:
await db.rollback()
raise
@router.get("/users/{user_id}/subscriptions")
async def list_user_subscriptions(
user_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.user_id == user_id)
.order_by(UserCreditSubscription.created_at.desc(), UserCreditSubscription.id.desc())
)
return [
{
"id": item.id,
"product_name": item.product_name_snapshot,
"product_type": item.product_type_snapshot,
"product_type_label": "团队订阅套餐" if item.product_type_snapshot == "team_subscription" else "个人订阅套餐",
"team_id": item.team_id,
"status": item.status,
"status_label": {"active": "有效", "expired": "已过期", "cancelled": "已取消", "pending": "待生效"}.get(item.status, "其他状态"),
"quantity": item.quantity_snapshot,
"monthly_total_credits": float(item.monthly_total_credits_snapshot),
"paid_amount": float(item.paid_amount_snapshot),
"start_at": item.start_at,
"expires_at": item.expires_at,
}
for item in result.scalars().all()
]
@router.get("/users/{user_id}/summary")
@@ -248,14 +345,20 @@ async def list_user_credit_balances(
checked_at = utc_now()
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == user_id)
stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
result = await db.execute(stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc()).offset((page - 1) * page_size).limit(page_size))
result = await db.execute(
stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
.offset((page - 1) * page_size).limit(page_size)
)
return [
{
"id": item.id,
"credit_scope": item.credit_scope,
"credit_scope_label": CREDIT_SCOPE_LABELS.get(item.credit_scope, "其他积分"),
"team_id": item.team_id,
"credit_level": item.credit_level,
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, item.credit_level),
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"),
"source_type": item.source_type,
"source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, item.source_type),
"source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"),
"source_id": item.source_id,
"grant_amount": float(item.grant_amount),
"unspent_amount": float(item.unspent_amount),
@@ -266,7 +369,7 @@ async def list_user_credit_balances(
"expires_at": item.expires_at,
"last_usable_at": last_usable_at(item.expires_at),
"status": (status_value := effective_balance_status(item, request_time=checked_at)),
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, status_value),
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, "其他状态"),
}
for item in result.scalars().all()
]
@@ -280,24 +383,18 @@ async def admin_grant_credit(
db: AsyncSession = Depends(get_db),
):
starts_at = data.valid_from or utc_now()
ends_at = starts_at + timedelta(days=data.validity_value) if data.validity_unit == "day" else add_natural_months(starts_at, data.validity_value)
ends_at = (
starts_at + timedelta(days=data.validity_value)
if data.validity_unit == "day"
else add_natural_months(starts_at, data.validity_value)
)
result = await grant_credits(
db,
user_id=user_id,
amount=data.amount,
description=data.description,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
source_id=admin.id,
valid_from=starts_at,
expires_at=ends_at,
credit_level=data.credit_level,
related_id=admin.id,
biz_key=f"admin-grant:{admin.id}:{generate_id()}",
)
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已增加{data.amount}积分。原因:{data.description}", "credit",
db, user_id=user_id, amount=data.amount, description=data.description,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value, source_id=admin.id,
valid_from=starts_at, expires_at=ends_at, credit_level=data.credit_level,
related_id=admin.id, biz_key=f"admin-grant:{admin.id}:{generate_id()}",
)
await create_notification(db, user_id, "积分变动通知", f"您的积分已增加{data.amount}积分。原因:{data.description}", "credit")
await log_operation(db, admin.id, admin.username, f"给用户 {user_id} 增加积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/grant")
return {"ok": True, "credits": result.balance_after}
@@ -311,20 +408,14 @@ async def admin_deduct_credit(
):
try:
result = await deduct_credits(
db,
user_id=user_id,
amount=data.amount,
description=data.description,
related_id=admin.id,
biz_key=f"admin-deduct:{admin.id}:{generate_id()}",
db, user_id=user_id, amount=data.amount, description=data.description,
related_id=admin.id, biz_key=f"admin-deduct:{admin.id}:{generate_id()}",
allowed_scopes={CreditScope.PERSONAL.value},
)
except Exception as exc:
if exc.__class__.__name__ == "InsufficientCreditsError":
raise HTTPException(status_code=400, detail="用户有效积分不足") from exc
raise
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已扣除{data.amount}积分。原因:{data.description}", "credit",
)
await create_notification(db, user_id, "积分变动通知", f"您的积分已扣除{data.amount}积分。原因:{data.description}", "credit")
await log_operation(db, admin.id, admin.username, f"扣除用户 {user_id} 积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/deduct")
return {"ok": True, "credits": result.balance_after}
@@ -18,7 +18,10 @@ async def admin_list_packages(
):
result = await db.execute(
select(CreditProduct)
.where(CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value)
.where(
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
CreditProduct.deleted_at.is_(None),
)
.order_by(CreditProduct.sort_order, CreditProduct.id)
)
return [product_to_dict(item) for item in result.scalars().all()]
+86 -40
View File
@@ -3,54 +3,57 @@ from __future__ import annotations
import json
from fastapi import APIRouter, Body, Depends, Query
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.user import User
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamUpdate
from app.schemas.team_manager import SetManagerRequest
from app.services.credit.team_subscription_service import (
list_member_period_usage,
list_team_subscriptions_for_management,
)
from app.services.operation_log import log_operation
from app.services.team_manager_service import set_team_manager
from app.services.team_manager_service import get_manager_history, set_team_manager
from app.services.team_service import create_team, list_team_options, list_teams, soft_delete_team, update_team
router = APIRouter(prefix="/admin/teams", tags=["admin-teams"])
async def _team_detail_payload(db: AsyncSession, team: Team) -> dict:
"""构造返回团队详情,包含 manager_name。"""
payload = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", "active"),
"sort_order": getattr(team, "sort_order", 0) or 0,
"member_count": 0,
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": None,
}
# 查询成员数和管理人用户名
from sqlalchemy import func
from app.enums.user import UserType
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
payload["member_count"] = int(member_count)
if getattr(team, "manager_id", None):
mgr = await db.execute(
manager_name = None
if team.manager_id:
manager_name = (await db.execute(
select(User.username).where(User.id == team.manager_id).limit(1)
)
payload["manager_name"] = mgr.scalar_one_or_none()
return payload
)).scalar_one_or_none()
status = team.status or TeamStatus.ACTIVE.value
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": status,
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
"is_read_only": status == TeamStatus.DISABLED.value,
"team_credit_frozen": status == TeamStatus.DISABLED.value,
"sort_order": team.sort_order or 0,
"member_count": int(member_count),
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": team.manager_id,
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
@router.get("", response_model=TeamListOut)
@@ -62,6 +65,7 @@ async def list_admin_teams(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_teams(db, page=page, page_size=page_size, keyword=keyword, status=status)
@@ -71,10 +75,11 @@ async def list_admin_team_options(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_team_options(db, include_disabled=include_disabled)
@router.post("", )
@router.post("")
async def create_admin_team(
req: TeamCreate,
admin: User = Depends(get_admin_user),
@@ -93,7 +98,22 @@ async def create_admin_team(
return await _team_detail_payload(db, team)
@router.put("/{team_id}", )
@router.get("/{team_id}")
async def get_admin_team(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
result = await db.execute(select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1))
team = result.scalar_one_or_none()
if not team:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="团队不存在")
return await _team_detail_payload(db, team)
@router.put("/{team_id}")
async def update_admin_team(
team_id: str,
req: TeamUpdate,
@@ -113,7 +133,7 @@ async def update_admin_team(
return await _team_detail_payload(db, team)
@router.put("/{team_id}/manager", )
@router.put("/{team_id}/manager")
async def set_team_manager_endpoint(
team_id: str,
req: SetManagerRequest = Body(...),
@@ -121,19 +141,14 @@ async def set_team_manager_endpoint(
db: AsyncSession = Depends(get_db),
):
team = await set_team_manager(db, team_id, req.user_id)
manager_name = None
# 使用 req.user_id 避免访问 team.manager_id 触发懒加载
if req.user_id:
mgr = await db.execute(
select(User.username).where(User.id == req.user_id).limit(1)
)
manager_name = mgr.scalar_one_or_none()
team_name = team.name
manager_name = (await db.execute(
select(User.username).where(User.id == req.user_id).limit(1)
)).scalar_one_or_none()
await log_operation(
db,
admin.id,
admin.username,
f"设置团队管理人 {team_name}: {manager_name or '取消'}",
f"更换团队队长 {team.name}: {manager_name or req.user_id}",
"PUT",
f"/admin/teams/{team_id}/manager",
detail=json.dumps({"manager_id": req.user_id}, ensure_ascii=False),
@@ -141,6 +156,37 @@ async def set_team_manager_endpoint(
return await _team_detail_payload(db, team)
@router.get("/{team_id}/subscriptions")
async def list_admin_team_subscriptions(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_team_subscriptions_for_management(db, team_id=team_id)
@router.get("/{team_id}/member-usage")
async def list_admin_team_member_usage(
team_id: str,
subscription_id: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_member_period_usage(db, team_id=team_id, subscription_id=subscription_id)
@router.get("/{team_id}/manager-history")
async def list_admin_team_manager_history(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await get_manager_history(db, team_id)
@router.delete("/{team_id}")
async def delete_admin_team(
team_id: str,
@@ -157,4 +203,4 @@ async def delete_admin_team(
f"/admin/teams/{team_id}",
detail=json.dumps({"before": before, "after": {"deleted_at": str(team.deleted_at)}}, ensure_ascii=False),
)
return {"message": "ok"}
return {"message": "团队已删除"}
+174 -134
View File
@@ -24,6 +24,8 @@ from app.models.credit_ratio import CreditRatio
from app.models.operation_log import OperationLog
from app.enums.user import FrontendUserKind, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.enums.common import PAYMENT_ORDER_SOURCE_LABELS
from app.schemas.payment import PAYMENT_METHOD_LABELS, PAYMENT_STATUS_LABELS, FULFILLMENT_STATUS_LABELS
from app.schemas.admin import (
CreditAdjustRequest,
ModelConfigCreate,
@@ -49,7 +51,7 @@ from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
from app.services.credits import add_credits, deduct_credits
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits, get_user_credit_map
from app.services.credit.query_service import attach_credit_snapshot, get_balance_summary, get_user_credit_summary_map
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import utc_now
from app.services.credit_record_meta_service import build_admin_adjust_meta
@@ -60,7 +62,6 @@ from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
from app.schemas.invoice import InvoiceStatusUpdateRequest
@@ -134,9 +135,10 @@ async def list_users(
team_ids = [getattr(u, "team_id", None) for u in users if getattr(u, "team_id", None)]
capacity_map = await batch_get_user_resource_capacity_usage(db, user_ids)
team_name_map = await batch_get_team_name_map(db, team_ids)
credit_map = await get_user_credit_map(db, user_ids)
credit_summary_map = await get_user_credit_summary_map(db, user_ids)
for item in users:
attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
summary = credit_summary_map.get(item.id)
attach_credit_snapshot(item, summary.available_credits if summary else 0)
return {
"items": [
AdminUserOut.model_validate(user)
@@ -144,6 +146,9 @@ async def list_users(
update={
"resource_capacity": capacity_map.get(user.id),
"team_name": team_name_map.get(getattr(user, "team_id", None)),
"personal_credits": float(credit_summary_map[user.id].personal_credits) if user.id in credit_summary_map else 0.0,
"team_available_credits": float(credit_summary_map[user.id].team_available_credits) if user.id in credit_summary_map else 0.0,
"team_frozen_credits": float(credit_summary_map[user.id].team_frozen_credits) if user.id in credit_summary_map else 0.0,
}
)
.model_dump(mode="json")
@@ -277,13 +282,17 @@ async def get_user(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
attach_credit_snapshot(user, await get_available_credits(db, user.id))
credit_summary = await get_balance_summary(db, user.id)
attach_credit_snapshot(user, credit_summary.available_credits)
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
team_name_map = await batch_get_team_name_map(db, [getattr(user, "team_id", None)])
return AdminUserOut.model_validate(user).model_copy(
update={
"resource_capacity": resource_capacity,
"team_name": team_name_map.get(getattr(user, "team_id", None)),
"personal_credits": float(credit_summary.personal_credits),
"team_available_credits": float(credit_summary.team_available_credits),
"team_frozen_credits": float(credit_summary.team_frozen_credits),
}
)
@@ -308,7 +317,14 @@ async def adjust_credits(
biz_key=f"admin-adjust-credit:{admin.id}:{generate_id()}",
)
else:
await deduct_credits(db, user_id, abs(req.amount), f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
await deduct_credits(
db,
user_id,
abs(req.amount),
f"管理员调整: {req.description}",
record_meta=build_admin_adjust_meta(),
allowed_scopes={"personal"},
)
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已{'增加' if req.amount > 0 else '扣除'}{abs(req.amount)}积分。原因:{req.description}",
@@ -568,6 +584,7 @@ async def list_credit_records(
user_type: str | None = Query(None),
frontend_user_kind: str | None = Query(None),
team_id: str | None = Query(None),
subscription_no: str | None = Query(None),
record_type: str | None = Query(None),
type: str | None = Query(None),
credit_subject: str | None = Query(None),
@@ -592,6 +609,7 @@ async def list_credit_records(
user_type=user_type,
frontend_user_kind=frontend_user_kind,
team_id=team_id,
subscription_no=subscription_no,
record_type=record_type or type,
credit_subject=credit_subject,
media_type=media_type,
@@ -806,107 +824,112 @@ async def batch_update_payment_configs(
@router.get("/payment-stats")
async def get_payment_stats(
payment_method: str | None = Query(None),
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
status: str | None = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return payment statistics for admin dashboard with filters."""
from sqlalchemy import func
# Ensure by_status has all expected statuses with defaults
"""支付统计:线上、后台线下和总真实收入可分别统计。"""
del admin
by_status = {
"pending": {"count": 0, "amount": 0.0},
"paid": {"count": 0, "amount": 0.0},
"cancelled": {"count": 0, "amount": 0.0},
"refunded": {"count": 0, "amount": 0.0},
"pending": {"label": "待支付", "count": 0, "amount": 0.0},
"paid": {"label": "已支付", "count": 0, "amount": 0.0},
"cancelled": {"label": "已取消", "count": 0, "amount": 0.0},
"expired": {"label": "已过期", "count": 0, "amount": 0.0},
"failed": {"label": "失败", "count": 0, "amount": 0.0},
"refunded": {"label": "已退款", "count": 0, "amount": 0.0},
}
# Parse dates and build base query filters
now_cst = datetime.now(CST)
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST) if start_date else today_start
query_end = (
(datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
if end_date else today_end
)
# Default to today if no date range provided
query_start = today_start
query_end = today_end
if start_date:
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST)
if end_date:
query_end = (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
# Build filter list for status breakdown
breakdown_filters = []
filters = [PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
if payment_method:
breakdown_filters.append(PaymentOrder.payment_method == payment_method)
filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
filters.append(PaymentOrder.order_source == order_source)
if status:
breakdown_filters.append(PaymentOrder.status == status)
# Always apply date range to breakdown
breakdown_filters.append(PaymentOrder.created_at >= query_start)
breakdown_filters.append(PaymentOrder.created_at < query_end)
filters.append(PaymentOrder.status == status)
# Status breakdown
status_result = await db.execute(
select(
PaymentOrder.status,
func.count().label("count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"),
)
.where(*breakdown_filters)
select(PaymentOrder.status, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(*filters)
.group_by(PaymentOrder.status)
)
for row in status_result.all():
if row.status in by_status:
if row.status not in by_status:
by_status[row.status] = {
"count": row.count,
"amount": round(float(row.amount), 2)
"label": PAYMENT_STATUS_LABELS.get(row.status, "其他状态"),
"count": 0,
"amount": 0.0,
}
else:
# Map any unexpected status to cancelled
by_status["cancelled"]["count"] += row.count
by_status["cancelled"]["amount"] += round(float(row.amount), 2)
by_status[row.status]["count"] = int(row.count or 0)
by_status[row.status]["amount"] = round(float(row.amount or 0), 2)
# Today's stats (CST time zone) - independent of filter
today_result = await db.execute(
select(
func.count().label("paid_count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= today_start,
PaymentOrder.paid_at < today_end,
)
source_filters = [PaymentOrder.status == "paid", PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
if payment_method:
source_filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
source_filters.append(PaymentOrder.order_source == order_source)
source_result = await db.execute(
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(*source_filters)
.group_by(PaymentOrder.order_source)
)
today_row = today_result.one()
by_source = {
"online_payment": {"label": "线上支付", "count": 0, "amount": 0.0},
"admin_offline": {"label": "后台线下成交", "count": 0, "amount": 0.0},
}
for row in source_result.all():
target = by_source.setdefault(
row.order_source,
{"label": PAYMENT_ORDER_SOURCE_LABELS.get(row.order_source, "其他订单来源"), "count": 0, "amount": 0.0},
)
target["count"] = int(row.count or 0)
target["amount"] = round(float(row.amount or 0), 2)
total_income = {
"label": "总真实收入",
"count": sum(int(item["count"]) for item in by_source.values()),
"amount": round(sum(float(item["amount"]) for item in by_source.values()), 2),
}
async def _period_income(start_at: datetime, end_at: datetime) -> dict:
result = await db.execute(
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= start_at,
PaymentOrder.paid_at < end_at,
)
.group_by(PaymentOrder.order_source)
)
source_map = {row.order_source: (int(row.count or 0), round(float(row.amount or 0), 2)) for row in result.all()}
online_count, online_amount = source_map.get("online_payment", (0, 0.0))
offline_count, offline_amount = source_map.get("admin_offline", (0, 0.0))
return {
"paid_count": online_count + offline_count,
"paid_amount": round(online_amount + offline_amount, 2),
"online_paid_count": online_count,
"online_paid_amount": online_amount,
"offline_paid_count": offline_count,
"offline_paid_amount": offline_amount,
}
# Monthly cumulative stats
month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_result = await db.execute(
select(
func.count().label("paid_count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= month_start,
PaymentOrder.paid_at < month_end,
)
)
month_row = month_result.one()
return {
"by_status": by_status,
"today": {
"paid_count": today_row.paid_count,
"paid_amount": round(float(today_row.paid_amount), 2),
},
"month": {
"paid_count": month_row.paid_count,
"paid_amount": round(float(month_row.paid_amount), 2),
},
"by_source": by_source,
"total_income": total_income,
"today": await _period_income(today_start, today_end),
"month": await _period_income(month_start, month_end),
}
@@ -915,6 +938,7 @@ async def list_payment_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
payment_method: str | None = Query(None),
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
status: str | None = Query(None),
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
start_date: str | None = Query(None),
@@ -922,13 +946,15 @@ async def list_payment_orders(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return paginated payment orders for admin dashboard."""
"""管理后台统一订单列表;线上和后台线下成交均来自 payment_orders"""
del admin
query = select(PaymentOrder, User.username, User.phone).join(User, PaymentOrder.user_id == User.id)
count_query = select(func.count(PaymentOrder.id))
count_query = select(func.count(PaymentOrder.id)).join(User, PaymentOrder.user_id == User.id)
filters = []
if payment_method:
filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
filters.append(PaymentOrder.order_source == order_source)
if status:
filters.append(PaymentOrder.status == status)
if phone:
@@ -937,43 +963,68 @@ async def list_payment_orders(
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
if end_date:
filters.append(PaymentOrder.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST))
if filters:
query = query.where(*filters)
count_query = count_query.where(*filters)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
query.order_by(PaymentOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
rows = result.all()
items = [
{
"id": o.id,
"orderNo": o.order_no,
"order_no": o.order_no,
"userId": o.user_id,
"user_id": o.user_id,
"username": username,
"phone": user_phone,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
"paymentMethod": o.payment_method,
"payment_method": o.payment_method,
"status": o.status,
"tradeNo": o.trade_no,
"trade_no": o.trade_no,
"paidAt": o.paid_at.isoformat() if o.paid_at else None,
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
"createdAt": o.created_at.isoformat() if o.created_at else None,
"created_at": o.created_at.isoformat() if o.created_at else None,
}
for o, username, user_phone in rows
]
total = int((await db.execute(count_query)).scalar() or 0)
rows = (await db.execute(
query.order_by(PaymentOrder.created_at.desc(), PaymentOrder.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)).all()
items = []
for order, username, user_phone in rows:
payment_label = PAYMENT_METHOD_LABELS.get(order.payment_method, "其他支付方式")
if order.payment_method == "other" and order.offline_payment_detail:
payment_label = f"其他-线下收款({order.offline_payment_detail}"
items.append(
{
"id": order.id,
"orderNo": order.order_no,
"order_no": order.order_no,
"userId": order.user_id,
"user_id": order.user_id,
"username": username,
"phone": user_phone,
"amount": round(float(order.amount), 2),
"credits": round(float(order.credits), 2),
"quantity": int(order.quantity or 1),
"quoted_unit_price_snapshot": float(order.quoted_unit_price_snapshot) if order.quoted_unit_price_snapshot is not None else None,
"quoted_amount_snapshot": float(order.quoted_amount_snapshot) if order.quoted_amount_snapshot is not None else None,
"actual_unit_price_snapshot": float(order.actual_unit_price_snapshot) if order.actual_unit_price_snapshot is not None else None,
"paymentMethod": order.payment_method,
"payment_method": order.payment_method,
"payment_method_label": payment_label,
"order_source": order.order_source,
"order_source_label": PAYMENT_ORDER_SOURCE_LABELS.get(order.order_source, "其他订单来源"),
"status": order.status,
"status_label": PAYMENT_STATUS_LABELS.get(order.status, "其他状态"),
"product_id": order.product_id,
"product_type": order.product_type,
"product_name_snapshot": order.product_name_snapshot,
"team_id_snapshot": order.team_id_snapshot,
"fulfillment_status": order.fulfillment_status,
"fulfillment_status_label": FULFILLMENT_STATUS_LABELS.get(order.fulfillment_status, "其他履约状态") if order.fulfillment_status else None,
"tradeNo": order.trade_no,
"trade_no": order.trade_no,
"offline_trade_no": order.offline_trade_no,
"offline_payment_detail": order.offline_payment_detail,
"remark": order.remark,
"refund_amount": float(order.refund_amount) if order.refund_amount is not None else None,
"refund_trade_no": order.refund_trade_no,
"refund_entitlement_status": order.refund_entitlement_status,
"paidAt": order.paid_at.isoformat() if order.paid_at else None,
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
"refunded_at": order.refunded_at.isoformat() if order.refunded_at else None,
"createdAt": order.created_at.isoformat() if order.created_at else None,
"created_at": order.created_at.isoformat() if order.created_at else None,
}
)
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.put("/payment-configs/{config_id}")
async def update_payment_config(
config_id: str,
@@ -1024,25 +1075,14 @@ async def refund_payment_order(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Refund a paid payment order."""
result = await process_refund(db, order_no)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "退款失败"))
await log_operation(
db,
admin.id,
admin.username,
f"订单退款: {order_no}",
"POST",
f"/admin/payment-orders/{order_no}/refund",
detail=json.dumps(
{
"order_no": order_no,
},
ensure_ascii=False,
),
)
return result
"""保留退款 API 路由兼容旧客户端,但本版本明确不开放主动订单退款。"""
del admin
exists = (await db.execute(
select(PaymentOrder.id).where(PaymentOrder.order_no == order_no).limit(1)
)).scalar_one_or_none()
if not exists:
raise HTTPException(status_code=404, detail="订单不存在")
raise HTTPException(status_code=409, detail="当前版本暂未开放订单退款")
# ── Industry Config ──────────────────────────────────────
+69 -2
View File
@@ -4,7 +4,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.credit.balance import UserCreditBalance
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit_record import CreditRecord
from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
CreditScope,
)
from app.enums.credit_record import CREDIT_RECORD_BILLING_SCENE_LABELS, CREDIT_RECORD_TYPE_LABELS
from app.models.credit_ratio import CreditRatio
from app.models.image_engine import ImageEngine
from app.models.user import User
@@ -28,8 +37,13 @@ router = APIRouter(prefix="/credits", tags=["credits"])
def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItemOut:
return CreditBalanceItemOut(
id=item.id,
credit_scope=item.credit_scope,
credit_scope_label=CREDIT_SCOPE_LABELS.get(item.credit_scope, "其他积分"),
team_id=item.team_id,
credit_level=item.credit_level,
credit_level_label=CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"),
source_type=item.source_type,
source_type_label=CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"),
source_id=item.source_id,
product_id=item.product_id,
payment_order_id=item.payment_order_id,
@@ -44,10 +58,58 @@ def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItem
expires_at=item.expires_at,
last_usable_at=last_usable_at(item.expires_at),
status=effective_balance_status(item, request_time=checked_at),
status_label=CREDIT_BALANCE_STATUS_LABELS.get(
effective_balance_status(item, request_time=checked_at), "其他状态"
),
created_at=item.created_at,
)
async def _records_with_scope_amounts(db: AsyncSession, records: list[CreditRecord]) -> list[dict]:
if not records:
return []
ids = [item.id for item in records]
result = await db.execute(
select(
CreditRecordAllocation.credit_record_id,
CreditRecordAllocation.credit_scope_snapshot,
func.coalesce(func.sum(CreditRecordAllocation.amount), 0).label("amount"),
)
.where(CreditRecordAllocation.credit_record_id.in_(ids))
.group_by(CreditRecordAllocation.credit_record_id, CreditRecordAllocation.credit_scope_snapshot)
)
scope_map: dict[str, dict[str, float]] = {}
for row in result.all():
scope_map.setdefault(str(row.credit_record_id), {})[str(row.credit_scope_snapshot)] = float(row.amount or 0)
output = []
for record in records:
parts = scope_map.get(record.id, {})
sign = -1.0 if float(record.amount or 0) < 0 else 1.0
output.append({
"id": record.id,
"type": record.type,
"type_label": CREDIT_RECORD_TYPE_LABELS.get(record.type, "其他"),
"amount": float(record.amount),
"personal_amount": sign * float(parts.get("personal", 0)),
"team_amount": sign * float(parts.get("team", 0)),
"balance_delta": float(record.balance_delta or 0),
"expired_amount": float(record.expired_amount or 0),
"balance_after": float(record.balance_after or 0),
"description": record.description,
"billing_scene": record.billing_scene,
"billing_scene_label": CREDIT_RECORD_BILLING_SCENE_LABELS.get(record.billing_scene, "其他场景") if record.billing_scene else None,
"scene_name_snapshot": record.scene_name_snapshot,
"input_tokens": record.input_tokens,
"output_tokens": record.output_tokens,
"total_tokens": record.total_tokens,
"llm_call_count": record.llm_call_count,
"llm_success_call_count": record.llm_success_call_count,
"llm_failed_call_count": record.llm_failed_call_count,
"created_at": record.created_at,
})
return output
@router.get("")
async def get_credits(
page: int = Query(1, ge=1),
@@ -68,7 +130,7 @@ async def get_credits(
total_granted, total_consumed, total_refunded, total_expired = totals_result.one()
return {
**summary.to_dict(),
"records": [CreditRecordOut.model_validate(r) for r in records],
"records": await _records_with_scope_amounts(db, records),
"total": total,
"total_granted": float(total_granted or 0),
"total_consumed": float(total_consumed or 0),
@@ -86,7 +148,12 @@ async def list_credit_balances(
db: AsyncSession = Depends(get_db),
):
checked_at = utc_now()
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == current_user.id)
# 通用积分页只展示用户自己的个人积分批次。团队资金池归属成交时队长,
# 不能因为 Balance.owner 是队长就在这里展示整个团队资金池;团队席位与资金池明细统一在团队管理页查看。
stmt = select(UserCreditBalance).where(
UserCreditBalance.user_id == current_user.id,
UserCreditBalance.credit_scope == CreditScope.PERSONAL.value,
)
stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
stmt = stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
result = await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))
+26 -15
View File
@@ -10,7 +10,6 @@ from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.payment_order import PaymentOrder
from app.models.credit.product import CreditProduct
from app.services.credit.upgrade_service import release_upgrade_reservation
from app.services.credit.utils import utc_now
from app.schemas.payment import RechargeRequest, PaymentOrderOut
from app.services.payment import (
@@ -60,6 +59,7 @@ async def recharge(
select(CreditProduct).where(
CreditProduct.id == req.plan,
CreditProduct.is_active.is_(True),
CreditProduct.deleted_at.is_(None),
).limit(1)
)
product = result.scalar_one_or_none()
@@ -71,6 +71,7 @@ async def recharge(
current_user.id,
method=req.method,
product_id=product.id,
quantity=req.quantity,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -243,28 +244,39 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
)
return {"code": "SUCCESS", "message": "OK"}
# 处理退款回调
# 处理退款回调:本版本只记录渠道退款事实,不撤销订阅、Period、Seat、积分或首购资格。
elif event_type == "REFUND.SUCCESS":
order_no = decrypted_data.get("out_trade_no", "")
refund_id = decrypted_data.get("refund_id", "")
refund_status = decrypted_data.get("status", "")
refund_amount_info = decrypted_data.get("amount", {}) or {}
refund_amount = refund_amount_info.get("refund", 0) / 100
if order_no and refund_status == "SUCCESS":
# 更新订单状态为已退款
from app.models import PaymentOrder
from sqlalchemy import select
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no))
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.order_no == order_no)
.with_for_update()
.limit(1)
)
order = result.scalar_one_or_none()
if order and order.status == "refunding":
if order:
# 幂等记录渠道退款事实:即便升级前本地已经写成 refunded,
# 也要补齐渠道退款号/金额/时间;本版本绝不触碰任何订阅或积分权益。
order.status = "refunded"
order.transaction_id = refund_id
if refund_id:
order.refund_trade_no = refund_id
if refund_amount > 0:
order.refund_amount = refund_amount
elif order.refund_amount is None:
order.refund_amount = order.amount
if order.refunded_at is None:
order.refunded_at = utc_now()
order.refund_entitlement_status = "record_only"
await db.commit()
logger.info(
f"WeChat refund callback processed: order_no={order_no}, "
f"refund_id={refund_id}, status={refund_status}"
f"WECHAT_REFUND_CALLBACK_RECORDED order_no={order_no} "
f"refund_id={refund_id} amount={refund_amount} entitlement=record_only"
)
return {"code": "SUCCESS", "message": "OK"}
@@ -325,6 +337,7 @@ async def list_orders(
conditions.append(PaymentOrder.status == status_filter)
if invoice_mode:
conditions.append(PaymentOrder.status == "paid")
conditions.append(PaymentOrder.order_source == "online_payment")
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
conditions.append(PaymentOrder.created_at >= start_dt)
@@ -451,8 +464,6 @@ async def cancel_order(
)
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(db, order=order, released_at=utc_now())
await db.flush()
logger.info(
f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}"
@@ -22,6 +22,7 @@ async def list_active_packages(
.where(
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
CreditProduct.is_active.is_(True),
CreditProduct.deleted_at.is_(None),
)
.order_by(CreditProduct.sort_order.asc(), CreditProduct.id.asc())
)
+267 -208
View File
@@ -1,38 +1,74 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
import csv
import io
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
from app.dependencies import get_current_user, get_db, get_optional_current_user
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
from app.schemas.team_join_request import (
JoinByCodeRequest,
JoinRequestHandle,
JoinRequestOut,
JoinTeamInfoOut,
)
from app.schemas.team_manager import (
ManagerTransferRequest,
)
from app.schemas.team_join_request import JoinByCodeRequest, JoinRequestHandle, JoinRequestOut, JoinTeamInfoOut
from app.schemas.team_manager import SetManagerRequest
from app.schemas.team_subscription import TeamSeatCreateRequest, TeamSeatUpdateRequest
from app.services import team_invitation_service
from app.services.credit.team_subscription_service import (
cancel_seat,
create_seat,
list_member_period_usage,
list_team_subscriptions_for_management,
update_seat,
)
from app.services.team_credit_record_service import list_team_credit_records as query_team_credit_records
from app.services.team_manager_service import (
get_managed_team,
get_manager_history,
get_team_members,
list_manager_access_teams,
set_team_manager,
)
router = APIRouter(prefix="/team", tags=["team"])
CST = timezone(timedelta(hours=8))
def _team_payload(team: Team, *, manager_name: str | None, member_count: int) -> dict:
status = team.status or TeamStatus.ACTIVE.value
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": status,
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
"is_read_only": status == TeamStatus.DISABLED.value,
"team_credit_frozen": status == TeamStatus.DISABLED.value,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
async def _resolve_flow_team_id(db: AsyncSession, *, current_user: User, team_id: str | None) -> str:
if team_id:
# 真正的当前/历史队长权限由流水 Service 根据 TeamManagerHistory 再校验。
return team_id
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=400, detail="请指定需要查看的历史团队")
return team.id
# ── 获取当前用户管理的团队 ──────────────────────────────
@router.get("/managed")
async def get_managed_team_info(
current_user: User = Depends(get_current_user),
@@ -40,30 +76,25 @@ async def get_managed_team_info(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=404, detail="您不是任何团队的管理人")
from sqlalchemy import func
from app.enums.user import UserType
raise HTTPException(status_code=404, detail="当前不是任何团队的队长")
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
return _team_payload(team, manager_name=current_user.username, member_count=int(member_count))
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": team.status,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": current_user.username,
}
@router.get("/manager-access")
async def list_manager_access(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""当前及历史队长可访问的团队列表,用于历史团队流水入口。"""
return await list_manager_access_teams(db, current_user.id)
# ── 团队成员列表 ──────────────────────────────────────
@router.get("/members")
async def list_team_members(
page: int = Query(1, ge=1),
@@ -73,23 +104,123 @@ async def list_team_members(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看成员列表")
return await get_team_members(db, team.id, page=page, page_size=page_size)
# ── 转账积分给成员 ────────────────────────────────────
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: ManagerTransferRequest,
@router.put("/manager")
async def transfer_manager(
req: SetManagerRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以转让队长")
await set_team_manager(db, team.id, req.user_id)
return {"message": "团队队长已更换"}
# ── 邀请码管理 ────────────────────────────────────────
@router.post("/invitations", )
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: dict = Body(default_factory=dict),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
del member_id, req, current_user, db
raise HTTPException(status_code=409, detail="当前版本不支持团队积分转账,请使用团队订阅席位额度")
@router.get("/subscriptions")
async def list_team_subscriptions(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
return await list_team_subscriptions_for_management(db, team_id=team.id)
@router.post("/subscriptions/{subscription_id}/seats")
async def create_subscription_seat(
subscription_id: str,
req: TeamSeatCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
seat = await create_seat(
db,
team_id=team.id,
subscription_id=subscription_id,
manager_user_id=current_user.id,
user_id=req.user_id,
monthly_allocated_credits=req.monthly_allocated_credits,
)
return {"message": "席位已创建", "seat_id": seat.id}
@router.put("/seats/{seat_id}")
async def update_subscription_seat(
seat_id: str,
req: TeamSeatUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
seat = await update_seat(
db,
team_id=team.id,
seat_id=seat_id,
manager_user_id=current_user.id,
monthly_allocated_credits=req.monthly_allocated_credits,
)
return {"message": "席位额度已更新", "seat_id": seat.id}
@router.delete("/seats/{seat_id}")
async def cancel_subscription_seat(
seat_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
await cancel_seat(db, team_id=team.id, seat_id=seat_id, manager_user_id=current_user.id)
return {"message": "席位已取消"}
@router.get("/member-usage")
async def get_member_usage(
subscription_id: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看成员团队积分消耗")
return await list_member_period_usage(db, team_id=team.id, subscription_id=subscription_id)
@router.get("/manager-history")
async def manager_history(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看完整队长任期历史")
return await get_manager_history(db, team.id)
@router.post("/invitations")
async def create_invitation(
req: TeamInvitationCreate,
current_user: User = Depends(get_current_user),
@@ -97,15 +228,13 @@ async def create_invitation(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可创建邀请码")
raise HTTPException(status_code=403, detail="只有团队队长可创建邀请码")
expires_at = None
if req.expires_at:
try:
expires_at = datetime.fromisoformat(req.expires_at)
except ValueError:
raise HTTPException(status_code=400, detail="过期时间格式错误")
except ValueError as exc:
raise HTTPException(status_code=400, detail="过期时间格式错误") from exc
invitation = await team_invitation_service.create_invitation(
db, team.id, current_user.id, req.max_uses, expires_at
)
@@ -128,7 +257,7 @@ async def list_invitations(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有团队队长可查看邀请码")
invitations = await team_invitation_service.get_invitations_for_team(db, team.id)
return [
{
@@ -152,10 +281,9 @@ async def revoke_invitation(
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.revoke_invitation(db, invitation_id, current_user.id)
return {"message": "ok"}
return {"message": "邀请码已撤销"}
# ── 加入申请 ──────────────────────────────────────────
@router.post("/join")
async def join_by_code(
req: JoinByCodeRequest,
@@ -163,42 +291,43 @@ async def join_by_code(
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.create_join_request(db, current_user.id, req.invitation_code)
return {"message": "申请已提交,请等待团队管理人审批"}
return {"message": "申请已提交,请等待团队队长审批"}
@router.get("/join-info", )
async def get_join_info(
code: str = Query(...),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
"""验证邀请码并返回团队信息(用于加入页面展示)。"""
async def _join_info_payload(
db: AsyncSession,
*,
code: str,
current_user: User | None,
) -> JoinTeamInfoOut:
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return JoinTeamInfoOut(team_name="", team_id="", valid=False, already_in_team=False, has_pending_request=False)
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
team_result = await db.execute(
select(Team).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
already_in_team = current_user and current_user.team_id == invitation.team_id
team = team_result.scalar_one_or_none()
if not team or team.status != TeamStatus.ACTIVE.value:
return JoinTeamInfoOut(
team_name=team.name if team else "",
team_id=invitation.team_id,
valid=False,
already_in_team=False,
has_pending_request=False,
)
already_in_team = bool(current_user and current_user.team_id == invitation.team_id)
has_pending_request = False
if current_user:
from app.models.team_join_request import TeamJoinRequest
pending = await db.execute(
select(TeamJoinRequest).where(
select(TeamJoinRequest.id).where(
TeamJoinRequest.user_id == current_user.id,
TeamJoinRequest.team_id == invitation.team_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
has_pending = pending.scalar_one_or_none()
has_pending_request = has_pending is not None
has_pending_request = pending.scalar_one_or_none() is not None
return JoinTeamInfoOut(
team_name=team_name,
team_name=team.name,
team_id=invitation.team_id,
valid=True,
already_in_team=already_in_team,
@@ -206,31 +335,21 @@ async def get_join_info(
)
@router.get("/join-info/public", )
async def get_join_info_public(
@router.get("/join-info")
async def get_join_info(
code: str = Query(...),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
"""公开接口:验证邀请码并返回团队信息(无需登录)。"""
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return {"team_name": "", "team_id": "", "valid": False, "already_in_team": False, "has_pending_request": False}
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
return {
"team_name": team_name,
"team_id": invitation.team_id,
"valid": True,
"already_in_team": False,
"has_pending_request": False,
}
return await _join_info_payload(db, code=code, current_user=current_user)
@router.get("/join-requests", )
@router.get("/join-info/public")
async def get_join_info_public(code: str = Query(...), db: AsyncSession = Depends(get_db)):
return await _join_info_payload(db, code=code, current_user=None)
@router.get("/join-requests")
async def list_join_requests(
status: str | None = Query(None),
current_user: User = Depends(get_current_user),
@@ -238,29 +357,22 @@ async def list_join_requests(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有团队队长可查看加入申请")
requests = await team_invitation_service.get_all_requests(db, team.id, status)
# 获取团队名
team_name_result = await db.execute(
select(Team.name).where(Team.id == team.id).limit(1)
)
team_name = team_name_result.scalar_one_or_none() or ""
return [
JoinRequestOut(
id=r["id"],
team_id=r["team_id"],
team_name=team_name,
user_id=r["user_id"],
username=r["username"],
phone=r.get("phone"),
status=r["status"],
note=r.get("note"),
created_at=r["created_at"],
handled_at=r.get("handled_at"),
id=item["id"],
team_id=item["team_id"],
team_name=team.name,
user_id=item["user_id"],
username=item["username"],
phone=item.get("phone"),
status=item["status"],
note=item.get("note"),
created_at=item["created_at"],
handled_at=item.get("handled_at"),
)
for r in requests
for item in requests
]
@@ -271,157 +383,104 @@ async def handle_join_request(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.handle_join_request(
db, request_id, current_user.id, req.action, req.note
)
return {"message": "ok"}
await team_invitation_service.handle_join_request(db, request_id, current_user.id, req.action, req.note)
return {"message": "申请已处理"}
# ── 团队积分变动记录 ────────────────────────────────────
@router.get("/credit-records")
async def list_team_credit_records(
team_id: str | None = Query(None, description="历史队长查看旧团队时传团队ID"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None),
phone: str | None = Query(None, description="按手机号搜索"),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
subscription_id: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
end_date: str | None = Query(None, description="截止日期 YYYY-MM-DD"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查看团队所有成员的积分变动记录(仅管理人)。"""
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
# 如果传了 phone,先找到对应的 user_id
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
if phone and not resolved_user_id:
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
resolved_user_id = phone_result.scalar_one_or_none()
if not resolved_user_id:
return {"items": [], "total": 0, "summary": {}}
return await list_admin_credit_records(
return {"items": [], "total": 0, "page": page, "page_size": page_size}
return await query_team_credit_records(
db,
team_id=resolved_team_id,
viewer_user_id=current_user.id,
page=page,
page_size=page_size,
team_id=team.id,
user_id=resolved_user_id,
member_user_id=resolved_user_id,
subscription_id=subscription_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# ── 团队积分导出 Excel ──────────────────────────────────
@router.get("/credit-records/export")
async def export_team_credit_records(
team_id: str | None = Query(None),
user_id: str | None = Query(None),
phone: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
subscription_id: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""导出团队积分变动记录为 Excel(仅管理人)。"""
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
if phone and not resolved_user_id:
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
resolved_user_id = phone_result.scalar_one_or_none()
# 拉取全部记录(不分页)
result = await list_admin_credit_records(
if not resolved_user_id:
# 导出筛选手机号不存在时必须返回空结果,不能因为 user_id=None 退化成导出整个团队流水。
resolved_user_id = "__not_found__"
result = await query_team_credit_records(
db,
team_id=resolved_team_id,
viewer_user_id=current_user.id,
page=1,
page_size=10000,
team_id=team.id,
user_id=resolved_user_id,
member_user_id=resolved_user_id,
subscription_id=subscription_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# 生成 CSV(兼容 Excel 打开,UTF-8 BOM
import csv
import io
from datetime import datetime as _dt
def _format_dt(val):
if val is None:
return "-"
try:
# 情况 1:已经是 datetime
if isinstance(val, _dt):
dt = val
elif isinstance(val, (int, float)):
# 情况 2:Unix 时间戳(极少,兼容旧代码)
dt = _dt.fromtimestamp(val)
elif isinstance(val, str):
# 情况 3ISO 字符串(admin_credit_record_service._iso 返回的格式)
s = val.strip()
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = _dt.fromisoformat(s)
except ValueError:
# 兼容旧格式 YYYY-MM-DD HH:MM:SS
dt = _dt.strptime(s, "%Y-%m-%d %H:%M:%S")
else:
return str(val)
# 统一转东八区展示
if getattr(dt, "tzinfo", None) is None:
dt = dt.replace(tzinfo=CST)
else:
dt = dt.astimezone(CST)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception: # noqa: BLE001
return str(val) if val else "-"
team_result = await db.execute(select(Team).where(Team.id == resolved_team_id).limit(1))
team = team_result.scalar_one_or_none()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["用户名", "手机号", "类型", "积分变动", "余额", "说明", "时间"])
writer.writerow(["用户名", "流水类型", "团队积分变动", "说明", "订阅实例", "周期ID", "席位ID", "时间"])
for item in result.get("items", []):
created_at = item.get("created_at")
if isinstance(created_at, datetime):
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=CST)
else:
created_at = created_at.astimezone(CST)
created_at = created_at.strftime("%Y-%m-%d %H:%M:%S")
writer.writerow([
item.get("username") or "-",
item.get("phone") or "-",
item.get("record_type_label") or item.get("type") or "-",
item.get("amount", 0),
item.get("balance_after", 0),
item.get("record_type_label") or "-",
item.get("team_amount", 0),
item.get("description") or "-",
_format_dt(item.get("created_at")),
item.get("subscription_no") or "历史订阅",
item.get("subscription_period_id") or "-",
item.get("seat_id") or "-",
created_at or "-",
])
from starlette.responses import StreamingResponse
from urllib.parse import quote
filename = f"团队积分_{(team.name if team else resolved_team_id)}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
output.seek(0)
safe_team_name = team.name or "team"
filename = f"团队积分_{safe_team_name}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
encoded_filename = quote(filename)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8-sig",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
iter(["\ufeff" + output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
+28
View File
@@ -133,3 +133,31 @@ class BillingBlockEventEnum(StrEnum):
INSUFFICIENT_CREDITS = "BILLING_BLOCKED_INSUFFICIENT_CREDITS"
NEGATIVE_BALANCE = "BILLING_BLOCKED_NEGATIVE_BALANCE"
class PaymentOrderSourceEnum(StrEnum):
"""订单来源。"""
ONLINE_PAYMENT = "online_payment"
ADMIN_OFFLINE = "admin_offline"
PAYMENT_ORDER_SOURCE_LABELS = {
PaymentOrderSourceEnum.ONLINE_PAYMENT.value: "线上支付",
PaymentOrderSourceEnum.ADMIN_OFFLINE.value: "后台线下成交",
}
class OfflinePaymentMethodEnum(StrEnum):
"""后台线下收款方式。"""
BANK_TRANSFER = "bank_transfer"
CASH = "cash"
OTHER = "other"
OFFLINE_PAYMENT_METHOD_LABELS = {
OfflinePaymentMethodEnum.BANK_TRANSFER.value: "银行转账",
OfflinePaymentMethodEnum.CASH.value: "现金",
OfflinePaymentMethodEnum.OTHER.value: "其他-线下收款",
}
+17 -4
View File
@@ -19,6 +19,23 @@ CREDIT_LEVEL_LABELS = {
}
class CreditScope(StrEnum):
PERSONAL = "personal"
TEAM = "team"
CREDIT_SCOPE_LABELS = {
CreditScope.PERSONAL.value: "个人积分",
CreditScope.TEAM.value: "团队积分",
}
CREDIT_SCOPE_SORT = {
CreditScope.TEAM.value: 10,
CreditScope.PERSONAL.value: 20,
}
class CreditBalanceSourceType(StrEnum):
REGISTER_GIFT = "register_gift"
DAILY_LOGIN = "daily_login"
@@ -70,8 +87,6 @@ class CreditAllocationAction(StrEnum):
REFUND_EXPIRED = "refund_expired"
EXPIRE = "expire"
REVOKE = "revoke"
UPGRADE_SOURCE_TRANSFER_OUT = "upgrade_source_transfer_out"
UPGRADE_SOURCE_TRANSFER_IN = "upgrade_source_transfer_in"
CREDIT_ALLOCATION_ACTION_LABELS = {
@@ -81,6 +96,4 @@ CREDIT_ALLOCATION_ACTION_LABELS = {
CreditAllocationAction.REFUND_EXPIRED.value: "过期积分退款",
CreditAllocationAction.EXPIRE.value: "积分过期",
CreditAllocationAction.REVOKE.value: "积分撤销",
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value: "升级积分转出",
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value: "升级积分转入",
}
+30 -1
View File
@@ -5,15 +5,30 @@ from enum import StrEnum
class CreditProductType(StrEnum):
SUBSCRIPTION = "subscription"
TEAM_SUBSCRIPTION = "team_subscription"
CREDIT_ADDON = "credit_addon"
CREDIT_PRODUCT_TYPE_LABELS = {
CreditProductType.SUBSCRIPTION.value: "个人订阅套餐",
CreditProductType.TEAM_SUBSCRIPTION.value: "团队订阅套餐",
CreditProductType.CREDIT_ADDON.value: "积分增值包",
}
class SubscriptionBillingCycle(StrEnum):
MONTHLY = "monthly"
QUARTERLY = "quarterly"
YEARLY = "yearly"
SUBSCRIPTION_BILLING_CYCLE_LABELS = {
SubscriptionBillingCycle.MONTHLY.value: "月卡",
SubscriptionBillingCycle.QUARTERLY.value: "季卡",
SubscriptionBillingCycle.YEARLY.value: "年卡",
}
SUBSCRIPTION_GRANT_COUNT = {
SubscriptionBillingCycle.MONTHLY.value: 1,
SubscriptionBillingCycle.QUARTERLY.value: 3,
@@ -28,8 +43,22 @@ class SubscriptionTierCode(StrEnum):
SUPER = "super"
SUBSCRIPTION_TIER_LABELS = {
SubscriptionTierCode.STARTER.value: "入门",
SubscriptionTierCode.STANDARD.value: "标准",
SubscriptionTierCode.ADVANCED.value: "高级",
SubscriptionTierCode.SUPER.value: "超级",
}
class ProductPriceType(StrEnum):
FIRST_PURCHASE = "first_purchase"
REGULAR = "regular"
ACTIVITY = "activity"
UPGRADE = "upgrade"
PRODUCT_PRICE_TYPE_LABELS = {
ProductPriceType.FIRST_PURCHASE.value: "首购价",
ProductPriceType.REGULAR.value: "常规价",
ProductPriceType.ACTIVITY.value: "活动价",
}
+16 -6
View File
@@ -7,17 +7,27 @@ class CreditSubscriptionStatus(StrEnum):
PENDING = "pending"
ACTIVE = "active"
EXPIRED = "expired"
UPGRADED = "upgraded"
CANCELLED = "cancelled"
REFUNDED = "refunded"
UPGRADE_RECONCILE_FAILED = "upgrade_reconcile_failed"
CREDIT_SUBSCRIPTION_STATUS_LABELS = {
CreditSubscriptionStatus.PENDING.value: "待生效",
CreditSubscriptionStatus.ACTIVE.value: "生效中",
CreditSubscriptionStatus.EXPIRED.value: "已到期",
CreditSubscriptionStatus.CANCELLED.value: "已取消",
}
class CreditSubscriptionPeriodStatus(StrEnum):
SCHEDULED = "scheduled"
UPGRADE_RESERVED = "upgrade_reserved"
GRANTED = "granted"
CANCELLED_BY_UPGRADE = "cancelled_by_upgrade"
REVOKED_BY_UPGRADE = "revoked_by_upgrade"
CANCELLED = "cancelled"
EXPIRED = "expired"
CREDIT_SUBSCRIPTION_PERIOD_STATUS_LABELS = {
CreditSubscriptionPeriodStatus.SCHEDULED.value: "待发放",
CreditSubscriptionPeriodStatus.GRANTED.value: "已发放",
CreditSubscriptionPeriodStatus.CANCELLED.value: "已取消",
CreditSubscriptionPeriodStatus.EXPIRED.value: "已到期",
}
+13
View File
@@ -25,5 +25,18 @@ TEAM_JOIN_REQUEST_STATUS_LABELS = {
}
class TeamSeatStatus(str, Enum):
ACTIVE = "active"
CANCELLED = "cancelled"
EXPIRED = "expired"
TEAM_SEAT_STATUS_LABELS = {
TeamSeatStatus.ACTIVE.value: "使用中",
TeamSeatStatus.CANCELLED.value: "已取消",
TeamSeatStatus.EXPIRED.value: "已到期",
}
# 前端筛选“未分配团队”时使用的稳定哨兵值,不与真实团队ID混用。
TEAM_UNASSIGNED_VALUE = "__none__"
+3
View File
@@ -1,6 +1,7 @@
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
from app.models.user import User
from app.models.team import Team
from app.models.team_manager_history import TeamManagerHistory
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.project import Project
@@ -20,6 +21,7 @@ from app.models.recharge_package import RechargePackage
from app.models.credit import (
CreditProduct, CreditRecordAllocation, UserCreditBalance,
UserCreditSubscription, UserCreditSubscriptionPeriod,
TeamSubscriptionSeat, TeamSubscriptionSeatUsage,
)
from app.models.llm_billing import LlmBillingPolicyModel, LlmBillingExecution, LlmCallAttempt
from app.models.operation_log import OperationLog
@@ -56,6 +58,7 @@ __all__ = [
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "CreditProduct", "CreditRecordAllocation", "UserCreditBalance",
"UserCreditSubscription", "UserCreditSubscriptionPeriod",
"TeamSubscriptionSeat", "TeamSubscriptionSeatUsage", "TeamManagerHistory",
"LlmBillingPolicyModel", "LlmBillingExecution", "LlmCallAttempt",
"OperationLog", "ContactRequest",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "VideoUpscaleTask",
@@ -3,6 +3,8 @@ from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.credit.team_seat import TeamSubscriptionSeat
from app.models.credit.team_seat_usage import TeamSubscriptionSeatUsage
__all__ = [
"CreditRecordAllocation",
@@ -10,4 +12,6 @@ __all__ = [
"CreditProduct",
"UserCreditSubscription",
"UserCreditSubscriptionPeriod",
"TeamSubscriptionSeat",
"TeamSubscriptionSeatUsage",
]
+22 -1
View File
@@ -6,6 +6,7 @@ from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_balance import CreditScope
from app.models.base import Base, TimestampMixin
@@ -16,6 +17,15 @@ class CreditRecordAllocation(Base, TimestampMixin):
Index("ix_credit_record_allocations_balance", "credit_balance_id", "created_at"),
Index("ix_credit_record_allocations_user_time", "user_id", "created_at"),
Index("ix_credit_record_allocations_source_allocation", "source_allocation_id"),
Index("ix_credit_record_allocations_team_time", "team_id_snapshot", "created_at", "id"),
Index(
"ix_credit_record_allocations_team_manager_time",
"team_id_snapshot", "team_manager_id_snapshot", "created_at", "id",
),
Index(
"ix_credit_record_allocations_team_period_user",
"subscription_period_id_snapshot", "user_id", "allocation_action",
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
@@ -26,7 +36,8 @@ class CreditRecordAllocation(Base, TimestampMixin):
String(32), ForeignKey("user_credit_balances.id", ondelete="RESTRICT"), nullable=False
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
comment="真实业务消费者;发放类记录为资金所有人",
)
source_allocation_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_record_allocations.id", ondelete="SET NULL"), nullable=True
@@ -36,8 +47,18 @@ class CreditRecordAllocation(Base, TimestampMixin):
request_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
credit_level_snapshot: Mapped[str] = mapped_column(String(32), nullable=False)
credit_scope_snapshot: Mapped[str] = mapped_column(
String(16), nullable=False, default=CreditScope.PERSONAL.value,
server_default=CreditScope.PERSONAL.value,
)
source_type_snapshot: Mapped[str] = mapped_column(String(48), nullable=False)
source_id_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
team_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
team_manager_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
subscription_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
subscription_period_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
seat_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
valid_from_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+25 -10
View File
@@ -6,7 +6,7 @@ from decimal import Decimal
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, JSON, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_balance import CreditBalanceStatus
from app.enums.credit_balance import CreditBalanceStatus, CreditScope
from app.models.base import Base, TimestampMixin
@@ -23,19 +23,25 @@ class UserCreditBalance(Base, TimestampMixin):
name="ck_user_credit_balances_amount_reconciled",
),
CheckConstraint("expires_at > valid_from", name="ck_user_credit_balances_valid_window"),
CheckConstraint(
"(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)",
name="ck_user_credit_balances_scope_fields",
),
Index(
"ix_user_credit_balances_spendable",
"user_id",
"credit_level_rank",
"expires_at",
"valid_from",
"id",
"user_id", "credit_scope", "credit_level_rank", "expires_at", "valid_from", "id",
postgresql_where=text("unspent_amount > 0 AND revoked_at IS NULL"),
),
Index(
"ix_user_credit_balances_team_spendable",
"team_id", "subscription_id", "subscription_period_id", "credit_level_rank", "expires_at", "id",
postgresql_where=text("credit_scope = 'team' AND unspent_amount > 0 AND revoked_at IS NULL"),
),
Index(
"ix_user_credit_balances_expire_due",
"expires_at",
"id",
"expires_at", "id",
postgresql_where=text(
"unspent_amount > 0 AND expired_processed_at IS NULL AND revoked_at IS NULL"
),
@@ -48,7 +54,15 @@ class UserCreditBalance(Base, TimestampMixin):
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True,
comment="资金所有人;团队积分为成交时队长",
)
credit_scope: Mapped[str] = mapped_column(
String(16), nullable=False, default=CreditScope.PERSONAL.value,
server_default=CreditScope.PERSONAL.value,
)
team_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=True
)
credit_level: Mapped[str] = mapped_column(String(32), nullable=False)
credit_level_rank: Mapped[int] = mapped_column(nullable=False, default=20, server_default="20")
@@ -86,7 +100,8 @@ class UserCreditBalance(Base, TimestampMixin):
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
status: Mapped[str] = mapped_column(
String(24), nullable=False, default=CreditBalanceStatus.ACTIVE.value, server_default=CreditBalanceStatus.ACTIVE.value
String(24), nullable=False, default=CreditBalanceStatus.ACTIVE.value,
server_default=CreditBalanceStatus.ACTIVE.value,
)
expired_processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+46 -15
View File
@@ -8,22 +8,38 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_balance import CreditLevel
from app.enums.credit_product import CreditProductType
from app.models.base import Base, TimestampMixin
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class CreditProduct(Base, TimestampMixin):
class CreditProduct(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "credit_products"
__table_args__ = (
Index("uq_credit_products_code", "product_code", unique=True),
Index("ix_credit_products_public", "product_type", "is_active", "sort_order"),
Index("ix_credit_products_public", "product_type", "deleted_at", "is_active", "sort_order"),
CheckConstraint("price >= 0", name="ck_credit_products_price_nonnegative"),
CheckConstraint("first_purchase_price IS NULL OR first_purchase_price >= 0", name="ck_credit_products_first_price_nonnegative"),
CheckConstraint("regular_price IS NULL OR regular_price >= 0", name="ck_credit_products_regular_price_nonnegative"),
CheckConstraint("activity_price IS NULL OR activity_price >= 0", name="ck_credit_products_activity_price_nonnegative"),
CheckConstraint("monthly_grant_credits IS NULL OR monthly_grant_credits > 0", name="ck_credit_products_monthly_grant_positive"),
CheckConstraint("grant_credits IS NULL OR grant_credits > 0", name="ck_credit_products_grant_positive"),
CheckConstraint(
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"first_purchase_price IS NULL OR first_purchase_price >= 0",
name="ck_credit_products_first_price_nonnegative",
),
CheckConstraint(
"regular_price IS NULL OR regular_price >= 0",
name="ck_credit_products_regular_price_nonnegative",
),
CheckConstraint(
"activity_price IS NULL OR activity_price >= 0",
name="ck_credit_products_activity_price_nonnegative",
),
CheckConstraint(
"monthly_grant_credits IS NULL OR monthly_grant_credits > 0",
name="ck_credit_products_monthly_grant_positive",
),
CheckConstraint(
"grant_credits IS NULL OR grant_credits > 0",
name="ck_credit_products_grant_positive",
),
CheckConstraint(
"(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) "
@@ -49,7 +65,7 @@ class CreditProduct(Base, TimestampMixin):
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
features_json: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 订阅套餐字段
# 个人/团队订阅套餐字段
tier_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
tier_rank: Mapped[int | None] = mapped_column(nullable=True)
billing_cycle: Mapped[str | None] = mapped_column(String(24), nullable=True)
@@ -59,6 +75,7 @@ class CreditProduct(Base, TimestampMixin):
activity_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
activity_start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
activity_end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# 是否允许失去首购资格后的再次购买。不是自动续费开关,不会自动创建订单或扣款。
renewal_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
@@ -67,17 +84,31 @@ class CreditProduct(Base, TimestampMixin):
grant_credits: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
validity_months: Mapped[int | None] = mapped_column(nullable=True)
price: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
price: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
credit_level: Mapped[str] = mapped_column(
String(32), nullable=False, default=CreditLevel.GENERAL.value, server_default=CreditLevel.GENERAL.value
String(32), nullable=False, default=CreditLevel.GENERAL.value,
server_default=CreditLevel.GENERAL.value,
)
currency: Mapped[str] = mapped_column(
String(8), nullable=False, default="CNY", server_default="CNY"
)
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="CNY", server_default="CNY")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
sort_order: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
@property
def is_subscription(self) -> bool:
return self.product_type == CreditProductType.SUBSCRIPTION.value
return self.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}
@property
def is_team_subscription(self) -> bool:
return self.product_type == CreditProductType.TEAM_SUBSCRIPTION.value
@property
def is_credit_addon(self) -> bool:
@@ -13,16 +13,26 @@ from app.models.base import Base, TimestampMixin
class UserCreditSubscription(Base, TimestampMixin):
__tablename__ = "user_credit_subscriptions"
__table_args__ = (
Index("ix_user_credit_subscriptions_current", "user_id", "status", "expires_at"),
Index("ix_user_credit_subscriptions_user_active", "user_id", "status", "expires_at"),
Index("ix_user_credit_subscriptions_team_active", "team_id", "status", "expires_at"),
Index("ix_user_credit_subscriptions_expire_due", "status", "expires_at", "id"),
Index("ix_user_credit_subscriptions_grant_due", "status", "next_grant_at", "id"),
Index("uq_user_credit_subscriptions_payment", "payment_order_id", unique=True),
Index("uq_user_credit_subscriptions_no", "subscription_no", unique=True),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
subscription_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="订阅业务实例编号,供用户/客服/开发定位"
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True,
comment="成交时的个人用户;团队订阅为成交时队长",
)
team_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=True
)
team_manager_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
)
@@ -34,21 +44,27 @@ class UserCreditSubscription(Base, TimestampMixin):
server_default=CreditSubscriptionStatus.PENDING.value,
)
purchase_scene: Mapped[str] = mapped_column(String(24), nullable=False)
product_type_snapshot: Mapped[str] = mapped_column(String(24), nullable=False)
product_name_snapshot: Mapped[str] = mapped_column(String(96), nullable=False)
tier_code: Mapped[str] = mapped_column(String(32), nullable=False)
tier_rank: Mapped[int] = mapped_column(nullable=False)
billing_cycle: Mapped[str] = mapped_column(String(24), nullable=False)
anchor_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
next_grant_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
monthly_grant_credits_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
monthly_total_credits_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
quantity_snapshot: Mapped[int] = mapped_column(nullable=False, default=1, server_default="1")
grant_count: Mapped[int] = mapped_column(nullable=False)
granted_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
first_purchase_price_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
regular_price_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
activity_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
actual_unit_price_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
paid_amount_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
product_snapshot_json: Mapped[dict] = mapped_column(JSON, nullable=False)
source_subscription_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="SET NULL"), nullable=True
)
upgrade_order_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
@@ -15,8 +15,8 @@ class UserCreditSubscriptionPeriod(Base, TimestampMixin):
__table_args__ = (
Index("uq_user_credit_subscription_periods_sequence", "subscription_id", "sequence", unique=True),
Index("ix_user_credit_subscription_periods_due", "status", "scheduled_at", "id"),
Index("ix_user_credit_subscription_periods_upgrade", "upgrade_order_id", "status"),
Index("uq_user_credit_subscription_periods_balance", "issued_balance_id", unique=True),
Index("ix_user_credit_subscription_periods_window", "subscription_id", "valid_from", "expires_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
@@ -37,9 +37,4 @@ class UserCreditSubscriptionPeriod(Base, TimestampMixin):
String(32), ForeignKey("user_credit_balances.id", ondelete="SET NULL"), nullable=True
)
issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
upgrade_order_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
reserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class TeamSubscriptionSeat(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "team_subscription_seats"
__table_args__ = (
Index("ix_team_subscription_seats_subscription", "subscription_id", "created_at"),
Index("ix_team_subscription_seats_team_id", "team_id"),
CheckConstraint("monthly_allocated_credits > 0", name="ck_team_subscription_seat_allocation_positive"),
Index("ix_team_subscription_seats_user", "user_id", "subscription_id"),
Index(
"uq_team_subscription_seats_active_user",
"subscription_id", "user_id",
unique=True,
postgresql_where=text("deleted_at IS NULL AND cancelled_at IS NULL"),
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False
)
subscription_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
monthly_allocated_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
created_by_user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,34 @@
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import CheckConstraint, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class TeamSubscriptionSeatUsage(Base, TimestampMixin):
__tablename__ = "team_subscription_seat_usages"
__table_args__ = (
Index("uq_team_subscription_seat_usage_period", "seat_id", "subscription_period_id", unique=True),
CheckConstraint("used_credits >= 0", name="ck_team_subscription_seat_usage_nonnegative"),
Index("ix_team_subscription_seat_usage_member", "subscription_period_id", "user_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
seat_id: Mapped[str] = mapped_column(
String(32), ForeignKey("team_subscription_seats.id", ondelete="RESTRICT"), nullable=False
)
subscription_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False
)
subscription_period_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscription_periods.id", ondelete="RESTRICT"), nullable=False
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
used_credits: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
+29 -8
View File
@@ -3,9 +3,10 @@ from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.common import PaymentOrderSourceEnum
from app.models.base import Base, TimestampMixin
@@ -14,6 +15,8 @@ class PaymentOrder(Base, TimestampMixin):
__table_args__ = (
Index("idx_payorder_user_status_created", "user_id", "status", "created_at"),
Index("idx_payorder_status_created", "status", "created_at"),
Index("ix_payorder_source_status_created", "order_source", "status", "created_at"),
Index("ix_payorder_team_status_created", "team_id_snapshot", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
@@ -21,15 +24,22 @@ class PaymentOrder(Base, TimestampMixin):
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), unique=True)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, comment="实际整单实收金额")
credits: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
payment_method: Mapped[str] = mapped_column(String(16))
order_source: Mapped[str] = mapped_column(
String(24), nullable=False, default=PaymentOrderSourceEnum.ONLINE_PAYMENT.value,
server_default=PaymentOrderSourceEnum.ONLINE_PAYMENT.value,
)
status: Mapped[str] = mapped_column(String(32), default="pending")
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refund_amount: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
refund_entitlement_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True, index=True
@@ -40,11 +50,22 @@ class PaymentOrder(Base, TimestampMixin):
product_code_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
product_name_snapshot: Mapped[str | None] = mapped_column(String(96), nullable=True)
product_snapshot_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
quoted_unit_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
quoted_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
actual_unit_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 6), nullable=True)
team_id_snapshot: Mapped[str | None] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=True
)
operator_admin_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
offline_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
offline_payment_detail: Mapped[str | None] = mapped_column(String(128), nullable=True)
remark: Mapped[str | None] = mapped_column(String(512), nullable=True)
subscription_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
source_subscription_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
upgrade_period_ids_json: Mapped[list | None] = mapped_column(JSON, nullable=True)
target_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
deduction_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
payable_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
fulfillment_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
fulfilled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+9 -13
View File
@@ -1,4 +1,6 @@
from sqlalchemy import ForeignKey, Index, Integer, String
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.team import TeamStatus
@@ -17,21 +19,15 @@ class Team(Base, TimestampMixin, SoftDeleteMixin):
code: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="团队编码")
description: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="团队备注")
status: Mapped[str] = mapped_column(
String(16),
default=TeamStatus.ACTIVE.value,
server_default=TeamStatus.ACTIVE.value,
nullable=False,
index=True,
comment="团队状态:active启用,disabled禁用",
String(16), default=TeamStatus.ACTIVE.value, server_default=TeamStatus.ACTIVE.value,
nullable=False, index=True, comment="团队状态:active启用,disabled禁用",
)
sort_order: Mapped[int] = mapped_column(
Integer,
default=0,
server_default="0",
nullable=False,
index=True,
comment="排序值,越小越靠前",
Integer, default=0, server_default="0", nullable=False, index=True, comment="排序值,越小越靠前",
)
manager_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id"), nullable=True, index=True, comment="团队管理人ID"
)
first_subscription_paid_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True, comment="团队首次真实订阅成交时间"
)
@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class TeamManagerHistory(Base, TimestampMixin):
__tablename__ = "team_manager_history"
__table_args__ = (
Index("ix_team_manager_history_team_time", "team_id", "started_at", "ended_at"),
Index("ix_team_manager_history_manager_time", "manager_user_id", "started_at", "ended_at"),
Index(
"uq_team_manager_history_current",
"team_id",
unique=True,
postgresql_where=text("ended_at IS NULL"),
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False
)
manager_user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+53
View File
@@ -55,6 +55,9 @@ class AdminUserOut(BaseModel):
email: str | None = None
phone: str | None = None
credits: float
personal_credits: float = 0
team_available_credits: float = 0
team_frozen_credits: float = 0
is_active: bool
is_admin: bool
user_type: str = "frontend"
@@ -196,9 +199,32 @@ class AdminCreditRecordAllocationOut(BaseModel):
credit_balance_id: str
source_allocation_id: str | None = None
allocation_action: str
allocation_action_label: str | None = None
amount: float
credit_level: str
credit_level_label: str | None = None
credit_scope: str | None = None
credit_scope_label: str | None = None
team_id: str | None = None
team_manager_id: str | None = None
subscription_id: str | None = None
subscription_no: str | None = None
product_name: str | None = None
product_type: str | None = None
product_type_label: str | None = None
tier_code: str | None = None
tier_label: str | None = None
tier_rank: int | None = None
billing_cycle: str | None = None
billing_cycle_label: str | None = None
subscription_period_id: str | None = None
period_sequence: int | None = None
period_label: str | None = None
period_valid_from: str | None = None
period_expires_at: str | None = None
seat_id: str | None = None
source_type: str
source_type_label: str | None = None
source_id: str | None = None
valid_from: str | None = None
expires_at: str | None = None
@@ -207,6 +233,28 @@ class AdminCreditRecordAllocationOut(BaseModel):
consumed_before: float = 0.0
consumed_after: float = 0.0
class AdminCreditRecordSubscriptionUsageOut(BaseModel):
credit_scope: str | None = None
credit_scope_label: str | None = None
subscription_id: str | None = None
subscription_no: str
product_name: str | None = None
product_type: str | None = None
product_type_label: str | None = None
tier_code: str | None = None
tier_label: str | None = None
tier_rank: int | None = None
billing_cycle: str | None = None
billing_cycle_label: str | None = None
subscription_period_id: str | None = None
period_sequence: int | None = None
period_label: str | None = None
period_valid_from: str | None = None
period_expires_at: str | None = None
amount: float = 0.0
class AdminCreditRecordOut(BaseModel):
id: str
user_id: str
@@ -260,6 +308,11 @@ class AdminCreditRecordOut(BaseModel):
llm_call_count: int = 0
llm_success_call_count: int = 0
llm_failed_call_count: int = 0
funding_scope: str = "none"
funding_scope_label: str = "无资金分摊"
team_allocation_amount: float = 0.0
personal_allocation_amount: float = 0.0
subscription_usages: list[AdminCreditRecordSubscriptionUsageOut] = Field(default_factory=list)
allocations: list[AdminCreditRecordAllocationOut] = Field(default_factory=list)
engine_type: str | None = None
engine_id: str | None = None
+9 -2
View File
@@ -2,18 +2,22 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, Field
class CreditRecordOut(BaseModel):
id: str
type: str
type_label: str = ""
amount: float
personal_amount: float = 0
team_amount: float = 0
balance_delta: float = 0
expired_amount: float = 0
balance_after: float
description: str
billing_scene: str | None = None
billing_scene_label: str | None = None
scene_name_snapshot: str | None = None
input_tokens: int | None = None
output_tokens: int | None = None
@@ -29,8 +33,11 @@ class CreditRecordOut(BaseModel):
class CreditBalanceOut(BaseModel):
credits: float
available_credits: float
personal_credits: float = 0
team_available_credits: float = 0
team_frozen_credits: float = 0
next_expiring_credits: float = 0
next_expires_at: datetime | None = None
next_last_usable_at: datetime | None = None
records: list[CreditRecordOut]
records: list[CreditRecordOut] = Field(default_factory=list)
total: int = 0
@@ -7,8 +7,13 @@ from pydantic import BaseModel, Field
class CreditBalanceItemOut(BaseModel):
id: str
credit_scope: str
credit_scope_label: str = ""
team_id: str | None = None
credit_level: str
credit_level_label: str = ""
source_type: str
source_type_label: str = ""
source_id: str | None = None
product_id: str | None = None
payment_order_id: str | None = None
@@ -23,12 +28,16 @@ class CreditBalanceItemOut(BaseModel):
expires_at: datetime
last_usable_at: datetime
status: str
status_label: str = ""
created_at: datetime
class CreditBalanceSummaryOut(BaseModel):
credits: float
available_credits: float
personal_credits: float = 0
team_available_credits: float = 0
team_frozen_credits: float = 0
next_expiring_credits: float
next_expires_at: datetime | None = None
next_last_usable_at: datetime | None = None
+50 -19
View File
@@ -6,16 +6,21 @@ from typing import Literal
from pydantic import BaseModel, Field, model_validator
ProductType = Literal["subscription", "team_subscription", "credit_addon"]
BillingCycle = Literal["monthly", "quarterly", "yearly"]
CreditLevelType = Literal["promotional", "general"]
class CreditProductBase(BaseModel):
product_code: str = Field(..., min_length=1, max_length=64)
product_type: Literal["subscription", "credit_addon"]
product_type: ProductType
name: str = Field(..., min_length=1, max_length=96)
description: str | None = Field(default=None, max_length=512)
features: list[str] = Field(default_factory=list)
tier_code: str | None = Field(default=None, max_length=32)
tier_rank: int | None = Field(default=None, ge=1, le=999)
billing_cycle: Literal["monthly", "quarterly", "yearly"] | None = None
billing_cycle: BillingCycle | None = None
monthly_grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
first_purchase_price: float | None = Field(default=None, ge=0, le=999999999.99)
regular_price: float | None = Field(default=None, ge=0, le=999999999.99)
@@ -27,14 +32,14 @@ class CreditProductBase(BaseModel):
price: float = Field(default=0, ge=0, le=999999999.99)
grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
validity_months: int | None = Field(default=None, ge=1, le=36)
credit_level: Literal["promotional", "general"] = "general"
credit_level: CreditLevelType = "general"
currency: str = Field(default="CNY", min_length=1, max_length=8)
is_active: bool = True
sort_order: int = Field(default=0, ge=-999999, le=999999)
@model_validator(mode="after")
def validate_product_fields(self):
if self.product_type == "subscription":
if self.product_type in {"subscription", "team_subscription"}:
required = {
"tier_code": self.tier_code,
"tier_rank": self.tier_rank,
@@ -46,6 +51,8 @@ class CreditProductBase(BaseModel):
missing = [name for name, value in required.items() if value is None]
if missing:
raise ValueError(f"订阅套餐缺少字段: {', '.join(missing)}")
if self.grant_credits is not None or self.validity_months is not None:
raise ValueError("订阅套餐不能配置积分增值包字段")
if self.activity_price is not None:
if not self.activity_start_at or not self.activity_end_at:
raise ValueError("配置活动价时必须同时配置活动开始和结束时间")
@@ -54,10 +61,23 @@ class CreditProductBase(BaseModel):
else:
if self.grant_credits is None:
raise ValueError("积分增值包必须配置积分数量")
if self.price < 0:
raise ValueError("增值包价格不能小于0")
if self.validity_months is not None and not 1 <= self.validity_months <= 36:
raise ValueError("积分增值包有效期必须为1-36个月")
if self.validity_months is None:
self.validity_months = 1
if any(
value is not None
for value in (
self.tier_code,
self.tier_rank,
self.billing_cycle,
self.monthly_grant_credits,
self.first_purchase_price,
self.regular_price,
self.activity_price,
self.activity_start_at,
self.activity_end_at,
)
):
raise ValueError("积分增值包不能配置订阅套餐字段")
return self
@@ -66,13 +86,10 @@ class CreditProductCreate(CreditProductBase):
class CreditProductUpdate(BaseModel):
product_code: str | None = Field(default=None, min_length=1, max_length=64)
# product_code / product_type / tier_code / tier_rank / billing_cycle 创建后永久不可修改。
name: str | None = Field(default=None, min_length=1, max_length=96)
description: str | None = Field(default=None, max_length=512)
features: list[str] | None = None
tier_code: str | None = Field(default=None, max_length=32)
tier_rank: int | None = Field(default=None, ge=1, le=999)
billing_cycle: Literal["monthly", "quarterly", "yearly"] | None = None
monthly_grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
first_purchase_price: float | None = Field(default=None, ge=0, le=999999999.99)
regular_price: float | None = Field(default=None, ge=0, le=999999999.99)
@@ -83,9 +100,8 @@ class CreditProductUpdate(BaseModel):
price: float | None = Field(default=None, ge=0, le=999999999.99)
grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
validity_months: int | None = Field(default=None, ge=1, le=36)
credit_level: Literal["promotional", "general"] | None = None
credit_level: CreditLevelType | None = None
currency: str | None = Field(default=None, min_length=1, max_length=8)
is_active: bool | None = None
sort_order: int | None = Field(default=None, ge=-999999, le=999999)
@@ -93,16 +109,23 @@ class CreditProductRenewalUpdate(BaseModel):
renewal_enabled: bool
class CreditProductStatusUpdate(BaseModel):
is_active: bool
class CreditProductOut(BaseModel):
id: str
product_code: str
product_type: str
product_type_label: str = ""
name: str
description: str | None = None
features: list[str] = Field(default_factory=list)
tier_code: str | None = None
tier_label: str | None = None
tier_rank: int | None = None
billing_cycle: str | None = None
billing_cycle_label: str | None = None
monthly_grant_credits: float = 0
grant_count: int = 1
first_purchase_price: float = 0
@@ -115,20 +138,28 @@ class CreditProductOut(BaseModel):
validity_months: int | None = None
price: float
current_price: float
target_price: float | None = None
deduction_amount: float = 0
price_type: str | None = None
price_type_label: str | None = None
credit_level: str
credit_level_label: str = ""
currency: str
is_active: bool
is_deleted: bool = False
deleted_at: datetime | None = None
status_label: str = ""
sort_order: int
can_purchase: bool | None = None
can_upgrade: bool = False
unavailable_reason: str | None = None
class CreditProductCatalogOut(BaseModel):
subscription_products: list[CreditProductOut]
team_subscription_products: list[CreditProductOut]
credit_addons: list[CreditProductOut]
first_purchase_available: bool
current_subscription: dict | None = None
personal_first_purchase_available: bool
team_first_purchase_available: bool
active_personal_subscriptions: list[dict] = Field(default_factory=list)
personal_entitlement: dict | None = None
team_entitlement: dict | None = None
team_purchase_available: bool = False
team_purchase_unavailable_reason: str | None = None
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, Field
class CreditSubscriptionPeriodOut(BaseModel):
@@ -14,6 +14,7 @@ class CreditSubscriptionPeriodOut(BaseModel):
grant_credits: float
allocated_paid_amount: float
status: str
status_label: str = ""
issued_balance_id: str | None = None
issued_at: datetime | None = None
@@ -22,22 +23,46 @@ class CreditSubscriptionPeriodOut(BaseModel):
class CreditSubscriptionOut(BaseModel):
id: str
subscription_no: str
user_id: str
team_id: str | None = None
team_manager_id_snapshot: str | None = None
product_id: str | None = None
payment_order_id: str
status: str
status_label: str = ""
purchase_scene: str
product_type_snapshot: str
product_type_label: str = ""
product_name_snapshot: str
tier_code: str
tier_rank: int
billing_cycle: str
billing_cycle_label: str = ""
anchor_at: datetime
start_at: datetime
expires_at: datetime
next_grant_at: datetime | None = None
monthly_grant_credits_snapshot: float
monthly_total_credits_snapshot: float
quantity_snapshot: int = 1
grant_count: int
granted_count: int
first_purchase_price_snapshot: float
regular_price_snapshot: float
activity_price_snapshot: float | None = None
actual_unit_price_snapshot: float
paid_amount_snapshot: float
source_subscription_id: str | None = None
periods: list[CreditSubscriptionPeriodOut] = []
periods: list[CreditSubscriptionPeriodOut] = Field(default_factory=list)
model_config = {"from_attributes": True}
class AdminOfflineSubscriptionCreate(BaseModel):
product_id: str
quantity: int = Field(default=1, ge=1, le=1000)
payment_method: str = Field(pattern="^(bank_transfer|cash|other)$")
actual_paid_amount: float | None = Field(default=None, ge=0)
offline_trade_no: str | None = Field(default=None, max_length=128)
offline_payment_detail: str | None = Field(default=None, max_length=128)
remark: str | None = Field(default=None, max_length=512)
+66 -5
View File
@@ -2,12 +2,40 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from app.enums.common import (
OFFLINE_PAYMENT_METHOD_LABELS,
PAYMENT_ORDER_SOURCE_LABELS,
)
from app.enums.credit_product import CREDIT_PRODUCT_TYPE_LABELS, PRODUCT_PRICE_TYPE_LABELS
PAYMENT_METHOD_LABELS = {
"alipay": "支付宝",
"wechat": "微信支付",
**OFFLINE_PAYMENT_METHOD_LABELS,
}
PAYMENT_STATUS_LABELS = {
"pending": "待支付",
"paid": "已支付",
"fulfilled": "已履约",
"refunded": "已退款",
"cancelled": "已取消",
"expired": "已过期",
"failed": "失败",
}
FULFILLMENT_STATUS_LABELS = {
"pending": "待履约",
"fulfilled": "已履约",
"failed": "履约失败",
}
class RechargeRequest(BaseModel):
plan: str = Field(..., description="积分商品ID;兼容旧字段名plan")
method: str = "wechat"
method: str = Field(default="wechat", pattern="^(wechat|alipay)$")
quantity: int = Field(default=1, ge=1, le=20, description="团队订阅购买席位数;个人商品固定为1")
class PaymentOrderOut(BaseModel):
@@ -16,18 +44,51 @@ class PaymentOrderOut(BaseModel):
amount: float
credits: float
payment_method: str
payment_method_label: str = ""
order_source: str = "online_payment"
order_source_label: str = ""
status: str
status_label: str = ""
product_id: str | None = None
product_type: str | None = None
product_type_label: str | None = None
purchase_scene: str | None = None
price_type: str | None = None
price_type_label: str | None = None
product_name_snapshot: str | None = None
target_price_snapshot: float | None = None
deduction_amount_snapshot: float | None = None
payable_amount_snapshot: float | None = None
quantity: int = 1
quoted_unit_price_snapshot: float | None = None
quoted_amount_snapshot: float | None = None
actual_unit_price_snapshot: float | None = None
team_id_snapshot: str | None = None
fulfillment_status: str | None = None
fulfillment_status_label: str | None = None
offline_trade_no: str | None = None
offline_payment_detail: str | None = None
remark: str | None = None
qr_url: str | None = None
created_at: datetime
paid_at: datetime | None = None
refund_amount: float | None = None
refunded_at: datetime | None = None
refund_trade_no: str | None = None
model_config = {"from_attributes": True}
@model_validator(mode="after")
def fill_chinese_labels(self):
if self.payment_method == "other" and self.offline_payment_detail:
self.payment_method_label = self.offline_payment_detail
else:
self.payment_method_label = PAYMENT_METHOD_LABELS.get(self.payment_method, "其他支付方式")
self.order_source_label = PAYMENT_ORDER_SOURCE_LABELS.get(self.order_source, "其他订单来源")
self.status_label = PAYMENT_STATUS_LABELS.get(self.status, "其他状态")
if self.product_type:
self.product_type_label = CREDIT_PRODUCT_TYPE_LABELS.get(self.product_type, "其他商品")
if self.price_type:
self.price_type_label = PRODUCT_PRICE_TYPE_LABELS.get(self.price_type, "其他价格")
if self.fulfillment_status:
self.fulfillment_status_label = FULFILLMENT_STATUS_LABELS.get(
self.fulfillment_status, "其他履约状态"
)
return self
+4
View File
@@ -26,11 +26,15 @@ class TeamUpdate(BaseModel):
class TeamOut(TeamBase):
id: str
status_label: str = ""
is_read_only: bool = False
team_credit_frozen: bool = False
member_count: int = 0
created_at: NaiveDatetime
updated_at: NaiveDatetimeOptional = None
manager_id: str | None = None
manager_name: str | None = None
first_subscription_paid_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
+5 -8
View File
@@ -4,7 +4,7 @@ from app.schemas.common import NaiveDatetime
class SetManagerRequest(BaseModel):
user_id: str | None = Field(None, description="设为管理人的前台用户ID;传 null 表示取消管理人")
user_id: str = Field(..., min_length=1, max_length=32, description="新团队队长的前台用户ID")
class TeamMemberOut(BaseModel):
@@ -12,25 +12,22 @@ class TeamMemberOut(BaseModel):
username: str
phone: str | None = None
credits: float
personal_credits: float = 0
team_available_credits: float = 0
team_frozen_credits: float = 0
is_active: bool = True
joined_at: NaiveDatetime
model_config = {"from_attributes": True}
class ManagerTransferRequest(BaseModel):
target_user_id: str = Field(..., description="接收积分的成员用户ID")
amount: float = Field(gt=0, description="转账积分数量(正数)")
direction: str = Field(default="increase", pattern="^(increase|decrease)$", description="increase=管理人转给成员;decrease=从成员扣减回管理人")
description: str | None = Field(None, max_length=256, description="转账说明")
class ManagedTeamOut(BaseModel):
id: str
name: str
code: str | None = None
description: str | None = None
status: str
status_label: str = ""
member_count: int = 0
manager_id: str | None = None
manager_name: str | None = None
@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.credit_subscription import CreditSubscriptionOut
class TeamSeatCreateRequest(BaseModel):
user_id: str = Field(..., min_length=1, max_length=32)
monthly_allocated_credits: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01)
class TeamSeatUpdateRequest(BaseModel):
monthly_allocated_credits: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01)
class TeamSeatOut(BaseModel):
id: str
team_id: str
subscription_id: str
user_id: str
username: str | None = None
monthly_allocated_credits: float
current_period_id: str | None = None
current_period_used_credits: float = 0
current_period_remaining_credits: float = 0
status: str
status_label: str
created_at: datetime
cancelled_at: datetime | None = None
class TeamSubscriptionManageOut(BaseModel):
subscription: CreditSubscriptionOut
current_period_id: str | None = None
current_period_start_at: datetime | None = None
current_period_expires_at: datetime | None = None
period_total_credits: float = 0
period_unspent_credits: float = 0
period_unallocated_credits: float = 0
seat_limit: int
active_seat_count: int
seats: list[TeamSeatOut] = Field(default_factory=list)
class TeamMemberUsageOut(BaseModel):
user_id: str
username: str | None = None
# 以下两个 ID 保留给内部关联/筛选,前端业务表格不直接展示。
subscription_id: str
subscription_no: str
subscription_period_id: str
subscription_name: str
tier_code: str
tier_label: str
tier_rank: int
billing_cycle: str
billing_cycle_label: str
period_sequence: int
period_label: str
period_start_at: datetime | None = None
period_expires_at: datetime | None = None
consumed_credits: float
class TeamManagerHistoryOut(BaseModel):
id: str
team_id: str
manager_user_id: str
manager_name: str | None = None
started_at: datetime
ended_at: datetime | None = None
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import and_, case, distinct, func, or_, select
@@ -10,6 +11,12 @@ from app.enums.credit_balance import (
CREDIT_ALLOCATION_ACTION_LABELS,
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
)
from app.enums.credit_product import (
CREDIT_PRODUCT_TYPE_LABELS,
SUBSCRIPTION_BILLING_CYCLE_LABELS,
SUBSCRIPTION_TIER_LABELS,
)
from app.enums.credit_record import (
CREDIT_RECORD_ACTION_LABELS,
@@ -26,6 +33,8 @@ from app.enums.user import FRONTEND_USER_KIND_LABELS, USER_TYPE_LABELS, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.models.chat_generation_task import ChatGenerationTask
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.credit_record import CreditRecord
from app.models.generation_record import GenerationRecord
from app.models.module_generation_project import ModuleGenerationProject
@@ -98,6 +107,7 @@ def _build_filters(
user_type: str | None = None,
frontend_user_kind: str | None = None,
team_id: str | None = None,
subscription_no: str | None = None,
record_type: str | None = None,
credit_subject: str | None = None,
media_type: str | None = None,
@@ -125,6 +135,20 @@ def _build_filters(
filters.append(CreditRecord.team_id_snapshot.is_(None))
else:
filters.append(CreditRecord.team_id_snapshot == team_id)
if subscription_no:
subscription_no_value = subscription_no.strip()
if subscription_no_value:
subscription_like = f"%{subscription_no_value}%"
filters.append(
CreditRecord.id.in_(
select(CreditRecordAllocation.credit_record_id)
.join(
UserCreditSubscription,
UserCreditSubscription.id == CreditRecordAllocation.subscription_id_snapshot,
)
.where(UserCreditSubscription.subscription_no.ilike(subscription_like))
)
)
if record_type:
filters.append(CreditRecord.type == record_type)
if credit_subject:
@@ -212,6 +236,75 @@ def _normalized_description(record: CreditRecord) -> str | None:
return description
def _build_funding_summary(allocations: list[dict[str, Any]]) -> dict[str, Any]:
team_amount = Decimal("0.00")
personal_amount = Decimal("0.00")
usage_map: dict[tuple[str, str, str], dict[str, Any]] = {}
for allocation in allocations:
amount = Decimal(str(allocation.get("amount") or 0))
scope = allocation.get("credit_scope")
if scope == "team":
team_amount += amount
elif scope == "personal":
personal_amount += amount
subscription_no = allocation.get("subscription_no")
if not subscription_no:
continue
period_id = allocation.get("subscription_period_id") or ""
key = (scope or "", subscription_no, period_id)
usage = usage_map.get(key)
if usage is None:
usage = {
"credit_scope": scope,
"credit_scope_label": allocation.get("credit_scope_label"),
"subscription_id": allocation.get("subscription_id"),
"subscription_no": subscription_no,
"product_name": allocation.get("product_name"),
"product_type": allocation.get("product_type"),
"product_type_label": allocation.get("product_type_label"),
"tier_code": allocation.get("tier_code"),
"tier_label": allocation.get("tier_label"),
"tier_rank": allocation.get("tier_rank"),
"billing_cycle": allocation.get("billing_cycle"),
"billing_cycle_label": allocation.get("billing_cycle_label"),
"subscription_period_id": allocation.get("subscription_period_id"),
"period_sequence": allocation.get("period_sequence"),
"period_label": allocation.get("period_label"),
"period_valid_from": allocation.get("period_valid_from"),
"period_expires_at": allocation.get("period_expires_at"),
"amount": 0.0,
}
usage_map[key] = usage
usage["amount"] = _round2(Decimal(str(usage.get("amount") or 0)) + amount)
if team_amount > 0 and personal_amount > 0:
scope_value, scope_label = "mixed", "团队积分 + 个人积分"
elif team_amount > 0:
scope_value, scope_label = "team", "团队积分"
elif personal_amount > 0:
scope_value, scope_label = "personal", "个人积分"
else:
scope_value, scope_label = "none", "无资金分摊"
usages = list(usage_map.values())
usages.sort(
key=lambda item: (
str(item.get("subscription_no") or ""),
int(item.get("period_sequence") or 0),
str(item.get("credit_scope") or ""),
)
)
return {
"funding_scope": scope_value,
"funding_scope_label": scope_label,
"team_allocation_amount": _round2(team_amount),
"personal_allocation_amount": _round2(personal_amount),
"subscription_usages": usages,
}
def _record_to_item(
record: CreditRecord,
user: User | None,
@@ -226,6 +319,8 @@ def _record_to_item(
user_type = record.user_type_snapshot or (user.user_type if user else None)
frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None)
charge_action = _normalized_charge_action(record)
allocations = allocation_map.get(record.id, [])
funding_summary = _build_funding_summary(allocations)
return {
"id": record.id,
"user_id": record.user_id,
@@ -279,7 +374,8 @@ def _record_to_item(
"llm_call_count": record.llm_call_count or 0,
"llm_success_call_count": record.llm_success_call_count or 0,
"llm_failed_call_count": record.llm_failed_call_count or 0,
"allocations": allocation_map.get(record.id, []),
"allocations": allocations,
**funding_summary,
"engine_type": record.engine_type,
"engine_id": record.engine_id,
"engine_name": record.engine_name,
@@ -299,6 +395,7 @@ async def list_admin_credit_records(
user_type: str | None = None,
frontend_user_kind: str | None = None,
team_id: str | None = None,
subscription_no: str | None = None,
record_type: str | None = None,
credit_subject: str | None = None,
media_type: str | None = None,
@@ -320,6 +417,7 @@ async def list_admin_credit_records(
user_type=user_type,
frontend_user_kind=frontend_user_kind,
team_id=team_id,
subscription_no=subscription_no,
record_type=record_type,
credit_subject=credit_subject,
media_type=media_type,
@@ -354,9 +452,45 @@ async def list_admin_credit_records(
allocation_result = await db.execute(
select(CreditRecordAllocation)
.where(CreditRecordAllocation.credit_record_id.in_(record_ids))
.order_by(CreditRecordAllocation.credit_record_id.asc(), CreditRecordAllocation.created_at.asc(), CreditRecordAllocation.id.asc())
.order_by(
CreditRecordAllocation.credit_record_id.asc(),
CreditRecordAllocation.created_at.asc(),
CreditRecordAllocation.id.asc(),
)
)
for allocation in allocation_result.scalars().all():
allocations = list(allocation_result.scalars().all())
subscription_ids = {
allocation.subscription_id_snapshot
for allocation in allocations
if allocation.subscription_id_snapshot
}
period_ids = {
allocation.subscription_period_id_snapshot
for allocation in allocations
if allocation.subscription_period_id_snapshot
}
subscription_map: dict[str, UserCreditSubscription] = {}
if subscription_ids:
subscription_result = await db.execute(
select(UserCreditSubscription).where(UserCreditSubscription.id.in_(subscription_ids))
)
subscription_map = {item.id: item for item in subscription_result.scalars().all()}
period_map: dict[str, UserCreditSubscriptionPeriod] = {}
if period_ids:
period_result = await db.execute(
select(UserCreditSubscriptionPeriod).where(UserCreditSubscriptionPeriod.id.in_(period_ids))
)
period_map = {item.id: item for item in period_result.scalars().all()}
for allocation in allocations:
subscription = subscription_map.get(allocation.subscription_id_snapshot or "")
period = period_map.get(allocation.subscription_period_id_snapshot or "")
tier_code = subscription.tier_code if subscription else None
billing_cycle = subscription.billing_cycle if subscription else None
product_type = subscription.product_type_snapshot if subscription else None
period_sequence = (int(period.sequence) + 1) if period is not None else None
allocation_map.setdefault(allocation.credit_record_id, []).append({
"id": allocation.id,
"credit_balance_id": allocation.credit_balance_id,
@@ -366,6 +500,26 @@ async def list_admin_credit_records(
"amount": _round2(allocation.amount),
"credit_level": allocation.credit_level_snapshot,
"credit_level_label": _label(CREDIT_LEVEL_LABELS, allocation.credit_level_snapshot),
"credit_scope": allocation.credit_scope_snapshot,
"credit_scope_label": _label(CREDIT_SCOPE_LABELS, allocation.credit_scope_snapshot),
"team_id": allocation.team_id_snapshot,
"team_manager_id": allocation.team_manager_id_snapshot,
"subscription_id": allocation.subscription_id_snapshot,
"subscription_no": subscription.subscription_no if subscription else None,
"product_name": subscription.product_name_snapshot if subscription else None,
"product_type": product_type,
"product_type_label": _label(CREDIT_PRODUCT_TYPE_LABELS, product_type),
"tier_code": tier_code,
"tier_label": _label(SUBSCRIPTION_TIER_LABELS, tier_code),
"tier_rank": subscription.tier_rank if subscription else None,
"billing_cycle": billing_cycle,
"billing_cycle_label": _label(SUBSCRIPTION_BILLING_CYCLE_LABELS, billing_cycle),
"subscription_period_id": allocation.subscription_period_id_snapshot,
"period_sequence": period_sequence,
"period_label": f"{period_sequence}个月" if period_sequence is not None else None,
"period_valid_from": _iso(period.valid_from) if period else _iso(allocation.valid_from_snapshot),
"period_expires_at": _iso(period.expires_at) if period else _iso(allocation.expires_at_snapshot),
"seat_id": allocation.seat_id_snapshot,
"source_type": allocation.source_type_snapshot,
"source_type_label": _label(CREDIT_BALANCE_SOURCE_TYPE_LABELS, allocation.source_type_snapshot),
"source_id": allocation.source_id_snapshot,
@@ -0,0 +1,112 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import case, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_product import CreditProductType, SubscriptionBillingCycle
from app.enums.credit_subscription import CreditSubscriptionStatus
from app.enums.team import TeamStatus
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.team_seat import TeamSubscriptionSeat
from app.models.team import Team
from app.services.credit.utils import utc_now
_BILLING_CYCLE_RANK = case(
(CreditProduct.billing_cycle == SubscriptionBillingCycle.YEARLY.value, 3),
(CreditProduct.billing_cycle == SubscriptionBillingCycle.QUARTERLY.value, 2),
(CreditProduct.billing_cycle == SubscriptionBillingCycle.MONTHLY.value, 1),
else_=0,
)
def _entitlement_payload(subscription: UserCreditSubscription, product: CreditProduct) -> dict:
return {
"subscription_id": subscription.id,
"product_id": product.id,
"product_name": product.name,
"product_type": subscription.product_type_snapshot,
"billing_cycle": product.billing_cycle,
"tier_code": product.tier_code,
"tier_rank": product.tier_rank,
"sort_order": product.sort_order,
"start_at": subscription.start_at,
"expires_at": subscription.expires_at,
"team_id": subscription.team_id,
}
async def get_personal_entitlement(
db: AsyncSession,
*,
user_id: str,
request_time: datetime | None = None,
) -> dict | None:
checked_at = request_time or utc_now()
result = await db.execute(
select(UserCreditSubscription, CreditProduct)
.join(CreditProduct, CreditProduct.id == UserCreditSubscription.product_id)
.where(
UserCreditSubscription.user_id == user_id,
UserCreditSubscription.product_type_snapshot == CreditProductType.SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.order_by(
_BILLING_CYCLE_RANK.desc(),
CreditProduct.tier_rank.desc(),
CreditProduct.sort_order.asc(),
CreditProduct.created_at.desc(),
CreditProduct.id.desc(),
)
.limit(1)
)
row = result.first()
return _entitlement_payload(row[0], row[1]) if row else None
async def get_team_entitlement(
db: AsyncSession,
*,
user_id: str,
request_time: datetime | None = None,
) -> dict | None:
checked_at = request_time or utc_now()
result = await db.execute(
select(UserCreditSubscription, CreditProduct, TeamSubscriptionSeat)
.join(CreditProduct, CreditProduct.id == UserCreditSubscription.product_id)
.join(
TeamSubscriptionSeat,
TeamSubscriptionSeat.subscription_id == UserCreditSubscription.id,
)
.join(Team, Team.id == UserCreditSubscription.team_id)
.where(
TeamSubscriptionSeat.user_id == user_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
Team.deleted_at.is_(None),
Team.status == TeamStatus.ACTIVE.value,
)
.order_by(
_BILLING_CYCLE_RANK.desc(),
CreditProduct.tier_rank.desc(),
CreditProduct.sort_order.asc(),
CreditProduct.created_at.desc(),
CreditProduct.id.desc(),
)
.limit(1)
)
row = result.first()
if not row:
return None
payload = _entitlement_payload(row[0], row[1])
payload["seat_id"] = row[2].id
return payload
@@ -7,10 +7,11 @@ from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CreditAllocationAction, CreditBalanceStatus
from app.enums.credit_balance import CreditAllocationAction, CreditBalanceStatus, CreditScope
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordType
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit_record import CreditRecord
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.query_service import get_available_credits
@@ -45,6 +46,14 @@ async def archive_expired_user_balances(
return 0
current_available = await get_available_credits(db, user_id, request_time=checked_at)
subscription_ids = [item.subscription_id for item in balances if item.subscription_id]
manager_map: dict[str, str | None] = {}
if subscription_ids:
sub_result = await db.execute(
select(UserCreditSubscription.id, UserCreditSubscription.team_manager_id_snapshot)
.where(UserCreditSubscription.id.in_(subscription_ids))
)
manager_map = {str(row.id): row.team_manager_id_snapshot for row in sub_result.all()}
for balance in balances:
amount = to_credit_decimal(balance.unspent_amount)
if amount <= 0:
@@ -77,8 +86,14 @@ async def archive_expired_user_balances(
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
credit_scope_snapshot=balance.credit_scope or CreditScope.PERSONAL.value,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
team_id_snapshot=balance.team_id,
team_manager_id_snapshot=manager_map.get(balance.subscription_id or ""),
subscription_id_snapshot=balance.subscription_id,
subscription_period_id_snapshot=balance.subscription_period_id,
seat_id_snapshot=None,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=amount,
File diff suppressed because it is too large Load Diff
+25 -3
View File
@@ -1,15 +1,37 @@
from __future__ import annotations
from collections.abc import Iterable
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
async def acquire_user_credit_lock(db: AsyncSession, user_id: str) -> None:
"""PostgreSQL 事务级用户锁SQLite 调试环境无需额外锁。"""
async def _acquire_key_lock(db: AsyncSession, lock_key: str) -> None:
"""PostgreSQL事务级 advisory lockSQLite 本地调试环境无需额外锁。"""
bind = db.get_bind()
dialect_name = bind.dialect.name if bind is not None else ""
if dialect_name == "postgresql":
await db.execute(
text("SELECT pg_advisory_xact_lock(hashtextextended(:lock_key, 0))"),
{"lock_key": f"credit:{user_id}"},
{"lock_key": lock_key},
)
async def acquire_user_credit_lock(db: AsyncSession, user_id: str) -> None:
await _acquire_key_lock(db, f"credit:user:{user_id}")
async def acquire_team_business_lock(db: AsyncSession, team_id: str) -> None:
await _acquire_key_lock(db, f"credit:team:{team_id}")
async def acquire_subscription_credit_lock(db: AsyncSession, subscription_id: str) -> None:
await _acquire_key_lock(db, f"credit:subscription:{subscription_id}")
async def acquire_subscription_credit_locks(
db: AsyncSession,
subscription_ids: Iterable[str],
) -> None:
for subscription_id in sorted({str(item) for item in subscription_ids if item}):
await acquire_subscription_credit_lock(db, subscription_id)
@@ -0,0 +1,218 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.common import OfflinePaymentMethodEnum, PaymentOrderSourceEnum
from app.enums.credit_product import CreditProductType
from app.models.credit.product import CreditProduct
from app.models.payment_order import PaymentOrder
from app.models.team import Team
from app.models.user import User
from app.services.credit.locking import acquire_team_business_lock, acquire_user_credit_lock
from app.services.credit.product_service import ensure_repeat_purchase_allowed, quote_product, resolve_team_purchase_context
from app.services.credit.subscription_service import fulfill_payment_product
from app.services.credit.utils import to_credit_decimal, utc_now
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id, generate_order_no
ALLOWED_OFFLINE_METHODS = {
OfflinePaymentMethodEnum.BANK_TRANSFER.value,
OfflinePaymentMethodEnum.CASH.value,
OfflinePaymentMethodEnum.OTHER.value,
}
def _snapshot(product: CreditProduct) -> dict:
return {
"id": product.id,
"product_code": product.product_code,
"product_type": product.product_type,
"name": product.name,
"description": product.description,
"features": product.features_json or [],
"tier_code": product.tier_code,
"tier_rank": product.tier_rank,
"billing_cycle": product.billing_cycle,
"monthly_grant_credits": float(product.monthly_grant_credits or 0),
"first_purchase_price": float(product.first_purchase_price or 0),
"regular_price": float(product.regular_price or 0),
"activity_price": float(product.activity_price) if product.activity_price is not None else None,
"activity_start_at": product.activity_start_at.isoformat() if product.activity_start_at else None,
"activity_end_at": product.activity_end_at.isoformat() if product.activity_end_at else None,
"renewal_enabled": bool(product.renewal_enabled),
"credit_level": product.credit_level,
"currency": product.currency,
}
async def create_offline_subscription_order(
db: AsyncSession,
*,
target_user_id: str,
product_id: str,
operator_admin_id: str,
payment_method: str,
quantity: int = 1,
actual_paid_amount: Decimal | float | int | None = None,
offline_trade_no: str | None = None,
offline_payment_detail: str | None = None,
remark: str | None = None,
request_time: datetime | None = None,
) -> PaymentOrder:
"""创建后台线下真实成交。
本函数不 commit。调用方必须在同一事务中提交;任何异常均应 rollback,数据库不保留失败订单。
"""
checked_at = request_time or utc_now()
if payment_method not in ALLOWED_OFFLINE_METHODS:
raise HTTPException(status_code=400, detail="线下收款方式仅支持银行转账、现金或其他")
await acquire_user_credit_lock(db, target_user_id)
user_result = await db.execute(
select(User).where(User.id == target_user_id).limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
product_result = await db.execute(
select(CreditProduct)
.where(
CreditProduct.id == product_id,
CreditProduct.deleted_at.is_(None),
CreditProduct.is_active.is_(True),
CreditProduct.product_type.in_([
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
]),
)
.limit(1)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="订阅套餐不存在、已下架或已删除")
incomplete = await db.execute(
select(PaymentOrder.id)
.where(
PaymentOrder.user_id == target_user_id,
PaymentOrder.product_type.in_([
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
]),
(
(PaymentOrder.status == "pending")
| ((PaymentOrder.status == "paid") & (PaymentOrder.fulfillment_status != "fulfilled"))
),
)
.limit(1)
)
if incomplete.scalar_one_or_none():
raise HTTPException(status_code=409, detail="用户存在未完成的订阅订单,请先处理原订单")
team: Team | None = None
if product.product_type == CreditProductType.TEAM_SUBSCRIPTION.value:
if not 2 <= int(quantity) <= 1000:
raise HTTPException(status_code=400, detail="后台团队套餐数量必须在2到1000之间")
team, available, reason = await resolve_team_purchase_context(db, user=user)
if not available:
raise HTTPException(status_code=409, detail=reason or "当前用户不能配置团队订阅")
if team is not None:
await acquire_team_business_lock(db, team.id)
team, available, reason = await resolve_team_purchase_context(db, user=user)
if not available or team is None:
raise HTTPException(status_code=409, detail=reason or "当前用户不能配置团队订阅")
first_purchase = bool(team is None or team.first_subscription_paid_at is None)
else:
quantity = 1
first_purchase = user.first_membership_paid_at is None
try:
ensure_repeat_purchase_allowed(product, first_purchase=first_purchase)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
locked_user_result = await db.execute(
select(User).where(User.id == target_user_id).limit(1).with_for_update()
)
locked_user = locked_user_result.scalar_one_or_none()
if not locked_user:
raise HTTPException(status_code=404, detail="用户不存在")
if locked_user.team_id != user.team_id:
raise HTTPException(status_code=409, detail="用户团队关系已发生变化,请刷新后重试")
user = locked_user
quote = quote_product(
product,
first_purchase=first_purchase,
request_time=checked_at,
quantity=int(quantity),
)
actual_amount = quote.quoted_amount if actual_paid_amount is None else to_credit_decimal(actual_paid_amount)
if actual_amount < 0:
raise HTTPException(status_code=400, detail="线下实际成交金额不能小于0")
actual_unit = (
Decimal(str(actual_amount)) / Decimal(int(quantity))
).quantize(Decimal("0.000001"))
order = PaymentOrder(
id=generate_id(),
user_id=target_user_id,
order_no=generate_order_no(),
amount=actual_amount,
credits=to_credit_decimal((product.monthly_grant_credits or 0) * int(quantity)),
payment_method=payment_method,
order_source=PaymentOrderSourceEnum.ADMIN_OFFLINE.value,
status="paid",
paid_at=checked_at,
product_id=product.id,
product_type=product.product_type,
purchase_scene=quote.purchase_scene,
price_type=quote.price_type,
product_code_snapshot=product.product_code,
product_name_snapshot=product.name,
product_snapshot_json=_snapshot(product),
quantity=int(quantity),
quoted_unit_price_snapshot=quote.quoted_unit_price,
quoted_amount_snapshot=quote.quoted_amount,
actual_unit_price_snapshot=actual_unit,
team_id_snapshot=team.id if team else None,
operator_admin_id=operator_admin_id,
offline_trade_no=(offline_trade_no or "").strip() or None,
offline_payment_detail=(offline_payment_detail or "").strip() or None,
remark=(remark or "").strip() or None,
fulfillment_status="pending",
)
db.add(order)
await db.flush()
await fulfill_payment_product(db, order=order, fulfilled_at=checked_at)
if order.fulfillment_status != "fulfilled":
raise RuntimeError("线下订阅成交履约未完成,事务必须回滚")
log_operation_event(
domain="payment",
module="offline_subscription",
event_type="ADMIN_OFFLINE_SUBSCRIPTION_CREATED",
user_id=target_user_id,
message="后台线下订阅真实成交成功",
detail={
"order_no": order.order_no,
"operator_admin_id": operator_admin_id,
"product_id": product.id,
"product_type": product.product_type,
"quantity": int(quantity),
"quoted_amount": float(quote.quoted_amount),
"actual_paid_amount": float(actual_amount),
"payment_method": payment_method,
"subscription_id": order.subscription_id,
"team_id": order.team_id_snapshot,
},
)
return order
@@ -7,20 +7,23 @@ from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CREDIT_LEVEL_LABELS
from app.enums.credit_product import (
CREDIT_PRODUCT_TYPE_LABELS,
PRODUCT_PRICE_TYPE_LABELS,
SUBSCRIPTION_BILLING_CYCLE_LABELS,
SUBSCRIPTION_GRANT_COUNT,
SUBSCRIPTION_TIER_LABELS,
CreditProductType,
ProductPriceType,
SUBSCRIPTION_GRANT_COUNT,
SubscriptionBillingCycle,
)
from app.enums.credit_subscription import (
CreditSubscriptionPeriodStatus,
CreditSubscriptionStatus,
)
from app.enums.credit_subscription import CREDIT_SUBSCRIPTION_STATUS_LABELS, CreditSubscriptionStatus
from app.enums.team import TeamStatus
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.team import Team
from app.models.user import User
from app.services.credit.entitlement_service import get_personal_entitlement, get_team_entitlement
from app.services.credit.utils import to_credit_decimal, utc_now
@@ -29,13 +32,16 @@ class ProductPriceQuote:
product: CreditProduct
purchase_scene: str
price_type: str
base_price: Decimal
activity_price: Decimal | None
target_price: Decimal
deduction_amount: Decimal
payable_amount: Decimal
source_subscription_id: str | None = None
upgrade_period_ids: tuple[str, ...] = ()
quoted_unit_price: Decimal
quoted_amount: Decimal
quantity: int
first_purchase: bool
SUBSCRIPTION_PRODUCT_TYPES = {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}
def grant_count_for_cycle(cycle: str | None) -> int:
@@ -60,87 +66,153 @@ def current_product_price(
*,
first_purchase: bool,
request_time: datetime,
upgrade: bool = False,
) -> tuple[Decimal, Decimal | None, Decimal, str]:
if product.product_type == CreditProductType.CREDIT_ADDON.value:
price = to_credit_decimal(product.price)
return price, None, price, ProductPriceType.REGULAR.value
base = to_credit_decimal(
product.regular_price if upgrade or not first_purchase else product.first_purchase_price
)
activity = activity_price_if_valid(product, request_time)
if activity is not None and activity < base:
return base, activity, activity, ProductPriceType.ACTIVITY.value
return base, activity, base, (
ProductPriceType.UPGRADE.value
if upgrade
else ProductPriceType.FIRST_PURCHASE.value if first_purchase else ProductPriceType.REGULAR.value
)
if first_purchase:
first_price = to_credit_decimal(product.first_purchase_price)
return first_price, activity, first_price, ProductPriceType.FIRST_PURCHASE.value
regular_price = to_credit_decimal(product.regular_price)
if activity is not None:
return regular_price, activity, activity, ProductPriceType.ACTIVITY.value
return regular_price, None, regular_price, ProductPriceType.REGULAR.value
async def get_active_subscription(
db: AsyncSession,
user_id: str,
def ensure_repeat_purchase_allowed(
product: CreditProduct,
*,
request_time: datetime | None = None,
for_update: bool = False,
) -> UserCreditSubscription | None:
checked_at = request_time or utc_now()
stmt = (
select(UserCreditSubscription)
.where(
UserCreditSubscription.user_id == user_id,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.order_by(UserCreditSubscription.start_at.desc(), UserCreditSubscription.id.desc())
.limit(1)
)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
first_purchase: bool,
) -> None:
"""校验订阅套餐续购开关。
renewal_enabled 仅控制失去首购资格后的再次购买,不代表自动续费。
已创建订单后续履约只读取订单快照,不再动态检查此开关。
"""
if (
product.product_type in SUBSCRIPTION_PRODUCT_TYPES
and not first_purchase
and not bool(product.renewal_enabled)
):
raise ValueError("该套餐当前未开启续费,已失去首购资格后不能再次购买")
async def get_upgrade_deduction_preview(
db: AsyncSession,
def quote_product(
product: CreditProduct,
*,
subscription: UserCreditSubscription,
first_purchase: bool,
request_time: datetime,
) -> Decimal:
if subscription.billing_cycle not in {
SubscriptionBillingCycle.QUARTERLY.value,
SubscriptionBillingCycle.YEARLY.value,
}:
return Decimal("0.00")
result = await db.execute(
select(UserCreditSubscriptionPeriod.allocated_paid_amount).where(
UserCreditSubscriptionPeriod.subscription_id == subscription.id,
UserCreditSubscriptionPeriod.scheduled_at > request_time,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
quantity: int = 1,
) -> ProductPriceQuote:
if quantity < 1:
raise ValueError("购买数量必须大于0")
if product.product_type != CreditProductType.TEAM_SUBSCRIPTION.value and quantity != 1:
raise ValueError("个人订阅和积分增值包不支持购买数量")
_, _, unit_price, price_type = current_product_price(
product, first_purchase=first_purchase, request_time=request_time
)
quoted_amount = to_credit_decimal(unit_price * quantity)
return ProductPriceQuote(
product=product,
purchase_scene=price_type,
price_type=price_type,
quoted_unit_price=unit_price,
quoted_amount=quoted_amount,
quantity=quantity,
first_purchase=first_purchase,
)
return sum((to_credit_decimal(value) for value in result.scalars().all()), Decimal("0.00"))
async def list_active_products(db: AsyncSession) -> list[CreditProduct]:
result = await db.execute(
select(CreditProduct)
.where(CreditProduct.is_active.is_(True))
.where(CreditProduct.deleted_at.is_(None), CreditProduct.is_active.is_(True))
.order_by(CreditProduct.product_type.asc(), CreditProduct.sort_order.asc(), CreditProduct.id.asc())
)
return list(result.scalars().all())
async def get_product(db: AsyncSession, product_id: str, *, for_update: bool = False) -> CreditProduct | None:
async def get_product(
db: AsyncSession,
product_id: str,
*,
for_update: bool = False,
include_deleted: bool = False,
) -> CreditProduct | None:
stmt = select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
if not include_deleted:
stmt = stmt.where(CreditProduct.deleted_at.is_(None))
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def list_active_personal_subscriptions(
db: AsyncSession,
*,
user_id: str,
request_time: datetime | None = None,
) -> list[UserCreditSubscription]:
checked_at = request_time or utc_now()
result = await db.execute(
select(UserCreditSubscription)
.where(
UserCreditSubscription.user_id == user_id,
UserCreditSubscription.product_type_snapshot == CreditProductType.SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.order_by(UserCreditSubscription.expires_at.asc(), UserCreditSubscription.id.asc())
)
return list(result.scalars().all())
async def resolve_team_purchase_context(
db: AsyncSession,
*,
user: User,
) -> tuple[Team | None, bool, str | None]:
if not user.team_id:
return None, True, None
result = await db.execute(
select(Team).where(Team.id == user.team_id, Team.deleted_at.is_(None)).limit(1)
)
team = result.scalar_one_or_none()
if team is None:
return None, False, "当前团队不存在"
if team.manager_id != user.id:
return team, False, "普通团队成员不能购买团队订阅套餐"
if team.status != TeamStatus.ACTIVE.value:
return team, False, "团队已禁用,当前只能查看团队数据,不能购买新的团队订阅"
return team, True, None
def _subscription_to_dict(subscription: UserCreditSubscription) -> dict:
return {
"id": subscription.id,
"subscription_no": subscription.subscription_no,
"product_id": subscription.product_id,
"product_name": subscription.product_name_snapshot,
"product_type": subscription.product_type_snapshot,
"status": subscription.status,
"status_label": CREDIT_SUBSCRIPTION_STATUS_LABELS.get(subscription.status, "其他状态"),
"billing_cycle": subscription.billing_cycle,
"billing_cycle_label": SUBSCRIPTION_BILLING_CYCLE_LABELS.get(
subscription.billing_cycle, "其他周期"
),
"tier_code": subscription.tier_code,
"tier_rank": subscription.tier_rank,
"start_at": subscription.start_at,
"expires_at": subscription.expires_at,
"monthly_grant_credits": float(subscription.monthly_grant_credits_snapshot),
"paid_amount": float(subscription.paid_amount_snapshot),
}
async def build_product_catalog(
db: AsyncSession,
*,
@@ -149,65 +221,66 @@ async def build_product_catalog(
) -> dict:
checked_at = request_time or utc_now()
products = await list_active_products(db)
current = await get_active_subscription(db, user.id, request_time=checked_at)
first_purchase = user.first_membership_paid_at is None
personal_first_purchase = user.first_membership_paid_at is None
team, team_purchase_available, team_reason = await resolve_team_purchase_context(db, user=user)
team_first_purchase = bool(team is None or team.first_subscription_paid_at is None)
subscription_products: list[dict] = []
team_subscription_products: list[dict] = []
credit_addons: list[dict] = []
upgrade_deduction = (
await get_upgrade_deduction_preview(db, subscription=current, request_time=checked_at)
if current is not None
else Decimal("0.00")
)
for product in products:
if product.product_type == CreditProductType.CREDIT_ADDON.value:
credit_addons.append(product_to_dict(product, user_price=to_credit_decimal(product.price), price_type=ProductPriceType.REGULAR.value, can_purchase=True))
continue
# 首订资格已经使用后,未开启续费的套餐不返回给客户端。
# 该过滤同时适用于过期后的续费和有效订阅期间的升级入口,
# 避免仅靠客户端隐藏后仍可被直接构造请求购买。
if not first_purchase and not bool(product.renewal_enabled):
credit_addons.append(
product_to_dict(
product,
user_price=to_credit_decimal(product.price),
price_type=ProductPriceType.REGULAR.value,
can_purchase=True,
)
)
continue
can_purchase = current is None
can_upgrade = False
reason = None
if current is not None:
can_upgrade = (
product.billing_cycle == current.billing_cycle
and int(product.tier_rank or 0) > int(current.tier_rank or 0)
)
can_purchase = can_upgrade
if not can_upgrade:
reason = "当前订阅有效,暂不能续费;仅可升级同周期更高等级套餐"
_, _, target_price, price_type = current_product_price(
is_team = product.product_type == CreditProductType.TEAM_SUBSCRIPTION.value
first_purchase = team_first_purchase if is_team else personal_first_purchase
if not first_purchase and not bool(product.renewal_enabled):
continue
_, _, unit_price, price_type = current_product_price(
product,
first_purchase=first_purchase,
request_time=checked_at,
upgrade=can_upgrade,
)
deduction_amount = upgrade_deduction if can_upgrade else Decimal("0.00")
user_price = max(Decimal("0.00"), target_price - deduction_amount)
if can_upgrade and user_price <= Decimal("0.00"):
can_purchase = False
reason = "当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理"
item = product_to_dict(
product,
user_price=user_price,
user_price=unit_price,
price_type=price_type,
can_purchase=can_purchase,
target_price=target_price,
deduction_amount=deduction_amount,
can_purchase=(team_purchase_available if is_team else True),
)
item["can_upgrade"] = can_upgrade
item["unavailable_reason"] = reason
subscription_products.append(item)
if is_team:
item["unavailable_reason"] = team_reason
if team_purchase_available:
team_subscription_products.append(item)
else:
subscription_products.append(item)
active_personal = await list_active_personal_subscriptions(
db, user_id=user.id, request_time=checked_at
)
return {
"subscription_products": subscription_products,
"team_subscription_products": team_subscription_products,
"credit_addons": credit_addons,
"first_purchase_available": first_purchase,
"current_subscription": subscription_to_dict(current) if current else None,
"personal_first_purchase_available": personal_first_purchase,
"team_first_purchase_available": team_first_purchase,
"active_personal_subscriptions": [_subscription_to_dict(item) for item in active_personal],
"personal_entitlement": await get_personal_entitlement(
db, user_id=user.id, request_time=checked_at
),
"team_entitlement": await get_team_entitlement(
db, user_id=user.id, request_time=checked_at
),
"team_purchase_available": team_purchase_available,
"team_purchase_unavailable_reason": team_reason,
}
@@ -217,19 +290,27 @@ def product_to_dict(
user_price: Decimal | None = None,
price_type: str | None = None,
can_purchase: bool | None = None,
target_price: Decimal | None = None,
deduction_amount: Decimal | None = None,
) -> dict:
deleted = product.deleted_at is not None
if deleted:
status_label = "已删除"
elif product.is_active:
status_label = "已上架"
else:
status_label = "已下架"
return {
"id": product.id,
"product_code": product.product_code,
"product_type": product.product_type,
"product_type_label": CREDIT_PRODUCT_TYPE_LABELS.get(product.product_type, "其他套餐类型"),
"name": product.name,
"description": product.description,
"features": product.features_json or [],
"tier_code": product.tier_code,
"tier_label": SUBSCRIPTION_TIER_LABELS.get(str(product.tier_code)) if product.tier_code else None,
"tier_rank": product.tier_rank,
"billing_cycle": product.billing_cycle,
"billing_cycle_label": SUBSCRIPTION_BILLING_CYCLE_LABELS.get(str(product.billing_cycle)) if product.billing_cycle else None,
"monthly_grant_credits": float(product.monthly_grant_credits or 0),
"grant_count": grant_count_for_cycle(product.billing_cycle) if product.is_subscription else 1,
"first_purchase_price": float(product.first_purchase_price or 0),
@@ -242,30 +323,16 @@ def product_to_dict(
"validity_months": int(product.validity_months or 1) if product.is_credit_addon else None,
"price": float(user_price if user_price is not None else product.price),
"current_price": float(user_price if user_price is not None else product.price),
"target_price": float(target_price) if target_price is not None else None,
"deduction_amount": float(deduction_amount or Decimal("0.00")),
"price_type": price_type,
"price_type_label": PRODUCT_PRICE_TYPE_LABELS.get(price_type or "") if price_type else None,
"credit_level": product.credit_level,
"credit_level_label": CREDIT_LEVEL_LABELS.get(product.credit_level, "其他积分等级"),
"currency": product.currency,
"is_active": product.is_active,
"is_active": bool(product.is_active),
"is_deleted": deleted,
"deleted_at": product.deleted_at,
"status_label": status_label,
"sort_order": product.sort_order,
"can_purchase": can_purchase,
}
def subscription_to_dict(subscription: UserCreditSubscription) -> dict:
return {
"id": subscription.id,
"product_id": subscription.product_id,
"status": subscription.status,
"purchase_scene": subscription.purchase_scene,
"tier_code": subscription.tier_code,
"tier_rank": subscription.tier_rank,
"billing_cycle": subscription.billing_cycle,
"anchor_at": subscription.anchor_at,
"start_at": subscription.start_at,
"expires_at": subscription.expires_at,
"monthly_grant_credits": float(subscription.monthly_grant_credits_snapshot),
"grant_count": subscription.grant_count,
"granted_count": subscription.granted_count,
"unavailable_reason": None,
}
@@ -8,14 +8,25 @@ from typing import Iterable
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CreditBalanceStatus
from app.enums.credit_balance import CreditBalanceStatus, CreditScope
from app.enums.credit_subscription import CreditSubscriptionStatus
from app.enums.team import TeamStatus
from app.models.credit.balance import UserCreditBalance
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.credit.team_seat import TeamSubscriptionSeat
from app.models.credit.team_seat_usage import TeamSubscriptionSeatUsage
from app.models.team import Team
from app.models.user import User
from app.services.credit.time_policy import last_usable_at
from app.services.credit.utils import to_credit_decimal, to_float, utc_now
@dataclass(slots=True, frozen=True)
class CreditBalanceSummary:
personal_credits: Decimal
team_available_credits: Decimal
team_frozen_credits: Decimal
available_credits: Decimal
next_expiring_credits: Decimal
next_expires_at: datetime | None
@@ -23,6 +34,9 @@ class CreditBalanceSummary:
def to_dict(self) -> dict:
return {
"personal_credits": to_float(self.personal_credits),
"team_available_credits": to_float(self.team_available_credits),
"team_frozen_credits": to_float(self.team_frozen_credits),
"available_credits": to_float(self.available_credits),
"credits": to_float(self.available_credits),
"next_expiring_credits": to_float(self.next_expiring_credits),
@@ -31,23 +45,114 @@ class CreditBalanceSummary:
}
async def get_available_credits(
def _blank_summary() -> CreditBalanceSummary:
zero = Decimal("0.00")
return CreditBalanceSummary(zero, zero, zero, zero, zero, None, None)
async def get_user_credit_summary_map(
db: AsyncSession,
user_id: str,
user_ids: Iterable[str],
*,
request_time: datetime | None = None,
) -> Decimal:
) -> dict[str, CreditBalanceSummary]:
ids = list(dict.fromkeys(str(item) for item in user_ids if item))
if not ids:
return {}
checked_at = request_time or utc_now()
result = await db.execute(
select(func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0)).where(
UserCreditBalance.user_id == user_id,
personal_map = {user_id: Decimal("0.00") for user_id in ids}
team_available_map = {user_id: Decimal("0.00") for user_id in ids}
team_frozen_map = {user_id: Decimal("0.00") for user_id in ids}
expiry_buckets: dict[str, dict[datetime, Decimal]] = {user_id: {} for user_id in ids}
personal_result = await db.execute(
select(UserCreditBalance.user_id, UserCreditBalance.expires_at, UserCreditBalance.unspent_amount)
.where(
UserCreditBalance.user_id.in_(ids),
UserCreditBalance.credit_scope == CreditScope.PERSONAL.value,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
)
return to_credit_decimal(result.scalar_one())
for row in personal_result.all():
amount = to_credit_decimal(row.unspent_amount)
user_id = str(row.user_id)
personal_map[user_id] += amount
expiry_buckets[user_id][row.expires_at] = expiry_buckets[user_id].get(row.expires_at, Decimal("0.00")) + amount
team_result = await db.execute(
select(
TeamSubscriptionSeat.user_id,
Team.status.label("team_status"),
UserCreditBalance.expires_at,
UserCreditBalance.unspent_amount,
TeamSubscriptionSeat.monthly_allocated_credits,
TeamSubscriptionSeatUsage.used_credits,
)
.join(UserCreditSubscription, UserCreditSubscription.id == TeamSubscriptionSeat.subscription_id)
.join(Team, Team.id == TeamSubscriptionSeat.team_id)
.join(
UserCreditSubscriptionPeriod,
UserCreditSubscriptionPeriod.subscription_id == UserCreditSubscription.id,
)
.join(UserCreditBalance, UserCreditBalance.id == UserCreditSubscriptionPeriod.issued_balance_id)
.outerjoin(
TeamSubscriptionSeatUsage,
(TeamSubscriptionSeatUsage.seat_id == TeamSubscriptionSeat.id)
& (TeamSubscriptionSeatUsage.subscription_period_id == UserCreditSubscriptionPeriod.id),
)
.where(
TeamSubscriptionSeat.user_id.in_(ids),
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
Team.deleted_at.is_(None),
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
UserCreditSubscriptionPeriod.valid_from <= checked_at,
UserCreditSubscriptionPeriod.expires_at > checked_at,
UserCreditBalance.credit_scope == CreditScope.TEAM.value,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
)
for row in team_result.all():
allocated = to_credit_decimal(row.monthly_allocated_credits)
used = to_credit_decimal(row.used_credits or 0)
seat_remaining = max(Decimal("0.00"), allocated - used)
amount = min(to_credit_decimal(row.unspent_amount), seat_remaining)
if amount <= 0:
continue
user_id = str(row.user_id)
if row.team_status == TeamStatus.ACTIVE.value:
team_available_map[user_id] += amount
expiry_buckets[user_id][row.expires_at] = expiry_buckets[user_id].get(row.expires_at, Decimal("0.00")) + amount
else:
team_frozen_map[user_id] += amount
output: dict[str, CreditBalanceSummary] = {}
for user_id in ids:
personal = personal_map[user_id]
team_available = team_available_map[user_id]
frozen = team_frozen_map[user_id]
available = personal + team_available
bucket = expiry_buckets[user_id]
next_expires_at = min(bucket.keys()) if bucket else None
next_expiring = bucket.get(next_expires_at, Decimal("0.00")) if next_expires_at else Decimal("0.00")
output[user_id] = CreditBalanceSummary(
personal_credits=personal,
team_available_credits=team_available,
team_frozen_credits=frozen,
available_credits=available,
next_expiring_credits=next_expiring,
next_expires_at=next_expires_at,
next_last_usable_at=last_usable_at(next_expires_at) if next_expires_at else None,
)
return output
async def get_balance_summary(
@@ -56,35 +161,20 @@ async def get_balance_summary(
*,
request_time: datetime | None = None,
) -> CreditBalanceSummary:
checked_at = request_time or utc_now()
available = await get_available_credits(db, user_id, request_time=checked_at)
expiry_result = await db.execute(
select(
UserCreditBalance.expires_at,
func.sum(UserCreditBalance.unspent_amount).label("amount"),
)
.where(
UserCreditBalance.user_id == user_id,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.group_by(UserCreditBalance.expires_at)
.order_by(UserCreditBalance.expires_at.asc())
.limit(1)
)
row = expiry_result.first()
expires_at = row.expires_at if row else None
expiring = to_credit_decimal(row.amount if row else 0)
return CreditBalanceSummary(
available_credits=available,
next_expiring_credits=expiring,
next_expires_at=expires_at,
next_last_usable_at=last_usable_at(expires_at) if expires_at else None,
return (await get_user_credit_summary_map(db, [user_id], request_time=request_time)).get(
user_id, _blank_summary()
)
async def get_available_credits(
db: AsyncSession,
user_id: str,
*,
request_time: datetime | None = None,
) -> Decimal:
return (await get_balance_summary(db, user_id, request_time=request_time)).available_credits
async def get_user_credit_map(
db: AsyncSession,
user_ids: Iterable[str],
@@ -92,31 +182,11 @@ async def get_user_credit_map(
request_time: datetime | None = None,
) -> dict[str, float]:
ids = list(dict.fromkeys(str(item) for item in user_ids if item))
if not ids:
return {}
checked_at = request_time or utc_now()
result = await db.execute(
select(
UserCreditBalance.user_id,
func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0).label("credits"),
)
.where(
UserCreditBalance.user_id.in_(ids),
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.group_by(UserCreditBalance.user_id)
)
output = {user_id: 0.0 for user_id in ids}
for row in result:
output[str(row.user_id)] = to_float(row.credits)
return output
summaries = await get_user_credit_summary_map(db, ids, request_time=request_time)
return {user_id: to_float(summaries.get(user_id, _blank_summary()).available_credits) for user_id in ids}
def attach_credit_snapshot(user: object, credits: Decimal | float | int) -> object:
# SQLAlchemy Declarative 对象允许附加非映射运行时属性;不会写回 users 表。
setattr(user, "credits", to_float(to_credit_decimal(credits)))
return user
@@ -138,13 +208,8 @@ def apply_balance_status_filter(stmt, status: str | None, *, request_time: datet
if not status:
return stmt
if status == CreditBalanceStatus.REVOKED.value:
return stmt.where(
or_(UserCreditBalance.revoked_at.is_not(None), UserCreditBalance.revoked_amount > 0)
)
base_not_revoked = and_(
UserCreditBalance.revoked_at.is_(None),
UserCreditBalance.revoked_amount <= 0,
)
return stmt.where(or_(UserCreditBalance.revoked_at.is_not(None), UserCreditBalance.revoked_amount > 0))
base_not_revoked = and_(UserCreditBalance.revoked_at.is_(None), UserCreditBalance.revoked_amount <= 0)
if status == CreditBalanceStatus.EXPIRED.value:
return stmt.where(base_not_revoked, UserCreditBalance.expires_at <= request_time)
if status == CreditBalanceStatus.SCHEDULED.value:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,890 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Any
from fastapi import HTTPException
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CreditAllocationAction, CreditScope
from app.enums.credit_product import (
SUBSCRIPTION_BILLING_CYCLE_LABELS,
SUBSCRIPTION_TIER_LABELS,
CreditProductType,
)
from app.enums.credit_subscription import (
CREDIT_SUBSCRIPTION_PERIOD_STATUS_LABELS,
CREDIT_SUBSCRIPTION_STATUS_LABELS,
CreditSubscriptionStatus,
)
from app.enums.team import TEAM_SEAT_STATUS_LABELS, TeamSeatStatus, TeamStatus
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.credit.team_seat import TeamSubscriptionSeat
from app.models.credit.team_seat_usage import TeamSubscriptionSeatUsage
from app.models.team import Team
from app.models.user import User
from app.services.credit.locking import (
acquire_subscription_credit_lock,
acquire_subscription_credit_locks,
acquire_team_business_lock,
)
from app.services.credit.utils import to_credit_decimal, utc_now
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
@dataclass(slots=True)
class TeamCreditCandidate:
balance: UserCreditBalance
seat: TeamSubscriptionSeat
usage: TeamSubscriptionSeatUsage | None
subscription: UserCreditSubscription
period: UserCreditSubscriptionPeriod
available: Decimal
def _remaining(seat: TeamSubscriptionSeat, usage: TeamSubscriptionSeatUsage | None) -> Decimal:
allocated = to_credit_decimal(seat.monthly_allocated_credits)
used = to_credit_decimal(usage.used_credits if usage else 0)
return max(Decimal("0.00"), allocated - used)
def _seat_status(
seat: TeamSubscriptionSeat,
subscription: UserCreditSubscription,
checked_at: datetime,
) -> str:
if seat.deleted_at is not None or seat.cancelled_at is not None:
return TeamSeatStatus.CANCELLED.value
if subscription.expires_at <= checked_at or subscription.status != CreditSubscriptionStatus.ACTIVE.value:
return TeamSeatStatus.EXPIRED.value
return TeamSeatStatus.ACTIVE.value
async def _load_team(db: AsyncSession, team_id: str, *, for_update: bool = False) -> Team:
stmt = select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
team = result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
return team
async def _assert_manager_active_team(
db: AsyncSession,
*,
team_id: str,
manager_user_id: str,
) -> Team:
await acquire_team_business_lock(db, team_id)
team = await _load_team(db, team_id, for_update=True)
if team.manager_id != manager_user_id:
raise HTTPException(status_code=403, detail="只有当前团队队长才能管理团队订阅席位")
if team.status != TeamStatus.ACTIVE.value:
raise HTTPException(status_code=409, detail="团队已禁用,当前仅允许查看,不能修改团队订阅席位")
return team
async def _load_subscription(
db: AsyncSession,
*,
subscription_id: str,
team_id: str | None = None,
request_time: datetime | None = None,
for_update: bool = False,
require_active: bool = False,
) -> UserCreditSubscription:
checked_at = request_time or utc_now()
stmt = select(UserCreditSubscription).where(
UserCreditSubscription.id == subscription_id,
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
)
if team_id:
stmt = stmt.where(UserCreditSubscription.team_id == team_id)
if require_active:
stmt = stmt.where(
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
stmt = stmt.limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
subscription = result.scalar_one_or_none()
if not subscription:
raise HTTPException(status_code=404, detail="团队订阅不存在或当前不可用")
return subscription
async def _load_current_period(
db: AsyncSession,
*,
subscription_id: str,
request_time: datetime,
) -> UserCreditSubscriptionPeriod | None:
result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.subscription_id == subscription_id,
UserCreditSubscriptionPeriod.valid_from <= request_time,
UserCreditSubscriptionPeriod.expires_at > request_time,
)
.order_by(UserCreditSubscriptionPeriod.sequence.asc())
.limit(1)
)
return result.scalar_one_or_none()
async def _load_period_balance(
db: AsyncSession,
*,
period: UserCreditSubscriptionPeriod | None,
for_update: bool = False,
) -> UserCreditBalance | None:
if not period or not period.issued_balance_id:
return None
stmt = select(UserCreditBalance).where(UserCreditBalance.id == period.issued_balance_id).limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def _usage_map(
db: AsyncSession,
*,
seat_ids: list[str],
period_id: str,
for_update: bool = False,
) -> dict[str, TeamSubscriptionSeatUsage]:
if not seat_ids:
return {}
stmt = select(TeamSubscriptionSeatUsage).where(
TeamSubscriptionSeatUsage.seat_id.in_(seat_ids),
TeamSubscriptionSeatUsage.subscription_period_id == period_id,
)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return {item.seat_id: item for item in result.scalars().all()}
async def _validate_allocation_pool(
db: AsyncSession,
*,
subscription: UserCreditSubscription,
target_seat_id: str | None,
target_allocated: Decimal,
request_time: datetime,
) -> None:
period = await _load_current_period(
db, subscription_id=subscription.id, request_time=request_time
)
balance = await _load_period_balance(db, period=period, for_update=True)
if not period or not balance:
raise HTTPException(status_code=409, detail="当前团队订阅周期积分尚未发放,暂不能调整席位额度")
result = await db.execute(
select(TeamSubscriptionSeat)
.where(
TeamSubscriptionSeat.subscription_id == subscription.id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
)
.order_by(TeamSubscriptionSeat.id.asc())
.with_for_update()
)
seats = list(result.scalars().all())
usage_map = await _usage_map(
db,
seat_ids=[item.id for item in seats],
period_id=period.id,
for_update=True,
)
total_remaining = Decimal("0.00")
for seat in seats:
if seat.id == target_seat_id:
used = to_credit_decimal(usage_map.get(seat.id).used_credits if usage_map.get(seat.id) else 0)
total_remaining += max(Decimal("0.00"), target_allocated - used)
else:
total_remaining += _remaining(seat, usage_map.get(seat.id))
if target_seat_id is None:
total_remaining += target_allocated
if total_remaining > to_credit_decimal(balance.unspent_amount):
raise HTTPException(
status_code=409,
detail=(
f"席位剩余可消费额度合计不能超过当前周期剩余团队积分,"
f"当前最多可分配 {float(max(Decimal('0.00'), to_credit_decimal(balance.unspent_amount) - (total_remaining - target_allocated))):.2f} 积分"
),
)
async def create_seat(
db: AsyncSession,
*,
team_id: str,
subscription_id: str,
user_id: str,
monthly_allocated_credits: Decimal | float | int,
manager_user_id: str,
request_time: datetime | None = None,
) -> TeamSubscriptionSeat:
checked_at = request_time or utc_now()
allocated = to_credit_decimal(monthly_allocated_credits)
if allocated <= 0:
raise HTTPException(status_code=400, detail="席位月额度必须大于0")
await _assert_manager_active_team(db, team_id=team_id, manager_user_id=manager_user_id)
await acquire_subscription_credit_lock(db, subscription_id)
subscription = await _load_subscription(
db, subscription_id=subscription_id, team_id=team_id,
request_time=checked_at, for_update=True, require_active=True,
)
user_result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
user = user_result.scalar_one_or_none()
if not user or user.team_id != team_id or not user.is_active:
raise HTTPException(status_code=400, detail="席位用户必须是当前团队的有效成员")
existing = (await db.execute(
select(TeamSubscriptionSeat.id).where(
TeamSubscriptionSeat.subscription_id == subscription_id,
TeamSubscriptionSeat.user_id == user_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
).limit(1)
)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=409, detail="该用户已经占用当前团队订阅的席位")
active_count = (await db.execute(
select(func.count(TeamSubscriptionSeat.id)).where(
TeamSubscriptionSeat.subscription_id == subscription_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
)
)).scalar() or 0
if int(active_count) >= int(subscription.quantity_snapshot):
raise HTTPException(status_code=409, detail="当前团队订阅席位已经分配完毕")
await _validate_allocation_pool(
db,
subscription=subscription,
target_seat_id=None,
target_allocated=allocated,
request_time=checked_at,
)
seat = TeamSubscriptionSeat(
id=generate_id(),
team_id=team_id,
subscription_id=subscription_id,
user_id=user_id,
monthly_allocated_credits=allocated,
created_by_user_id=manager_user_id,
)
db.add(seat)
await db.flush()
log_operation_event(
domain="team",
module="team_subscription",
event_type="TEAM_SUBSCRIPTION_SEAT_CREATED",
user_id=manager_user_id,
message="团队订阅席位创建成功",
detail={
"team_id": team_id,
"subscription_id": subscription_id,
"seat_id": seat.id,
"seat_user_id": user_id,
"monthly_allocated_credits": float(allocated),
},
)
return seat
async def update_seat(
db: AsyncSession,
*,
team_id: str,
seat_id: str,
monthly_allocated_credits: Decimal | float | int,
manager_user_id: str,
request_time: datetime | None = None,
) -> TeamSubscriptionSeat:
checked_at = request_time or utc_now()
allocated = to_credit_decimal(monthly_allocated_credits)
if allocated <= 0:
raise HTTPException(status_code=400, detail="席位月额度必须大于0")
await _assert_manager_active_team(db, team_id=team_id, manager_user_id=manager_user_id)
seat_result = await db.execute(
select(TeamSubscriptionSeat)
.where(
TeamSubscriptionSeat.id == seat_id,
TeamSubscriptionSeat.team_id == team_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
)
.with_for_update()
.limit(1)
)
seat = seat_result.scalar_one_or_none()
if not seat:
raise HTTPException(status_code=404, detail="团队订阅席位不存在或已取消")
await acquire_subscription_credit_lock(db, seat.subscription_id)
subscription = await _load_subscription(
db, subscription_id=seat.subscription_id, team_id=team_id,
request_time=checked_at, for_update=True, require_active=True,
)
await _validate_allocation_pool(
db,
subscription=subscription,
target_seat_id=seat.id,
target_allocated=allocated,
request_time=checked_at,
)
before_allocated = to_credit_decimal(seat.monthly_allocated_credits)
seat.monthly_allocated_credits = allocated
await db.flush()
log_operation_event(
domain="team",
module="team_subscription",
event_type="TEAM_SUBSCRIPTION_SEAT_UPDATED",
user_id=manager_user_id,
message="团队订阅席位额度修改成功",
detail={
"team_id": team_id,
"subscription_id": seat.subscription_id,
"seat_id": seat.id,
"seat_user_id": seat.user_id,
"before_monthly_allocated_credits": float(before_allocated),
"monthly_allocated_credits": float(allocated),
},
)
return seat
async def cancel_seat(
db: AsyncSession,
*,
team_id: str,
seat_id: str,
manager_user_id: str,
request_time: datetime | None = None,
) -> TeamSubscriptionSeat:
checked_at = request_time or utc_now()
await _assert_manager_active_team(db, team_id=team_id, manager_user_id=manager_user_id)
result = await db.execute(
select(TeamSubscriptionSeat)
.where(
TeamSubscriptionSeat.id == seat_id,
TeamSubscriptionSeat.team_id == team_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
)
.with_for_update()
.limit(1)
)
seat = result.scalar_one_or_none()
if not seat:
raise HTTPException(status_code=404, detail="团队订阅席位不存在或已取消")
seat.cancelled_at = checked_at
seat.deleted_at = checked_at
await db.flush()
log_operation_event(
domain="team",
module="team_subscription",
event_type="TEAM_SUBSCRIPTION_SEAT_CANCELLED",
user_id=manager_user_id,
message="团队订阅席位取消成功",
detail={
"team_id": team_id,
"subscription_id": seat.subscription_id,
"seat_id": seat.id,
"seat_user_id": seat.user_id,
"monthly_allocated_credits": float(to_credit_decimal(seat.monthly_allocated_credits)),
},
)
return seat
async def get_or_create_usage_for_update(
db: AsyncSession,
*,
seat: TeamSubscriptionSeat,
period: UserCreditSubscriptionPeriod,
) -> TeamSubscriptionSeatUsage:
result = await db.execute(
select(TeamSubscriptionSeatUsage)
.where(
TeamSubscriptionSeatUsage.seat_id == seat.id,
TeamSubscriptionSeatUsage.subscription_period_id == period.id,
)
.with_for_update()
.limit(1)
)
usage = result.scalar_one_or_none()
if usage:
return usage
usage = TeamSubscriptionSeatUsage(
id=generate_id(),
seat_id=seat.id,
subscription_id=seat.subscription_id,
subscription_period_id=period.id,
user_id=seat.user_id,
used_credits=Decimal("0.00"),
)
db.add(usage)
await db.flush()
return usage
async def list_user_team_credit_candidates(
db: AsyncSession,
*,
user_id: str,
request_time: datetime | None = None,
require_team_active: bool = True,
) -> list[TeamCreditCandidate]:
checked_at = request_time or utc_now()
user_team_id = (await db.execute(select(User.team_id).where(User.id == user_id).limit(1))).scalar_one_or_none()
if not user_team_id:
return []
team = (await db.execute(
select(Team).where(Team.id == user_team_id, Team.deleted_at.is_(None)).limit(1)
)).scalar_one_or_none()
if not team:
return []
if require_team_active and team.status != TeamStatus.ACTIVE.value:
return []
result = await db.execute(
select(
TeamSubscriptionSeat,
UserCreditSubscription,
UserCreditSubscriptionPeriod,
UserCreditBalance,
TeamSubscriptionSeatUsage,
)
.join(UserCreditSubscription, UserCreditSubscription.id == TeamSubscriptionSeat.subscription_id)
.join(
UserCreditSubscriptionPeriod,
UserCreditSubscriptionPeriod.subscription_id == UserCreditSubscription.id,
)
.join(
UserCreditBalance,
UserCreditBalance.id == UserCreditSubscriptionPeriod.issued_balance_id,
)
.outerjoin(
TeamSubscriptionSeatUsage,
(TeamSubscriptionSeatUsage.seat_id == TeamSubscriptionSeat.id)
& (TeamSubscriptionSeatUsage.subscription_period_id == UserCreditSubscriptionPeriod.id),
)
.where(
TeamSubscriptionSeat.user_id == user_id,
TeamSubscriptionSeat.team_id == user_team_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
UserCreditSubscriptionPeriod.valid_from <= checked_at,
UserCreditSubscriptionPeriod.expires_at > checked_at,
UserCreditBalance.credit_scope == CreditScope.TEAM.value,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.order_by(
UserCreditBalance.credit_level_rank.asc(),
UserCreditBalance.expires_at.asc(),
UserCreditBalance.valid_from.asc(),
UserCreditBalance.id.asc(),
)
)
output: list[TeamCreditCandidate] = []
for seat, subscription, period, balance, usage in result.all():
available = min(to_credit_decimal(balance.unspent_amount), _remaining(seat, usage))
if available > 0:
output.append(TeamCreditCandidate(balance, seat, usage, subscription, period, available))
return output
async def lock_user_team_credit_candidates(
db: AsyncSession,
*,
user_id: str,
request_time: datetime,
) -> list[TeamCreditCandidate]:
user_team_id = (await db.execute(select(User.team_id).where(User.id == user_id).limit(1))).scalar_one_or_none()
if not user_team_id:
return []
await acquire_team_business_lock(db, user_team_id)
team = await _load_team(db, user_team_id, for_update=True)
if team.status != TeamStatus.ACTIVE.value:
return []
ids_result = await db.execute(
select(
TeamSubscriptionSeat.id,
TeamSubscriptionSeat.subscription_id,
UserCreditSubscriptionPeriod.id.label("period_id"),
UserCreditSubscriptionPeriod.issued_balance_id,
)
.join(UserCreditSubscription, UserCreditSubscription.id == TeamSubscriptionSeat.subscription_id)
.join(
UserCreditSubscriptionPeriod,
UserCreditSubscriptionPeriod.subscription_id == UserCreditSubscription.id,
)
.where(
TeamSubscriptionSeat.user_id == user_id,
TeamSubscriptionSeat.team_id == user_team_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= request_time,
UserCreditSubscription.expires_at > request_time,
UserCreditSubscriptionPeriod.valid_from <= request_time,
UserCreditSubscriptionPeriod.expires_at > request_time,
UserCreditSubscriptionPeriod.issued_balance_id.is_not(None),
)
)
raw = list(ids_result.all())
if not raw:
return []
await acquire_subscription_credit_locks(db, [str(row.subscription_id) for row in raw])
seat_ids = [str(row.id) for row in raw]
period_ids = [str(row.period_id) for row in raw]
balance_ids = [str(row.issued_balance_id) for row in raw]
seats_result = await db.execute(
select(TeamSubscriptionSeat)
.where(TeamSubscriptionSeat.id.in_(seat_ids))
.order_by(TeamSubscriptionSeat.id.asc())
.with_for_update()
)
seats = {item.id: item for item in seats_result.scalars().all()}
subs_result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id.in_([str(row.subscription_id) for row in raw]))
.order_by(UserCreditSubscription.id.asc())
.with_for_update()
)
subs = {item.id: item for item in subs_result.scalars().all()}
periods_result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(UserCreditSubscriptionPeriod.id.in_(period_ids))
.order_by(UserCreditSubscriptionPeriod.id.asc())
.with_for_update()
)
periods = {item.id: item for item in periods_result.scalars().all()}
balances_result = await db.execute(
select(UserCreditBalance)
.where(
UserCreditBalance.id.in_(balance_ids),
UserCreditBalance.credit_scope == CreditScope.TEAM.value,
UserCreditBalance.valid_from <= request_time,
UserCreditBalance.expires_at > request_time,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.order_by(UserCreditBalance.id.asc())
.with_for_update()
)
balances = {item.id: item for item in balances_result.scalars().all()}
usage_result = await db.execute(
select(TeamSubscriptionSeatUsage)
.where(
TeamSubscriptionSeatUsage.seat_id.in_(seat_ids),
TeamSubscriptionSeatUsage.subscription_period_id.in_(period_ids),
)
.order_by(TeamSubscriptionSeatUsage.id.asc())
.with_for_update()
)
usages = {(item.seat_id, item.subscription_period_id): item for item in usage_result.scalars().all()}
output: list[TeamCreditCandidate] = []
for row in raw:
seat = seats.get(str(row.id))
subscription = subs.get(str(row.subscription_id))
period = periods.get(str(row.period_id))
balance = balances.get(str(row.issued_balance_id))
if not seat or not subscription or not period or not balance:
continue
if seat.deleted_at is not None or seat.cancelled_at is not None:
continue
usage = usages.get((seat.id, period.id))
available = min(to_credit_decimal(balance.unspent_amount), _remaining(seat, usage))
if available > 0:
output.append(TeamCreditCandidate(balance, seat, usage, subscription, period, available))
return output
async def list_team_subscriptions_for_management(
db: AsyncSession,
*,
team_id: str,
request_time: datetime | None = None,
) -> list[dict[str, Any]]:
"""按 Subscription 实例返回团队席位管理视图;全程批量查询,避免 N+1。"""
checked_at = request_time or utc_now()
team = await _load_team(db, team_id)
subs_result = await db.execute(
select(UserCreditSubscription)
.where(
UserCreditSubscription.team_id == team_id,
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
)
.order_by(UserCreditSubscription.created_at.desc(), UserCreditSubscription.id.desc())
)
subscriptions = list(subs_result.scalars().all())
if not subscriptions:
return []
subscription_ids = [item.id for item in subscriptions]
periods_result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.subscription_id.in_(subscription_ids),
UserCreditSubscriptionPeriod.valid_from <= checked_at,
UserCreditSubscriptionPeriod.expires_at > checked_at,
)
.order_by(UserCreditSubscriptionPeriod.subscription_id.asc(), UserCreditSubscriptionPeriod.sequence.asc())
)
period_map: dict[str, UserCreditSubscriptionPeriod] = {}
for period in periods_result.scalars().all():
period_map.setdefault(period.subscription_id, period)
balance_ids = [period.issued_balance_id for period in period_map.values() if period.issued_balance_id]
balance_map: dict[str, UserCreditBalance] = {}
if balance_ids:
balances_result = await db.execute(select(UserCreditBalance).where(UserCreditBalance.id.in_(balance_ids)))
balance_map = {item.id: item for item in balances_result.scalars().all()}
seats_result = await db.execute(
select(TeamSubscriptionSeat, User.username)
.join(User, User.id == TeamSubscriptionSeat.user_id)
.where(TeamSubscriptionSeat.subscription_id.in_(subscription_ids))
.order_by(
TeamSubscriptionSeat.subscription_id.asc(),
TeamSubscriptionSeat.created_at.asc(),
TeamSubscriptionSeat.id.asc(),
)
)
seat_rows_by_subscription: dict[str, list[tuple[TeamSubscriptionSeat, str]]] = {}
all_seat_ids: list[str] = []
for seat, username in seats_result.all():
seat_rows_by_subscription.setdefault(seat.subscription_id, []).append((seat, username))
all_seat_ids.append(seat.id)
current_period_ids = [period.id for period in period_map.values()]
usage_map: dict[tuple[str, str], TeamSubscriptionSeatUsage] = {}
if all_seat_ids and current_period_ids:
usage_result = await db.execute(
select(TeamSubscriptionSeatUsage).where(
TeamSubscriptionSeatUsage.seat_id.in_(all_seat_ids),
TeamSubscriptionSeatUsage.subscription_period_id.in_(current_period_ids),
)
)
usage_map = {
(item.seat_id, item.subscription_period_id): item
for item in usage_result.scalars().all()
}
output: list[dict[str, Any]] = []
for subscription in subscriptions:
period = period_map.get(subscription.id)
balance = balance_map.get(period.issued_balance_id) if period and period.issued_balance_id else None
rows = seat_rows_by_subscription.get(subscription.id, [])
active_seats = [seat for seat, _ in rows if seat.deleted_at is None and seat.cancelled_at is None]
seats_payload = []
active_remaining = Decimal("0.00")
for seat, username in rows:
usage = usage_map.get((seat.id, period.id)) if period else None
remaining = (
_remaining(seat, usage)
if seat.deleted_at is None and seat.cancelled_at is None
else Decimal("0.00")
)
active_remaining += remaining
status = _seat_status(seat, subscription, checked_at)
seats_payload.append(
{
"id": seat.id,
"team_id": seat.team_id,
"subscription_id": seat.subscription_id,
"user_id": seat.user_id,
"username": username,
"monthly_allocated_credits": float(seat.monthly_allocated_credits),
"current_period_id": period.id if period else None,
"current_period_used_credits": float(usage.used_credits if usage else 0),
"current_period_remaining_credits": float(remaining),
"status": status,
"status_label": TEAM_SEAT_STATUS_LABELS.get(status, "其他状态"),
"created_at": seat.created_at,
"cancelled_at": seat.cancelled_at,
}
)
period_unspent = to_credit_decimal(balance.unspent_amount if balance else 0)
output.append(
{
"subscription": {
"id": subscription.id,
"subscription_no": subscription.subscription_no,
"user_id": subscription.user_id,
"team_id": subscription.team_id,
"team_manager_id_snapshot": subscription.team_manager_id_snapshot,
"product_id": subscription.product_id,
"payment_order_id": subscription.payment_order_id,
"status": subscription.status,
"status_label": CREDIT_SUBSCRIPTION_STATUS_LABELS.get(subscription.status, "其他状态"),
"purchase_scene": subscription.purchase_scene,
"product_type_snapshot": subscription.product_type_snapshot,
"product_type_label": "团队订阅套餐",
"product_name_snapshot": subscription.product_name_snapshot,
"tier_code": subscription.tier_code,
"tier_rank": subscription.tier_rank,
"billing_cycle": subscription.billing_cycle,
"billing_cycle_label": {"monthly": "月卡", "quarterly": "季卡", "yearly": "年卡"}.get(subscription.billing_cycle, "其他周期"),
"anchor_at": subscription.anchor_at,
"start_at": subscription.start_at,
"expires_at": subscription.expires_at,
"next_grant_at": subscription.next_grant_at,
"monthly_grant_credits_snapshot": float(subscription.monthly_grant_credits_snapshot),
"monthly_total_credits_snapshot": float(subscription.monthly_total_credits_snapshot),
"quantity_snapshot": subscription.quantity_snapshot,
"grant_count": subscription.grant_count,
"granted_count": subscription.granted_count,
"first_purchase_price_snapshot": float(subscription.first_purchase_price_snapshot),
"regular_price_snapshot": float(subscription.regular_price_snapshot),
"activity_price_snapshot": float(subscription.activity_price_snapshot) if subscription.activity_price_snapshot is not None else None,
"actual_unit_price_snapshot": float(subscription.actual_unit_price_snapshot),
"paid_amount_snapshot": float(subscription.paid_amount_snapshot),
"periods": [],
},
"current_period_id": period.id if period else None,
"current_period_start_at": period.valid_from if period else None,
"current_period_expires_at": period.expires_at if period else None,
"period_total_credits": float(period.grant_credits if period else 0),
"period_unspent_credits": float(period_unspent),
"period_unallocated_credits": float(max(Decimal("0.00"), period_unspent - active_remaining)),
"seat_limit": int(subscription.quantity_snapshot),
"active_seat_count": len(active_seats),
"seats": seats_payload,
"team_status": team.status,
"team_status_label": "启用" if team.status == TeamStatus.ACTIVE.value else "禁用",
}
)
return output
async def list_member_period_usage(
db: AsyncSession,
*,
team_id: str,
subscription_id: str | None = None,
) -> list[dict[str, Any]]:
conditions = [
CreditRecordAllocation.credit_scope_snapshot == CreditScope.TEAM.value,
CreditRecordAllocation.team_id_snapshot == team_id,
CreditRecordAllocation.allocation_action.in_([
CreditAllocationAction.CONSUME.value,
CreditAllocationAction.REFUND_AVAILABLE.value,
CreditAllocationAction.REFUND_EXPIRED.value,
]),
]
if subscription_id:
conditions.append(CreditRecordAllocation.subscription_id_snapshot == subscription_id)
result = await db.execute(
select(
CreditRecordAllocation.user_id,
CreditRecordAllocation.subscription_id_snapshot,
CreditRecordAllocation.subscription_period_id_snapshot,
User.username,
func.sum(
case(
(CreditRecordAllocation.allocation_action == CreditAllocationAction.CONSUME.value, CreditRecordAllocation.amount),
else_=-CreditRecordAllocation.amount,
)
).label("net_used"),
)
.join(User, User.id == CreditRecordAllocation.user_id)
.where(*conditions)
.group_by(
CreditRecordAllocation.user_id,
CreditRecordAllocation.subscription_id_snapshot,
CreditRecordAllocation.subscription_period_id_snapshot,
User.username,
)
)
rows = list(result.all())
if not rows:
return []
subscription_ids = [str(row.subscription_id_snapshot) for row in rows if row.subscription_id_snapshot]
period_ids = [str(row.subscription_period_id_snapshot) for row in rows if row.subscription_period_id_snapshot]
subscriptions_result = await db.execute(
select(
UserCreditSubscription.id,
UserCreditSubscription.subscription_no,
UserCreditSubscription.product_name_snapshot,
UserCreditSubscription.tier_code,
UserCreditSubscription.tier_rank,
UserCreditSubscription.billing_cycle,
).where(UserCreditSubscription.id.in_(subscription_ids))
) if subscription_ids else None
subscription_map = {
row.id: row for row in subscriptions_result.all()
} if subscriptions_result is not None else {}
periods_result = await db.execute(
select(
UserCreditSubscriptionPeriod.id,
UserCreditSubscriptionPeriod.sequence,
UserCreditSubscriptionPeriod.valid_from,
UserCreditSubscriptionPeriod.expires_at,
).where(UserCreditSubscriptionPeriod.id.in_(period_ids))
) if period_ids else None
period_map = {
row.id: row for row in periods_result.all()
} if periods_result is not None else {}
output: list[dict[str, Any]] = []
for row in rows:
subscription = subscription_map.get(row.subscription_id_snapshot)
period = period_map.get(row.subscription_period_id_snapshot)
tier_code = str(subscription.tier_code) if subscription else ""
billing_cycle = str(subscription.billing_cycle) if subscription else ""
period_sequence = int(period.sequence) + 1 if period else 0
output.append(
{
"user_id": row.user_id,
"username": row.username,
# ID 仍用于接口内部关联/筛选,但客户端不直接展示。
"subscription_id": row.subscription_id_snapshot,
"subscription_no": subscription.subscription_no if subscription else "历史订阅",
"subscription_name": subscription.product_name_snapshot if subscription else "历史团队订阅",
"tier_code": tier_code,
"tier_label": SUBSCRIPTION_TIER_LABELS.get(tier_code, tier_code or "未知等级"),
"tier_rank": int(subscription.tier_rank) if subscription else 0,
"billing_cycle": billing_cycle,
"billing_cycle_label": SUBSCRIPTION_BILLING_CYCLE_LABELS.get(
billing_cycle, billing_cycle or "未知周期"
),
"subscription_period_id": row.subscription_period_id_snapshot,
"period_sequence": period_sequence,
"period_label": f"{period_sequence}个月" if period_sequence > 0 else "历史周期",
"period_start_at": period.valid_from if period else None,
"period_expires_at": period.expires_at if period else None,
"consumed_credits": float(max(Decimal("0.00"), to_credit_decimal(row.net_used))),
}
)
return output
@@ -1,163 +0,0 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_product import SubscriptionBillingCycle
from app.enums.credit_subscription import CreditSubscriptionPeriodStatus
from app.models.credit.product import CreditProduct
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.payment_order import PaymentOrder
from app.models.user import User
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.product_service import (
ProductPriceQuote,
current_product_price,
get_active_subscription,
)
from app.services.credit.utils import to_credit_decimal
async def quote_and_reserve_product_purchase(
db: AsyncSession,
*,
user: User,
product: CreditProduct,
order_id: str,
request_time: datetime,
) -> ProductPriceQuote:
if product.is_credit_addon:
price = to_credit_decimal(product.price)
return ProductPriceQuote(
product=product,
purchase_scene="credit_addon",
price_type="regular",
base_price=price,
activity_price=None,
target_price=price,
deduction_amount=Decimal("0.00"),
payable_amount=price,
)
# 所有订阅购买/升级先取得统一用户积分事务锁,再锁订阅和周期,
# 与月度发放、支付履约保持同一锁顺序,避免升级边界死锁。
await acquire_user_credit_lock(db, user.id)
pending_result = await db.execute(
select(PaymentOrder.id).where(
PaymentOrder.user_id == user.id,
PaymentOrder.id != order_id,
PaymentOrder.status == "pending",
PaymentOrder.product_type == "subscription",
).limit(1)
)
if pending_result.scalar_one_or_none() is not None:
raise ValueError("已有待支付的订阅或升级订单,请先完成或等待订单过期")
current = await get_active_subscription(
db,
user.id,
request_time=request_time,
for_update=True,
)
first_purchase = user.first_membership_paid_at is None
if not first_purchase and not bool(product.renewal_enabled):
raise ValueError("该订阅套餐暂未开放续费或升级")
if current is None:
base, activity, target, price_type = current_product_price(
product,
first_purchase=first_purchase,
request_time=request_time,
upgrade=False,
)
return ProductPriceQuote(
product=product,
purchase_scene="first_purchase" if first_purchase else "renewal",
price_type=price_type,
base_price=base,
activity_price=activity,
target_price=target,
deduction_amount=Decimal("0.00"),
payable_amount=target,
)
if product.billing_cycle != current.billing_cycle:
raise ValueError("当前订阅有效,只能升级同周期更高等级套餐")
if int(product.tier_rank or 0) <= int(current.tier_rank or 0):
raise ValueError("当前订阅有效,不能提前续费或降级")
base, activity, target, price_type = current_product_price(
product,
first_purchase=False,
request_time=request_time,
upgrade=True,
)
period_ids: list[str] = []
periods: list[UserCreditSubscriptionPeriod] = []
deduction = Decimal("0.00")
if current.billing_cycle in {
SubscriptionBillingCycle.QUARTERLY.value,
SubscriptionBillingCycle.YEARLY.value,
}:
result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.subscription_id == current.id,
UserCreditSubscriptionPeriod.scheduled_at > request_time,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
.order_by(UserCreditSubscriptionPeriod.sequence.asc())
.with_for_update()
)
periods = list(result.scalars().all())
for period in periods:
deduction += to_credit_decimal(period.allocated_paid_amount)
period_ids.append(period.id)
payable = target - deduction
if payable <= Decimal("0.00"):
raise ValueError("当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理")
for period in periods:
period.status = CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value
period.upgrade_order_id = order_id
period.reserved_at = request_time
return ProductPriceQuote(
product=product,
purchase_scene="upgrade",
price_type=price_type,
base_price=base,
activity_price=activity,
target_price=target,
deduction_amount=deduction,
payable_amount=payable,
source_subscription_id=current.id,
upgrade_period_ids=tuple(period_ids),
)
async def release_upgrade_reservation(
db: AsyncSession,
*,
order: PaymentOrder,
released_at: datetime,
) -> int:
period_ids = list(order.upgrade_period_ids_json or [])
if not period_ids:
return 0
await acquire_user_credit_lock(db, order.user_id)
result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.id.in_(period_ids),
UserCreditSubscriptionPeriod.upgrade_order_id == order.id,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
)
.with_for_update()
)
periods = list(result.scalars().all())
for period in periods:
period.status = CreditSubscriptionPeriodStatus.SCHEDULED.value
period.upgrade_order_id = None
period.reserved_at = None
await db.flush()
return len(periods)
+4
View File
@@ -187,6 +187,7 @@ async def deduct_credits_result(
allow_negative: bool = False,
create_zero_record: bool = False,
request_time: datetime | None = None,
allowed_scopes: set[str] | None = None,
) -> CreditMutationResult:
"""旧调用兼容门面;新账本始终足额同步扣除,allow_negative 不再生效。"""
return await deduct_dynamic_credits(
@@ -201,6 +202,7 @@ async def deduct_credits_result(
record_type=record_type,
create_zero_record=create_zero_record,
request_time=request_time,
allowed_scopes=allowed_scopes,
)
@@ -218,6 +220,7 @@ async def deduct_credits(
allow_negative: bool = False,
create_zero_record: bool = False,
request_time: datetime | None = None,
allowed_scopes: set[str] | None = None,
) -> User:
return (
await deduct_credits_result(
@@ -233,6 +236,7 @@ async def deduct_credits(
allow_negative=allow_negative,
create_zero_record=create_zero_record,
request_time=request_time,
allowed_scopes=allowed_scopes,
)
).user
+5
View File
@@ -84,6 +84,11 @@ async def create_invoice(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"订单 {order.order_no} 未支付,无法开票",
)
if getattr(order, "order_source", "online_payment") != "online_payment":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"订单 {order.order_no} 为后台线下成交订单,本版本不支持申请发票",
)
# 2. 检查订单唯一性
occupied = await check_orders_available(db, data.order_ids)
+170 -180
View File
@@ -30,9 +30,12 @@ from app.enums.credit_record import (
)
from app.services.credit_record_meta_service import CreditRecordMeta, build_recharge_meta
from app.services.credits import add_credits, deduct_credits
from app.services.credit.product_service import product_to_dict
from app.services.credit.subscription_service import fulfill_payment_product, revoke_payment_order_credits
from app.services.credit.upgrade_service import quote_and_reserve_product_purchase, release_upgrade_reservation
from app.enums.common import PaymentOrderSourceEnum
from app.enums.credit_product import CreditProductType
from app.models.team import Team
from app.services.credit.locking import acquire_team_business_lock, acquire_user_credit_lock
from app.services.credit.product_service import ensure_repeat_purchase_allowed, quote_product, resolve_team_purchase_context
from app.services.credit.subscription_service import fulfill_payment_product
from app.services.credit.utils import to_credit_decimal, utc_now
from app.utils.id_gen import generate_id, generate_order_no
@@ -259,8 +262,6 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
return False
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(db, order=order, released_at=utc_now())
await db.flush()
logger.info(
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
@@ -334,10 +335,6 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
log_user_id = str(order.user_id)
log_amount = order.amount
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
await db.commit()
expired_count += 1
@@ -420,9 +417,14 @@ async def create_recharge_order(
method: str = "wechat",
*,
product_id: str | None = None,
quantity: int = 1,
request_time: datetime | None = None,
) -> PaymentOrder:
"""创建支付订单;支付渠道流程保持原样,仅增加积分商品快照和订阅升级预留。"""
"""创建线上支付订单
商品订单在创建时冻结商品、定价和数量快照;之后商品改价、下架或软删除均不改变订单合同。
个人/团队订阅存在未完成订单时全局阻止再次创建订阅订单。
"""
db_configs = await _get_payment_configs(db)
mock_mode = _is_mock_mode(db_configs)
if not mock_mode:
@@ -434,120 +436,184 @@ async def create_recharge_order(
raise ValueError("支付宝支付未完成配置,请联系管理员")
elif method == "wechat":
required_configs = [
"payment_wechat_appid",
"payment_wechat_mch_id",
"payment_wechat_private_key",
"payment_wechat_cert_serial_no",
"payment_wechat_api_v3_key",
"payment_wechat_appid", "payment_wechat_mch_id", "payment_wechat_private_key",
"payment_wechat_cert_serial_no", "payment_wechat_api_v3_key",
]
missing_configs = [key for key in required_configs if not db_configs.get(key)]
if missing_configs:
raise ValueError(f"微信支付未完成配置,缺少: {', '.join(missing_configs)},请联系管理员")
else:
raise ValueError("不支持的线上支付方式")
checked_at = request_time or utc_now()
await acquire_user_credit_lock(db, user_id)
user_result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = user_result.scalar_one_or_none()
if user is None:
raise ValueError("用户不存在")
order_id = generate_id()
order_no = generate_order_no()
product: CreditProduct | None = None
quote = None
final_price = to_credit_decimal(price)
total_credits = to_credit_decimal(float(credits or 0) + float(bonus_credits or 0))
snapshot = None
product_type = None
team_id_snapshot = None
quoted_unit = final_price
quoted_amount = final_price
actual_unit = final_price
purchase_scene = "legacy_recharge"
price_type = "regular"
if product_id:
product_result = await db.execute(
select(CreditProduct)
.where(
CreditProduct.id == product_id,
CreditProduct.deleted_at.is_(None),
CreditProduct.is_active.is_(True),
)
.limit(1)
)
product = product_result.scalar_one_or_none()
if product is None:
raise ValueError("积分商品不存在、已下架或已删除")
product_type = product.product_type
if product_type == CreditProductType.TEAM_SUBSCRIPTION.value:
if not 2 <= int(quantity) <= 20:
raise ValueError("客户端团队套餐单次购买数量必须在2到20之间")
team, available, reason = await resolve_team_purchase_context(db, user=user)
if not available:
raise ValueError(reason or "当前不能购买团队订阅套餐")
if team is not None:
await acquire_team_business_lock(db, team.id)
team, available, reason = await resolve_team_purchase_context(db, user=user)
if not available or team is None:
raise ValueError(reason or "当前不能购买团队订阅套餐")
team_id_snapshot = team.id if team else None
first_purchase = bool(team is None or team.first_subscription_paid_at is None)
else:
if int(quantity) != 1:
raise ValueError("个人订阅和积分增值包购买数量固定为1")
quantity = 1
first_purchase = user.first_membership_paid_at is None if product_type == CreditProductType.SUBSCRIPTION.value else False
if product_type in {CreditProductType.SUBSCRIPTION.value, CreditProductType.TEAM_SUBSCRIPTION.value}:
ensure_repeat_purchase_allowed(product, first_purchase=first_purchase)
incomplete_result = await db.execute(
select(PaymentOrder.id).where(
PaymentOrder.user_id == user_id,
PaymentOrder.product_type.in_([
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
]),
(
(PaymentOrder.status == "pending")
| ((PaymentOrder.status == "paid") & (PaymentOrder.fulfillment_status != "fulfilled"))
),
).limit(1)
)
if incomplete_result.scalar_one_or_none():
raise ValueError("存在未完成的订阅订单,请先前往订单记录完成支付或处理原订单")
quote = quote_product(
product,
first_purchase=first_purchase,
request_time=checked_at,
quantity=int(quantity),
)
quoted_unit = quote.quoted_unit_price
quoted_amount = quote.quoted_amount
final_price = quoted_amount
actual_unit = quoted_unit
purchase_scene = quote.purchase_scene
price_type = quote.price_type
label = product.name
if product_type == CreditProductType.TEAM_SUBSCRIPTION.value:
total_credits = to_credit_decimal((product.monthly_grant_credits or 0) * int(quantity))
elif product_type == CreditProductType.SUBSCRIPTION.value:
total_credits = to_credit_decimal(product.monthly_grant_credits or 0)
else:
total_credits = to_credit_decimal(product.grant_credits or 0)
snapshot = {
"id": product.id,
"product_code": product.product_code,
"product_type": product.product_type,
"name": product.name,
"description": product.description,
"features": product.features_json or [],
"tier_code": product.tier_code,
"tier_rank": product.tier_rank,
"billing_cycle": product.billing_cycle,
"monthly_grant_credits": float(product.monthly_grant_credits or 0),
"first_purchase_price": float(product.first_purchase_price or 0),
"regular_price": float(product.regular_price or 0),
"activity_price": float(product.activity_price) if product.activity_price is not None else None,
"activity_start_at": product.activity_start_at.isoformat() if product.activity_start_at else None,
"activity_end_at": product.activity_end_at.isoformat() if product.activity_end_at else None,
"renewal_enabled": bool(product.renewal_enabled),
"grant_credits": float(product.grant_credits or 0),
"validity_months": product.validity_months,
"credit_level": product.credit_level,
"currency": product.currency,
}
# 固定锁序:user advisory 已获取;团队订单已先获取 Team advisory,最后才锁 User 行。
locked_user_result = await db.execute(select(User).where(User.id == user_id).limit(1).with_for_update())
locked_user = locked_user_result.scalar_one_or_none()
if locked_user is None:
raise ValueError("用户不存在")
if locked_user.team_id != user.team_id:
raise ValueError("用户团队关系已发生变化,请刷新后重试")
user = locked_user
# 先持久化订单主记录,再做升级周期预留。订阅周期的 upgrade_order_id
# 有外键约束,若先更新周期后插入订单,flush 顺序可能触发外键异常。
order = PaymentOrder(
id=order_id,
user_id=user_id,
order_no=order_no,
amount=price,
credits=round(float(credits or 0) + float(bonus_credits or 0), 2),
amount=final_price,
credits=total_credits,
payment_method=method,
order_source=PaymentOrderSourceEnum.ONLINE_PAYMENT.value,
status="pending",
purchase_scene="legacy_recharge",
price_type="regular",
product_id=product.id if product else None,
product_type=product_type,
purchase_scene=purchase_scene,
price_type=price_type,
product_code_snapshot=product.product_code if product else None,
product_name_snapshot=label,
target_price_snapshot=price,
deduction_amount_snapshot=0,
payable_amount_snapshot=price,
product_snapshot_json=snapshot,
quantity=int(quantity),
quoted_unit_price_snapshot=quoted_unit,
quoted_amount_snapshot=quoted_amount,
actual_unit_price_snapshot=actual_unit,
team_id_snapshot=team_id_snapshot,
fulfillment_status="pending" if product else None,
)
db.add(order)
await db.flush()
if product_id:
product_result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id, CreditProduct.is_active.is_(True)).limit(1)
)
product = product_result.scalar_one_or_none()
if product is None:
raise ValueError("积分商品不存在或已下架")
# 订阅报价服务内部先获取用户级 advisory lock,再按统一顺序锁订阅周期。
# 此处不预先锁 users 行,避免与支付履约(advisory -> users)形成反向锁序。
user_result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = user_result.scalar_one_or_none()
if user is None:
raise ValueError("用户不存在")
quote = await quote_and_reserve_product_purchase(
db, user=user, product=product, order_id=order.id, request_time=checked_at
)
price = float(quote.payable_amount)
label = product.name
credits = float(product.grant_credits or product.monthly_grant_credits or 0)
bonus_credits = 0.0
order.amount = quote.payable_amount
order.credits = round(float(credits or 0), 2)
order.product_id = product.id
order.product_type = product.product_type
order.purchase_scene = quote.purchase_scene
order.price_type = quote.price_type
order.product_code_snapshot = product.product_code
order.product_name_snapshot = product.name
product_snapshot = product_to_dict(product)
for time_key in ("activity_start_at", "activity_end_at"):
value = product_snapshot.get(time_key)
if value is not None:
product_snapshot[time_key] = value.isoformat()
order.product_snapshot_json = product_snapshot
order.source_subscription_id = quote.source_subscription_id
order.upgrade_period_ids_json = list(quote.upgrade_period_ids) or None
order.target_price_snapshot = quote.target_price
order.deduction_amount_snapshot = quote.deduction_amount
order.payable_amount_snapshot = quote.payable_amount
order.fulfillment_status = "pending"
total_credits = round(float(credits or 0) + float(bonus_credits or 0), 2)
await db.flush()
logger.info(
f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={price} "
f"credits={total_credits} method={method} product_id={product_id} mock={mock_mode}"
f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={order.amount} "
f"credits={order.credits} method={method} product_id={product_id} quantity={quantity} mock={mock_mode}"
)
if mock_mode:
order.status = "paid"
order.paid_at = checked_at
desc = f"充值{label}({total_credits}积分)"
if bonus_credits > 0:
desc += f"(含赠送{bonus_credits}积分)"
await _fulfill_paid_order(
db,
order=order,
fulfilled_at=checked_at,
legacy_description=desc,
)
desc = f"充值{label}({order.credits}积分)"
await _fulfill_paid_order(db, order=order, fulfilled_at=checked_at, legacy_description=desc)
await db.flush()
else:
if method == "wechat":
qr_code_content = _create_wechat_order(order, db_configs)
if qr_code_content:
order.qr_url = qr_code_content # type: ignore[attr-defined]
else:
if quote and quote.upgrade_period_ids:
await release_upgrade_reservation(db, order=order, released_at=checked_at)
raise ValueError("微信支付预下单失败,请检查配置或稍后重试")
elif method == "alipay":
qr_url = _create_alipay_order(order, db_configs)
if qr_url:
order.qr_url = qr_url # type: ignore[attr-defined]
else:
if quote and quote.upgrade_period_ids:
await release_upgrade_reservation(db, order=order, released_at=checked_at)
raise ValueError("支付宝预下单失败,请检查配置或稍后重试")
elif method == "wechat":
qr_code_content = _create_wechat_order(order, db_configs)
if not qr_code_content:
raise ValueError("微信支付预下单失败,请检查配置或稍后重试")
order.qr_url = qr_code_content # type: ignore[attr-defined]
elif method == "alipay":
qr_url = _create_alipay_order(order, db_configs)
if not qr_url:
raise ValueError("支付预下单失败,请检查配置或稍后重试")
order.qr_url = qr_url # type: ignore[attr-defined]
return order
@@ -1238,10 +1304,6 @@ async def sync_pending_orders(db: AsyncSession) -> int:
updated_count += 1
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
await db.commit()
updated_count += 1
@@ -1259,10 +1321,6 @@ async def sync_pending_orders(db: AsyncSession) -> int:
updated_count += 1
elif trade_state in ("CLOSED", "REVOKED"):
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
await db.commit()
updated_count += 1
except Exception as e:
@@ -1596,86 +1654,18 @@ async def process_refund(
db: AsyncSession,
order_no: str,
refund_amount: float | None = None,
refund_reason: str = "管理员退款"
refund_reason: str = "管理员退款",
) -> dict:
"""Process a refund for a paid order.
Args:
db: async database session
order_no: merchant order number
refund_amount: amount to refund (defaults to full order amount)
refund_reason: reason for refund
Returns:
dict with refund result
"""
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
)
"""本版本保留退款 Service 入口,但主动订单退款统一关闭。"""
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1))
order = result.scalar_one_or_none()
if not order:
return {"success": False, "message": "订单不存在"}
if order.status != "paid":
return {"success": False, "message": f"订单状态为{order.status},无法退款"}
if order.refunded_at is not None:
return {"success": False, "message": "订单已退款"}
refund_amount = to_credit_decimal(refund_amount if refund_amount is not None else order.amount)
# 金额校验
if refund_amount > to_credit_decimal(order.amount):
return {"success": False, "message": "退款金额超过订单金额"}
# 根据支付方式调用相应的退款API
db_configs = await _get_payment_configs(db)
if order.payment_method == "alipay":
refund_result = await _refund_alipay_order(
db, order, refund_amount, refund_reason, db_configs
)
if not refund_result.get("success"):
return refund_result
elif order.payment_method == "wechat":
refund_result = await _refund_wechat_order(
db, order, refund_amount, refund_reason, db_configs
)
if not refund_result.get("success"):
return refund_result
# 按原支付业务位置适配新积分账本;不改变支付渠道退款流程。
try:
if order.product_id:
await revoke_payment_order_credits(db, order=order, reason=refund_reason)
else:
await deduct_credits(
db,
order.user_id,
order.credits,
refund_reason,
related_id=order.id,
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.REFUND.value, action=CreditRecordAction.REFUND.value),
refund_for_biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
record_meta=_payment_refund_meta(order),
)
except Exception as e:
logger.exception(f"Failed to deduct credits for refund: {e}")
return {"success": False, "message": "积分扣除失败"}
# 更新订单状态
order.status = "refunded"
order.refund_amount = refund_amount
order.refunded_at = utc_now()
if order.payment_method == "alipay":
order.refund_trade_no = db_configs.get("refund_trade_no", "")
await db.commit()
logger.info(
f"REFUND_SUCCESS order_no={order_no} user={order.user_id} "
f"refund_amount={refund_amount}"
logger.warning(
"REFUND_BLOCKED order_no=%s user=%s requested_amount=%s reason=%s",
order_no, order.user_id, refund_amount, refund_reason,
)
return {"success": True, "message": "退款成功"}
return {"success": False, "message": "当前版本暂未开放订单退款"}
async def _refund_alipay_order(
@@ -0,0 +1,163 @@
from __future__ import annotations
from datetime import datetime, time, timezone
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CREDIT_ALLOCATION_ACTION_LABELS, CreditAllocationAction, CreditScope
from app.enums.credit_record import CREDIT_RECORD_TYPE_LABELS
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit_record import CreditRecord
from app.models.team import Team
from app.models.team_manager_history import TeamManagerHistory
from app.models.user import User
from app.services.credit.utils import to_credit_decimal
POSITIVE_ACTIONS = {
CreditAllocationAction.GRANT.value,
CreditAllocationAction.REFUND_AVAILABLE.value,
CreditAllocationAction.REFUND_EXPIRED.value,
}
NEGATIVE_ACTIONS = {
CreditAllocationAction.CONSUME.value,
CreditAllocationAction.EXPIRE.value,
CreditAllocationAction.REVOKE.value,
}
def _signed_amount(action: str, amount) -> float:
value = to_credit_decimal(amount)
if action in NEGATIVE_ACTIONS:
value = -value
return float(value)
async def _resolve_team_flow_permission(
db: AsyncSession,
*,
team_id: str,
viewer_user_id: str,
) -> tuple[Team, bool]:
team_result = await db.execute(
select(Team).where(Team.id == team_id).limit(1)
)
team = team_result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
if team.manager_id == viewer_user_id:
return team, True
history_result = await db.execute(
select(TeamManagerHistory.id).where(
TeamManagerHistory.team_id == team_id,
TeamManagerHistory.manager_user_id == viewer_user_id,
).limit(1)
)
if not history_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="仅当前队长或历史队长可以查看团队积分流水")
return team, False
async def list_team_credit_records(
db: AsyncSession,
*,
team_id: str,
viewer_user_id: str,
page: int = 1,
page_size: int = 20,
member_user_id: str | None = None,
subscription_id: str | None = None,
record_type: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> dict:
_, current_manager = await _resolve_team_flow_permission(
db, team_id=team_id, viewer_user_id=viewer_user_id
)
conditions = [
CreditRecordAllocation.credit_scope_snapshot == CreditScope.TEAM.value,
CreditRecordAllocation.team_id_snapshot == team_id,
]
if not current_manager:
conditions.append(CreditRecordAllocation.team_manager_id_snapshot == viewer_user_id)
if member_user_id:
conditions.append(CreditRecordAllocation.user_id == member_user_id)
if subscription_id:
conditions.append(CreditRecordAllocation.subscription_id_snapshot == subscription_id)
if record_type:
conditions.append(CreditRecord.type == record_type)
if start_date:
try:
start_at = datetime.combine(datetime.strptime(start_date, "%Y-%m-%d").date(), time.min).replace(tzinfo=timezone.utc)
except ValueError as exc:
raise HTTPException(status_code=400, detail="起始日期格式应为 YYYY-MM-DD") from exc
conditions.append(CreditRecord.created_at >= start_at)
if end_date:
try:
end_at = datetime.combine(datetime.strptime(end_date, "%Y-%m-%d").date(), time.max).replace(tzinfo=timezone.utc)
except ValueError as exc:
raise HTTPException(status_code=400, detail="截止日期格式应为 YYYY-MM-DD") from exc
conditions.append(CreditRecord.created_at <= end_at)
total_result = await db.execute(
select(func.count(CreditRecordAllocation.id))
.join(CreditRecord, CreditRecord.id == CreditRecordAllocation.credit_record_id)
.where(*conditions)
)
total = int(total_result.scalar_one() or 0)
result = await db.execute(
select(
CreditRecordAllocation,
CreditRecord,
User.username,
UserCreditSubscription.subscription_no,
)
.join(CreditRecord, CreditRecord.id == CreditRecordAllocation.credit_record_id)
.join(User, User.id == CreditRecordAllocation.user_id)
.outerjoin(
UserCreditSubscription,
UserCreditSubscription.id == CreditRecordAllocation.subscription_id_snapshot,
)
.where(*conditions)
.order_by(CreditRecordAllocation.created_at.desc(), CreditRecordAllocation.id.desc())
.offset(max(0, page - 1) * max(1, page_size))
.limit(max(1, min(page_size, 10000)))
)
items = []
for allocation, record, username, subscription_no in result.all():
items.append(
{
"id": allocation.id,
"credit_record_id": record.id,
"user_id": allocation.user_id,
"username": username,
"record_type": record.type,
"record_type_label": CREDIT_RECORD_TYPE_LABELS.get(record.type, "其他"),
"allocation_action": allocation.allocation_action,
"allocation_action_label": CREDIT_ALLOCATION_ACTION_LABELS.get(
allocation.allocation_action, "其他"
),
"team_amount": _signed_amount(allocation.allocation_action, allocation.amount),
"description": record.description,
"request_time": record.request_time,
"created_at": record.created_at,
"credit_level": allocation.credit_level_snapshot,
"subscription_id": allocation.subscription_id_snapshot,
"subscription_no": subscription_no or "历史订阅",
"subscription_period_id": allocation.subscription_period_id_snapshot,
"seat_id": allocation.seat_id_snapshot,
"team_manager_id_snapshot": allocation.team_manager_id_snapshot,
# 团队流水明确不输出总 CreditRecord 金额、个人 Allocation 和 Token 相关字段。
}
)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"is_current_manager": current_manager,
}
@@ -11,6 +11,8 @@ from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.services.credit.locking import acquire_team_business_lock, acquire_user_credit_lock
from app.services.team_service import assert_team_active, set_frontend_user_team
from app.utils.id_gen import generate_id
import secrets
@@ -44,7 +46,9 @@ async def create_invitation(
expires_at: datetime | None = None,
) -> TeamInvitation:
"""创建邀请码(仅团队管理人)。"""
await acquire_team_business_lock(db, team_id)
await _assert_is_manager(db, created_by, team_id)
await assert_team_active(db, team_id, for_update=True)
code = _generate_invite_code()
if expires_at is None:
expires_at = datetime.now(timezone.utc) + timedelta(days=1)
@@ -92,7 +96,9 @@ async def revoke_invitation(db: AsyncSession, invitation_id: str, revoked_by: st
invitation = result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="邀请码不存在")
await acquire_team_business_lock(db, invitation.team_id)
await _assert_is_manager(db, revoked_by, invitation.team_id)
await assert_team_active(db, invitation.team_id, for_update=True)
invitation.status = "revoked"
await db.flush()
@@ -106,6 +112,8 @@ async def create_join_request(
invitation = await get_invitation_by_code(db, invitation_code)
if not invitation:
raise HTTPException(status_code=400, detail="邀请码无效或已过期/已用完")
await acquire_team_business_lock(db, invitation.team_id)
await assert_team_active(db, invitation.team_id, for_update=True)
# 验证用户存在
user_result = await db.execute(
@@ -166,8 +174,20 @@ async def handle_join_request(
note: str | None = None,
) -> None:
"""审批/拒绝加入申请(仅团队管理人)。"""
preview = await db.execute(
select(TeamJoinRequest.user_id, TeamJoinRequest.team_id)
.where(TeamJoinRequest.id == request_id)
.limit(1)
)
preview_row = preview.first()
if not preview_row:
raise HTTPException(status_code=404, detail="申请不存在")
# 固定锁序:user advisory -> team advisory -> request/user row。
await acquire_user_credit_lock(db, str(preview_row.user_id))
await acquire_team_business_lock(db, str(preview_row.team_id))
result = await db.execute(
select(TeamJoinRequest).where(TeamJoinRequest.id == request_id).limit(1)
select(TeamJoinRequest).where(TeamJoinRequest.id == request_id).with_for_update().limit(1)
)
request = result.scalar_one_or_none()
if not request:
@@ -176,9 +196,9 @@ async def handle_join_request(
raise HTTPException(status_code=400, detail="该申请已处理")
await _assert_is_manager(db, manager_id, request.team_id)
await assert_team_active(db, request.team_id, for_update=True)
if action == "approve":
# 检查用户是否已在其他团队
user_result = await db.execute(
select(User).where(User.id == request.user_id, User.is_active == True).limit(1)
)
@@ -187,8 +207,7 @@ async def handle_join_request(
raise HTTPException(status_code=404, detail="用户不存在")
if user.team_id and user.team_id != request.team_id:
raise HTTPException(status_code=400, detail="用户已在其他团队中,无法加入")
user.team_id = request.team_id
await set_frontend_user_team(db, user_id=user.id, team_id=request.team_id)
request.status = "approved"
elif action == "reject":
request.status = "rejected"
@@ -3,61 +3,139 @@ from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_product import CreditProductType
from app.enums.credit_subscription import CreditSubscriptionStatus
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.credit.subscription import UserCreditSubscription
from app.models.payment_order import PaymentOrder
from app.models.team import Team
from app.models.team_manager_history import TeamManagerHistory
from app.models.user import User
from app.services.credit.query_service import attach_credit_snapshot, get_user_credit_map
from app.services.credit.locking import acquire_team_business_lock
from app.services.credit.query_service import attach_credit_snapshot, get_user_credit_summary_map
from app.services.credit.utils import utc_now
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
async def set_team_manager(db: AsyncSession, team_id: str, user_id: str | None) -> Team:
"""设置团队管理人。user_id 为 None 表示取消管理人。"""
async def _assert_transfer_allowed(db: AsyncSession, team: Team) -> None:
if team.status != TeamStatus.ACTIVE.value:
raise HTTPException(status_code=409, detail="团队已禁用,当前仅允许查看,不能更换队长")
checked_at = utc_now()
active_subscription = (await db.execute(
select(UserCreditSubscription.id)
.where(
UserCreditSubscription.team_id == team.id,
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.limit(1)
)).scalar_one_or_none()
if active_subscription:
raise HTTPException(status_code=409, detail="团队仍有有效团队订阅,全部订阅结束后才能更换队长")
incomplete_order = (await db.execute(
select(PaymentOrder.id)
.where(
PaymentOrder.team_id_snapshot == team.id,
PaymentOrder.product_type == CreditProductType.TEAM_SUBSCRIPTION.value,
or_(
PaymentOrder.status == "pending",
and_(PaymentOrder.status == "paid", PaymentOrder.fulfillment_status != "fulfilled"),
),
)
.limit(1)
)).scalar_one_or_none()
if incomplete_order:
raise HTTPException(status_code=409, detail="团队仍有待支付或待履约团队订阅订单,暂不能更换队长")
async def set_team_manager(db: AsyncSession, team_id: str, user_id: str) -> Team:
"""更换团队队长;仅允许转给当前团队成员。"""
await acquire_team_business_lock(db, team_id)
result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
select(Team)
.where(Team.id == team_id, Team.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
team = result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
if user_id is None:
team.manager_id = None
await db.flush()
await db.refresh(team)
if team.manager_id == user_id:
return team
await _assert_transfer_allowed(db, team)
# 验证用户存在、是前台用户、属于该团队
user_result = await db.execute(
select(User).where(User.id == user_id, User.is_active.is_(True)).limit(1)
select(User).where(User.id == user_id, User.is_active.is_(True)).with_for_update().limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.user_type != UserType.FRONTEND.value:
raise HTTPException(status_code=400, detail="仅前台用户可设为团队管理人")
raise HTTPException(status_code=400, detail="仅前台用户可设为团队队长")
if user.team_id != team_id:
raise HTTPException(status_code=400, detail="用户不属于该团队,请先将其加入团队")
raise HTTPException(status_code=400, detail="新队长必须是当前团队成员")
checked_at = utc_now()
if team.manager_id:
current_history_result = await db.execute(
select(TeamManagerHistory)
.where(
TeamManagerHistory.team_id == team.id,
TeamManagerHistory.manager_user_id == team.manager_id,
TeamManagerHistory.ended_at.is_(None),
)
.order_by(TeamManagerHistory.started_at.desc(), TeamManagerHistory.id.desc())
.with_for_update()
.limit(1)
)
current_history = current_history_result.scalar_one_or_none()
if current_history:
current_history.ended_at = checked_at
previous_manager_id = team.manager_id
db.add(
TeamManagerHistory(
id=generate_id(),
team_id=team.id,
manager_user_id=user_id,
started_at=checked_at,
)
)
team.manager_id = user_id
await db.flush()
await db.refresh(team)
log_operation_event(
domain="team",
module="team_manager",
event_type="TEAM_MANAGER_CHANGED",
user_id=user_id,
message="团队队长更换成功",
detail={
"team_id": team.id,
"previous_manager_user_id": previous_manager_id,
"new_manager_user_id": user_id,
},
)
return team
async def is_team_manager(db: AsyncSession, user_id: str, team_id: str | None) -> bool:
"""判断用户是否是指定团队的管理人。"""
if not team_id:
return False
result = await db.execute(
select(Team.manager_id).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
manager_id = result.scalar_one_or_none()
return manager_id == user_id
return result.scalar_one_or_none() == user_id
async def get_managed_team(db: AsyncSession, user_id: str) -> Team | None:
"""获取用户管理的团队。"""
result = await db.execute(
select(Team).where(Team.manager_id == user_id, Team.deleted_at.is_(None)).limit(1)
)
@@ -71,19 +149,14 @@ async def get_team_members(
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""列出团队成员(仅前台用户)。"""
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 100)
# 验证团队存在
team_result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
if not team_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="团队不存在")
# 总数
from sqlalchemy import func
total = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
@@ -91,10 +164,9 @@ async def get_team_members(
User.is_active.is_(True),
)
)).scalar() or 0
# 列表
result = await db.execute(
select(User).where(
select(User)
.where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team_id,
User.is_active.is_(True),
@@ -104,32 +176,100 @@ async def get_team_members(
.limit(page_size)
)
members = list(result.scalars().all())
credit_map = await get_user_credit_map(db, [item.id for item in members])
for item in members:
attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
return {
"items": [
summary_map = await get_user_credit_summary_map(db, [item.id for item in members])
items = []
for member in members:
summary = summary_map.get(member.id)
credits = float(summary.available_credits if summary else 0)
attach_credit_snapshot(member, credits)
items.append(
{
"id": m.id,
"username": m.username,
"phone": m.phone,
"credits": m.credits,
"is_active": m.is_active,
"joined_at": m.created_at,
"id": member.id,
"username": member.username,
"phone": member.phone,
"credits": credits,
"personal_credits": float(summary.personal_credits if summary else 0),
"team_available_credits": float(summary.team_available_credits if summary else 0),
"team_frozen_credits": float(summary.team_frozen_credits if summary else 0),
"is_active": member.is_active,
"joined_at": member.created_at,
}
for m in members
],
"total": total,
}
)
return {"items": items, "total": int(total)}
async def transfer_credits_to_member(
db: AsyncSession,
manager_id: str,
target_member_id: str,
amount: float,
direction: str = "increase", # "increase" 管理人→成员; "decrease" 成员扣减
description: str | None = None,
) -> None:
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
async def get_manager_history(db: AsyncSession, team_id: str) -> list[dict[str, Any]]:
result = await db.execute(
select(TeamManagerHistory, User.username)
.join(User, User.id == TeamManagerHistory.manager_user_id)
.where(TeamManagerHistory.team_id == team_id)
.order_by(TeamManagerHistory.started_at.desc(), TeamManagerHistory.id.desc())
)
return [
{
"id": history.id,
"team_id": history.team_id,
"manager_user_id": history.manager_user_id,
"manager_name": username,
"started_at": history.started_at,
"ended_at": history.ended_at,
}
for history, username in result.all()
]
async def transfer_credits_to_member(*args, **kwargs) -> None:
raise HTTPException(status_code=409, detail="当前版本不支持团队积分转账,请使用团队订阅席位额度")
async def list_manager_access_teams(db: AsyncSession, user_id: str) -> list[dict[str, Any]]:
"""返回用户作为当前/历史队长可查看团队流水的团队,按当前团队优先、最近任期倒序。"""
history_result = await db.execute(
select(TeamManagerHistory.team_id, func.max(TeamManagerHistory.started_at).label("last_started_at"))
.where(TeamManagerHistory.manager_user_id == user_id)
.group_by(TeamManagerHistory.team_id)
)
history_rows = list(history_result.all())
team_ids = [row.team_id for row in history_rows]
current_result = await db.execute(
select(Team.id).where(
Team.manager_id == user_id,
Team.deleted_at.is_(None),
)
)
for team_id in current_result.scalars().all():
if team_id not in team_ids:
team_ids.append(team_id)
if not team_ids:
return []
teams_result = await db.execute(select(Team).where(Team.id.in_(team_ids)))
team_map = {item.id: item for item in teams_result.scalars().all()}
started_map = {row.team_id: row.last_started_at for row in history_rows}
items: list[dict[str, Any]] = []
for team_id in team_ids:
team = team_map.get(team_id)
if not team:
continue
is_current = team.deleted_at is None and team.manager_id == user_id
items.append(
{
"id": team.id,
"name": team.name,
"code": team.code,
"status": team.status,
"status_label": TEAM_STATUS_LABELS.get(team.status, "其他状态"),
"is_current_manager": is_current,
"last_managed_at": started_map.get(team.id),
"deleted": team.deleted_at is not None,
}
)
items.sort(
key=lambda item: (
0 if item["is_current_manager"] else 1,
-(item["last_managed_at"].timestamp() if item["last_managed_at"] else 0),
item["id"],
)
)
return items
+282 -61
View File
@@ -1,20 +1,32 @@
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime
from typing import Any
from fastapi import HTTPException
from sqlalchemy import and_, func, or_, select
from sqlalchemy import and_, func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.team import TeamStatus
from app.enums.credit_product import CreditProductType
from app.enums.credit_subscription import CreditSubscriptionStatus
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.team_seat import TeamSubscriptionSeat
from app.models.payment_order import PaymentOrder
from app.models.team import Team
from app.models.team_manager_history import TeamManagerHistory
from app.models.user import User
from app.schemas.team import TeamCreate, TeamUpdate
from app.services.credit.locking import acquire_team_business_lock, acquire_user_credit_lock
from app.services.credit.utils import utc_now
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
INCOMPLETE_ORDER_STATUSES = ("pending", "paid")
def _clean_text(value: str | None) -> str | None:
if value is None:
return None
@@ -25,36 +37,64 @@ def _clean_text(value: str | None) -> str | None:
def _team_snapshot(team: Team | None) -> dict[str, Any]:
if not team:
return {"team_id": None, "team_name": None}
return {
"team_id": team.id,
"team_name": getattr(team, "name", None),
}
return {"team_id": team.id, "team_name": getattr(team, "name", None)}
def _team_out_payload(team: Team, member_count: int = 0, manager_name: str | None = None) -> dict[str, Any]:
def _team_out_payload(
team: Team,
member_count: int = 0,
manager_name: str | None = None,
) -> dict[str, Any]:
status = getattr(team, "status", TeamStatus.ACTIVE.value)
return {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"status": status,
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
"is_read_only": status == TeamStatus.DISABLED.value,
"team_credit_frozen": status == TeamStatus.DISABLED.value,
"sort_order": getattr(team, "sort_order", 0) or 0,
"member_count": int(member_count or 0),
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
async def _get_team(db: AsyncSession, team_id: str, *, include_deleted: bool = False) -> Team | None:
async def _get_team(
db: AsyncSession,
team_id: str,
*,
include_deleted: bool = False,
for_update: bool = False,
) -> Team | None:
query = select(Team).where(Team.id == team_id).limit(1)
if not include_deleted:
query = query.where(Team.deleted_at.is_(None))
if for_update:
query = query.with_for_update()
result = await db.execute(query)
return result.scalar_one_or_none()
async def assert_team_active(
db: AsyncSession,
team_id: str,
*,
for_update: bool = False,
) -> Team:
team = await _get_team(db, team_id, for_update=for_update)
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
if team.status != TeamStatus.ACTIVE.value:
raise HTTPException(status_code=409, detail="团队已禁用,当前仅允许查看,不能执行团队业务操作")
return team
async def _get_team_name(db: AsyncSession, team_id: str | None) -> str | None:
if not team_id:
return None
@@ -62,7 +102,13 @@ async def _get_team_name(db: AsyncSession, team_id: str | None) -> str | None:
return result.scalar_one_or_none()
async def _assert_unique_team(db: AsyncSession, *, name: str, code: str | None, exclude_id: str | None = None) -> None:
async def _assert_unique_team(
db: AsyncSession,
*,
name: str,
code: str | None,
exclude_id: str | None = None,
) -> None:
conditions = [Team.deleted_at.is_(None)]
duplicate_filters = [Team.name == name]
if code:
@@ -75,6 +121,22 @@ async def _assert_unique_team(db: AsyncSession, *, name: str, code: str | None,
raise HTTPException(status_code=400, detail="团队名称或编码已存在")
async def _has_incomplete_team_order(db: AsyncSession, team_id: str) -> bool:
result = await db.execute(
select(PaymentOrder.id)
.where(
PaymentOrder.team_id_snapshot == team_id,
PaymentOrder.product_type == CreditProductType.TEAM_SUBSCRIPTION.value,
or_(
PaymentOrder.status == "pending",
and_(PaymentOrder.status == "paid", PaymentOrder.fulfillment_status != "fulfilled"),
),
)
.limit(1)
)
return result.scalar_one_or_none() is not None
async def list_teams(
db: AsyncSession,
*,
@@ -85,7 +147,6 @@ async def list_teams(
) -> dict[str, Any]:
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 500)
filters: list[Any] = [Team.deleted_at.is_(None)]
kw = _clean_text(keyword)
if kw:
@@ -105,7 +166,7 @@ async def list_teams(
)
teams = list(result.scalars().all())
if not teams:
return {"items": [], "total": total}
return {"items": [], "total": int(total)}
team_ids = [team.id for team in teams]
member_result = await db.execute(
@@ -114,26 +175,18 @@ async def list_teams(
.group_by(User.team_id)
)
member_map = {row[0]: int(row[1] or 0) for row in member_result.all()}
# 批量获取管理人用户名
manager_ids = [getattr(t, "manager_id", None) for t in teams if getattr(t, "manager_id", None)]
manager_ids = [team.manager_id for team in teams if team.manager_id]
manager_name_map: dict[str, str] = {}
if manager_ids:
mgr_result = await db.execute(
select(User.id, User.username).where(User.id.in_(manager_ids))
)
mgr_result = await db.execute(select(User.id, User.username).where(User.id.in_(manager_ids)))
manager_name_map = {row[0]: row[1] for row in mgr_result.all()}
return {
"items": [
_team_out_payload(
team,
member_map.get(team.id, 0),
manager_name_map.get(getattr(team, "manager_id", None)),
)
_team_out_payload(team, member_map.get(team.id, 0), manager_name_map.get(team.manager_id))
for team in teams
],
"total": total,
"total": int(total),
}
@@ -150,22 +203,22 @@ async def list_team_options(db: AsyncSession, *, include_disabled: bool = True)
{
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"code": team.code,
"status": team.status,
"status_label": TEAM_STATUS_LABELS.get(team.status, "其他状态"),
}
for team in result.scalars().all()
]
async def batch_get_team_name_map(db: AsyncSession, team_ids: list[str] | set[str] | tuple[str, ...]) -> dict[str, str]:
"""Batch load team names for list pages. Avoid joining teams in high-frequency user queries."""
async def batch_get_team_name_map(
db: AsyncSession,
team_ids: list[str] | set[str] | tuple[str, ...],
) -> dict[str, str]:
ids = [team_id for team_id in dict.fromkeys(team_ids or []) if team_id]
if not ids:
return {}
result = await db.execute(
select(Team.id, Team.name)
.where(Team.id.in_(ids), Team.deleted_at.is_(None))
)
result = await db.execute(select(Team.id, Team.name).where(Team.id.in_(ids)))
return {row[0]: row[1] for row in result.all()}
@@ -183,48 +236,122 @@ async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
)
db.add(team)
await db.flush()
await db.refresh(team)
return team
async def update_team(db: AsyncSession, team_id: str, req: TeamUpdate) -> tuple[Team, dict[str, Any], dict[str, Any]]:
team = await _get_team(db, team_id)
async def _next_auto_team_name(db: AsyncSession) -> str:
bind = db.get_bind()
dialect = bind.dialect.name if bind is not None else ""
if dialect == "postgresql":
value = (await db.execute(text("SELECT nextval('team_auto_name_seq')"))).scalar_one()
return f"团队{int(value):04d}"
# SQLite/本地调试兜底;正式 PostgreSQL 始终走 Sequence,不使用 COUNT(*) + 1。
return f"团队{generate_id()[-6:]}"
async def create_team_for_subscription(
db: AsyncSession,
*,
manager_user: User,
started_at: datetime | None = None,
) -> Team:
checked_at = started_at or utc_now()
if manager_user.team_id:
raise HTTPException(status_code=409, detail="用户已加入团队,不能自动创建新团队")
team = Team(
id=generate_id(),
name=await _next_auto_team_name(db),
status=TeamStatus.ACTIVE.value,
sort_order=0,
manager_id=manager_user.id,
)
db.add(team)
await db.flush()
db.add(
TeamManagerHistory(
id=generate_id(),
team_id=team.id,
manager_user_id=manager_user.id,
started_at=checked_at,
)
)
manager_user.team_id = team.id
await db.flush()
log_operation_event(
domain="team",
module="team",
event_type="TEAM_AUTO_CREATED_FOR_SUBSCRIPTION",
user_id=manager_user.id,
message="团队订阅履约自动创建团队成功",
detail={
"team_id": team.id,
"team_name": team.name,
"manager_user_id": manager_user.id,
},
)
return team
async def update_team(
db: AsyncSession,
team_id: str,
req: TeamUpdate,
) -> tuple[Team, dict[str, Any], dict[str, Any]]:
await acquire_team_business_lock(db, team_id)
team = await _get_team(db, team_id, for_update=True)
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
before = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"sort_order": getattr(team, "sort_order", 0) or 0,
"code": team.code,
"description": team.description,
"status": team.status,
"sort_order": team.sort_order or 0,
}
name = req.name.strip()
code = _clean_text(req.code)
await _assert_unique_team(db, name=name, code=code, exclude_id=team_id)
requested_status = req.status or TeamStatus.ACTIVE.value
# 禁用后的团队只能“重新启用”,不能趁禁用状态修改名称、编码、备注、排序等业务数据。
if team.status == TeamStatus.DISABLED.value:
unchanged = (
name == team.name
and code == _clean_text(team.code)
and _clean_text(req.description) == _clean_text(team.description)
and int(req.sort_order or 0) == int(team.sort_order or 0)
)
if requested_status != TeamStatus.ACTIVE.value or not unchanged:
raise HTTPException(
status_code=409,
detail="团队已禁用,当前仅允许查看;如需继续操作,请先保持其他信息不变并重新启用团队",
)
else:
await _assert_unique_team(db, name=name, code=code, exclude_id=team_id)
if requested_status == TeamStatus.DISABLED.value and await _has_incomplete_team_order(db, team_id):
raise HTTPException(status_code=409, detail="团队存在待支付或待履约的团队订阅订单,暂不能禁用")
team.name = name
team.code = code
team.description = _clean_text(req.description)
team.status = req.status or TeamStatus.ACTIVE.value
team.status = requested_status
team.sort_order = req.sort_order or 0
await db.flush()
await db.refresh(team)
after = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"sort_order": getattr(team, "sort_order", 0) or 0,
"code": team.code,
"description": team.description,
"status": team.status,
"sort_order": team.sort_order or 0,
}
return team, before, after
async def soft_delete_team(db: AsyncSession, team_id: str) -> tuple[Team, dict[str, Any]]:
team = await _get_team(db, team_id)
await acquire_team_business_lock(db, team_id)
team = await _get_team(db, team_id, for_update=True)
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
@@ -235,46 +362,140 @@ async def soft_delete_team(db: AsyncSession, team_id: str) -> tuple[Team, dict[s
)
)).scalar() or 0
if member_count > 0:
raise HTTPException(status_code=400, detail="该团队下仍有前台用户,请先迁移或取消团队归属")
raise HTTPException(status_code=400, detail="该团队下仍有成员,请先迁移或解除团队关系")
checked_at = utc_now()
active_subscription = (await db.execute(
select(UserCreditSubscription.id)
.where(
UserCreditSubscription.team_id == team_id,
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.limit(1)
)).scalar_one_or_none()
if active_subscription:
raise HTTPException(status_code=409, detail="团队仍有有效团队订阅,不能删除")
if await _has_incomplete_team_order(db, team_id):
raise HTTPException(status_code=409, detail="团队仍有待支付或待履约团队订阅订单,不能删除")
before = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"code": team.code,
"status": team.status,
"member_count": int(member_count or 0),
}
team.deleted_at = datetime.now(timezone.utc)
team.deleted_at = checked_at
await db.flush()
return team, before
async def _cancel_user_active_seats_for_team(
db: AsyncSession,
*,
team_id: str,
user_id: str,
cancelled_at: datetime,
) -> None:
result = await db.execute(
select(TeamSubscriptionSeat)
.where(
TeamSubscriptionSeat.team_id == team_id,
TeamSubscriptionSeat.user_id == user_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
)
.order_by(TeamSubscriptionSeat.id.asc())
.with_for_update()
)
for seat in result.scalars().all():
seat.cancelled_at = cancelled_at
seat.deleted_at = cancelled_at
async def _has_pending_team_purchase_without_team(db: AsyncSession, user_id: str) -> bool:
result = await db.execute(
select(PaymentOrder.id)
.where(
PaymentOrder.user_id == user_id,
PaymentOrder.product_type == CreditProductType.TEAM_SUBSCRIPTION.value,
PaymentOrder.team_id_snapshot.is_(None),
or_(
PaymentOrder.status == "pending",
and_(PaymentOrder.status == "paid", PaymentOrder.fulfillment_status != "fulfilled"),
),
)
.limit(1)
)
return result.scalar_one_or_none() is not None
async def set_frontend_user_team(
db: AsyncSession,
*,
user_id: str,
team_id: str | None,
) -> tuple[User, dict[str, Any], dict[str, Any]]:
result = await db.execute(select(User).where(User.id == user_id).limit(1))
# 固定锁顺序:user advisory -> team advisory(按ID排序)-> User row -> Seat row。
await acquire_user_credit_lock(db, user_id)
initial = await db.execute(select(User.user_type, User.team_id).where(User.id == user_id).limit(1))
initial_row = initial.first()
if not initial_row:
raise HTTPException(status_code=404, detail="用户不存在")
if initial_row.user_type != UserType.FRONTEND.value:
raise HTTPException(status_code=400, detail="仅前台用户支持设置团队")
old_team_id = initial_row.team_id
for lock_team_id in sorted({item for item in (old_team_id, team_id) if item}):
await acquire_team_business_lock(db, lock_team_id)
result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.user_type != UserType.FRONTEND.value:
raise HTTPException(status_code=400, detail="仅前台用户支持设置团队")
# user advisory lock 下理论上不会变化;仍显式复核,避免旁路代码破坏锁约定。
if user.team_id != old_team_id:
raise HTTPException(status_code=409, detail="用户团队关系已发生变化,请刷新后重试")
old_team_id = getattr(user, "team_id", None)
old_team = await _get_team(db, old_team_id, include_deleted=True) if old_team_id else None
old_team = await _get_team(db, old_team_id, include_deleted=True, for_update=bool(old_team_id)) if old_team_id else None
before = _team_snapshot(old_team)
if old_team_id == team_id:
return user, before, before
checked_at = utc_now()
if old_team:
if old_team.deleted_at is None and old_team.status != TeamStatus.ACTIVE.value:
raise HTTPException(status_code=409, detail="团队已禁用,当前仅允许查看,不能变更成员关系")
if old_team.manager_id == user.id:
raise HTTPException(status_code=409, detail="当前用户是团队队长,请先完成队长转让")
new_team: Team | None = None
if team_id:
new_team = await _get_team(db, team_id)
if not new_team:
raise HTTPException(status_code=404, detail="团队不存在")
if getattr(new_team, "status", TeamStatus.ACTIVE.value) != TeamStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="禁用团队不能设置给用户")
if not old_team_id and await _has_pending_team_purchase_without_team(db, user.id):
raise HTTPException(status_code=409, detail="当前存在待处理的团队订阅订单,请先完成或取消订单")
new_team = await assert_team_active(db, team_id, for_update=True)
if old_team_id:
await _cancel_user_active_seats_for_team(
db, team_id=old_team_id, user_id=user.id, cancelled_at=checked_at
)
user.team_id = team_id or None
await db.flush()
after = _team_snapshot(new_team)
log_operation_event(
domain="team",
module="team",
event_type="TEAM_MEMBER_RELATION_CHANGED",
user_id=user.id,
message="用户团队关系变更成功",
detail={
"user_id": user.id,
"old_team_id": old_team_id,
"new_team_id": team_id,
},
)
return user, before, after
+2 -1
View File
@@ -60,7 +60,7 @@ async def _run_credit_maintenance_once(batch_size: int = 500) -> dict[str, Any]:
)
granted = 0
for period_id, subscription_id, user_id in grant_candidates:
for period_id, subscription_id, user_id, team_id in grant_candidates:
async with async_session() as db:
try:
changed = await grant_due_subscription_period_by_id(
@@ -68,6 +68,7 @@ async def _run_credit_maintenance_once(batch_size: int = 500) -> dict[str, Any]:
period_id=period_id,
subscription_id=subscription_id,
user_id=user_id,
team_id=team_id,
request_time=checked_at,
)
await db.commit()