219 lines
8.8 KiB
Python
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
|