57ddcd356b
客户端已删手动兑入口、改「0 点自动兑现金」,服务端补这条批处理: - wallet.exchange_coins_to_cash 加 remark / enforce_min 参数:enforce_min=False(自动兑)只要够 1 分(COIN_PER_CENT)即可,不受手动兑下限约束;remark 可定制。 - 新增 wallet.daily_auto_exchange(db):扫 coin_balance>=100 的用户,到分全额(floor(余额/100)*100) 兑现金,零头留下次;幂等键=当天是否已有 exchange_in 流水(_has_exchange_in_on);逐用户独立事务, 单用户异常 rollback 不中断。 - scripts/daily_auto_exchange.py(--once / --loop):带 .env AUTO_EXCHANGE_ENABLED 开关(false→no-op) + 文件锁防重入;deploy/ 配 systemd service+timer(0 点触发)+ 说明。 - config.AUTO_EXCHANGE_ENABLED(默认 true)+ .env.example。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #45 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
114 lines
3.9 KiB
Python
114 lines
3.9 KiB
Python
"""0 点自动兑金币(每日定时:把每个用户金币「到分全额」兑成现金)。
|
|
|
|
背景:客户端已删手动「兑金币」入口、改文案「0 点自动兑现金(可能存在延迟)」,故服务端需在
|
|
每天 0 点把金币按汇率(100 金币 = 1 分)兑成现金。复用 exchange_in 流水类型(手动兑入口已下线,
|
|
不会与之混淆),幂等键 = 用户当天是否已有 exchange_in 流水。核心逻辑见
|
|
app/repositories/wallet.py::daily_auto_exchange。
|
|
|
|
用法:
|
|
# 单轮(给 systemd timer / cron 用,线上每天 0 点一次)
|
|
python -m scripts.daily_auto_exchange --once
|
|
|
|
# 本地循环(调试用,默认每小时一轮——真到分兑换幂等,空跑无害)
|
|
python -m scripts.daily_auto_exchange --loop --interval 3600
|
|
|
|
开关:.env 的 AUTO_EXCHANGE_ENABLED=false 时脚本直接 no-op 退出(免动 systemd timer)。
|
|
带文件锁,防上一轮没跑完下一轮又起。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from datetime import datetime
|
|
|
|
# Windows 控制台按 UTF-8 输出中文/¥
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
from app.core.config import settings
|
|
from app.db.session import SessionLocal
|
|
from app.repositories import wallet as wallet_repo
|
|
|
|
# 锁放系统临时目录(任何账号可写),不依赖代码目录 data/ 的写权限。需要指定位置时用环境变量覆盖。
|
|
LOCK_FILE = os.environ.get("DAILY_EXCHANGE_LOCK") or os.path.join(
|
|
tempfile.gettempdir(), "daily_auto_exchange.lock"
|
|
)
|
|
LOCK_STALE_SEC = 30 * 60 # 锁超过 30min 视为陈旧(进程异常退出残留),自动接管
|
|
|
|
|
|
def _acquire_lock() -> bool:
|
|
os.makedirs(os.path.dirname(LOCK_FILE) or ".", exist_ok=True)
|
|
try:
|
|
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
os.write(fd, f"{os.getpid()} {time.time()}".encode())
|
|
os.close(fd)
|
|
return True
|
|
except FileExistsError:
|
|
try:
|
|
with open(LOCK_FILE) as f:
|
|
parts = f.read().split()
|
|
ts = float(parts[1]) if len(parts) > 1 else 0.0
|
|
if time.time() - ts > LOCK_STALE_SEC:
|
|
os.remove(LOCK_FILE)
|
|
return _acquire_lock()
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _release_lock() -> None:
|
|
try:
|
|
os.remove(LOCK_FILE)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def run_once() -> None:
|
|
if not settings.AUTO_EXCHANGE_ENABLED:
|
|
print(f"[{datetime.now():%H:%M:%S}] AUTO_EXCHANGE_ENABLED=false,跳过(no-op)")
|
|
return
|
|
if not _acquire_lock():
|
|
print(f"[{datetime.now():%H:%M:%S}] 上一轮还在跑(锁占用),跳过本轮")
|
|
return
|
|
t0 = time.time()
|
|
try:
|
|
with SessionLocal() as db:
|
|
stats = wallet_repo.daily_auto_exchange(db)
|
|
dt = time.time() - t0
|
|
print(
|
|
f"[{datetime.now():%H:%M:%S}] 自动兑完成 用时{dt:.1f}s "
|
|
f"扫描{stats['scanned']} 兑换{stats['converted']} "
|
|
f"已兑过跳过{stats['skipped_done']} 零头跳过{stats['skipped_dust']} "
|
|
f"失败{stats['failed']} 累计兑入{stats['total_cents']}分"
|
|
)
|
|
finally:
|
|
_release_lock()
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description="0 点自动兑金币")
|
|
ap.add_argument("--once", action="store_true", help="只跑一轮(默认)")
|
|
ap.add_argument("--loop", action="store_true", help="循环跑(本地调试)")
|
|
ap.add_argument("--interval", type=int, default=3600, help="循环间隔秒(默认 3600=1h)")
|
|
args = ap.parse_args()
|
|
|
|
if args.loop:
|
|
print(f"循环模式,每 {args.interval}s 一轮(Ctrl+C 退出)")
|
|
try:
|
|
while True:
|
|
run_once()
|
|
time.sleep(args.interval)
|
|
except KeyboardInterrupt:
|
|
print("已退出")
|
|
else:
|
|
run_once()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|