会员积分改版V6 修复订单异步时区校验BUG

This commit is contained in:
2026-08-11 11:57:55 +08:00
parent 890c1ea83e
commit 809ff47120
2 changed files with 288 additions and 169 deletions
+41 -15
View File
@@ -217,11 +217,30 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
total_amount = amount_info.get("total", 0) / 100 # 转换为元 total_amount = amount_info.get("total", 0) / 100 # 转换为元
if order_no: if order_no:
await process_payment_success_by_order_no(db, order_no, transaction_id, total_amount) try:
logger.info( processed = await process_payment_success_by_order_no(
f"WeChat callback processed: order_no={order_no}, " db, order_no, transaction_id, total_amount
f"transaction_id={transaction_id}, amount={total_amount}" )
) if processed:
logger.info(
f"WeChat callback processed: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}"
)
else:
logger.warning(
f"WeChat payment success not fulfilled locally: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}"
)
except Exception as e:
# 微信已经通过验签、解密并明确通知支付成功。
# 本地履约异常属于项目内部故障:回滚本地事务,但仍向微信返回 SUCCESS,
# 后续由 pending 主动查单重试或日志人工对账处理。
await db.rollback()
logger.exception(
f"WeChat payment fulfillment error: order_no={order_no}, "
f"transaction_id={transaction_id}, error={e}"
)
return {"code": "SUCCESS", "message": "OK"}
# 处理退款回调 # 处理退款回调
elif event_type == "REFUND.SUCCESS": elif event_type == "REFUND.SUCCESS":
@@ -402,19 +421,26 @@ async def cancel_order(
if order.status != "pending": if order.status != "pending":
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消") raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
# If it's an Alipay or WeChat order, call close API first # Alipay/WeChat must confirm remote close before local cancellation.
# If close fails or the remote state is uncertain, keep the local order
# pending so a later gateway query can still discover a successful payment.
db_configs = await _get_payment_configs(db) db_configs = await _get_payment_configs(db)
closed = True
if order.payment_method == "alipay": if order.payment_method == "alipay":
try: closed = await _close_alipay_order(db, order, db_configs)
await _close_alipay_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close Alipay order {order_no}: {e}")
elif order.payment_method == "wechat": elif order.payment_method == "wechat":
try: from app.services.payment import _close_wechat_order
from app.services.payment import _close_wechat_order closed = await _close_wechat_order(db, order, db_configs)
await _close_wechat_order(db, order, db_configs)
except Exception as e: if not closed:
logger.exception(f"Failed to close WeChat order {order_no}: {e}") logger.warning(
f"ORDER_CANCEL_CLOSE_PENDING order_no={order_no} "
f"user={current_user.id} method={order.payment_method}"
)
raise HTTPException(
status_code=409,
detail="支付渠道暂未确认订单关闭,请稍后重试",
)
order.status = "cancelled" order.status = "cancelled"
if order.upgrade_period_ids_json: if order.upgrade_period_ids_json:
+247 -154
View File
@@ -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()} 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: async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool:
"""If a pending order has passed its expiry, mark it cancelled. """Expire a pending order only after the payment gateway confirms close.
Returns True if the order was expired.
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": if order.status != "pending":
return False return False
db_configs = await _get_payment_configs(db) db_configs = await _get_payment_configs(db)
expire_seconds = _get_order_expire_seconds(db_configs) expire_seconds = _get_order_expire_seconds(db_configs)
expiry = order.created_at + timedelta(seconds=expire_seconds) expiry = order.created_at + timedelta(seconds=expire_seconds)
if datetime.now(order.created_at.tzinfo) >= expiry: if utc_now() < expiry:
order.status = "cancelled" return False
if order.upgrade_period_ids_json:
await release_upgrade_reservation(db, order=order, released_at=utc_now()) closed = await _close_order_for_cancellation(db, order, db_configs)
await db.flush() if not closed:
logger.info( logger.warning(
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} " f"ORDER_EXPIRE_CLOSE_PENDING order_no={order.order_no} "
f"amount={order.amount} created_at={order.created_at.isoformat()}" f"user={order.user_id} amount={order.amount}"
) )
# Also call close API if it was an Alipay or WeChat order return False
if order.payment_method == "alipay":
try: order.status = "cancelled"
await _close_alipay_order(db, order, db_configs) if order.upgrade_period_ids_json:
except Exception as e: await release_upgrade_reservation(db, order=order, released_at=utc_now())
logger.exception(f"Failed to close Alipay order {order.order_no}: {e}") await db.flush()
elif order.payment_method == "wechat": logger.info(
try: f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
await _close_wechat_order(db, order, db_configs) f"amount={order.amount} created_at={order.created_at.isoformat()}"
except Exception as e: )
logger.exception(f"Failed to close WeChat order {order.order_no}: {e}") return True
return True
return False
async def expire_all_pending_orders(db: AsyncSession) -> int: async def expire_all_pending_orders(db: AsyncSession) -> int:
"""Background task: mark all expired pending orders as cancelled. """Background task: close and cancel expired pending orders.
Returns the number of orders expired.
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) db_configs = await _get_payment_configs(db)
expire_seconds = _get_order_expire_seconds(db_configs) 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( result = await db.execute(
select(PaymentOrder).where( select(PaymentOrder).where(
PaymentOrder.status == "pending", PaymentOrder.status == "pending",
@@ -218,27 +241,27 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
) )
orders = result.scalars().all() orders = result.scalars().all()
expired_count = 0 expired_count = 0
for o in orders:
o.status = "cancelled" for order in orders:
if o.upgrade_period_ids_json: closed = await _close_order_for_cancellation(db, order, db_configs)
await release_upgrade_reservation(db, order=o, released_at=utc_now()) 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 expired_count += 1
logger.info( 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": if expired_count > 0:
try: await db.commit()
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()
return expired_count return expired_count
@@ -409,19 +432,15 @@ async def create_recharge_order(
if mock_mode: if mock_mode:
order.status = "paid" order.status = "paid"
order.paid_at = checked_at order.paid_at = checked_at
if product: desc = f"充值{label}({total_credits}积分)"
await fulfill_payment_product(db, order=order, fulfilled_at=checked_at) if bonus_credits > 0:
else: desc += f"(含赠送{bonus_credits}积分)"
desc = f"充值{label}({total_credits}积分)" await _fulfill_paid_order(
if bonus_credits > 0: db,
desc += f"(含赠送{bonus_credits}积分)" order=order,
await add_credits( fulfilled_at=checked_at,
db, user_id, total_credits, desc, related_id=order.id, legacy_description=desc,
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 db.flush() await db.flush()
else: else:
if method == "wechat": 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: async def sync_pending_orders(db: AsyncSession) -> int:
"""Check pending orders via Alipay/WeChat query and update status. """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( result = await db.execute(
select(PaymentOrder).where( select(PaymentOrder.order_no, PaymentOrder.payment_method).where(
PaymentOrder.status == "pending", PaymentOrder.status == "pending",
) )
) )
orders = result.scalars().all() candidates = [(str(row.order_no), str(row.payment_method)) for row in result.all()]
updated_count = 0 updated_count = 0
db_configs = await _get_payment_configs(db) db_configs = await _get_payment_configs(db)
for order in orders: for order_no, payment_method in candidates:
try: 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) data = await _query_alipay_order(db, order, db_configs)
if data: if data:
trade_status = data.get("trade_status") trade_status = data.get("trade_status")
if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED"): if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED"):
# Order was paid but we missed the callback
trade_no = data.get("trade_no", "") trade_no = data.get("trade_no", "")
total_amount_str = data.get("total_amount", "") total_amount_str = data.get("total_amount", "")
total_amount = float(total_amount_str) if total_amount_str else None 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) processed = await process_payment_success_by_order_no(
updated_count += 1 db, order_no, trade_no, total_amount
)
if processed:
updated_count += 1
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"): elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
# Order was closed on Alipay side
order.status = "cancelled" order.status = "cancelled"
if order.upgrade_period_ids_json: if order.upgrade_period_ids_json:
await release_upgrade_reservation(db, order=order, released_at=utc_now()) await release_upgrade_reservation(
await db.flush() db, order=order, released_at=utc_now()
)
await db.commit()
updated_count += 1 updated_count += 1
elif order.payment_method == "wechat":
elif payment_method == "wechat":
data = await _query_wechat_order(db, order, db_configs) data = await _query_wechat_order(db, order, db_configs)
if data: if data:
trade_state = data.get("trade_state") trade_state = data.get("trade_state")
if trade_state == "SUCCESS": if trade_state == "SUCCESS":
# Order was paid but we missed the callback
transaction_id = data.get("transaction_id", "") transaction_id = data.get("transaction_id", "")
total_amount = float(data.get("amount", {}).get("total", 0)) / 100 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) processed = await process_payment_success_by_order_no(
updated_count += 1 db, order_no, transaction_id, total_amount
)
if processed:
updated_count += 1
elif trade_state in ("CLOSED", "REVOKED"): elif trade_state in ("CLOSED", "REVOKED"):
# Order was closed on WeChat side
order.status = "cancelled" order.status = "cancelled"
if order.upgrade_period_ids_json: if order.upgrade_period_ids_json:
await release_upgrade_reservation(db, order=order, released_at=utc_now()) await release_upgrade_reservation(
await db.flush() db, order=order, released_at=utc_now()
)
await db.commit()
updated_count += 1 updated_count += 1
except Exception as e: 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 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): async def _fulfill_paid_order(
"""Process successful payment: update order and add credits.""" db: AsyncSession,
result = await db.execute( *,
select(PaymentOrder).where(PaymentOrder.id == order_id).with_for_update().limit(1) order: PaymentOrder,
) fulfilled_at: datetime,
order = result.scalar_one_or_none() legacy_description: str,
if not order or order.status != "pending": ) -> 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 return
order.status = "paid" await add_credits(
order.paid_at = datetime.now() db,
if order.product_id: order.user_id,
await fulfill_payment_product(db, order=order, fulfilled_at=order.paid_at) order.credits,
else: legacy_description,
await add_credits( related_id=order.id,
db, order.user_id, order.credits, f"充值成功({order.credits}积分)", biz_key=_payment_biz_key(
related_id=order.id, order,
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value), charge_kind=CreditRecordChargeKind.RECHARGE.value,
record_meta=_payment_recharge_meta(order), action=CreditRecordAction.CHARGE.value,
payment_order_id=order.id, ),
source_id=order.id, 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( async def process_payment_success_by_order_no(
db: AsyncSession, db: AsyncSession,
order_no: str, order_no: str,
trade_no: str = "", trade_no: str = "",
total_amount: float | None = None total_amount: float | None = None,
): ) -> bool:
"""Process successful payment by order_no (used by Alipay/WeChat callbacks). """Atomically process an Alipay/WeChat payment success.
Args: ``paid`` is persisted only together with successful local fulfillment.
db: async database session When fulfillment fails, the whole local transaction is rolled back and the
order_no: the merchant order number (out_trade_no) order remains ``pending``; the existing gateway query loop may retry it.
trade_no: the Alipay trade number (trade_no), optional
total_amount: the payment amount from the gateway, for consistency check
""" """
result = await db.execute( try:
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1) result = await db.execute(
) select(PaymentOrder)
order = result.scalar_one_or_none() .where(PaymentOrder.order_no == order_no)
.with_for_update()
if not order: .limit(1)
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}"
) )
return order = result.scalar_one_or_none()
# 幂等性检查:如果trade_no已存在且相同,则跳过 if not order:
if trade_no and order.trade_no and order.trade_no == trade_no: logger.info(f"Order {order_no} not found, skipping")
logger.info(f"Trade no {trade_no} already processed, skipping") return False
return
order.status = "paid" if order.status == "paid":
order.paid_at = datetime.now() logger.info(f"Order {order_no} already processed, skipping")
if trade_no: return True
order.trade_no = trade_no
if order.product_id: if order.status != "pending":
await fulfill_payment_product(db, order=order, fulfilled_at=order.paid_at) logger.info(f"Order {order_no} is in {order.status} state, cannot process")
else: return False
await add_credits(
db, order.user_id, order.credits, if (
f"充值成功({order.credits}积分), 订单号: {order_no}, 金额: {order.amount}", total_amount is not None
related_id=order.id, and abs(
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value), to_credit_decimal(total_amount) - to_credit_decimal(order.amount)
record_meta=_payment_recharge_meta(order), ) > to_credit_decimal("0.01")
payment_order_id=order.id, ):
source_id=order.id, 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( await db.commit()
f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} " logger.info(
f"amount={order.amount} credits={order.credits} trade_no={trade_no}" 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( async def process_refund(
@@ -1345,7 +1438,7 @@ async def process_refund(
# 更新订单状态 # 更新订单状态
order.status = "refunded" order.status = "refunded"
order.refund_amount = refund_amount order.refund_amount = refund_amount
order.refunded_at = datetime.now() order.refunded_at = utc_now()
if order.payment_method == "alipay": if order.payment_method == "alipay":
order.refund_trade_no = db_configs.get("refund_trade_no", "") order.refund_trade_no = db_configs.get("refund_trade_no", "")