feat(payment): expire pending orders after one hour

This commit is contained in:
stefanfeng
2026-10-16 17:24:57 +08:00
parent ddb8ea9802
commit 08a7bc8b90
5 changed files with 111 additions and 14 deletions
+26 -1
View File
@@ -22,7 +22,11 @@ import routers.takeover
from responses import ok
from services.chat_attachment_service import purge_expired_chat_attachments
from services.knowledge_vectorizer import knowledge_vectorizer
from services.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations
from services.token_billing import (
DEFAULT_TOKEN_GRANT,
close_expired_pending_orders,
release_stale_reservations,
)
logger = logging.getLogger(__name__)
@@ -255,10 +259,24 @@ def purge_expired_chat_attachments_job():
db.close()
def close_expired_pending_orders_job():
db = SessionLocal()
try:
count = close_expired_pending_orders(db)
if count:
logger.info("Closed %s expired pending token payment order(s)", count)
except Exception as exc:
db.rollback()
logger.warning("Failed to close expired token payment orders: %s", exc)
finally:
db.close()
def start_maintenance_scheduler():
global maintenance_scheduler
purge_expired_chat_attachments_job()
close_expired_pending_orders_job()
interval_minutes = max(
5, min(1440, int(os.getenv("CHAT_ATTACHMENT_CLEANUP_MINUTES", "60")))
)
@@ -270,6 +288,13 @@ def start_maintenance_scheduler():
max_instances=1,
coalesce=True,
)
maintenance_scheduler.add_job(
close_expired_pending_orders_job,
trigger=IntervalTrigger(minutes=1),
id="close_expired_token_orders",
max_instances=1,
coalesce=True,
)
maintenance_scheduler.start()
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from models import Avatar, TokenAccount, TokenUsage, User
from models import Avatar, TokenAccount, TokenPaymentOrder, TokenUsage, User
DEFAULT_TOKEN_GRANT = 1_000_000
@@ -201,3 +201,22 @@ def release_stale_reservations(db: Session, older_than_minutes: int = 10) -> int
"stale_reservation_recovered",
)
return len(stale)
def close_expired_pending_orders(db: Session, older_than_minutes: int = 60) -> int:
"""Close unpaid recharge orders after their one-hour payment window."""
now = datetime.utcnow()
cutoff = now - timedelta(minutes=older_than_minutes)
closed_count = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.status == "pending",
TokenPaymentOrder.created_at <= cutoff,
).update(
{
TokenPaymentOrder.status: "closed",
TokenPaymentOrder.failure_reason: "支付超时(超过1小时)",
TokenPaymentOrder.updated_at: now,
},
synchronize_session=False,
)
db.commit()
return int(closed_count or 0)
@@ -53,8 +53,12 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
max_message_age_seconds=600,
)
maintenance_scheduler.add_job.assert_called_once()
assert maintenance_scheduler.add_job.call_args.kwargs["id"] == "chat_attachment_cleanup"
assert maintenance_scheduler.add_job.call_count == 2
cleanup_call, close_orders_call = maintenance_scheduler.add_job.call_args_list
assert cleanup_call.kwargs["id"] == "chat_attachment_cleanup"
assert close_orders_call.args[0] is main.close_expired_pending_orders_job
assert close_orders_call.kwargs["id"] == "close_expired_token_orders"
assert close_orders_call.kwargs["trigger"].interval.total_seconds() == 60
maintenance_scheduler.start.assert_called_once_with()
assert scheduler.add_job.call_count == 2
@@ -7,6 +7,7 @@ from threading import Barrier
from unittest.mock import Mock, patch
import pytest
from datetime import datetime, timedelta
from fastapi.testclient import TestClient
from database import SessionLocal
@@ -16,6 +17,7 @@ from routers.chat import _resolve_reply, _stream_reply
from services.token_billing import (
DEFAULT_TOKEN_GRANT,
InsufficientTokensError,
close_expired_pending_orders,
get_or_create_account,
release_reservation,
reserve_avatar_tokens,
@@ -107,7 +109,7 @@ def test_org_recharge_only_credits_after_verified_provider_order(authorization_c
provider_no = "261016111200090123456"
created_data = {"orderNo": provider_no, "chargeType": 9,
"chargeAmount": 10, "payStatus": 1}
with patch("routers.tokens.HuihuiOrgPaymentClient") as client_factory:
with patch.dict(os.environ, {"HUIHUI_ORG_BASE_URL": "https://fat-open.99hui.com/api/huihuiorg"}), patch("routers.tokens.HuihuiOrgPaymentClient") as client_factory:
provider = client_factory.return_value
provider.create_order.return_value = created_data
created = client.post(
@@ -117,18 +119,65 @@ def test_org_recharge_only_credits_after_verified_provider_order(authorization_c
assert created["providerOrderNo"] == provider_no
assert created["status"] == "pending"
assert created["balance"] == DEFAULT_TOKEN_GRANT
assert provider.create_order.call_args.kwargs["pay_type"] == 1
assert created["checkoutToken"] == f"huihui-payment-{context['suffix']}"
assert "pay_type" not in provider.create_order.call_args.kwargs
details = {**created_data, "payStatus": 2,
"userId": context["owner"].huihui_user_id}
provider.get_order.return_value = {**details, "chargeAmount": 9}
assert client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]["status"] == "pending"
provider.get_order.return_value = details
paid = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
assert paid["status"] == "paid"
assert paid["balance"] == DEFAULT_TOKEN_GRANT + 2_000_000
duplicate = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
assert duplicate["balance"] == paid["balance"]
unchanged = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
assert unchanged["status"] == "pending"
assert unchanged["balance"] == DEFAULT_TOKEN_GRANT
provider.get_order.assert_not_called()
def test_org_recharge_does_not_return_checkout_token_outside_test_environment(authorization_context):
seed()
context = authorization_context
_enable_huihui_payment_login(context)
with patch.dict(os.environ, {"HUIHUI_ORG_BASE_URL": "https://open.99hui.com/api/huihuiorg"}), patch(
"routers.tokens.HuihuiOrgPaymentClient"
) as client_factory:
client_factory.return_value.create_order.return_value = {
"orderNo": "261016111200090123457", "chargeType": 9,
"chargeAmount": 10, "payStatus": 1,
}
response = client.post(
"/api/token/charge/org", headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
)
assert response.status_code == 200
assert "checkoutToken" not in response.json()["data"]
def test_expired_pending_payment_orders_are_closed_without_touching_recent_orders(authorization_context):
context = authorization_context
db = SessionLocal()
try:
expired = TokenPaymentOrder(
order_no=f"EXPIRED-{uuid.uuid4().hex}", user_id=context["owner"].id, plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="H5", points_amount=100,
price_cents=100, status="pending", provider="huihui_org",
created_at=datetime.utcnow() - timedelta(hours=1, seconds=1),
)
recent = TokenPaymentOrder(
order_no=f"RECENT-{uuid.uuid4().hex}", user_id=context["owner"].id, plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="H5", points_amount=100,
price_cents=100, status="pending", provider="huihui_org",
created_at=datetime.utcnow() - timedelta(minutes=59),
)
db.add_all([expired, recent])
db.commit()
assert close_expired_pending_orders(db) == 1
db.refresh(expired)
db.refresh(recent)
assert expired.status == "closed"
assert expired.failure_reason == "支付超时(超过1小时)"
assert recent.status == "pending"
finally:
db.close()
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
+1 -1
View File
@@ -228,7 +228,7 @@ const doCharge = async () => {
const tokenPart = order.checkoutToken
? `&token=${encodeURIComponent(order.checkoutToken)}`
: ''
window.location.assign(`${checkoutBase}#/payOrder?orderNo=${encodeURIComponent(order.providerOrderNo)}&isOrg=true${evnPart}${tokenPart}`)
window.location.assign(`${checkoutBase}#/payOrder?id=${encodeURIComponent(order.providerOrderNo)}&isOrgUrl=true${tokenPart}${evnPart}`)
} catch (error: any) {
paymentNoticeTone.value = 'error'
paymentNotice.value = error?.message || '充值失败,请重试'