From 08a7bc8b90db4fb4f4a14d766652adcdbf7eb0c2 Mon Sep 17 00:00:00 2001 From: stefanfeng Date: Fri, 16 Oct 2026 17:24:57 +0800 Subject: [PATCH 1/2] feat(payment): expire pending orders after one hour --- digital-avatar-app/backend/main.py | 27 +++++++- .../backend/services/token_billing.py | 21 +++++- .../backend/tests/test_takeover_scheduler.py | 8 ++- .../backend/tests/test_token_billing.py | 67 ++++++++++++++++--- digital-avatar-app/src/views/TokenCharge.vue | 2 +- 5 files changed, 111 insertions(+), 14 deletions(-) diff --git a/digital-avatar-app/backend/main.py b/digital-avatar-app/backend/main.py index fe54432..3f1211f 100644 --- a/digital-avatar-app/backend/main.py +++ b/digital-avatar-app/backend/main.py @@ -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() diff --git a/digital-avatar-app/backend/services/token_billing.py b/digital-avatar-app/backend/services/token_billing.py index 28fb5ce..5b77045 100644 --- a/digital-avatar-app/backend/services/token_billing.py +++ b/digital-avatar-app/backend/services/token_billing.py @@ -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) diff --git a/digital-avatar-app/backend/tests/test_takeover_scheduler.py b/digital-avatar-app/backend/tests/test_takeover_scheduler.py index 3c4747e..7503878 100644 --- a/digital-avatar-app/backend/tests/test_takeover_scheduler.py +++ b/digital-avatar-app/backend/tests/test_takeover_scheduler.py @@ -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 diff --git a/digital-avatar-app/backend/tests/test_token_billing.py b/digital-avatar-app/backend/tests/test_token_billing.py index ae9ba97..14535a2 100644 --- a/digital-avatar-app/backend/tests/test_token_billing.py +++ b/digital-avatar-app/backend/tests/test_token_billing.py @@ -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): diff --git a/digital-avatar-app/src/views/TokenCharge.vue b/digital-avatar-app/src/views/TokenCharge.vue index a664d34..a544c7f 100644 --- a/digital-avatar-app/src/views/TokenCharge.vue +++ b/digital-avatar-app/src/views/TokenCharge.vue @@ -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 || '充值失败,请重试' -- 2.54.0 From 0302ee81e07414fecd29f66b394b172a310eaa0a Mon Sep 17 00:00:00 2001 From: stefanfeng Date: Fri, 16 Oct 2026 17:50:29 +0800 Subject: [PATCH 2/2] fix(finance): share avatar sqlite WAL with admin --- docker-compose.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index e174bde..5a96899 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,14 +19,15 @@ services: - AES_KEY=your-aes-key-32-chars-change-now! - AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-} - TZ=Asia/Shanghai - - AVATAR_DB_PATH=/app/avatar.db + - AVATAR_DB_PATH=/data/avatar-db/avatar.db - AVATAR_BACKEND_URL=${AVATAR_BACKEND_URL:-} - AVATAR_FINANCE_ADMIN_SECRET=${AVATAR_FINANCE_ADMIN_SECRET:-} volumes: - ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建 - ./backend/logs:/app/logs - ./backend/config:/app/config - - ./digital-avatar-app/backend/avatar.db:/app/avatar.db # 财务管理需要写入退款与开票状态 + # Mount the directory so admin and avatar services share SQLite WAL/SHM files too. + - ./digital-avatar-app/backend:/data/avatar-db depends_on: - ai-virtual-mysql - ai-virtual-redis -- 2.54.0