b7cfcf7495
## 需求 - 保留现有后台手动对账逻辑不变 - 每天北京时间 05:00 自动刷新 CPS 对账 - 当前仅处理美团和京东 - 每次按更新时间回拉近 3 天,覆盖延迟更新和订单状态变化 ## 实现 - 新增进程内 CPS 自动对账 worker,并接入应用生命周期 - 美团使用更新时间查询类型 2,京东使用更新时间查询类型 3 - 复用现有仓储层对账及 order_id 幂等更新逻辑 - 美团和京东独立会话、独立异常处理,单个平台失败不阻塞另一平台 - 增加单实例锁、开关、执行小时、回拉天数和轮询间隔配置 - 服务在 05:00 后重启时会补跑当天任务 ## 验证 - `pytest tests/test_cps_reconcile_worker.py tests/test_cps_admin.py tests/test_admin_read.py -q`:27 passed - 相关文件 Ruff 检查通过 - `git diff --check` 通过 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: #162 Co-authored-by: linkeyu <linkeyu@wonderable.ai> Co-committed-by: linkeyu <linkeyu@wonderable.ai>
176 lines
6.2 KiB
Python
176 lines
6.2 KiB
Python
"""美团、京东 CPS 订单每日自动对账任务。
|
|
|
|
每天北京时间 `CPS_AUTO_RECONCILE_RUN_HOUR`(默认 05:00)后执行一次,按更新时间
|
|
回拉最近若干天订单并复用 admin CPS 仓储层的幂等 upsert。手动对账接口保持独立、不受影响。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import time
|
|
from collections.abc import Iterator
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from app.admin.repositories import cps as cps_repo
|
|
from app.core.config import settings
|
|
from app.core.rewards import CN_TZ
|
|
from app.db.session import SessionLocal
|
|
|
|
logger = logging.getLogger("shagua.cps_reconcile")
|
|
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "cps_reconcile.lock"
|
|
|
|
|
|
def _cn_now() -> datetime:
|
|
return datetime.now(CN_TZ)
|
|
|
|
|
|
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]:
|
|
"""同机多进程保护:同一时间只允许一个 CPS 自动对账 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 _empty_result() -> dict:
|
|
return {"fetched": 0, "inserted": 0, "updated": 0, "pages": 0}
|
|
|
|
|
|
def _reconcile_once(now: datetime | None = None) -> dict:
|
|
"""独立拉取美团和京东;单平台异常只记日志,不影响另一平台。"""
|
|
end = (now or _cn_now()).astimezone(CN_TZ)
|
|
lookback_days = max(1, int(settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS))
|
|
start = end - timedelta(days=lookback_days)
|
|
result = {
|
|
"window_start": start.isoformat(),
|
|
"window_end": end.isoformat(),
|
|
"meituan": None,
|
|
"jd": None,
|
|
"errors": {},
|
|
}
|
|
|
|
if settings.mt_cps_configured:
|
|
try:
|
|
with SessionLocal() as db:
|
|
result["meituan"] = cps_repo.reconcile_orders(
|
|
db,
|
|
start_time=int(start.timestamp()),
|
|
end_time=int(end.timestamp()),
|
|
query_time_type=2,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - 单平台失败不能阻断另一平台
|
|
result["errors"]["meituan"] = str(exc)
|
|
logger.exception("CPS auto reconcile failed platform=meituan")
|
|
else:
|
|
result["meituan"] = {**_empty_result(), "skipped": "not_configured"}
|
|
|
|
_touch_lock()
|
|
if settings.jd_union_configured:
|
|
try:
|
|
with SessionLocal() as db:
|
|
result["jd"] = cps_repo.reconcile_jd_orders(
|
|
db,
|
|
start_time=start,
|
|
end_time=end,
|
|
query_time_type=3,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - 单平台失败不能阻断另一平台
|
|
result["errors"]["jd"] = str(exc)
|
|
logger.exception("CPS auto reconcile failed platform=jd")
|
|
else:
|
|
result["jd"] = {**_empty_result(), "skipped": "not_configured"}
|
|
|
|
return result
|
|
|
|
|
|
def _should_run(last_run: date | None, now: datetime, run_hour: int) -> bool:
|
|
return last_run != now.date() and now.hour >= run_hour
|
|
|
|
|
|
async def _run_loop() -> None:
|
|
interval = max(30, int(settings.CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC))
|
|
run_hour = min(max(int(settings.CPS_AUTO_RECONCILE_RUN_HOUR), 0), 23)
|
|
lock_stale_after = max(interval * 3, 1800)
|
|
with _single_instance_lock(lock_stale_after) as lock_acquired:
|
|
if not lock_acquired:
|
|
logger.warning("CPS auto reconcile skipped: another worker owns lock")
|
|
return
|
|
await _run_locked_loop(interval, run_hour)
|
|
|
|
|
|
async def _run_locked_loop(interval: int, run_hour: int) -> None:
|
|
logger.info(
|
|
"CPS auto reconcile worker started run_hour=%s interval=%ss lookback_days=%s",
|
|
run_hour,
|
|
interval,
|
|
settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS,
|
|
)
|
|
last_run: date | None = None
|
|
try:
|
|
while True:
|
|
try:
|
|
_touch_lock()
|
|
now = _cn_now()
|
|
if _should_run(last_run, now, run_hour):
|
|
result = await asyncio.to_thread(_reconcile_once, now)
|
|
last_run = now.date()
|
|
logger.info("CPS auto reconcile done date=%s result=%s", last_run, result)
|
|
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
|
|
logger.exception("CPS auto reconcile unexpected error")
|
|
await asyncio.sleep(interval)
|
|
except asyncio.CancelledError:
|
|
logger.info("CPS auto reconcile worker stopped")
|
|
raise
|
|
|
|
|
|
def start_cps_reconcile_worker() -> asyncio.Task | None:
|
|
if not settings.CPS_AUTO_RECONCILE_ENABLED:
|
|
logger.info("CPS auto reconcile disabled")
|
|
return None
|
|
if not settings.mt_cps_configured and not settings.jd_union_configured:
|
|
logger.warning("CPS auto reconcile not started: Meituan and JD credentials are missing")
|
|
return None
|
|
return asyncio.create_task(_run_loop(), name="cps-auto-reconcile")
|
|
|
|
|
|
async def stop_cps_reconcile_worker(task: asyncio.Task | None) -> None:
|
|
if task is None:
|
|
return
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|