会员积分改版V6 修复订单异步时区校验BUG
This commit is contained in:
@@ -170,46 +170,69 @@ async def _get_payment_configs(db: AsyncSession) -> dict[str, str]:
|
||||
return {c.key: c.value for c in result.scalars().all()}
|
||||
|
||||
|
||||
async def _close_order_for_cancellation(
|
||||
db: AsyncSession,
|
||||
order: PaymentOrder,
|
||||
db_configs: dict[str, str],
|
||||
) -> bool:
|
||||
"""Close a gateway order before changing the local order to cancelled.
|
||||
|
||||
For Alipay/WeChat, local cancellation is only allowed after the gateway
|
||||
confirms that the order has been closed. Unknown/legacy payment methods
|
||||
keep the historical local-only cancellation behaviour.
|
||||
"""
|
||||
if order.payment_method == "alipay":
|
||||
return await _close_alipay_order(db, order, db_configs)
|
||||
if order.payment_method == "wechat":
|
||||
return await _close_wechat_order(db, order, db_configs)
|
||||
return True
|
||||
|
||||
|
||||
async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool:
|
||||
"""If a pending order has passed its expiry, mark it cancelled.
|
||||
Returns True if the order was expired.
|
||||
"""Expire a pending order only after the payment gateway confirms close.
|
||||
|
||||
Returns True only when the local order was actually changed to cancelled.
|
||||
If the gateway close result is uncertain/failed, the order remains pending
|
||||
so that later query/reconciliation can still discover a successful payment.
|
||||
"""
|
||||
if order.status != "pending":
|
||||
return False
|
||||
|
||||
db_configs = await _get_payment_configs(db)
|
||||
expire_seconds = _get_order_expire_seconds(db_configs)
|
||||
expiry = order.created_at + timedelta(seconds=expire_seconds)
|
||||
if datetime.now(order.created_at.tzinfo) >= expiry:
|
||||
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} "
|
||||
f"amount={order.amount} created_at={order.created_at.isoformat()}"
|
||||
if utc_now() < expiry:
|
||||
return False
|
||||
|
||||
closed = await _close_order_for_cancellation(db, order, db_configs)
|
||||
if not closed:
|
||||
logger.warning(
|
||||
f"ORDER_EXPIRE_CLOSE_PENDING order_no={order.order_no} "
|
||||
f"user={order.user_id} amount={order.amount}"
|
||||
)
|
||||
# Also call close API if it was an Alipay or WeChat order
|
||||
if order.payment_method == "alipay":
|
||||
try:
|
||||
await _close_alipay_order(db, order, db_configs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to close Alipay order {order.order_no}: {e}")
|
||||
elif order.payment_method == "wechat":
|
||||
try:
|
||||
await _close_wechat_order(db, order, db_configs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to close WeChat order {order.order_no}: {e}")
|
||||
return True
|
||||
return False
|
||||
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} "
|
||||
f"amount={order.amount} created_at={order.created_at.isoformat()}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def expire_all_pending_orders(db: AsyncSession) -> int:
|
||||
"""Background task: mark all expired pending orders as cancelled.
|
||||
Returns the number of orders expired.
|
||||
"""Background task: close and cancel expired pending orders.
|
||||
|
||||
Alipay/WeChat orders remain pending when the remote close cannot be
|
||||
confirmed. This prevents a real paid order from being persisted locally
|
||||
as cancelled.
|
||||
"""
|
||||
db_configs = await _get_payment_configs(db)
|
||||
expire_seconds = _get_order_expire_seconds(db_configs)
|
||||
threshold = datetime.now() - timedelta(seconds=expire_seconds)
|
||||
threshold = utc_now() - timedelta(seconds=expire_seconds)
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.status == "pending",
|
||||
@@ -218,27 +241,27 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
expired_count = 0
|
||||
for o in orders:
|
||||
o.status = "cancelled"
|
||||
if o.upgrade_period_ids_json:
|
||||
await release_upgrade_reservation(db, order=o, released_at=utc_now())
|
||||
|
||||
for order in orders:
|
||||
closed = await _close_order_for_cancellation(db, order, db_configs)
|
||||
if not closed:
|
||||
logger.warning(
|
||||
f"ORDER_EXPIRE_CLOSE_PENDING order_no={order.order_no} "
|
||||
f"user={order.user_id} amount={order.amount}"
|
||||
)
|
||||
continue
|
||||
|
||||
order.status = "cancelled"
|
||||
if order.upgrade_period_ids_json:
|
||||
await release_upgrade_reservation(db, order=order, released_at=utc_now())
|
||||
expired_count += 1
|
||||
logger.info(
|
||||
f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
|
||||
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
|
||||
f"amount={order.amount}"
|
||||
)
|
||||
# Also call close API if it was an Alipay or WeChat order
|
||||
if o.payment_method == "alipay":
|
||||
try:
|
||||
await _close_alipay_order(db, o, db_configs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to close Alipay order {o.order_no}: {e}")
|
||||
elif o.payment_method == "wechat":
|
||||
try:
|
||||
await _close_wechat_order(db, o, db_configs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to close WeChat order {o.order_no}: {e}")
|
||||
if orders:
|
||||
await db.flush()
|
||||
|
||||
if expired_count > 0:
|
||||
await db.commit()
|
||||
return expired_count
|
||||
|
||||
|
||||
@@ -409,19 +432,15 @@ async def create_recharge_order(
|
||||
if mock_mode:
|
||||
order.status = "paid"
|
||||
order.paid_at = checked_at
|
||||
if product:
|
||||
await fulfill_payment_product(db, order=order, fulfilled_at=checked_at)
|
||||
else:
|
||||
desc = f"充值{label}({total_credits}积分)"
|
||||
if bonus_credits > 0:
|
||||
desc += f"(含赠送{bonus_credits}积分)"
|
||||
await add_credits(
|
||||
db, user_id, total_credits, desc, related_id=order.id,
|
||||
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
|
||||
record_meta=_payment_recharge_meta(order),
|
||||
payment_order_id=order.id,
|
||||
source_id=order.id,
|
||||
)
|
||||
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,
|
||||
)
|
||||
await db.flush()
|
||||
else:
|
||||
if method == "wechat":
|
||||
@@ -953,60 +972,78 @@ async def _query_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs:
|
||||
|
||||
async def sync_pending_orders(db: AsyncSession) -> int:
|
||||
"""Check pending orders via Alipay/WeChat query and update status.
|
||||
Returns the number of orders updated.
|
||||
|
||||
Candidate rows are collected as primitive values first. A failed payment
|
||||
fulfillment may roll back the session; using primitive candidates prevents
|
||||
that rollback from expiring ORM objects needed by later iterations.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(
|
||||
select(PaymentOrder.order_no, PaymentOrder.payment_method).where(
|
||||
PaymentOrder.status == "pending",
|
||||
)
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
candidates = [(str(row.order_no), str(row.payment_method)) for row in result.all()]
|
||||
updated_count = 0
|
||||
|
||||
db_configs = await _get_payment_configs(db)
|
||||
|
||||
for order in orders:
|
||||
for order_no, payment_method in candidates:
|
||||
try:
|
||||
if order.payment_method == "alipay":
|
||||
order_result = await db.execute(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.order_no == order_no,
|
||||
PaymentOrder.status == "pending",
|
||||
).limit(1)
|
||||
)
|
||||
order = order_result.scalar_one_or_none()
|
||||
if order is None:
|
||||
continue
|
||||
|
||||
if payment_method == "alipay":
|
||||
data = await _query_alipay_order(db, order, db_configs)
|
||||
if data:
|
||||
trade_status = data.get("trade_status")
|
||||
if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED"):
|
||||
# Order was paid but we missed the callback
|
||||
trade_no = data.get("trade_no", "")
|
||||
total_amount_str = data.get("total_amount", "")
|
||||
total_amount = float(total_amount_str) if total_amount_str else None
|
||||
await process_payment_success_by_order_no(db, order.order_no, trade_no, total_amount)
|
||||
updated_count += 1
|
||||
processed = await process_payment_success_by_order_no(
|
||||
db, order_no, trade_no, total_amount
|
||||
)
|
||||
if processed:
|
||||
updated_count += 1
|
||||
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
|
||||
# Order was closed on Alipay side
|
||||
order.status = "cancelled"
|
||||
if order.upgrade_period_ids_json:
|
||||
await release_upgrade_reservation(db, order=order, released_at=utc_now())
|
||||
await db.flush()
|
||||
await release_upgrade_reservation(
|
||||
db, order=order, released_at=utc_now()
|
||||
)
|
||||
await db.commit()
|
||||
updated_count += 1
|
||||
elif order.payment_method == "wechat":
|
||||
|
||||
elif payment_method == "wechat":
|
||||
data = await _query_wechat_order(db, order, db_configs)
|
||||
if data:
|
||||
trade_state = data.get("trade_state")
|
||||
if trade_state == "SUCCESS":
|
||||
# Order was paid but we missed the callback
|
||||
transaction_id = data.get("transaction_id", "")
|
||||
total_amount = float(data.get("amount", {}).get("total", 0)) / 100
|
||||
await process_payment_success_by_order_no(db, order.order_no, transaction_id, total_amount)
|
||||
updated_count += 1
|
||||
processed = await process_payment_success_by_order_no(
|
||||
db, order_no, transaction_id, total_amount
|
||||
)
|
||||
if processed:
|
||||
updated_count += 1
|
||||
elif trade_state in ("CLOSED", "REVOKED"):
|
||||
# Order was closed on WeChat side
|
||||
order.status = "cancelled"
|
||||
if order.upgrade_period_ids_json:
|
||||
await release_upgrade_reservation(db, order=order, released_at=utc_now())
|
||||
await db.flush()
|
||||
await release_upgrade_reservation(
|
||||
db, order=order, released_at=utc_now()
|
||||
)
|
||||
await db.commit()
|
||||
updated_count += 1
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to sync order {order.order_no}: {e}")
|
||||
await db.rollback()
|
||||
logger.exception(f"Failed to sync order {order_no}: {e}")
|
||||
|
||||
if updated_count > 0:
|
||||
await db.flush()
|
||||
return updated_count
|
||||
|
||||
|
||||
@@ -1179,96 +1216,152 @@ async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def process_payment_success(db: AsyncSession, order_id: str):
|
||||
"""Process successful payment: update order and add credits."""
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(PaymentOrder.id == order_id).with_for_update().limit(1)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order or order.status != "pending":
|
||||
async def _fulfill_paid_order(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
order: PaymentOrder,
|
||||
fulfilled_at: datetime,
|
||||
legacy_description: str,
|
||||
) -> None:
|
||||
"""Fulfill the business side of a paid order without committing.
|
||||
|
||||
Product orders are considered successfully paid locally only when the
|
||||
product service leaves the order in the explicit ``fulfilled`` state.
|
||||
This turns any internal upgrade/reconciliation failure into an exception so
|
||||
the outer payment transaction can roll back the temporary ``paid`` state.
|
||||
"""
|
||||
if order.product_id:
|
||||
await fulfill_payment_product(db, order=order, fulfilled_at=fulfilled_at)
|
||||
if order.fulfillment_status != "fulfilled":
|
||||
raise RuntimeError(
|
||||
f"Payment product fulfillment incomplete: order_no={order.order_no}, "
|
||||
f"fulfillment_status={order.fulfillment_status}"
|
||||
)
|
||||
return
|
||||
|
||||
order.status = "paid"
|
||||
order.paid_at = datetime.now()
|
||||
if order.product_id:
|
||||
await fulfill_payment_product(db, order=order, fulfilled_at=order.paid_at)
|
||||
else:
|
||||
await add_credits(
|
||||
db, order.user_id, order.credits, f"充值成功({order.credits}积分)",
|
||||
related_id=order.id,
|
||||
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
|
||||
record_meta=_payment_recharge_meta(order),
|
||||
payment_order_id=order.id,
|
||||
source_id=order.id,
|
||||
await add_credits(
|
||||
db,
|
||||
order.user_id,
|
||||
order.credits,
|
||||
legacy_description,
|
||||
related_id=order.id,
|
||||
biz_key=_payment_biz_key(
|
||||
order,
|
||||
charge_kind=CreditRecordChargeKind.RECHARGE.value,
|
||||
action=CreditRecordAction.CHARGE.value,
|
||||
),
|
||||
record_meta=_payment_recharge_meta(order),
|
||||
payment_order_id=order.id,
|
||||
source_id=order.id,
|
||||
)
|
||||
|
||||
|
||||
async def process_payment_success(db: AsyncSession, order_id: str) -> bool:
|
||||
"""Atomically mark a pending order paid and fulfill its business value."""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.id == order_id)
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
await db.commit()
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return False
|
||||
if order.status == "paid":
|
||||
return True
|
||||
if order.status != "pending":
|
||||
return False
|
||||
|
||||
paid_at = utc_now()
|
||||
order.status = "paid"
|
||||
order.paid_at = paid_at
|
||||
await _fulfill_paid_order(
|
||||
db,
|
||||
order=order,
|
||||
fulfilled_at=paid_at,
|
||||
legacy_description=f"充值成功({order.credits}积分)",
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def process_payment_success_by_order_no(
|
||||
db: AsyncSession,
|
||||
order_no: str,
|
||||
trade_no: str = "",
|
||||
total_amount: float | None = None
|
||||
):
|
||||
"""Process successful payment by order_no (used by Alipay/WeChat callbacks).
|
||||
total_amount: float | None = None,
|
||||
) -> bool:
|
||||
"""Atomically process an Alipay/WeChat payment success.
|
||||
|
||||
Args:
|
||||
db: async database session
|
||||
order_no: the merchant order number (out_trade_no)
|
||||
trade_no: the Alipay trade number (trade_no), optional
|
||||
total_amount: the payment amount from the gateway, for consistency check
|
||||
``paid`` is persisted only together with successful local fulfillment.
|
||||
When fulfillment fails, the whole local transaction is rolled back and the
|
||||
order remains ``pending``; the existing gateway query loop may retry it.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
|
||||
if not order:
|
||||
logger.info(f"Order {order_no} not found, skipping")
|
||||
return
|
||||
|
||||
if order.status == "paid":
|
||||
logger.info(f"Order {order_no} already processed, skipping")
|
||||
return
|
||||
|
||||
if order.status != "pending":
|
||||
logger.info(f"Order {order_no} is in {order.status} state, cannot process")
|
||||
return
|
||||
|
||||
# 金额一致性校验
|
||||
if total_amount is not None and abs(to_credit_decimal(total_amount) - to_credit_decimal(order.amount)) > to_credit_decimal("0.01"):
|
||||
logger.error(
|
||||
f"Amount mismatch: order amount {order.amount}, gateway amount {total_amount}"
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.order_no == order_no)
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
return
|
||||
order = result.scalar_one_or_none()
|
||||
|
||||
# 幂等性检查:如果trade_no已存在且相同,则跳过
|
||||
if trade_no and order.trade_no and order.trade_no == trade_no:
|
||||
logger.info(f"Trade no {trade_no} already processed, skipping")
|
||||
return
|
||||
if not order:
|
||||
logger.info(f"Order {order_no} not found, skipping")
|
||||
return False
|
||||
|
||||
order.status = "paid"
|
||||
order.paid_at = datetime.now()
|
||||
if trade_no:
|
||||
order.trade_no = trade_no
|
||||
if order.status == "paid":
|
||||
logger.info(f"Order {order_no} already processed, skipping")
|
||||
return True
|
||||
|
||||
if order.product_id:
|
||||
await fulfill_payment_product(db, order=order, fulfilled_at=order.paid_at)
|
||||
else:
|
||||
await add_credits(
|
||||
db, order.user_id, order.credits,
|
||||
f"充值成功({order.credits}积分), 订单号: {order_no}, 金额: {order.amount}",
|
||||
related_id=order.id,
|
||||
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
|
||||
record_meta=_payment_recharge_meta(order),
|
||||
payment_order_id=order.id,
|
||||
source_id=order.id,
|
||||
if order.status != "pending":
|
||||
logger.info(f"Order {order_no} is in {order.status} state, cannot process")
|
||||
return False
|
||||
|
||||
if (
|
||||
total_amount is not None
|
||||
and abs(
|
||||
to_credit_decimal(total_amount) - to_credit_decimal(order.amount)
|
||||
) > to_credit_decimal("0.01")
|
||||
):
|
||||
logger.error(
|
||||
f"Amount mismatch: order amount {order.amount}, gateway amount {total_amount}"
|
||||
)
|
||||
return False
|
||||
|
||||
# A pending order must never be skipped merely because trade_no is
|
||||
# already present. Historical partial writes can otherwise remain
|
||||
# pending forever. The row lock + paid state + credit biz_key provide
|
||||
# the actual idempotency boundary.
|
||||
paid_at = utc_now()
|
||||
order.status = "paid"
|
||||
order.paid_at = paid_at
|
||||
if trade_no:
|
||||
order.trade_no = trade_no
|
||||
|
||||
await _fulfill_paid_order(
|
||||
db,
|
||||
order=order,
|
||||
fulfilled_at=paid_at,
|
||||
legacy_description=(
|
||||
f"充值成功({order.credits}积分), "
|
||||
f"订单号: {order_no}, 金额: {order.amount}"
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} "
|
||||
f"amount={order.amount} credits={order.credits} trade_no={trade_no}"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} "
|
||||
f"amount={order.amount} credits={order.credits} trade_no={trade_no}"
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def process_refund(
|
||||
@@ -1345,7 +1438,7 @@ async def process_refund(
|
||||
# 更新订单状态
|
||||
order.status = "refunded"
|
||||
order.refund_amount = refund_amount
|
||||
order.refunded_at = datetime.now()
|
||||
order.refunded_at = utc_now()
|
||||
if order.payment_method == "alipay":
|
||||
order.refund_trade_no = db_configs.get("refund_trade_no", "")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user