调整会会收银台参数并自动关闭超时订单 #39
@@ -22,7 +22,11 @@ import routers.takeover
|
|||||||
from responses import ok
|
from responses import ok
|
||||||
from services.chat_attachment_service import purge_expired_chat_attachments
|
from services.chat_attachment_service import purge_expired_chat_attachments
|
||||||
from services.knowledge_vectorizer import knowledge_vectorizer
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -255,10 +259,24 @@ def purge_expired_chat_attachments_job():
|
|||||||
db.close()
|
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():
|
def start_maintenance_scheduler():
|
||||||
global maintenance_scheduler
|
global maintenance_scheduler
|
||||||
|
|
||||||
purge_expired_chat_attachments_job()
|
purge_expired_chat_attachments_job()
|
||||||
|
close_expired_pending_orders_job()
|
||||||
interval_minutes = max(
|
interval_minutes = max(
|
||||||
5, min(1440, int(os.getenv("CHAT_ATTACHMENT_CLEANUP_MINUTES", "60")))
|
5, min(1440, int(os.getenv("CHAT_ATTACHMENT_CLEANUP_MINUTES", "60")))
|
||||||
)
|
)
|
||||||
@@ -270,6 +288,13 @@ def start_maintenance_scheduler():
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
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()
|
maintenance_scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
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
|
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",
|
"stale_reservation_recovered",
|
||||||
)
|
)
|
||||||
return len(stale)
|
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,
|
max_message_age_seconds=600,
|
||||||
)
|
)
|
||||||
|
|
||||||
maintenance_scheduler.add_job.assert_called_once()
|
assert maintenance_scheduler.add_job.call_count == 2
|
||||||
assert maintenance_scheduler.add_job.call_args.kwargs["id"] == "chat_attachment_cleanup"
|
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()
|
maintenance_scheduler.start.assert_called_once_with()
|
||||||
|
|
||||||
assert scheduler.add_job.call_count == 2
|
assert scheduler.add_job.call_count == 2
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from threading import Barrier
|
|||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
@@ -16,6 +17,7 @@ from routers.chat import _resolve_reply, _stream_reply
|
|||||||
from services.token_billing import (
|
from services.token_billing import (
|
||||||
DEFAULT_TOKEN_GRANT,
|
DEFAULT_TOKEN_GRANT,
|
||||||
InsufficientTokensError,
|
InsufficientTokensError,
|
||||||
|
close_expired_pending_orders,
|
||||||
get_or_create_account,
|
get_or_create_account,
|
||||||
release_reservation,
|
release_reservation,
|
||||||
reserve_avatar_tokens,
|
reserve_avatar_tokens,
|
||||||
@@ -107,7 +109,7 @@ def test_org_recharge_only_credits_after_verified_provider_order(authorization_c
|
|||||||
provider_no = "261016111200090123456"
|
provider_no = "261016111200090123456"
|
||||||
created_data = {"orderNo": provider_no, "chargeType": 9,
|
created_data = {"orderNo": provider_no, "chargeType": 9,
|
||||||
"chargeAmount": 10, "payStatus": 1}
|
"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 = client_factory.return_value
|
||||||
provider.create_order.return_value = created_data
|
provider.create_order.return_value = created_data
|
||||||
created = client.post(
|
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["providerOrderNo"] == provider_no
|
||||||
assert created["status"] == "pending"
|
assert created["status"] == "pending"
|
||||||
assert created["balance"] == DEFAULT_TOKEN_GRANT
|
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,
|
details = {**created_data, "payStatus": 2,
|
||||||
"userId": context["owner"].huihui_user_id}
|
"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
|
provider.get_order.return_value = details
|
||||||
paid = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
|
unchanged = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
|
||||||
assert paid["status"] == "paid"
|
assert unchanged["status"] == "pending"
|
||||||
assert paid["balance"] == DEFAULT_TOKEN_GRANT + 2_000_000
|
assert unchanged["balance"] == DEFAULT_TOKEN_GRANT
|
||||||
duplicate = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
|
provider.get_order.assert_not_called()
|
||||||
assert duplicate["balance"] == paid["balance"]
|
|
||||||
|
|
||||||
|
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):
|
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ const doCharge = async () => {
|
|||||||
const tokenPart = order.checkoutToken
|
const tokenPart = order.checkoutToken
|
||||||
? `&token=${encodeURIComponent(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) {
|
} catch (error: any) {
|
||||||
paymentNoticeTone.value = 'error'
|
paymentNoticeTone.value = 'error'
|
||||||
paymentNotice.value = error?.message || '充值失败,请重试'
|
paymentNotice.value = error?.message || '充值失败,请重试'
|
||||||
|
|||||||
+3
-2
@@ -19,14 +19,15 @@ services:
|
|||||||
- AES_KEY=your-aes-key-32-chars-change-now!
|
- AES_KEY=your-aes-key-32-chars-change-now!
|
||||||
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
|
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
- AVATAR_DB_PATH=/app/avatar.db
|
- AVATAR_DB_PATH=/data/avatar-db/avatar.db
|
||||||
- AVATAR_BACKEND_URL=${AVATAR_BACKEND_URL:-}
|
- AVATAR_BACKEND_URL=${AVATAR_BACKEND_URL:-}
|
||||||
- AVATAR_FINANCE_ADMIN_SECRET=${AVATAR_FINANCE_ADMIN_SECRET:-}
|
- AVATAR_FINANCE_ADMIN_SECRET=${AVATAR_FINANCE_ADMIN_SECRET:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建
|
- ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建
|
||||||
- ./backend/logs:/app/logs
|
- ./backend/logs:/app/logs
|
||||||
- ./backend/config:/app/config
|
- ./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:
|
depends_on:
|
||||||
- ai-virtual-mysql
|
- ai-virtual-mysql
|
||||||
- ai-virtual-redis
|
- ai-virtual-redis
|
||||||
|
|||||||
Reference in New Issue
Block a user