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