Files
video-gen/video-gen-api/app/services/credit/offline_subscription_service.py
T
2026-08-14 15:23:46 +08:00

219 lines
8.8 KiB
Python

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