04edb50acc
- 自动对账接入 app_config 运行时配置(新增 bool 配置类型):env 部署总闸 + DB 运营日常开关 双层;worker 每轮读 DB 即时生效,健康检查改报「env AND DB」实际生效态 - 新增 GET /api/v1/platform/flags(不鉴权)下发 comparing_ad_enabled 远程 kill-switch, 客户端拉取后缓存;空库回退默认 True - 穿山甲发奖回调支持多激励位 m-key(三命名项 PANGLE_REWARD_SECRET_TEST/_DEDICATED/_PROD + 旧逗号分隔合并去重),verify_callback_sign_any 逐个验签任一通过即受理;向后兼容旧单 key - 补 test_platform / bool config 用例;app_config 文档同步 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #59 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
132 lines
4.9 KiB
Python
132 lines
4.9 KiB
Python
"""提现 pending 单自动对账后台任务。"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import time
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
|
|
from app.core.config import settings
|
|
from app.db.session import SessionLocal
|
|
from app.integrations.wxpay import WxPayNotConfiguredError
|
|
from app.repositories import app_config
|
|
from app.repositories import wallet as wallet_repo
|
|
|
|
_AUTO_RECONCILE_KEY = "withdraw_auto_reconcile_enabled"
|
|
|
|
logger = logging.getLogger("shagua.withdraw_reconcile")
|
|
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "withdraw_reconcile.lock"
|
|
|
|
|
|
def _touch_lock() -> None:
|
|
with contextlib.suppress(FileNotFoundError):
|
|
os.utime(_LOCK_PATH, None)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
|
|
"""同机多进程保护:同一时间只允许一个自动查单 worker 运行。"""
|
|
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
fd: int | None = None
|
|
try:
|
|
try:
|
|
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
except FileExistsError:
|
|
try:
|
|
age = time.time() - _LOCK_PATH.stat().st_mtime
|
|
except FileNotFoundError:
|
|
age = stale_after_sec + 1
|
|
if age > stale_after_sec:
|
|
with contextlib.suppress(FileNotFoundError):
|
|
_LOCK_PATH.unlink()
|
|
try:
|
|
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
except FileExistsError:
|
|
fd = None
|
|
|
|
if fd is None:
|
|
yield False
|
|
return
|
|
|
|
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
|
|
yield True
|
|
finally:
|
|
if fd is not None:
|
|
os.close(fd)
|
|
with contextlib.suppress(FileNotFoundError):
|
|
_LOCK_PATH.unlink()
|
|
|
|
|
|
def _reconcile_once(older_than_minutes: int) -> dict | None:
|
|
"""读运营开关(app_config):关着返回 None(本轮跳过),开着才真扫单。
|
|
|
|
env WITHDRAW_AUTO_RECONCILE_ENABLED 是部署级总闸(决定 worker 起不起);
|
|
这里的 DB 开关是运营级日常开关,后台一改下一轮即生效、跨进程一致、无需重启。
|
|
"""
|
|
with SessionLocal() as db:
|
|
if not app_config.get_value(db, _AUTO_RECONCILE_KEY):
|
|
return None
|
|
return wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes)
|
|
|
|
|
|
async def _run_loop() -> None:
|
|
interval = max(30, int(settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC))
|
|
older_than = max(1, int(settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES))
|
|
lock_stale_after = max(interval * 3, 600)
|
|
with _single_instance_lock(lock_stale_after) as lock_acquired:
|
|
if not lock_acquired:
|
|
logger.warning("withdraw auto reconcile skipped: another worker owns lock")
|
|
return
|
|
|
|
await _run_locked_loop(interval, older_than)
|
|
|
|
|
|
async def _run_locked_loop(interval: int, older_than: int) -> None:
|
|
logger.info(
|
|
"withdraw auto reconcile worker started interval=%ss older_than=%sm "
|
|
"(runtime on/off via app_config '%s')",
|
|
interval,
|
|
older_than,
|
|
_AUTO_RECONCILE_KEY,
|
|
)
|
|
try:
|
|
while True:
|
|
try:
|
|
_touch_lock()
|
|
result = await asyncio.to_thread(_reconcile_once, older_than)
|
|
if result is not None and (result["checked"] or result["resolved"]):
|
|
logger.info("withdraw auto reconcile result=%s", result)
|
|
except WxPayNotConfiguredError:
|
|
logger.warning("withdraw auto reconcile skipped: wxpay not configured")
|
|
except SQLAlchemyError:
|
|
logger.exception("withdraw auto reconcile db error")
|
|
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
|
|
logger.exception("withdraw auto reconcile unexpected error")
|
|
await asyncio.sleep(interval)
|
|
except asyncio.CancelledError:
|
|
logger.info("withdraw auto reconcile stopped")
|
|
raise
|
|
|
|
|
|
def start_withdraw_reconcile_worker() -> asyncio.Task | None:
|
|
if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED:
|
|
# 部署级总闸关:worker 进程不启动(运营后台开关此时无效,需先在 env 打开)。
|
|
logger.info("withdraw auto reconcile worker not started (env master switch off)")
|
|
return None
|
|
if not settings.wxpay_configured:
|
|
logger.warning("withdraw auto reconcile enabled but wxpay not configured")
|
|
return asyncio.create_task(_run_loop(), name="withdraw-auto-reconcile")
|
|
|
|
|
|
async def stop_withdraw_reconcile_worker(task: asyncio.Task | None) -> None:
|
|
if task is None:
|
|
return
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|