Merge pull request '调整会会收银台参数并自动关闭超时订单' (#39) from codex/avatar-token-recharge-huihui into main

Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
2026-10-16 17:52:44 +08:00
6 changed files with 114 additions and 16 deletions
+26 -1
View File
@@ -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):
+1 -1
View File
@@ -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
View File
@@ -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