会员积分改版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
+36 -10
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:
processed = await process_payment_success_by_order_no(
db, order_no, transaction_id, total_amount
)
if processed:
logger.info( logger.info(
f"WeChat callback processed: order_no={order_no}, " f"WeChat callback processed: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}" 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
await _close_wechat_order(db, order, db_configs) closed = await _close_wechat_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close WeChat order {order_no}: {e}") if not closed:
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:
+204 -111
View File
@@ -170,16 +170,48 @@ 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:
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}"
)
return False
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(db, order=order, released_at=utc_now())
@@ -188,28 +220,19 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} " f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
f"amount={order.amount} created_at={order.created_at.isoformat()}" f"amount={order.amount} created_at={order.created_at.isoformat()}"
) )
# 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 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,18 +432,14 @@ 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:
await fulfill_payment_product(db, order=order, fulfilled_at=checked_at)
else:
desc = f"充值{label}({total_credits}积分)" desc = f"充值{label}({total_credits}积分)"
if bonus_credits > 0: if bonus_credits > 0:
desc += f"(含赠送{bonus_credits}积分)" desc += f"(含赠送{bonus_credits}积分)"
await add_credits( await _fulfill_paid_order(
db, user_id, total_credits, desc, related_id=order.id, db,
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value), order=order,
record_meta=_payment_recharge_meta(order), fulfilled_at=checked_at,
payment_order_id=order.id, legacy_description=desc,
source_id=order.id,
) )
await db.flush() await db.flush()
else: else:
@@ -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(
db, order_no, trade_no, total_amount
)
if processed:
updated_count += 1 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(
db, order_no, transaction_id, total_amount
)
if processed:
updated_count += 1 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,
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}"
) )
order = result.scalar_one_or_none()
if not order or order.status != "pending":
return 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( await add_credits(
db, order.user_id, order.credits, f"充值成功({order.credits}积分)", db,
order.user_id,
order.credits,
legacy_description,
related_id=order.id, related_id=order.id,
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value), biz_key=_payment_biz_key(
order,
charge_kind=CreditRecordChargeKind.RECHARGE.value,
action=CreditRecordAction.CHARGE.value,
),
record_meta=_payment_recharge_meta(order), record_meta=_payment_recharge_meta(order),
payment_order_id=order.id, payment_order_id=order.id,
source_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)
)
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() 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
""" """
try:
result = await db.execute( result = await db.execute(
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1) select(PaymentOrder)
.where(PaymentOrder.order_no == order_no)
.with_for_update()
.limit(1)
) )
order = result.scalar_one_or_none() order = result.scalar_one_or_none()
if not order: if not order:
logger.info(f"Order {order_no} not found, skipping") logger.info(f"Order {order_no} not found, skipping")
return return False
if order.status == "paid": if order.status == "paid":
logger.info(f"Order {order_no} already processed, skipping") logger.info(f"Order {order_no} already processed, skipping")
return return True
if order.status != "pending": if order.status != "pending":
logger.info(f"Order {order_no} is in {order.status} state, cannot process") logger.info(f"Order {order_no} is in {order.status} state, cannot process")
return return False
# 金额一致性校验 if (
if total_amount is not None and abs(to_credit_decimal(total_amount) - to_credit_decimal(order.amount)) > to_credit_decimal("0.01"): total_amount is not None
and abs(
to_credit_decimal(total_amount) - to_credit_decimal(order.amount)
) > to_credit_decimal("0.01")
):
logger.error( logger.error(
f"Amount mismatch: order amount {order.amount}, gateway amount {total_amount}" f"Amount mismatch: order amount {order.amount}, gateway amount {total_amount}"
) )
return return False
# 幂等性检查:如果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
# 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.status = "paid"
order.paid_at = datetime.now() order.paid_at = paid_at
if trade_no: if trade_no:
order.trade_no = trade_no order.trade_no = trade_no
if order.product_id: await _fulfill_paid_order(
await fulfill_payment_product(db, order=order, fulfilled_at=order.paid_at) db,
else: order=order,
await add_credits( fulfilled_at=paid_at,
db, order.user_id, order.credits, legacy_description=(
f"充值成功({order.credits}积分), 订单号: {order_no}, 金额: {order.amount}", f"充值成功({order.credits}积分), "
related_id=order.id, f"订单号: {order_no}, 金额: {order.amount}"
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.commit() await db.commit()
logger.info( logger.info(
f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} " f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} "
f"amount={order.amount} credits={order.credits} trade_no={trade_no}" 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", "")