Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
"""个人/团队订阅多实例、团队席位与统一线下订单
|
||||
|
||||
Revision ID: 20260814_multi_sub_team
|
||||
Revises: 20260813_split_device_type
|
||||
Create Date: 2026-08-14 11:31:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "20260814_multi_sub_team"
|
||||
down_revision = "20260813_bank_scheduled"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _columns(table_name: str) -> set[str]:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if table_name not in inspector.get_table_names():
|
||||
return set()
|
||||
return {item["name"] for item in inspector.get_columns(table_name)}
|
||||
|
||||
|
||||
def _indexes(table_name: str) -> set[str]:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if table_name not in inspector.get_table_names():
|
||||
return set()
|
||||
return {item["name"] for item in inspector.get_indexes(table_name)}
|
||||
|
||||
|
||||
def _constraints(table_name: str) -> set[str]:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if table_name not in inspector.get_table_names():
|
||||
return set()
|
||||
names = {item.get("name") for item in inspector.get_check_constraints(table_name)}
|
||||
names |= {item.get("name") for item in inspector.get_unique_constraints(table_name)}
|
||||
names |= {item.get("name") for item in inspector.get_foreign_keys(table_name)}
|
||||
return {name for name in names if name}
|
||||
|
||||
|
||||
def _add_column(table: str, column: sa.Column) -> None:
|
||||
if column.name not in _columns(table):
|
||||
op.add_column(table, column)
|
||||
|
||||
|
||||
def _drop_column(table: str, name: str) -> None:
|
||||
if name in _columns(table):
|
||||
op.drop_column(table, name)
|
||||
|
||||
|
||||
def _drop_index(table: str, name: str) -> None:
|
||||
if name in _indexes(table):
|
||||
op.drop_index(name, table_name=table)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
# Product:上下架与软删除分离;保留续费开关;product_code 保持原全局唯一索引。
|
||||
_add_column("credit_products", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
|
||||
if "uq_credit_products_code" not in _indexes("credit_products"):
|
||||
op.create_index("uq_credit_products_code", "credit_products", ["product_code"], unique=True)
|
||||
if "ix_credit_products_deleted_at" not in _indexes("credit_products"):
|
||||
op.create_index("ix_credit_products_deleted_at", "credit_products", ["deleted_at"], unique=False)
|
||||
# 旧版本同名索引不含 deleted_at,必须重建,不能只按名称判断存在。
|
||||
_drop_index("credit_products", "ix_credit_products_public")
|
||||
op.create_index("ix_credit_products_public", "credit_products", ["product_type", "deleted_at", "is_active", "sort_order"])
|
||||
_add_column("credit_products", sa.Column("renewal_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
|
||||
op.execute("UPDATE credit_products SET renewal_enabled=false WHERE product_type='credit_addon'")
|
||||
if "ck_credit_products_type_required_fields" in _constraints("credit_products"):
|
||||
op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_credit_products_type_required_fields",
|
||||
"credit_products",
|
||||
"(product_type IN ('subscription', 'team_subscription') "
|
||||
"AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
|
||||
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
|
||||
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
|
||||
"AND grant_credits IS NULL AND validity_months IS NULL) "
|
||||
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
|
||||
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
|
||||
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
|
||||
"AND first_purchase_price IS NULL AND regular_price IS NULL "
|
||||
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
|
||||
)
|
||||
|
||||
# Subscription:个人/团队共表,多实例独立,不再保留升级链。
|
||||
_add_column("user_credit_subscriptions", sa.Column("team_id", sa.String(32), nullable=True))
|
||||
_add_column("user_credit_subscriptions", sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True))
|
||||
_add_column("user_credit_subscriptions", sa.Column("product_type_snapshot", sa.String(24), nullable=True))
|
||||
_add_column("user_credit_subscriptions", sa.Column("product_name_snapshot", sa.String(96), nullable=True))
|
||||
_add_column("user_credit_subscriptions", sa.Column("monthly_total_credits_snapshot", sa.Numeric(20, 2), nullable=True))
|
||||
_add_column("user_credit_subscriptions", sa.Column("quantity_snapshot", sa.Integer(), nullable=True, server_default="1"))
|
||||
_add_column("user_credit_subscriptions", sa.Column("first_purchase_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0"))
|
||||
_add_column("user_credit_subscriptions", sa.Column("regular_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0"))
|
||||
_add_column("user_credit_subscriptions", sa.Column("activity_price_snapshot", sa.Numeric(20, 2), nullable=True))
|
||||
_add_column("user_credit_subscriptions", sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True, server_default="0"))
|
||||
if "fk_user_credit_subscriptions_team" not in _constraints("user_credit_subscriptions"):
|
||||
op.create_foreign_key(
|
||||
"fk_user_credit_subscriptions_team", "user_credit_subscriptions", "teams", ["team_id"], ["id"], ondelete="RESTRICT"
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE user_credit_subscriptions s SET "
|
||||
"product_type_snapshot = COALESCE(product_type_snapshot, 'subscription'), "
|
||||
"product_name_snapshot = COALESCE(product_name_snapshot, NULLIF(s.product_snapshot_json->>'name',''), '历史订阅套餐'), "
|
||||
"monthly_total_credits_snapshot = COALESCE(monthly_total_credits_snapshot, monthly_grant_credits_snapshot), "
|
||||
"quantity_snapshot = COALESCE(quantity_snapshot, 1), "
|
||||
"first_purchase_price_snapshot = COALESCE(first_purchase_price_snapshot, NULLIF(s.product_snapshot_json->>'first_purchase_price','')::numeric, 0), "
|
||||
"regular_price_snapshot = COALESCE(regular_price_snapshot, NULLIF(s.product_snapshot_json->>'regular_price','')::numeric, paid_amount_snapshot), "
|
||||
"activity_price_snapshot = COALESCE(activity_price_snapshot, NULLIF(s.product_snapshot_json->>'activity_price','')::numeric), "
|
||||
"actual_unit_price_snapshot = COALESCE(actual_unit_price_snapshot, paid_amount_snapshot)"
|
||||
)
|
||||
for col in (
|
||||
"product_type_snapshot", "product_name_snapshot", "monthly_total_credits_snapshot",
|
||||
"quantity_snapshot", "first_purchase_price_snapshot", "regular_price_snapshot", "actual_unit_price_snapshot",
|
||||
):
|
||||
if col in _columns("user_credit_subscriptions"):
|
||||
op.alter_column("user_credit_subscriptions", col, nullable=False)
|
||||
_drop_column("user_credit_subscriptions", "source_subscription_id")
|
||||
_drop_column("user_credit_subscriptions", "upgrade_order_id")
|
||||
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_current")
|
||||
if "ix_user_credit_subscriptions_user_active" not in _indexes("user_credit_subscriptions"):
|
||||
op.create_index("ix_user_credit_subscriptions_user_active", "user_credit_subscriptions", ["user_id", "status", "expires_at"])
|
||||
if "ix_user_credit_subscriptions_team_active" not in _indexes("user_credit_subscriptions"):
|
||||
op.create_index("ix_user_credit_subscriptions_team_active", "user_credit_subscriptions", ["team_id", "status", "expires_at"])
|
||||
|
||||
# Period:删除升级预留字段,补充窗口索引。
|
||||
_drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_upgrade")
|
||||
_drop_column("user_credit_subscription_periods", "upgrade_order_id")
|
||||
_drop_column("user_credit_subscription_periods", "reserved_at")
|
||||
_drop_column("user_credit_subscription_periods", "revoked_at")
|
||||
if "ix_user_credit_subscription_periods_window" not in _indexes("user_credit_subscription_periods"):
|
||||
op.create_index(
|
||||
"ix_user_credit_subscription_periods_window",
|
||||
"user_credit_subscription_periods",
|
||||
["subscription_id", "valid_from", "expires_at"],
|
||||
)
|
||||
|
||||
# Balance:增加个人/团队资金域。
|
||||
_add_column("user_credit_balances", sa.Column("credit_scope", sa.String(16), nullable=True, server_default="personal"))
|
||||
_add_column("user_credit_balances", sa.Column("team_id", sa.String(32), nullable=True))
|
||||
op.execute("UPDATE user_credit_balances SET credit_scope='personal' WHERE credit_scope IS NULL")
|
||||
op.alter_column("user_credit_balances", "credit_scope", nullable=False, server_default="personal")
|
||||
if "fk_user_credit_balances_team" not in _constraints("user_credit_balances"):
|
||||
op.create_foreign_key("fk_user_credit_balances_team", "user_credit_balances", "teams", ["team_id"], ["id"], ondelete="RESTRICT")
|
||||
if "ck_user_credit_balances_scope_fields" not in _constraints("user_credit_balances"):
|
||||
op.create_check_constraint(
|
||||
"ck_user_credit_balances_scope_fields",
|
||||
"user_credit_balances",
|
||||
"(credit_scope='personal' AND team_id IS NULL) OR "
|
||||
"(credit_scope='team' AND team_id IS NOT NULL AND subscription_id IS NOT NULL AND subscription_period_id IS NOT NULL)",
|
||||
)
|
||||
_drop_index("user_credit_balances", "ix_user_credit_balances_spendable")
|
||||
op.create_index(
|
||||
"ix_user_credit_balances_spendable",
|
||||
"user_credit_balances",
|
||||
["user_id", "credit_scope", "credit_level_rank", "expires_at", "valid_from", "id"],
|
||||
postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
|
||||
)
|
||||
if "ix_user_credit_balances_team_spendable" not in _indexes("user_credit_balances"):
|
||||
op.create_index(
|
||||
"ix_user_credit_balances_team_spendable",
|
||||
"user_credit_balances",
|
||||
["team_id", "subscription_id", "subscription_period_id", "credit_level_rank", "expires_at", "id"],
|
||||
postgresql_where=sa.text("credit_scope = 'team' AND unspent_amount > 0 AND revoked_at IS NULL"),
|
||||
)
|
||||
|
||||
# Allocation:资金来源、团队任期、Subscription/Period/Seat 全部冷备。
|
||||
allocation_columns = [
|
||||
sa.Column("credit_scope_snapshot", sa.String(16), nullable=True, server_default="personal"),
|
||||
sa.Column("team_id_snapshot", sa.String(32), nullable=True),
|
||||
sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True),
|
||||
sa.Column("subscription_id_snapshot", sa.String(32), nullable=True),
|
||||
sa.Column("subscription_period_id_snapshot", sa.String(32), nullable=True),
|
||||
sa.Column("seat_id_snapshot", sa.String(32), nullable=True),
|
||||
]
|
||||
for column in allocation_columns:
|
||||
_add_column("credit_record_allocations", column)
|
||||
op.execute("UPDATE credit_record_allocations SET credit_scope_snapshot='personal' WHERE credit_scope_snapshot IS NULL")
|
||||
op.alter_column("credit_record_allocations", "credit_scope_snapshot", nullable=False, server_default="personal")
|
||||
for name, cols in (
|
||||
("ix_credit_record_allocations_team_time", ["team_id_snapshot", "created_at", "id"]),
|
||||
("ix_credit_record_allocations_team_manager_time", ["team_id_snapshot", "team_manager_id_snapshot", "created_at", "id"]),
|
||||
("ix_credit_record_allocations_team_period_user", ["subscription_period_id_snapshot", "user_id", "allocation_action"]),
|
||||
):
|
||||
if name not in _indexes("credit_record_allocations"):
|
||||
op.create_index(name, "credit_record_allocations", cols)
|
||||
|
||||
# PaymentOrder:线上/线下统一主表,删除升级价格字段。
|
||||
payment_columns = [
|
||||
sa.Column("order_source", sa.String(24), nullable=True, server_default="online_payment"),
|
||||
sa.Column("quantity", sa.Integer(), nullable=True, server_default="1"),
|
||||
sa.Column("quoted_unit_price_snapshot", sa.Numeric(20, 2), nullable=True),
|
||||
sa.Column("quoted_amount_snapshot", sa.Numeric(20, 2), nullable=True),
|
||||
sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True),
|
||||
sa.Column("team_id_snapshot", sa.String(32), nullable=True),
|
||||
sa.Column("operator_admin_id", sa.String(32), nullable=True),
|
||||
sa.Column("offline_trade_no", sa.String(128), nullable=True),
|
||||
sa.Column("offline_payment_detail", sa.String(128), nullable=True),
|
||||
sa.Column("remark", sa.String(512), nullable=True),
|
||||
sa.Column("refund_entitlement_status", sa.String(32), nullable=True),
|
||||
]
|
||||
for column in payment_columns:
|
||||
_add_column("payment_orders", column)
|
||||
op.execute(
|
||||
"UPDATE payment_orders SET order_source=COALESCE(order_source,'online_payment'), quantity=COALESCE(quantity,1), "
|
||||
"quoted_amount_snapshot=COALESCE(quoted_amount_snapshot, amount), "
|
||||
"quoted_unit_price_snapshot=COALESCE(quoted_unit_price_snapshot, amount), "
|
||||
"actual_unit_price_snapshot=COALESCE(actual_unit_price_snapshot, amount)"
|
||||
)
|
||||
op.alter_column("payment_orders", "order_source", nullable=False, server_default="online_payment")
|
||||
op.alter_column("payment_orders", "quantity", nullable=False, server_default="1")
|
||||
for col in (
|
||||
"source_subscription_id", "upgrade_period_ids_json", "target_price_snapshot",
|
||||
"deduction_amount_snapshot", "payable_amount_snapshot",
|
||||
):
|
||||
_drop_column("payment_orders", col)
|
||||
if "fk_payment_orders_team_snapshot" not in _constraints("payment_orders"):
|
||||
op.create_foreign_key("fk_payment_orders_team_snapshot", "payment_orders", "teams", ["team_id_snapshot"], ["id"], ondelete="RESTRICT")
|
||||
if "fk_payment_orders_operator_admin" not in _constraints("payment_orders"):
|
||||
op.create_foreign_key("fk_payment_orders_operator_admin", "payment_orders", "users", ["operator_admin_id"], ["id"], ondelete="SET NULL")
|
||||
if "ix_payorder_source_status_created" not in _indexes("payment_orders"):
|
||||
op.create_index("ix_payorder_source_status_created", "payment_orders", ["order_source", "status", "created_at"])
|
||||
if "ix_payorder_team_status_created" not in _indexes("payment_orders"):
|
||||
op.create_index("ix_payorder_team_status_created", "payment_orders", ["team_id_snapshot", "status", "created_at"])
|
||||
|
||||
_add_column("teams", sa.Column("first_subscription_paid_at", sa.DateTime(timezone=True), nullable=True))
|
||||
if "ix_teams_first_subscription_paid_at" not in _indexes("teams"):
|
||||
op.create_index("ix_teams_first_subscription_paid_at", "teams", ["first_subscription_paid_at"])
|
||||
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute("CREATE SEQUENCE IF NOT EXISTS team_auto_name_seq START WITH 1 INCREMENT BY 1")
|
||||
# 避免历史上已经存在“团队000N”时从1开始产生重名;is_called=false 让下一次 nextval 直接返回 max+1。
|
||||
op.execute(
|
||||
"SELECT setval('team_auto_name_seq', "
|
||||
"GREATEST(COALESCE(MAX((substring(name from '^团队([0-9]+)$'))::bigint), 0) + 1, 1), false) "
|
||||
"FROM teams WHERE name ~ '^团队[0-9]+$'"
|
||||
)
|
||||
|
||||
if "team_manager_history" not in tables:
|
||||
op.create_table(
|
||||
"team_manager_history",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("manager_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_team_manager_history_team_time", "team_manager_history", ["team_id", "started_at", "ended_at"])
|
||||
op.create_index("ix_team_manager_history_manager_time", "team_manager_history", ["manager_user_id", "started_at", "ended_at"])
|
||||
op.create_index(
|
||||
"uq_team_manager_history_current",
|
||||
"team_manager_history",
|
||||
["team_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("ended_at IS NULL"),
|
||||
)
|
||||
op.execute(
|
||||
"INSERT INTO team_manager_history(id, team_id, manager_user_id, started_at, created_at, updated_at) "
|
||||
"SELECT md5(random()::text || clock_timestamp()::text), id, manager_id, COALESCE(created_at, now()), now(), now() "
|
||||
"FROM teams WHERE manager_id IS NOT NULL AND deleted_at IS NULL"
|
||||
)
|
||||
|
||||
if "team_subscription_seats" not in tables:
|
||||
op.create_table(
|
||||
"team_subscription_seats",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("monthly_allocated_credits", sa.Numeric(20, 2), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.CheckConstraint("monthly_allocated_credits > 0", name="ck_team_subscription_seat_allocation_positive"),
|
||||
)
|
||||
op.create_index("ix_team_subscription_seats_subscription", "team_subscription_seats", ["subscription_id", "created_at"])
|
||||
op.create_index("ix_team_subscription_seats_team_id", "team_subscription_seats", ["team_id"])
|
||||
op.create_index("ix_team_subscription_seats_deleted_at", "team_subscription_seats", ["deleted_at"])
|
||||
op.create_index("ix_team_subscription_seats_user", "team_subscription_seats", ["user_id", "subscription_id"])
|
||||
op.create_index(
|
||||
"uq_team_subscription_seats_active_user",
|
||||
"team_subscription_seats",
|
||||
["subscription_id", "user_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("deleted_at IS NULL AND cancelled_at IS NULL"),
|
||||
)
|
||||
|
||||
if "team_subscription_seat_usages" not in tables:
|
||||
op.create_table(
|
||||
"team_subscription_seat_usages",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("seat_id", sa.String(32), sa.ForeignKey("team_subscription_seats.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("subscription_period_id", sa.String(32), sa.ForeignKey("user_credit_subscription_periods.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("used_credits", sa.Numeric(20, 2), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.CheckConstraint("used_credits >= 0", name="ck_team_subscription_seat_usage_nonnegative"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_team_subscription_seat_usage_period",
|
||||
"team_subscription_seat_usages",
|
||||
["seat_id", "subscription_period_id"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index("ix_team_subscription_seat_usage_member", "team_subscription_seat_usages", ["subscription_period_id", "user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
if "team_subscription_seat_usages" in tables:
|
||||
op.drop_table("team_subscription_seat_usages")
|
||||
if "team_subscription_seats" in tables:
|
||||
op.drop_table("team_subscription_seats")
|
||||
if "team_manager_history" in tables:
|
||||
op.drop_table("team_manager_history")
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute("DROP SEQUENCE IF EXISTS team_auto_name_seq")
|
||||
|
||||
_drop_index("teams", "ix_teams_first_subscription_paid_at")
|
||||
_drop_column("teams", "first_subscription_paid_at")
|
||||
|
||||
for name in ("ix_payorder_team_status_created", "ix_payorder_source_status_created"):
|
||||
_drop_index("payment_orders", name)
|
||||
for fk in ("fk_payment_orders_operator_admin", "fk_payment_orders_team_snapshot"):
|
||||
if fk in _constraints("payment_orders"):
|
||||
op.drop_constraint(fk, "payment_orders", type_="foreignkey")
|
||||
for col in (
|
||||
"refund_entitlement_status", "remark", "offline_payment_detail", "offline_trade_no",
|
||||
"operator_admin_id", "team_id_snapshot", "actual_unit_price_snapshot", "quoted_amount_snapshot",
|
||||
"quoted_unit_price_snapshot", "quantity", "order_source",
|
||||
):
|
||||
_drop_column("payment_orders", col)
|
||||
_add_column("payment_orders", sa.Column("source_subscription_id", sa.String(32), nullable=True))
|
||||
if "ix_payment_orders_source_subscription_id" not in _indexes("payment_orders"):
|
||||
op.create_index("ix_payment_orders_source_subscription_id", "payment_orders", ["source_subscription_id"])
|
||||
_add_column("payment_orders", sa.Column("upgrade_period_ids_json", sa.JSON(), nullable=True))
|
||||
_add_column("payment_orders", sa.Column("target_price_snapshot", sa.Numeric(20, 2), nullable=True))
|
||||
_add_column("payment_orders", sa.Column("deduction_amount_snapshot", sa.Numeric(20, 2), nullable=True))
|
||||
_add_column("payment_orders", sa.Column("payable_amount_snapshot", sa.Numeric(20, 2), nullable=True))
|
||||
|
||||
for name in (
|
||||
"ix_credit_record_allocations_team_period_user",
|
||||
"ix_credit_record_allocations_team_manager_time",
|
||||
"ix_credit_record_allocations_team_time",
|
||||
):
|
||||
_drop_index("credit_record_allocations", name)
|
||||
for col in (
|
||||
"seat_id_snapshot", "subscription_period_id_snapshot", "subscription_id_snapshot",
|
||||
"team_manager_id_snapshot", "team_id_snapshot", "credit_scope_snapshot",
|
||||
):
|
||||
_drop_column("credit_record_allocations", col)
|
||||
|
||||
_drop_index("user_credit_balances", "ix_user_credit_balances_team_spendable")
|
||||
_drop_index("user_credit_balances", "ix_user_credit_balances_spendable")
|
||||
if "ck_user_credit_balances_scope_fields" in _constraints("user_credit_balances"):
|
||||
op.drop_constraint("ck_user_credit_balances_scope_fields", "user_credit_balances", type_="check")
|
||||
if "fk_user_credit_balances_team" in _constraints("user_credit_balances"):
|
||||
op.drop_constraint("fk_user_credit_balances_team", "user_credit_balances", type_="foreignkey")
|
||||
_drop_column("user_credit_balances", "team_id")
|
||||
_drop_column("user_credit_balances", "credit_scope")
|
||||
op.create_index(
|
||||
"ix_user_credit_balances_spendable",
|
||||
"user_credit_balances",
|
||||
["user_id", "credit_level_rank", "expires_at", "valid_from", "id"],
|
||||
postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
|
||||
)
|
||||
|
||||
_drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_window")
|
||||
_add_column("user_credit_subscription_periods", sa.Column("upgrade_order_id", sa.String(32), nullable=True))
|
||||
if "fk_user_credit_subscription_periods_upgrade_order" not in _constraints("user_credit_subscription_periods"):
|
||||
op.create_foreign_key(
|
||||
"fk_user_credit_subscription_periods_upgrade_order",
|
||||
"user_credit_subscription_periods", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL"
|
||||
)
|
||||
_add_column("user_credit_subscription_periods", sa.Column("reserved_at", sa.DateTime(timezone=True), nullable=True))
|
||||
_add_column("user_credit_subscription_periods", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.create_index("ix_user_credit_subscription_periods_upgrade", "user_credit_subscription_periods", ["upgrade_order_id", "status"])
|
||||
|
||||
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_team_active")
|
||||
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_user_active")
|
||||
if "fk_user_credit_subscriptions_team" in _constraints("user_credit_subscriptions"):
|
||||
op.drop_constraint("fk_user_credit_subscriptions_team", "user_credit_subscriptions", type_="foreignkey")
|
||||
for col in (
|
||||
"actual_unit_price_snapshot", "activity_price_snapshot", "regular_price_snapshot",
|
||||
"first_purchase_price_snapshot", "quantity_snapshot", "monthly_total_credits_snapshot",
|
||||
"product_name_snapshot", "product_type_snapshot", "team_manager_id_snapshot", "team_id",
|
||||
):
|
||||
_drop_column("user_credit_subscriptions", col)
|
||||
_add_column("user_credit_subscriptions", sa.Column("source_subscription_id", sa.String(32), nullable=True))
|
||||
_add_column("user_credit_subscriptions", sa.Column("upgrade_order_id", sa.String(32), nullable=True))
|
||||
if "fk_user_credit_subscriptions_source_subscription" not in _constraints("user_credit_subscriptions"):
|
||||
op.create_foreign_key(
|
||||
"fk_user_credit_subscriptions_source_subscription",
|
||||
"user_credit_subscriptions", "user_credit_subscriptions", ["source_subscription_id"], ["id"], ondelete="SET NULL"
|
||||
)
|
||||
if "fk_user_credit_subscriptions_upgrade_order" not in _constraints("user_credit_subscriptions"):
|
||||
op.create_foreign_key(
|
||||
"fk_user_credit_subscriptions_upgrade_order",
|
||||
"user_credit_subscriptions", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL"
|
||||
)
|
||||
op.create_index("ix_user_credit_subscriptions_current", "user_credit_subscriptions", ["user_id", "status", "expires_at"])
|
||||
|
||||
if "ck_credit_products_type_required_fields" in _constraints("credit_products"):
|
||||
op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check")
|
||||
# 旧版本不认识 team_subscription。降级时保留商品与永久唯一 product_code,
|
||||
# 但将团队套餐转换为旧版可识别的 subscription 并强制下架,避免旧代码误售。
|
||||
op.execute(
|
||||
"UPDATE credit_products "
|
||||
"SET product_type='subscription', is_active=false "
|
||||
"WHERE product_type='team_subscription'"
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_credit_products_type_required_fields",
|
||||
"credit_products",
|
||||
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
|
||||
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
|
||||
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
|
||||
"AND grant_credits IS NULL AND validity_months IS NULL) "
|
||||
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
|
||||
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
|
||||
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
|
||||
"AND first_purchase_price IS NULL AND regular_price IS NULL "
|
||||
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
|
||||
)
|
||||
_add_column("credit_products", sa.Column("renewal_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
|
||||
op.execute("UPDATE credit_products SET renewal_enabled=false WHERE product_type='credit_addon'")
|
||||
_drop_index("credit_products", "ix_credit_products_public")
|
||||
op.create_index("ix_credit_products_public", "credit_products", ["product_type", "is_active", "sort_order"])
|
||||
_drop_index("credit_products", "ix_credit_products_deleted_at")
|
||||
_drop_column("credit_products", "deleted_at")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""兼容恢复积分套餐续费开关
|
||||
|
||||
Revision ID: 20260814_restore_renewal
|
||||
Revises: 20260814_multi_sub_team
|
||||
Create Date: 2026-08-14 13:25:00
|
||||
|
||||
说明:
|
||||
- 修正版 20260814_multi_sub_team 已不再删除 renewal_enabled;新环境执行到本迁移时为 no-op。
|
||||
- 如果数据库已经执行过上一版会删除 renewal_enabled 的同 revision 迁移,本迁移负责安全补回字段。
|
||||
- 已经被旧迁移删除的历史 false 值无法从数据库自身恢复,补回时订阅套餐默认 true,积分增值包统一 false。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260814_restore_renewal"
|
||||
down_revision = "20260814_multi_sub_team"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _columns(table_name: str) -> set[str]:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if table_name not in inspector.get_table_names():
|
||||
return set()
|
||||
return {item["name"] for item in inspector.get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if "renewal_enabled" not in _columns("credit_products"):
|
||||
op.add_column(
|
||||
"credit_products",
|
||||
sa.Column(
|
||||
"renewal_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("true"),
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE credit_products "
|
||||
"SET renewal_enabled=false "
|
||||
"WHERE product_type='credit_addon'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 当前修正版上一个 revision 本身就保留 renewal_enabled,降级到它时字段也应继续存在。
|
||||
pass
|
||||
@@ -0,0 +1,92 @@
|
||||
"""订阅实例业务流水号
|
||||
|
||||
Revision ID: 20260814_subscription_no
|
||||
Revises: 20260814_restore_renewal
|
||||
Create Date: 2026-08-14 14:23:00
|
||||
|
||||
说明:
|
||||
- 每张 user_credit_subscriptions 增加永久唯一 subscription_no。
|
||||
- 个人订阅前缀 PS,团队订阅前缀 TS。
|
||||
- 全局共用 PostgreSQL Sequence,避免 COUNT/MAX 并发冲突。
|
||||
- 历史记录按原 created_at 日期生成业务编号;编号只用于展示/客服定位,不参与业务排序和结算。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260814_subscription_no"
|
||||
down_revision = "20260814_restore_renewal"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _columns(table_name: str) -> set[str]:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if table_name not in inspector.get_table_names():
|
||||
return set()
|
||||
return {item["name"] for item in inspector.get_columns(table_name)}
|
||||
|
||||
|
||||
def _indexes(table_name: str) -> set[str]:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if table_name not in inspector.get_table_names():
|
||||
return set()
|
||||
return {item["name"] for item in inspector.get_indexes(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
raise RuntimeError("订阅实例流水号迁移当前仅支持 PostgreSQL")
|
||||
|
||||
op.execute("CREATE SEQUENCE IF NOT EXISTS credit_subscription_no_seq START WITH 1 INCREMENT BY 1")
|
||||
|
||||
if "subscription_no" not in _columns("user_credit_subscriptions"):
|
||||
op.add_column(
|
||||
"user_credit_subscriptions",
|
||||
sa.Column(
|
||||
"subscription_no",
|
||||
sa.String(length=32),
|
||||
nullable=True,
|
||||
comment="订阅业务实例编号,供用户/客服/开发定位",
|
||||
),
|
||||
)
|
||||
|
||||
# 历史数据一次性补号。日期使用东八区业务日期;Sequence 只保证唯一,不要求无跳号。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE user_credit_subscriptions
|
||||
SET subscription_no =
|
||||
CASE
|
||||
WHEN product_type_snapshot = 'team_subscription' THEN 'TS'
|
||||
ELSE 'PS'
|
||||
END
|
||||
|| to_char(COALESCE(created_at, start_at, now()) AT TIME ZONE 'Asia/Shanghai', 'YYYYMMDD')
|
||||
|| lpad(nextval('credit_subscription_no_seq')::text, 6, '0')
|
||||
WHERE subscription_no IS NULL OR btrim(subscription_no) = ''
|
||||
"""
|
||||
)
|
||||
|
||||
op.alter_column(
|
||||
"user_credit_subscriptions",
|
||||
"subscription_no",
|
||||
existing_type=sa.String(length=32),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
if "uq_user_credit_subscriptions_no" not in _indexes("user_credit_subscriptions"):
|
||||
op.create_index(
|
||||
"uq_user_credit_subscriptions_no",
|
||||
"user_credit_subscriptions",
|
||||
["subscription_no"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if "uq_user_credit_subscriptions_no" in _indexes("user_credit_subscriptions"):
|
||||
op.drop_index("uq_user_credit_subscriptions_no", table_name="user_credit_subscriptions")
|
||||
if "subscription_no" in _columns("user_credit_subscriptions"):
|
||||
op.drop_column("user_credit_subscriptions", "subscription_no")
|
||||
op.execute("DROP SEQUENCE IF EXISTS credit_subscription_no_seq")
|
||||
@@ -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
|
||||
@@ -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()]
|
||||
|
||||
@@ -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
@@ -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 ──────────────────────────────────────
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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):
|
||||
# 情况 3:ISO 字符串(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)}"},
|
||||
)
|
||||
|
||||
@@ -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: "其他-线下收款",
|
||||
}
|
||||
|
||||
@@ -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: "升级积分转入",
|
||||
}
|
||||
|
||||
@@ -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: "活动价",
|
||||
}
|
||||
|
||||
@@ -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: "已到期",
|
||||
}
|
||||
|
||||
@@ -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__"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
@@ -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 lock;SQLite 本地调试环境无需额外锁。"""
|
||||
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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user